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|mySet)(?:\.|$)/, /^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/, ]; const comparedLifecycleIgnoredPaths = [ /^nationTurns/, /^logs/, /^messages/, /^world\.turnTime$/, /^generalTurns\[[^\]]+\]\.args(?:\.|$)/, /^generals\[[^\]]+\]\.(?:lastTurn|recentWarTime|turnTime)(?:\.|$)/, /^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/, ]; const timestampMillis = (value: unknown): number => { const raw = String(value); const normalized = raw.includes('T') ? raw : `${raw.replace(' ', 'T').replace(/\.(\d{3})\d*$/, '.$1')}Z`; return new Date(normalized).getTime(); }; const normalizeStoredLogText = (value: unknown): string => String(value) .replace(/^(?:●<\/>|◆<\/>|★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '') .replace(/(.*?)<\/span>/g, '$1') .replace(/ <1>\d{2}:\d{2}<\/>$/, ''); const semanticLogSignatures = (logs: Array>): string[] => logs .map((entry) => JSON.stringify({ scope: String(entry.scope).toLowerCase(), category: String(entry.category).toLowerCase(), generalId: Number(entry.generalId) || null, nationId: Number(entry.nationId) || null, text: normalizeStoredLogText(entry.text), }) ) .sort(); 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, isolateWorld: true, 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'], [ 'live sortie collapsed nation conflict cleanup', 'fixtures/turn-differential/live-sortie-collapse-conflict.json', ], ['live sortie against a defending general', 'fixtures/turn-differential/live-sortie-defender.json'], [ 'live sortie against multiple defending generals', 'fixtures/turn-differential/live-sortie-multiple-defenders.json', ], ['live sortie supply retreat', 'fixtures/turn-differential/live-sortie-supply-retreat.json'], ['live sortie noncapital conquest', 'fixtures/turn-differential/live-sortie-noncapital-conquest.json'], ['live sortie emergency capital', 'fixtures/turn-differential/live-sortie-emergency-capital.json'], ['live sortie conflict arbitration', 'fixtures/turn-differential/live-sortie-conflict-arbitration.json'], ['live sortie tied conflict', 'fixtures/turn-differential/live-sortie-conflict-tie.json'], ['live sortie outer lifecycle: conquest', 'fixtures/turn-differential/live-sortie-conquest.json'], [ 'live sortie outer lifecycle: collapsed nation conflict cleanup', 'fixtures/turn-differential/live-sortie-collapse-conflict.json', ], ['live sortie outer lifecycle: defender', 'fixtures/turn-differential/live-sortie-defender.json'], [ 'live sortie outer lifecycle: multiple defenders', 'fixtures/turn-differential/live-sortie-multiple-defenders.json', ], ['live sortie outer lifecycle: supply retreat', 'fixtures/turn-differential/live-sortie-supply-retreat.json'], [ 'live sortie outer lifecycle: noncapital conquest', 'fixtures/turn-differential/live-sortie-noncapital-conquest.json', ], [ 'live sortie outer lifecycle: emergency capital', 'fixtures/turn-differential/live-sortie-emergency-capital.json', ], [ 'live sortie outer lifecycle: conflict arbitration', 'fixtures/turn-differential/live-sortie-conflict-arbitration.json', ], ['live sortie outer lifecycle: tied conflict', 'fixtures/turn-differential/live-sortie-conflict-tie.json'], ])( '%s matches command RNG and canonical state delta', async (label, fixturePath) => { const request = readFixture(fixturePath); request.includeLifecycle = label.startsWith('live sortie outer lifecycle:'); if (request.action === 'che_출병') { request.observe!.includeNationHistoryLogs = true; request.observe!.includeGlobalHistoryLogs = true; } const reference = runReferenceTurnCommandTraceRequest( workspaceRoot!, request as unknown as Record ); const core = await runCoreTurnCommandTrace(request, reference.before); if (fixturePath.endsWith('live-sortie-multiple-defenders.json')) { for (const generalId of [2, 3]) { const beforeGeneral = reference.before.generals.find((general) => general.id === generalId); const afterGeneral = reference.after.generals.find((general) => general.id === generalId); expect(Number(beforeGeneral?.crew)).toBeGreaterThan(0); expect(Number(afterGeneral?.crew)).toBeLessThan(Number(beforeGeneral?.crew)); } const beforeCity = reference.before.cities.find((city) => city.id === 70); const afterCity = reference.after.cities.find((city) => city.id === 70); expect(Number(afterCity?.defence)).toBeLessThan(Number(beforeCity?.defence)); } if (fixturePath.endsWith('live-sortie-supply-retreat.json')) { const retreatLogs = reference.after.logs.filter( (log) => (Number(log.id) || 0) > reference.before.watermarks.historyLogId ); expect(retreatLogs.some((log) => String(log.text).includes('병량 부족'))).toBe(true); expect(reference.after.cities.find((city) => city.id === 70)?.nationId).toBe(1); } if (fixturePath.endsWith('live-sortie-noncapital-conquest.json')) { expect(reference.after.cities.find((city) => city.id === 70)?.nationId).toBe(1); expect(reference.after.cities.find((city) => city.id === 71)?.nationId).toBe(2); expect(reference.after.nations.some((nation) => nation.id === 2)).toBe(true); expect(reference.after.generals.find((general) => general.id === 2)?.nationId).toBe(2); } if (fixturePath.endsWith('live-sortie-emergency-capital.json')) { const defenderNation = reference.after.nations.find((nation) => nation.id === 2); expect(defenderNation?.capitalCityId).toBe(71); expect(defenderNation?.gold).toBe(50_000); expect(defenderNation?.rice).toBe(40_000); expect(reference.after.generals.find((general) => general.id === 2)).toMatchObject({ nationId: 2, cityId: 71, atmos: 80, }); expect(reference.after.cities.find((city) => city.id === 71)?.supplyState).toBe(1); } if (fixturePath.endsWith('live-sortie-collapse-conflict.json')) { expect(reference.before.cities.find((city) => city.id === 71)?.conflict).toEqual({ 2: 1234 }); expect(reference.after.nations.some((nation) => nation.id === 2)).toBe(false); expect(Object.keys(reference.after.cities.find((city) => city.id === 71)?.conflict ?? {})).toEqual([]); expect(Object.keys(core.after.cities.find((city) => city.id === 71)?.conflict ?? {})).toEqual([]); } if (fixturePath.endsWith('live-sortie-conflict-arbitration.json')) { expect(reference.before.cities.find((city) => city.id === 70)?.conflict).toEqual({ 3: 10_000 }); expect(reference.after.cities.find((city) => city.id === 70)).toMatchObject({ nationId: 3, conflict: {}, }); expect(reference.after.generals.find((general) => general.id === 1)?.cityId).toBe(3); const arbitrationLogs = reference.after.logs.filter( (log) => (Number(log.id) || 0) > reference.before.watermarks.historyLogId ); expect(arbitrationLogs.some((log) => String(log.text).includes('【분쟁협상】'))).toBe(true); } if (fixturePath.endsWith('live-sortie-conflict-tie.json')) { expect(reference.after.cities.find((city) => city.id === 70)).toMatchObject({ nationId: 4, conflict: {}, }); expect(reference.after.generals.find((general) => general.id === 1)?.cityId).toBe(3); } if (request.includeLifecycle) { const actorGeneralId = request.actorGeneralId; const beforeActor = reference.before.generals.find((general) => general.id === actorGeneralId); const afterActor = reference.after.generals.find((general) => general.id === actorGeneralId); const coreBeforeActor = core.before.generals.find((general) => general.id === actorGeneralId); const coreAfterActor = core.after.generals.find((general) => general.id === actorGeneralId); expect( reference.before.generalTurns.find( (turn) => turn.generalId === actorGeneralId && turn.turnIndex === 0 )?.action ).toBe('che_출병'); expect( reference.after.generalTurns.find( (turn) => turn.generalId === actorGeneralId && turn.turnIndex === 0 )?.action ).toBe('휴식'); expect(timestampMillis(afterActor?.turnTime) - timestampMillis(beforeActor?.turnTime)).toBe( 10 * 60_000 ); expect(timestampMillis(coreAfterActor?.turnTime) - timestampMillis(coreBeforeActor?.turnTime)).toBe( 10 * 60_000 ); expect(timestampMillis(coreAfterActor?.turnTime)).toBe(timestampMillis(afterActor?.turnTime)); expect(afterActor?.mySet).toBe(Math.min(9, Number(beforeActor?.mySet) + 3)); expect(coreAfterActor?.mySet).toBe(afterActor?.mySet); expect(coreAfterActor?.killTurn).toBe(afterActor?.killTurn); expect(afterActor?.lastTurn).not.toBeNull(); expect((coreAfterActor?.lastTurn as Record)?.command).toBe( (afterActor?.lastTurn as Record)?.command ); for (const referenceGeneral of reference.after.generals) { const coreRecentWar = core.after.generals.find( (general) => general.id === referenceGeneral.id )?.recentWarTime; if (referenceGeneral.recentWarTime == null) { expect(coreRecentWar).toBeNull(); } else { expect(coreRecentWar).not.toBeNull(); expect(timestampMillis(coreRecentWar)).toBe(timestampMillis(referenceGeneral.recentWarTime)); } } } 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); if (request.action === 'che_출병') { const generalLogWatermark = reference.before.watermarks.logId; const historyLogWatermark = reference.before.watermarks.historyLogId; const referenceAddedLogs = reference.after.logs.filter((entry) => String(entry.scope).toLowerCase() === 'nation' || (String(entry.scope).toLowerCase() === 'system' && String(entry.category).toLowerCase() === 'history') ? (Number(entry.id) || 0) > historyLogWatermark : (Number(entry.id) || 0) > generalLogWatermark ); expect(semanticLogSignatures(core.after.logs)).toEqual(semanticLogSignatures(referenceAddedLogs)); } expect( compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { ignoredPathPatterns: request.action === 'che_출병' ? request.includeLifecycle ? comparedLifecycleIgnoredPaths : ignoredLifecyclePaths : [...ignoredLifecyclePaths, /^generals\[[^\]]+\]\.killTurn(?:\.|$)/], }) ).toEqual([]); }, 120_000 ); });