diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index ec931f8..630e725 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -21,8 +21,8 @@ canonical case │ ├─ execute legacy GeneralCommand or NationCommand │ └─ before/after snapshot + command RNG trace └─ core2026 execution boundary - ├─ snapshot selected PostgreSQL rows - ├─ execute reserved turn or daemon command + ├─ construct the in-memory turn world from the prepared ref snapshot + ├─ execute the real reserved-turn handler └─ before/after snapshot + command RNG trace ref delta ─┐ @@ -52,9 +52,16 @@ normalized to the core `*Id` spelling before command identity comparison. - Core integration tools - `canonical.ts`: shared snapshot and trace contracts. - `databaseSnapshot.ts`: PostgreSQL projection. + - `coreCommandTrace.ts`: real in-memory reserved-turn execution and + canonical projection. - `trace.ts`: before/execute/after capture boundary. - `compare.ts`: exact snapshot and delta comparison. - - `turnTraceFiles.integration.test.ts`: compares saved ref/core traces. + - `turnCommandGeneralMatrix.integration.test.ts`: 21 successful general + command paths, including the four-call `전투태세` completion path. + - `turnCommandNationMatrix.integration.test.ts`: 8 successful nation + command paths. + - `turnCommandCoreReference.integration.test.ts`: declaration and live + sortie fixtures. The ref runner refuses mutation unless `TURN_DIFFERENTIAL_ENABLED=1` is present. The wrapper injects it only into the disposable tool container. Direct @@ -154,3 +161,14 @@ Compatibility is established per case only when: 3. the semantic delta comparison is empty; 4. live sortie also passes the battle trace comparison; 5. any ignored path is documented in the case evidence. + +As of 2026-07-25, 21 general cases, 8 nation cases, declaration and live sortie +pass this boundary. Live sortie covers battle entry, conquest, defeated-general +neutralization and last-city nation collapse. This is 31 executable comparison +cases, not a claim that all 55 general and 38 nation command classes have been +dynamically compared. + +The fixture runner also reports whether the requested legacy command reached +its completed execution path. For multi-turn commands this is derived from the +pre-execution `LastTurn`, because commands such as `전투태세` reset their result +term to `1` on the completion call. diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index 335d245..7f67094 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -113,6 +113,13 @@ export const projectCoreDatabaseSnapshot = (rows: { experience: row.experience, dedication: row.dedication, officerLevel: row.officerLevel, + personality: row.personality ?? null, + specialDomestic: row.specialDomestic ?? null, + specialWar: row.specialWar ?? null, + itemHorse: row.itemHorse ?? null, + itemWeapon: row.itemWeapon ?? null, + itemBook: row.itemBook ?? null, + itemExtra: row.itemExtra ?? null, injury: row.injury, gold: row.gold, rice: row.rice, @@ -129,6 +136,11 @@ export const projectCoreDatabaseSnapshot = (rows: { leadershipExp: readNumber(meta, 'leadership_exp'), strengthExp: readNumber(meta, 'strength_exp'), intelExp: readNumber(meta, 'intel_exp'), + dex1: readNumber(meta, 'dex1'), + dex2: readNumber(meta, 'dex2'), + dex3: readNumber(meta, 'dex3'), + dex4: readNumber(meta, 'dex4'), + dex5: readNumber(meta, 'dex5'), killTurn: readNumber(meta, 'killturn'), mySet: readNumber(meta, 'myset'), }; @@ -175,6 +187,7 @@ export const projectCoreDatabaseSnapshot = (rows: { generalCount: readNumber(meta, 'gennum'), power: readNumber(meta, 'power'), war: readNumber(meta, 'war'), + diplomacyLimit: readNumber(meta, 'surlimit'), meta, }; }); diff --git a/tools/integration-tests/src/turn-differential/compare.ts b/tools/integration-tests/src/turn-differential/compare.ts index 73b9c31..7d3b518 100644 --- a/tools/integration-tests/src/turn-differential/compare.ts +++ b/tools/integration-tests/src/turn-differential/compare.ts @@ -63,6 +63,18 @@ const valuesEqual = (left: unknown, right: unknown, numericTolerance: number): b if (typeof left === 'number' && typeof right === 'number') { return Math.abs(left - right) <= numericTolerance; } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + return left.every((value, index) => valuesEqual(value, right[index], numericTolerance)); + } + if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) { + const leftRecord = left as Record; + const rightRecord = right as Record; + const keys = [...new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)])].sort(); + return keys.every((key) => valuesEqual(leftRecord[key], rightRecord[key], numericTolerance)); + } return Object.is(left, right); }; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts new file mode 100644 index 0000000..fd0bbf5 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -0,0 +1,663 @@ +import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; +import { + GENERAL_TURN_COMMAND_KEYS, + NATION_TURN_COMMAND_KEYS, + type MapDefinition, + type Nation, + type TurnCommandProfile, + type UnitSetDefinition, +} from '@sammo-ts/logic'; +import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js'; +import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js'; +import { InMemoryReservedTurnStore } from '@sammo-ts/game-engine/turn/reservedTurnStore.js'; +import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; +import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js'; +import type { + TurnDiplomacy, + TurnGeneral, + TurnWorldSnapshot, + TurnWorldState, +} from '@sammo-ts/game-engine/turn/types.js'; + +import { + canonicalizeTurnCommandArgs, + type CanonicalTurnCommandTrace, + type CanonicalTurnSnapshot, +} from './canonical.js'; + +export interface TurnCommandFixtureRequest { + kind: 'general' | 'nation'; + actorGeneralId: number; + action: string; + args?: unknown; + coreArgs?: unknown; + setup?: { + world?: { + startYear?: number; + year?: number; + month?: number; + hiddenSeed?: string; + }; + isolateWorld?: boolean; + generals?: Array>; + nations?: Array>; + cities?: Array>; + diplomacy?: Array>; + }; + observe?: { + generalIds?: number[]; + cityIds?: number[]; + nationIds?: number[]; + logAfterId?: number; + messageAfterId?: number; + }; +} + +interface RandomCall { + seq: number; + operation: string; + arguments: Record; + result: unknown; +} + +class TracingRng implements RNG { + public readonly calls: RandomCall[] = []; + + public constructor(private readonly inner: RNG) {} + + public getMaxInt(): number { + return this.inner.getMaxInt(); + } + + public nextBytes(bytes: number): Uint8Array { + const result = this.inner.nextBytes(bytes); + this.record('nextBytes', { bytes }, Buffer.from(result).toString('hex')); + return result; + } + + public nextBits(bits: number): Uint8Array { + const result = this.inner.nextBits(bits); + this.record('nextBits', { bits }, Buffer.from(result).toString('hex')); + return result; + } + + public nextInt(max?: number): number { + const result = this.inner.nextInt(max); + this.record('nextInt', { maxInclusive: max ?? null }, result); + return result; + } + + public nextFloat1(): number { + const result = this.inner.nextFloat1(); + this.record('nextFloat1', {}, result); + return result; + } + + private record(operation: string, args: Record, result: unknown): void { + this.calls.push({ + seq: this.calls.length, + operation, + arguments: args, + result, + }); + } +} + +const asRecord = (value: unknown): Record => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; + +const readNumber = (record: Record, key: string, fallback = 0): number => { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +}; + +const readString = (record: Record, key: string, fallback: string): string => { + const value = record[key]; + return typeof value === 'string' ? value : fallback; +}; + +const readNullableString = (record: Record, key: string): string | null => { + const value = record[key]; + return typeof value === 'string' && value !== '' && value !== 'None' ? value : null; +}; + +const toDatabaseInt = (value: number): number => Math.round(value); + +const COMMANDS_WITH_LEGACY_CORE_ARG_KEYS = new Set([ + 'che_장수대상임관', + 'che_선양', + 'che_증여', + 'che_천도', + 'che_몰수', +]); + +const resolveCoreArgs = (request: TurnCommandFixtureRequest): Record => { + const explicit = request.coreArgs; + if (explicit !== undefined) { + return asRecord(explicit); + } + if (COMMANDS_WITH_LEGACY_CORE_ARG_KEYS.has(request.action)) { + return asRecord(request.args); + } + return asRecord(canonicalizeTurnCommandArgs(request.args ?? {})); +}; + +const createCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandProfile => { + if (request.kind === 'general') { + if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) { + throw new Error(`Unknown general command: ${request.action}`); + } + return { + general: [request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number], '휴식'], + nation: ['휴식'], + }; + } + if (!NATION_TURN_COMMAND_KEYS.includes(request.action as (typeof NATION_TURN_COMMAND_KEYS)[number])) { + throw new Error(`Unknown nation command: ${request.action}`); + } + return { + general: ['휴식'], + nation: [request.action as (typeof NATION_TURN_COMMAND_KEYS)[number], '휴식'], + }; +}; + +const buildGeneral = (row: Record, turnTime: Date): TurnGeneral => { + const meta = asRecord(row.meta); + const rawLastTurn = asRecord(row.lastTurn); + const lastTurn = + typeof rawLastTurn.command === 'string' + ? { + command: rawLastTurn.command, + ...(typeof rawLastTurn.term === 'number' ? { term: rawLastTurn.term } : {}), + ...(typeof rawLastTurn.seq === 'number' ? { seq: rawLastTurn.seq } : {}), + ...(Object.keys(asRecord(rawLastTurn.arg)).length > 0 ? { arg: asRecord(rawLastTurn.arg) } : {}), + } + : undefined; + return { + id: readNumber(row, 'id'), + name: readString(row, 'name', '장수'), + nationId: readNumber(row, 'nationId'), + cityId: readNumber(row, 'cityId'), + troopId: readNumber(row, 'troopId'), + stats: { + leadership: readNumber(row, 'leadership', 80), + strength: readNumber(row, 'strength', 70), + intelligence: readNumber(row, 'intelligence', 60), + }, + experience: readNumber(row, 'experience'), + dedication: readNumber(row, 'dedication'), + officerLevel: readNumber(row, 'officerLevel', 1), + role: { + personality: readNullableString(row, 'personality'), + specialDomestic: readNullableString(row, 'specialDomestic'), + specialWar: readNullableString(row, 'specialWar'), + items: { + horse: readNullableString(row, 'itemHorse'), + weapon: readNullableString(row, 'itemWeapon'), + book: readNullableString(row, 'itemBook'), + item: readNullableString(row, 'itemExtra'), + }, + }, + injury: readNumber(row, 'injury'), + gold: readNumber(row, 'gold'), + rice: readNumber(row, 'rice'), + crew: readNumber(row, 'crew'), + crewTypeId: readNumber(row, 'crewTypeId', 1100), + train: readNumber(row, 'train'), + atmos: readNumber(row, 'atmos'), + age: readNumber(row, 'age', 30), + npcState: readNumber(row, 'npcState'), + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + ...meta, + killturn: readNumber(row, 'killTurn', readNumber(meta, 'killturn', 24)), + leadership_exp: readNumber(row, 'leadershipExp', readNumber(meta, 'leadership_exp')), + strength_exp: readNumber(row, 'strengthExp', readNumber(meta, 'strength_exp')), + intel_exp: readNumber(row, 'intelExp', readNumber(meta, 'intel_exp')), + dex1: readNumber(row, 'dex1', readNumber(meta, 'dex1')), + dex2: readNumber(row, 'dex2', readNumber(meta, 'dex2')), + dex3: readNumber(row, 'dex3', readNumber(meta, 'dex3')), + dex4: readNumber(row, 'dex4', readNumber(meta, 'dex4')), + dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')), + explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')), + officerCityId: readNumber(row, 'officerCityId', readNumber(meta, 'officerCityId')), + block: readNumber(row, 'blockState', readNumber(meta, 'block')), + }, + ...(lastTurn ? { lastTurn } : {}), + turnTime, + recentWarTime: null, + }; +}; + +const buildNation = (row: Record, generals: TurnGeneral[]): Nation => { + const id = readNumber(row, 'id'); + const meta = asRecord(row.meta); + return { + id, + name: readString(row, 'name', `국가${id}`), + color: readString(row, 'color', '#777777'), + capitalCityId: readNumber(row, 'capitalCityId') || null, + chiefGeneralId: generals.find((general) => general.nationId === id && general.officerLevel === 12)?.id ?? null, + gold: readNumber(row, 'gold'), + rice: readNumber(row, 'rice'), + power: readNumber(row, 'power'), + level: readNumber(row, 'level', 1), + typeCode: readString(row, 'typeCode', 'che_중립'), + meta: { + ...meta, + tech: readNumber(row, 'tech', readNumber(meta, 'tech')), + gennum: readNumber(row, 'generalCount', readNumber(meta, 'gennum')), + war: readNumber(row, 'war', readNumber(meta, 'war')), + surlimit: readNumber(row, 'diplomacyLimit', readNumber(meta, 'surlimit')), + }, + }; +}; + +const buildWorldInput = ( + request: TurnCommandFixtureRequest, + referenceBefore: CanonicalTurnSnapshot, + unitSet: UnitSetDefinition, + map: MapDefinition +): { state: TurnWorldState; snapshot: TurnWorldSnapshot; map: MapDefinition } => { + const year = readNumber(referenceBefore.world, 'year', request.setup?.world?.year ?? 185); + const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1); + const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`); + const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime)); + const nations = referenceBefore.nations.map((row) => buildNation(row, generals)); + const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const)); + const diplomacy: TurnDiplomacy[] = referenceBefore.diplomacy.map((row) => ({ + fromNationId: readNumber(row, 'fromNationId'), + toNationId: readNumber(row, 'toNationId'), + state: readNumber(row, 'state', 3), + term: readNumber(row, 'term'), + dead: readNumber(row, 'dead'), + meta: {}, + })); + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + develCost: readNumber(referenceBefore.world, 'develCost'), + trainDelta: 30, + atmosDelta: 30, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + openingPartYear: 3, + initialNationGenLimit: 10, + maxGeneral: 500, + baseGold: 0, + baseRice: 2_000, + maxResourceActionAmount: 10_000, + maxTechLevel: 12, + maxLevel: 255, + maxDedLevel: 30, + upgradeLimit: 30, + }, + environment: { mapName: map.id, unitSet: unitSet.id }, + }, + scenarioMeta: { + title: '턴 명령 차등', + startYear: request.setup?.world?.startYear ?? Math.max(1, year - 5), + life: null, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }, + map, + unitSet, + nations, + cities: map.cities.map((definition) => { + const row = observedCityRows.get(definition.id) ?? {}; + return { + id: definition.id, + name: readString(row, 'name', definition.name), + nationId: readNumber(row, 'nationId'), + level: readNumber(row, 'level', definition.level), + state: readNumber(row, 'state'), + population: readNumber(row, 'population', definition.initial.population), + populationMax: readNumber(row, 'populationMax', definition.max.population), + agriculture: readNumber(row, 'agriculture', definition.initial.agriculture), + agricultureMax: readNumber(row, 'agricultureMax', definition.max.agriculture), + commerce: readNumber(row, 'commerce', definition.initial.commerce), + commerceMax: readNumber(row, 'commerceMax', definition.max.commerce), + security: readNumber(row, 'security', definition.initial.security), + securityMax: readNumber(row, 'securityMax', definition.max.security), + supplyState: readNumber(row, 'supplyState', map.defaults?.supplyState ?? 1), + frontState: readNumber(row, 'frontState', map.defaults?.frontState ?? 0), + defence: readNumber(row, 'defence', definition.initial.defence), + defenceMax: readNumber(row, 'defenceMax', definition.max.defence), + wall: readNumber(row, 'wall', definition.initial.wall), + wallMax: readNumber(row, 'wallMax', definition.max.wall), + meta: { + trust: readNumber(row, 'trust', map.defaults?.trust ?? 50), + trade: readNumber(row, 'trade', map.defaults?.trade ?? 100), + term: readNumber(row, 'term'), + }, + }; + }), + generals, + troops: [], + diplomacy, + events: [], + initialEvents: [], + }; + return { + state: { + id: 1, + currentYear: year, + currentMonth: month, + tickSeconds: readNumber(referenceBefore.world, 'tickMinutes', 10) * 60, + lastTurnTime: turnTime, + meta: { + hiddenSeed: request.setup?.world?.hiddenSeed ?? 'turn-command-differential-seed', + killturn: 24, + isUnited: readNumber(referenceBefore.world, 'isUnited'), + scenarioId: readNumber(referenceBefore.world, 'scenarioId'), + initYear: readNumber(referenceBefore.world, 'initYear', request.setup?.world?.startYear ?? year), + initMonth: readNumber(referenceBefore.world, 'initMonth', 1), + }, + }, + snapshot, + map, + }; +}; + +const projectWorld = ( + world: InMemoryTurnWorld, + reservedTurns: InMemoryReservedTurnStore, + logs: CanonicalTurnSnapshot['logs'], + messages: CanonicalTurnSnapshot['messages'], + selector: { + generalIds: Set; + cityIds: Set; + nationIds: Set; + } +): CanonicalTurnSnapshot => { + const state = world.getState(); + const generals = world + .listGenerals() + .filter((general) => selector.generalIds.has(general.id)) + .map((general) => ({ + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + leadership: general.stats.leadership, + strength: general.stats.strength, + intelligence: general.stats.intelligence, + experience: toDatabaseInt(general.experience), + dedication: toDatabaseInt(general.dedication), + expLevel: readNumber(general.meta, 'explevel'), + officerLevel: general.officerLevel, + personality: general.role.personality, + specialDomestic: general.role.specialDomestic, + specialWar: general.role.specialWar, + itemHorse: general.role.items.horse, + itemWeapon: general.role.items.weapon, + itemBook: general.role.items.book, + itemExtra: general.role.items.item, + injury: general.injury, + gold: toDatabaseInt(general.gold), + rice: toDatabaseInt(general.rice), + crew: toDatabaseInt(general.crew), + crewTypeId: general.crewTypeId, + train: toDatabaseInt(general.train), + atmos: toDatabaseInt(general.atmos), + age: general.age, + npcState: general.npcState, + turnTime: general.turnTime.toISOString(), + recentWarTime: general.recentWarTime?.toISOString() ?? null, + lastTurn: general.lastTurn ?? null, + meta: general.meta, + leadershipExp: toDatabaseInt(readNumber(general.meta, 'leadership_exp')), + strengthExp: toDatabaseInt(readNumber(general.meta, 'strength_exp')), + intelExp: toDatabaseInt(readNumber(general.meta, 'intel_exp')), + dex1: toDatabaseInt(readNumber(general.meta, 'dex1')), + dex2: toDatabaseInt(readNumber(general.meta, 'dex2')), + dex3: toDatabaseInt(readNumber(general.meta, 'dex3')), + dex4: toDatabaseInt(readNumber(general.meta, 'dex4')), + dex5: toDatabaseInt(readNumber(general.meta, 'dex5')), + killTurn: readNumber(general.meta, 'killturn'), + mySet: readNumber(general.meta, 'myset'), + })); + const nations = world.listNations(); + return { + schemaVersion: 1, + engine: 'core2026', + world: { + year: state.currentYear, + month: state.currentMonth, + tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)), + turnTime: state.lastTurnTime.toISOString(), + isUnited: readNumber(state.meta, 'isUnited'), + }, + generals, + cities: world + .listCities() + .filter((city) => selector.cityIds.has(city.id)) + .map((city) => ({ + id: city.id, + name: city.name, + nationId: city.nationId, + level: city.level, + population: toDatabaseInt(city.population), + populationMax: city.populationMax, + agriculture: toDatabaseInt(city.agriculture), + agricultureMax: city.agricultureMax, + commerce: toDatabaseInt(city.commerce), + commerceMax: city.commerceMax, + security: toDatabaseInt(city.security), + securityMax: city.securityMax, + supplyState: city.supplyState, + frontState: city.frontState, + defence: toDatabaseInt(city.defence), + defenceMax: city.defenceMax, + wall: toDatabaseInt(city.wall), + wallMax: city.wallMax, + state: city.state, + term: readNumber(city.meta, 'term'), + trust: readNumber(city.meta, 'trust'), + trade: readNumber(city.meta, 'trade'), + })), + nations: nations + .filter((nation) => selector.nationIds.has(nation.id)) + .map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + gold: toDatabaseInt(nation.gold), + rice: toDatabaseInt(nation.rice), + tech: readNumber(nation.meta, 'tech'), + level: nation.level, + typeCode: nation.typeCode, + generalCount: world.listGenerals().filter((general) => general.nationId === nation.id).length, + power: nation.power, + war: readNumber(nation.meta, 'war'), + diplomacyLimit: readNumber(nation.meta, 'surlimit'), + meta: nation.meta, + })), + diplomacy: world + .listDiplomacy() + .filter((entry) => selector.nationIds.has(entry.fromNationId) && selector.nationIds.has(entry.toNationId)) + .map((entry) => ({ ...entry })), + generalTurns: generals.flatMap((general) => + reservedTurns.getGeneralTurns(Number(general.id)).map((turn, turnIndex) => ({ + generalId: general.id, + turnIndex, + action: turn.action, + args: turn.args, + })) + ), + nationTurns: nations.flatMap((nation) => + [12, 11, 10, 9, 8, 7, 6, 5].flatMap((officerLevel) => + reservedTurns.getNationTurns(nation.id, officerLevel).map((turn, turnIndex) => ({ + nationId: nation.id, + officerLevel, + turnIndex, + action: turn.action, + args: turn.args, + })) + ) + ), + logs, + messages, + watermarks: { logId: logs.length, messageId: messages.length }, + }; +}; + +const emptyDatabaseClient = { + generalTurn: { + findMany: async () => [], + deleteMany: async () => ({ count: 0 }), + createMany: async () => ({ count: 0 }), + }, + nationTurn: { + findMany: async () => [], + deleteMany: async () => ({ count: 0 }), + createMany: async () => ({ count: 0 }), + }, +}; + +export const runCoreTurnCommandTrace = async ( + request: TurnCommandFixtureRequest, + referenceBefore: CanonicalTurnSnapshot +): Promise => { + const unitSet = await loadUnitSetDefinitionByName('che'); + const map = await loadMapDefinitionByName('che'); + const worldInput = buildWorldInput(request, referenceBefore, unitSet, map); + const { state, snapshot } = worldInput; + const selector = { + generalIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))), + cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), + nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))), + }; + const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, { + maxGeneralTurns: 10, + maxNationTurns: 12, + }); + await reservedTurns.loadAll(); + const actor = snapshot.generals.find((general) => general.id === request.actorGeneralId); + if (!actor) { + throw new Error(`Missing actor general ${request.actorGeneralId}`); + } + const args = resolveCoreArgs(request); + if (request.kind === 'general') { + reservedTurns.getGeneralTurns(actor.id)[0] = { action: request.action, args }; + } else { + reservedTurns.getNationTurns(actor.nationId, actor.officerLevel)[0] = { + action: request.action, + args, + }; + } + + let world: InMemoryTurnWorld | null = null; + let resolution: + | { + kind: 'nation' | 'general'; + actionKey: string; + requestedAction: string; + usedFallback: boolean; + blockedReason?: string; + } + | undefined; + const commandRngCalls: RandomCall[] = []; + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig: snapshot.scenarioConfig, + scenarioMeta: snapshot.scenarioMeta, + map, + unitSet, + getWorld: () => world, + commandProfile: createCommandProfile(request), + commandRngFactory: ({ kind, actionKey, seed }) => { + const tracing = new TracingRng(new LiteHashDRBG(seed)); + if (kind === request.kind && actionKey === request.action) { + commandRngCalls.push(...tracing.calls); + return new RandUtil({ + getMaxInt: () => tracing.getMaxInt(), + nextBytes: (bytes) => { + const result = tracing.nextBytes(bytes); + commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls); + return result; + }, + nextBits: (bits) => { + const result = tracing.nextBits(bits); + commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls); + return result; + }, + nextInt: (max) => { + const result = tracing.nextInt(max); + commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls); + return result; + }, + nextFloat1: () => { + const result = tracing.nextFloat1(); + commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls); + return result; + }, + }); + } + return new RandUtil(new LiteHashDRBG(seed)); + }, + onActionResolved: (payload) => { + if (payload.kind === request.kind && payload.requestedAction === request.action) { + resolution = payload; + } + }, + }); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: handler, + }); + const before = projectWorld(world, reservedTurns, [], [], selector); + world.executeGeneralTurn(actor); + const dirty = world.peekDirtyState(); + const after = projectWorld( + world, + reservedTurns, + dirty.logs.map((log, index) => ({ + id: index + 1, + scope: log.scope, + category: log.category, + generalId: log.generalId ?? actor.id, + nationId: log.nationId ?? actor.nationId, + year: state.currentYear, + month: state.currentMonth, + text: log.text, + })), + dirty.messages.map((message, index) => ({ + id: index + 1, + payload: message, + })), + selector + ); + + return { + schemaVersion: 1, + engine: 'core2026', + execution: { + kind: request.kind, + actorGeneralId: request.actorGeneralId, + action: request.action, + args, + seedDomain: request.kind === 'general' ? 'generalCommand' : 'nationCommand', + outcome: resolution, + }, + before, + after, + rng: commandRngCalls, + }; +}; diff --git a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts index f5d46d2..206b99a 100644 --- a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts +++ b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts @@ -62,3 +62,22 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath: }); return JSON.parse(stdout) as CanonicalTurnCommandTrace; }; + +export const runReferenceTurnCommandTraceRequest = ( + workspaceRoot: string, + request: Record +): CanonicalTurnCommandTrace => { + const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference'); + const runner = process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT ?? './scripts/run-turn-differential-case.sh'; + const stdout = execFileSync(runner, ['-'], { + cwd: stackDirectory, + input: JSON.stringify(request), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + TURN_DIFFERENTIAL_STACK_DIR: stackDirectory, + }, + }); + return JSON.parse(stdout) as CanonicalTurnCommandTrace; +}; diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts new file mode 100644 index 0000000..5371b27 --- /dev/null +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; +import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; +import { + findTurnDifferentialWorkspaceRoot, + runReferenceTurnCommandTraceRequest, +} from '../src/turn-differential/referenceSnapshot.js'; + +const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; +const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); +const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); + +const ignoredLifecyclePaths = [ + /^generalTurns/, + /^nationTurns/, + /^logs/, + /^messages/, + /^world\.turnTime$/, + /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, + /^generals\[[^\]]+\]\.meta(?:\.|$)/, + /^nations\[[^\]]+\]\.meta(?:\.|$)/, +]; + +const readFixture = (relativePath: string): TurnCommandFixtureRequest => { + const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference'); + const fixture = JSON.parse( + fs.readFileSync(path.join(stackRoot, relativePath), 'utf8') + ) as TurnCommandFixtureRequest; + return { + ...fixture, + setup: { + ...fixture.setup, + world: { + ...fixture.setup?.world, + hiddenSeed: 'turn-command-differential-seed', + }, + generals: fixture.setup?.generals?.map((general) => ({ + ...general, + personality: 'None', + specialDomestic: 'None', + specialWar: 'None', + itemHorse: 'None', + itemWeapon: 'None', + itemBook: 'None', + itemExtra: 'None', + })), + }, + }; +}; + +integration('core ↔ legacy command-boundary differential', () => { + it.each([ + ['nation declaration', 'fixtures/turn-differential/nation-declaration.json'], + ['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'], + ])( + '%s matches command RNG and canonical state delta', + async (_label, fixturePath) => { + const request = readFixture(fixturePath); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(core.execution.outcome).toMatchObject({ + requestedAction: request.action, + actionKey: request.action, + usedFallback: false, + }); + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts new file mode 100644 index 0000000..626fbc2 --- /dev/null +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; + +import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; +import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; +import { + findTurnDifferentialWorkspaceRoot, + runReferenceTurnCommandTraceRequest, +} from '../src/turn-differential/referenceSnapshot.js'; + +const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; +const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); +const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); + +const ignoredLifecyclePaths = [ + /^generalTurns/, + /^nationTurns/, + /^logs/, + /^messages/, + /^world\.turnTime$/, + /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, + /^generals\[[^\]]+\]\.meta(?:\.|$)/, + /^nations\[[^\]]+\]\.meta(?:\.|$)/, +]; + +const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record => ({ + id, + nationId, + cityId, + troopId: 0, + leadership: 90, + strength: 80, + intelligence: 70, + leadershipExp: 0, + strengthExp: 0, + intelExp: 0, + experience: 1000, + dedication: 1000, + expLevel: 0, + officerLevel, + officerCityId: officerLevel >= 5 ? cityId : 0, + injury: 0, + age: 30, + gold: 100_000, + rice: 100_000, + crew: 1_000, + crewTypeId: 1100, + train: 50, + atmos: 50, + dex1: 0, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + killTurn: 24, + npcState: 0, + blockState: 0, + personality: 'None', + specialDomestic: 'None', + specialWar: 'None', + itemHorse: 'None', + itemWeapon: 'None', + itemBook: 'None', + itemExtra: 'None', + meta: {}, +}); + +const buildRequest = ( + action: string, + args?: Record, + actorPatch: Record = {} +): TurnCommandFixtureRequest => ({ + kind: 'general', + actorGeneralId: 1, + action, + ...(args ? { args } : {}), + setup: { + isolateWorld: true, + world: { + startYear: 180, + year: 190, + month: 1, + hiddenSeed: 'turn-command-general-matrix-v1', + }, + nations: [ + { + id: 1, + name: '아국', + capitalCityId: 3, + gold: 1_000_000, + rice: 1_000_000, + tech: 1000, + level: 1, + typeCode: 'che_중립', + war: 0, + generalCount: 2, + meta: {}, + }, + { + id: 2, + name: '타국', + capitalCityId: 70, + gold: 1_000_000, + rice: 1_000_000, + tech: 1000, + level: 1, + typeCode: 'che_중립', + war: 0, + generalCount: 1, + meta: {}, + }, + ], + cities: [ + { + id: 3, + nationId: 1, + population: 100_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + supplyState: 1, + frontState: 0, + state: 0, + term: 0, + trust: 80, + trade: 100, + }, + { + id: 70, + nationId: 2, + population: 100_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + supplyState: 1, + frontState: 1, + state: 0, + term: 0, + trust: 80, + trade: 100, + }, + ], + generals: [{ ...general(1, 1, 3, 12), ...actorPatch }, general(2, 2, 70, 12), general(3, 1, 3, 1)], + diplomacy: [ + { fromNationId: 1, toNationId: 2, state: 0, term: 12, dead: 0 }, + { fromNationId: 2, toNationId: 1, state: 0, term: 12, dead: 0 }, + ], + }, + observe: { + generalIds: [1, 2, 3], + cityIds: [3, 70], + nationIds: [1, 2], + logAfterId: 0, + messageAfterId: 0, + }, +}); + +const cases: Array<[string, Record | undefined, Record | undefined]> = [ + ['휴식', undefined, undefined], + ['che_훈련', undefined, undefined], + ['cr_맹훈련', undefined, undefined], + ['che_전투태세', undefined, { lastTurn: { command: '전투태세', term: 3 } }], + ['che_단련', undefined, undefined], + ['che_사기진작', undefined, undefined], + ['che_요양', undefined, { injury: 30 }], + ['che_견문', undefined, undefined], + ['che_주민선정', undefined, undefined], + ['che_정착장려', undefined, undefined], + ['che_농지개간', undefined, undefined], + ['che_상업투자', undefined, undefined], + ['che_기술연구', undefined, undefined], + ['che_치안강화', undefined, undefined], + ['che_수비강화', undefined, undefined], + ['che_성벽보수', undefined, undefined], + ['che_인재탐색', undefined, undefined], + ['che_소집해제', undefined, undefined], + ['che_군량매매', { buyRice: true, amount: 100 }, undefined], + ['che_물자조달', undefined, undefined], + ['che_헌납', { isGold: true, amount: 100 }, undefined], +]; + +integration('general command success matrix', () => { + it.each(cases)( + '%s matches the legacy state delta and command RNG', + async (action, args, actorPatch) => { + const request = buildRequest(action, args, actorPatch); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') { + process.stderr.write( + `${JSON.stringify( + { + action, + referenceRng: reference.rng, + coreRng: core.rng, + referenceGeneralDelta: compareTurnSnapshotDeltas( + reference.before, + reference.after, + reference.before, + reference.before, + { ignoredPathPatterns: ignoredLifecyclePaths } + ).filter((entry) => entry.path.startsWith('generals')), + coreGeneralDelta: compareTurnSnapshotDeltas( + core.before, + core.after, + core.before, + core.before, + { ignoredPathPatterns: ignoredLifecyclePaths } + ).filter((entry) => entry.path.startsWith('generals')), + referenceGenerals: reference.after.generals, + coreGenerals: core.after.generals, + }, + null, + 2 + )}\n` + ); + } + + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).not.toHaveProperty('blockedReason'); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts new file mode 100644 index 0000000..9a8a988 --- /dev/null +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; + +import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; +import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; +import { + findTurnDifferentialWorkspaceRoot, + runReferenceTurnCommandTraceRequest, +} from '../src/turn-differential/referenceSnapshot.js'; + +const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; +const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); +const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); + +const ignoredLifecyclePaths = [ + /^generalTurns/, + /^nationTurns/, + /^logs/, + /^messages/, + /^world\.turnTime$/, + /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, + /^generals\[[^\]]+\]\.meta(?:\.|$)/, + /^nations\[[^\]]+\]\.meta(?:\.|$)/, +]; + +const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record => ({ + id, + nationId, + cityId, + troopId: 0, + leadership: 90, + strength: 80, + intelligence: 70, + leadershipExp: 0, + strengthExp: 0, + intelExp: 0, + experience: 1000, + dedication: 1000, + expLevel: 0, + officerLevel, + officerCityId: officerLevel >= 5 ? cityId : 0, + injury: 0, + age: 30, + gold: 100_000, + rice: 100_000, + crew: 1_000, + crewTypeId: 1100, + train: 50, + atmos: 50, + killTurn: 24, + npcState: 0, + blockState: 0, + personality: 'None', + specialDomestic: 'None', + specialWar: 'None', + itemHorse: 'None', + itemWeapon: 'None', + itemBook: 'None', + itemExtra: 'None', + meta: {}, +}); + +const buildRequest = (action: string, args?: Record): TurnCommandFixtureRequest => ({ + kind: 'nation', + actorGeneralId: 1, + action, + ...(args ? { args } : {}), + setup: { + isolateWorld: true, + world: { + startYear: 180, + year: 190, + month: 1, + hiddenSeed: 'turn-command-nation-matrix-v1', + }, + nations: [ + { + id: 1, + name: '아국', + capitalCityId: 3, + gold: 1_000_000, + rice: 1_000_000, + tech: 1000, + level: 1, + typeCode: 'che_명가', + war: 0, + diplomacyLimit: 0, + generalCount: 2, + meta: { can_국호변경: 1, can_국기변경: 1, surlimit: 0 }, + }, + { + id: 2, + name: '타국', + capitalCityId: 70, + gold: 1_000_000, + rice: 1_000_000, + tech: 1000, + level: 1, + typeCode: 'che_명가', + war: 0, + diplomacyLimit: 0, + generalCount: 1, + meta: { surlimit: 0 }, + }, + ], + cities: [ + { + id: 3, + nationId: 1, + population: 100_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + supplyState: 1, + frontState: 0, + state: 0, + term: 0, + trust: 80, + trade: 100, + }, + { + id: 70, + nationId: 2, + population: 100_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + supplyState: 1, + frontState: 1, + state: 0, + term: 0, + trust: 80, + trade: 100, + }, + ], + generals: [general(1, 1, 3, 12), general(2, 2, 70, 12), general(3, 1, 3, 1)], + diplomacy: [ + { fromNationId: 1, toNationId: 2, state: 3, term: 0, dead: 0 }, + { fromNationId: 2, toNationId: 1, state: 3, term: 0, dead: 0 }, + ], + }, + observe: { + generalIds: [1, 2, 3], + cityIds: [3, 70], + nationIds: [1, 2], + logAfterId: 0, + messageAfterId: 0, + }, +}); + +const cases: Array<[string, Record | undefined]> = [ + ['휴식', undefined], + ['che_포상', { isGold: true, amount: 100, destGeneralID: 3 }], + ['che_선전포고', { destNationID: 2 }], + ['che_국호변경', { nationName: '신아국' }], + ['che_국기변경', { colorType: 1 }], + ['che_몰수', { isGold: true, amount: 100, destGeneralID: 3 }], + ['che_물자원조', { destNationID: 2, amountList: [100, 200] }], + ['che_불가침제의', { destNationID: 2, year: 191, month: 1 }], +]; + +integration('nation command success matrix', () => { + it.each(cases)( + '%s matches the legacy state delta and command RNG', + async (action, args) => { + const request = buildRequest(action, args); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') { + process.stderr.write( + `${JSON.stringify( + { + action, + referenceOutcome: reference.execution.outcome, + coreOutcome: core.execution.outcome, + differences: compareTurnSnapshotDeltas( + reference.before, + reference.after, + core.before, + core.after, + { ignoredPathPatterns: ignoredLifecyclePaths } + ), + }, + null, + 2 + )}\n` + ); + } + + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).not.toHaveProperty('blockedReason'); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +});