fix deterministic authoritative RNG fallbacks
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { buildScenarioBootstrap, type GeneralMeta, type ScenarioBootstrapWarning, type WorldSeedPayload } from '@sammo-ts/logic';
|
||||
import {
|
||||
buildScenarioBootstrap,
|
||||
resolveScenarioGeneralDeathMonth,
|
||||
type GeneralMeta,
|
||||
type ScenarioBootstrapWarning,
|
||||
type WorldSeedPayload,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import type { MapLoaderOptions } from './mapLoader.js';
|
||||
import { loadMapDefinitionByName } from './mapLoader.js';
|
||||
@@ -150,12 +156,12 @@ const resolveKillturnFromDeathYear = (
|
||||
currentYear: number,
|
||||
currentMonth: number,
|
||||
deathYear: number,
|
||||
deathMonth: number,
|
||||
fallback: number
|
||||
): number => {
|
||||
if (!Number.isFinite(deathYear) || deathYear <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
const deathMonth = Math.floor(Math.random() * 12) + 1;
|
||||
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
|
||||
return Math.max(diff, 0);
|
||||
};
|
||||
@@ -442,15 +448,32 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
delete meta.deadYear;
|
||||
const fallbackKillturn =
|
||||
typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0;
|
||||
const deathMonth =
|
||||
typeof meta.deathMonth === 'number' &&
|
||||
Number.isInteger(meta.deathMonth) &&
|
||||
meta.deathMonth >= 1 &&
|
||||
meta.deathMonth <= 12
|
||||
? meta.deathMonth
|
||||
: resolveScenarioGeneralDeathMonth({
|
||||
scenarioTitle: String(seed.scenarioMeta?.title ?? ''),
|
||||
startYear: seed.scenarioMeta?.startYear ?? null,
|
||||
contextLabel:
|
||||
typeof meta.source === 'string' ? meta.source : 'general',
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
deathYear: general.deathYear,
|
||||
});
|
||||
const killturn = resolveKillturnFromDeathYear(
|
||||
startState.currentYear,
|
||||
startState.currentMonth,
|
||||
general.deathYear,
|
||||
deathMonth,
|
||||
fallbackKillturn
|
||||
);
|
||||
return {
|
||||
...meta,
|
||||
killturn,
|
||||
deathMonth,
|
||||
npcType: general.npcType,
|
||||
crewTypeId: general.crewTypeId,
|
||||
} satisfies GeneralMeta;
|
||||
|
||||
@@ -27,17 +27,6 @@ const resolveServerId = (world: InMemoryTurnWorld): string | null => {
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
};
|
||||
|
||||
const shuffleLegacy = <T>(values: T[]): T[] => {
|
||||
const result = [...values];
|
||||
// ref의 follower 병종 숙련 배열은 action DRBG가 아니라 PHP process-global
|
||||
// shuffle()로 섞인다.
|
||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
||||
const target = Math.floor(Math.random() * (index + 1));
|
||||
[result[index], result[target]] = [result[target]!, result[index]!];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const createTurnTime = (rng: RandUtil, environment: MonthlyEventEnvironment, tickSeconds: number): Date => {
|
||||
const turnMinutes = tickSeconds / 60;
|
||||
if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) {
|
||||
@@ -235,6 +224,17 @@ export const createRaiseInvaderHandler = (options: {
|
||||
simpleSerialize(resolveHiddenSeed(world), 'RaiseInvader', environment.year, environment.month)
|
||||
)
|
||||
);
|
||||
const dexShuffleRng = new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
simpleSerialize(
|
||||
resolveHiddenSeed(world),
|
||||
'RaiseInvader',
|
||||
environment.year,
|
||||
environment.month,
|
||||
'martialDex'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const nation of world.listNations()) {
|
||||
world.updateNation(nation.id, {
|
||||
@@ -363,7 +363,7 @@ export const createRaiseInvaderHandler = (options: {
|
||||
const mainStat = rng.nextRangeInt(toInteger(specAverage * 1.2), toInteger(specAverage * 1.4));
|
||||
const subStat = specAverage * 3 - leadership - mainStat;
|
||||
const isWarrior = rng.nextBit();
|
||||
const martialDex = isWarrior ? shuffleLegacy([dex * 2, dex, dex]) : [dex, dex, dex];
|
||||
const martialDex = isWarrior ? dexShuffleRng.shuffle([dex * 2, dex, dex]) : [dex, dex, dex];
|
||||
createInvaderGeneral({
|
||||
world,
|
||||
reservedTurns: options.reservedTurns,
|
||||
|
||||
@@ -111,17 +111,6 @@ const calculateAverageCity = (rng: RandUtil, cities: City[]): CityValues => {
|
||||
) as CityValues;
|
||||
};
|
||||
|
||||
const shuffleLegacy = <T>(values: T[]): T[] => {
|
||||
const result = [...values];
|
||||
// ref Util::shuffle_assoc()도 action DRBG가 아닌 PHP의 process-global
|
||||
// shuffle()을 사용한다.
|
||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
||||
const target = Math.floor(Math.random() * (index + 1));
|
||||
[result[index], result[target]] = [result[target]!, result[index]!];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
||||
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||
|
||||
@@ -245,9 +234,20 @@ export const createRaiseNpcNationHandler = (options: {
|
||||
simpleSerialize(resolveHiddenSeed(world), 'RaiseNPCNation', environment.year, environment.month)
|
||||
)
|
||||
);
|
||||
const cityShuffleRng = new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
simpleSerialize(
|
||||
resolveHiddenSeed(world),
|
||||
'RaiseNPCNation',
|
||||
environment.year,
|
||||
environment.month,
|
||||
'emptyCities'
|
||||
)
|
||||
)
|
||||
);
|
||||
const averageCity = calculateAverageCity(rng, targetCities);
|
||||
const occupiedCityIds = targetCities.filter((city) => city.nationId !== 0).map((city) => city.id);
|
||||
const emptyCities = shuffleLegacy(targetCities.filter((city) => city.nationId === 0));
|
||||
const emptyCities = cityShuffleRng.shuffle(targetCities.filter((city) => city.nationId === 0));
|
||||
const activeNations = world.listNations().filter((nation) => nation.id !== 0 && nation.level > 0);
|
||||
const generalCounts = activeNations.map(
|
||||
(nation) => world.listGenerals().filter((general) => general.nationId === nation.id).length
|
||||
|
||||
@@ -57,14 +57,16 @@ const readPattern = (world: InMemoryTurnWorld, config: Record<string, unknown>):
|
||||
);
|
||||
};
|
||||
|
||||
const shuffledDefaultPattern = (): number[] => {
|
||||
const pattern = [0, 0, 1, 2, 3];
|
||||
for (let index = pattern.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(Math.random() * (index + 1));
|
||||
[pattern[index], pattern[swapIndex]] = [pattern[swapIndex]!, pattern[index]!];
|
||||
}
|
||||
return pattern;
|
||||
};
|
||||
const shuffledDefaultPattern = (
|
||||
hiddenSeed: string | number,
|
||||
previousYear: number,
|
||||
previousMonth: number
|
||||
): number[] =>
|
||||
new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
simpleSerialize(hiddenSeed, 'monthly', previousYear, previousMonth, 'tournamentPattern')
|
||||
)
|
||||
).shuffle([0, 0, 1, 2, 3]);
|
||||
|
||||
export const createTournamentAutoStartHandler = (options: {
|
||||
profileName: string;
|
||||
@@ -112,7 +114,10 @@ export const createTournamentAutoStartHandler = (options: {
|
||||
}
|
||||
|
||||
const pattern = readPattern(world, config);
|
||||
const resolvedPattern = pattern.length > 0 ? pattern : shuffledDefaultPattern();
|
||||
const resolvedPattern =
|
||||
pattern.length > 0
|
||||
? pattern
|
||||
: shuffledDefaultPattern(hiddenSeed, context.previousYear, context.previousMonth);
|
||||
const type = resolvedPattern.pop() ?? 0;
|
||||
world.updateWorldMeta({ tournamentPattern: resolvedPattern });
|
||||
const now = options.now?.() ?? new Date();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
@@ -166,6 +166,16 @@ const buildHarness = (options?: {
|
||||
};
|
||||
|
||||
describe('invader monthly actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
throw new Error('monthly invader actions must not use Math.random');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates the invader nation, generals, diplomacy, follow-up events, and city state', async () => {
|
||||
const harness = buildHarness();
|
||||
const handler = createRaiseInvaderHandler({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PERSONALITY_TRAIT_KEYS, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
@@ -192,6 +192,16 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi
|
||||
};
|
||||
|
||||
describe('RaiseNPCNation monthly action', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
throw new Error('RaiseNPCNation must not use Math.random');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates only distance-qualified NPC nations and initializes their ruler and turns', async () => {
|
||||
const { world, reservedTurns, handler, environment } = buildHarness();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
import type { Nation } from '@sammo-ts/logic';
|
||||
|
||||
@@ -143,4 +143,86 @@ describe('monthly tournament auto start', () => {
|
||||
expect(consumed).toEqual([false]);
|
||||
expect(world.peekDirtyState().logs).toEqual([]);
|
||||
});
|
||||
|
||||
it('derives an empty tournament pattern from the monthly seed without Math.random', async () => {
|
||||
const run = async () => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 193,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0193-01-01T00:00:00.000Z'),
|
||||
meta: { hiddenSeed: 'monthly-post-tail-2' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: {
|
||||
total: 300,
|
||||
min: 10,
|
||||
max: 100,
|
||||
npcTotal: 150,
|
||||
npcMax: 50,
|
||||
npcMin: 10,
|
||||
chiefMin: 70,
|
||||
},
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generals: [],
|
||||
cities: [],
|
||||
nations: [buildNation(1), buildNation(2)],
|
||||
troops: [],
|
||||
};
|
||||
const values = new Map<string, string>();
|
||||
const redis = {
|
||||
get: async (key: string) => values.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
return 'OK';
|
||||
},
|
||||
} as unknown as RedisConnector['client'];
|
||||
let world: InMemoryTurnWorld | null = null;
|
||||
world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
calendarHandler: createTournamentAutoStartHandler({
|
||||
profileName: 'test',
|
||||
getWorld: () => world,
|
||||
getRedisClient: () => redis,
|
||||
getWorldConfig: () => ({ tournamentTrig: true }),
|
||||
getNationPowerRollCount: () => 2,
|
||||
now: () => new Date('2026-07-25T00:00:00.000Z'),
|
||||
}),
|
||||
});
|
||||
|
||||
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||
|
||||
return {
|
||||
tournamentState: JSON.parse(values.get('sammo:test:tournament:state') ?? '{}') as {
|
||||
type?: number;
|
||||
},
|
||||
remainingPattern: world.getState().meta.tournamentPattern,
|
||||
};
|
||||
};
|
||||
|
||||
const random = vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
throw new Error('tournament fallback must not use Math.random');
|
||||
});
|
||||
try {
|
||||
const first = await run();
|
||||
const second = await run();
|
||||
expect(second).toEqual(first);
|
||||
expect(first).toEqual({
|
||||
tournamentState: expect.objectContaining({ type: 1 }),
|
||||
remainingPattern: [2, 0, 0, 3],
|
||||
});
|
||||
} finally {
|
||||
random.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,30 @@ Dynamic event actions can use deterministic RNG by constructing
|
||||
`LiteHashDRBG` with `UniqueConst::$hiddenSeed` and an event-specific tag.
|
||||
Examples include `RandomizeCityTradeRate` and `UpdateNationLevel`.
|
||||
|
||||
core2026의 authoritative game-state 계산에서는 `Math.random()`을 사용하지
|
||||
않는다. 레거시 PHP 전역 `shuffle()`을 사용하던 다음 경로도 입력별 독립
|
||||
`LiteHashDRBG` substream으로 고정한다.
|
||||
|
||||
- `RaiseNPCNation`: `hiddenSeed, "RaiseNPCNation", year, month,
|
||||
"emptyCities"`
|
||||
- `RaiseInvader`: `hiddenSeed, "RaiseInvader", year, month,
|
||||
"martialDex"`
|
||||
- 빈 tournament pattern: `hiddenSeed, "monthly", previousYear,
|
||||
previousMonth, "tournamentPattern"`
|
||||
- scenario general 사망월: scenario title/start year, source group,
|
||||
general id/name/death year와 `"deathMonth"`
|
||||
|
||||
독립 substream을 쓰는 이유는 레거시 전역 shuffle의 비결정성만 제거하고
|
||||
이미 호환 검증된 action/monthly RNG의 후속 소비 위치는 바꾸지 않기
|
||||
위해서다. 테스트는 해당 경로에서 `Math.random()`을 호출하면 즉시
|
||||
실패한다. 인증 token, request/event correlation ID 같은 보안·운영 식별자의
|
||||
`crypto` RNG와 사용자가 랜덤 능력치 버튼으로 만드는 클라이언트 입력은
|
||||
이 게임-state 재현 계약과 구분한다.
|
||||
|
||||
root ESLint 설정도 `app/game-engine/src`와 `packages/logic/src`에서
|
||||
`Math.random` property 사용을 오류로 처리하므로 새 authoritative 경로가
|
||||
같은 결함을 다시 도입할 수 없다.
|
||||
|
||||
## Open Questions / Follow-ups
|
||||
|
||||
- `Event\Engine` is a stub with a TODO; it is not currently used in the main
|
||||
|
||||
@@ -98,6 +98,20 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['app/game-engine/src/**/*.{ts,tsx}', 'packages/logic/src/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'Math',
|
||||
property: 'random',
|
||||
message:
|
||||
'Authoritative game state must use an explicitly seeded RandUtil/LiteHashDRBG stream.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.mjs', '**/*.cjs'],
|
||||
rules: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import type {
|
||||
City,
|
||||
General,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
WorldSeedPayload,
|
||||
WorldSnapshot,
|
||||
} from './types.js';
|
||||
import { simpleSerialize } from '../war/utils.js';
|
||||
|
||||
export interface ScenarioBootstrapOptions {
|
||||
includeNeutralNation?: boolean;
|
||||
@@ -144,16 +146,38 @@ const resolveKillturnFromDeathYear = (
|
||||
currentYear: number | null,
|
||||
currentMonth: number,
|
||||
deathYear: number,
|
||||
deathMonth: number,
|
||||
fallback: number
|
||||
): number => {
|
||||
if (currentYear === null || !Number.isFinite(deathYear) || deathYear <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
const deathMonth = Math.floor(Math.random() * 12) + 1;
|
||||
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
|
||||
return Math.max(diff, 0);
|
||||
};
|
||||
|
||||
export const resolveScenarioGeneralDeathMonth = (input: {
|
||||
scenarioTitle: string;
|
||||
startYear: number | null;
|
||||
contextLabel: string;
|
||||
generalId: number;
|
||||
generalName: string;
|
||||
deathYear: number;
|
||||
}): number =>
|
||||
new RandUtil(
|
||||
new LiteHashDRBG(
|
||||
simpleSerialize(
|
||||
input.scenarioTitle,
|
||||
input.startYear ?? 0,
|
||||
input.contextLabel,
|
||||
input.generalId,
|
||||
input.generalName,
|
||||
input.deathYear,
|
||||
'deathMonth'
|
||||
)
|
||||
)
|
||||
).nextRangeInt(1, 12);
|
||||
|
||||
const resolveAge = (startYear: number | null, birthYear: number): number => {
|
||||
if (startYear === null || birthYear <= 0) {
|
||||
return 20;
|
||||
@@ -309,6 +333,14 @@ const buildGeneralSeeds = (
|
||||
const cityId = resolveCityId(row.city, cityByName, warnings, row.name);
|
||||
const birthYear = resolveBirthYear(row.birthYear, scenario.startYear);
|
||||
const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear);
|
||||
const deathMonth = resolveScenarioGeneralDeathMonth({
|
||||
scenarioTitle: scenario.title,
|
||||
startYear: scenario.startYear,
|
||||
contextLabel,
|
||||
generalId: id,
|
||||
generalName: row.name,
|
||||
deathYear,
|
||||
});
|
||||
const officerLevel = resolveOfficerLevel(row.officerLevel, nationId);
|
||||
const age = resolveAge(scenario.startYear, birthYear);
|
||||
const stats = {
|
||||
@@ -319,6 +351,7 @@ const buildGeneralSeeds = (
|
||||
|
||||
const seedMeta: Record<string, unknown> = {
|
||||
source: contextLabel,
|
||||
deathMonth,
|
||||
specage: buildSpecialityAge(retirementYear, age, 12),
|
||||
specage2: buildSpecialityAge(retirementYear, age, 6),
|
||||
};
|
||||
@@ -379,7 +412,14 @@ const buildGeneralSeeds = (
|
||||
seeds.push(seed);
|
||||
|
||||
const generalMeta: GeneralMeta = {
|
||||
killturn: resolveKillturnFromDeathYear(scenario.startYear, 1, deathYear, DEFAULT_GENERAL_KILLTURN),
|
||||
killturn: resolveKillturnFromDeathYear(
|
||||
scenario.startYear,
|
||||
1,
|
||||
deathYear,
|
||||
deathMonth,
|
||||
DEFAULT_GENERAL_KILLTURN
|
||||
),
|
||||
deathMonth,
|
||||
npcType,
|
||||
crewTypeId: defaultCrewTypeId,
|
||||
specage: buildSpecialityAge(retirementYear, age, 12),
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ScenarioDefinition } from '../src/scenario/types.js';
|
||||
import type { MapDefinition, UnitSetDefinition } from '../src/world/types.js';
|
||||
import { buildScenarioBootstrap } from '../src/world/bootstrap.js';
|
||||
|
||||
describe('scenario bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
throw new Error('scenario bootstrap must not use Math.random');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds snapshot and seed from scenario/map inputs', () => {
|
||||
const scenario: ScenarioDefinition = {
|
||||
title: 'Test Scenario',
|
||||
@@ -120,7 +130,14 @@ describe('scenario bootstrap', () => {
|
||||
expect(result.snapshot.generals[0]?.role.specialDomestic).toBe('Special');
|
||||
expect(result.snapshot.generals[0]?.role.specialWar).toBeNull();
|
||||
expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
||||
expect(result.seed.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 });
|
||||
expect(result.seed.generals[0]?.meta).toMatchObject({
|
||||
deathMonth: expect.any(Number),
|
||||
specage: 25,
|
||||
specage2: 30,
|
||||
});
|
||||
expect(buildScenarioBootstrap({ scenario, map, unitSet }).snapshot.generals[0]?.meta).toEqual(
|
||||
result.snapshot.generals[0]?.meta
|
||||
);
|
||||
expect(result.seed.generals[0]?.npcType).toBe(2);
|
||||
expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user