diff --git a/tools/integration-tests/src/turn-differential/compare.ts b/tools/integration-tests/src/turn-differential/compare.ts index f97d9bf8..2e920aa3 100644 --- a/tools/integration-tests/src/turn-differential/compare.ts +++ b/tools/integration-tests/src/turn-differential/compare.ts @@ -28,13 +28,24 @@ interface EntityIdentity { semantic: boolean; } -const entityIdentity = (value: Record, index: number): EntityIdentity => { +const entityIdentity = (value: Record, index: number, path: string): EntityIdentity => { if ( (typeof value.generalId === 'number' || typeof value.generalId === 'string') && typeof value.type === 'string' ) { return { key: `${String(value.generalId)}:${value.type}`, semantic: true }; } + if ( + (path === 'world.generalCooldowns' || path === 'world.nationCooldowns') && + typeof value.actionName === 'string' + ) { + for (const key of ['generalId', 'nationId']) { + const candidate = value[key]; + if (typeof candidate === 'number' || typeof candidate === 'string') { + return { key: `${String(candidate)}:${value.actionName}`, semantic: true }; + } + } + } for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) { const candidate = value[key]; if (typeof candidate === 'number' || typeof candidate === 'string') { @@ -62,7 +73,7 @@ const flatten = (value: unknown, path: string, output: FlatSnapshot): void => { path === 'logs' || path === 'messages' ? { key: String(index), semantic: false } : typeof entry === 'object' && entry !== null && !Array.isArray(entry) - ? entityIdentity(entry as Record, index) + ? entityIdentity(entry as Record, index, path) : { key: String(index), semantic: false }; if (identity.semantic) { const firstIndex = semanticKeys.get(identity.key); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 3b4ea3e8..30cfd27d 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -72,6 +72,8 @@ export interface TurnCommandFixtureRequest { initMonth?: number; year?: number; month?: number; + develCost?: number; + isUnited?: 0 | 1 | 2 | 3; hiddenSeed?: string; scenarioEffect?: string | null; staticEventHandlers?: Record; @@ -333,6 +335,9 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur : row.hasOwner === true ? 'turn-differential-owner' : null, + inheritancePoints: { + active_action: readNumber(row, 'inheritActiveActionPoints'), + }, penalty: row.penalty, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { @@ -376,6 +381,7 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur belong: readNumber(row, 'belong', readNumber(meta, 'belong')), permission: readString(row, 'permission', readString(meta, 'permission', 'normal')), block: readNumber(row, 'blockState', readNumber(meta, 'block')), + inherit_active_action: readNumber(row, 'inheritActiveActionPoints') / 3, }, ...(lastTurn ? { lastTurn } : {}), ...(typeof row.turnTick === 'number' ? { turnTick: row.turnTick } : {}), diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 65abf2ce..41820b50 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -136,6 +136,8 @@ const buildRequest = ( startYear: 180, year: 190, month: 1, + develCost: 18, + isUnited: 0, hiddenSeed: 'turn-command-general-matrix-v1', freezeClock: true, ...fixturePatches.world, @@ -575,6 +577,7 @@ type GeneralActiveActionInheritanceCase = { name: string; action: string; args?: Record; + initialPoint?: number; expectedPointDelta?: number; }; @@ -582,6 +585,7 @@ const generalActiveActionInheritanceCases: GeneralActiveActionInheritanceCase[] { name: 'ordinary training does not count as a legacy active action', action: 'che_훈련', + initialPoint: 6, expectedPointDelta: 0, }, { @@ -616,8 +620,11 @@ const readActiveActionPoints = (snapshot: { generals: Array { it.each(generalActiveActionInheritanceCases)( '$name', - async ({ name, action, args, expectedPointDelta }) => { - const request = buildRequest(action, args); + async ({ name, action, args, initialPoint = 0, expectedPointDelta }) => { + const request = buildRequest(action, args, { + ownerId: 2_000_000_001, + inheritActiveActionPoints: initialPoint, + }); request.setup!.world!.hiddenSeed = `general-active-action-${name}`; const reference = runReferenceTurnCommandTraceRequest( workspaceRoot!, @@ -628,6 +635,8 @@ integration('general active-action inheritance point parity', () => { readActiveActionPoints(reference.after, 1) - readActiveActionPoints(reference.before, 1); const corePointDelta = readActiveActionPoints(core.after, 1) - readActiveActionPoints(core.before, 1); + expect(readActiveActionPoints(reference.before, 1)).toBe(initialPoint); + expect(readActiveActionPoints(core.before, 1)).toBe(initialPoint); expect(reference.execution.outcome).toMatchObject({ completed: true }); expect(core.execution.outcome).toMatchObject({ requestedAction: action, diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 4cdfa499..0596aa2e 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -205,6 +205,8 @@ const buildRequest = ( startYear: 180, year: 190, month: 1, + develCost: 18, + isUnited: 0, hiddenSeed: 'turn-command-nation-matrix-v1', freezeClock: true, ...fixturePatches.world, @@ -819,6 +821,7 @@ integration('legacy nation lifecycle comparison guard', () => { type NationActiveActionInheritanceCase = { name: string; request: NationMatrixCase; + initialPoint?: number; expectedPointDelta: number; }; @@ -826,6 +829,7 @@ const nationActiveActionInheritanceCases: NationActiveActionInheritanceCase[] = { name: 'ordinary award does not count as a legacy active action', request: ['che_포상', { isGold: true, amount: 100, destGeneralID: 3 }], + initialPoint: 6, expectedPointDelta: 0, }, { @@ -857,8 +861,18 @@ const readNationActorActiveActionPoints = ( integration('nation active-action inheritance point parity', () => { it.each(nationActiveActionInheritanceCases)( '$name', - async ({ name, request: [action, args, fixturePatches], expectedPointDelta }) => { - const request = buildRequest(action, args, fixturePatches); + async ({ name, request: [action, args, fixturePatches], initialPoint = 0, expectedPointDelta }) => { + const request = buildRequest(action, args, { + ...fixturePatches, + generals: { + ...fixturePatches?.generals, + 1: { + ...fixturePatches?.generals?.[1], + ownerId: 2_000_000_001, + inheritActiveActionPoints: initialPoint, + }, + }, + }); request.setup!.world!.hiddenSeed = `nation-active-action-${name}`; const reference = runReferenceTurnCommandTraceRequest( workspaceRoot!, @@ -871,6 +885,8 @@ integration('nation active-action inheritance point parity', () => { const corePointDelta = readNationActorActiveActionPoints(core.after, 1) - readNationActorActiveActionPoints(core.before, 1); + expect(readNationActorActiveActionPoints(reference.before, 1)).toBe(initialPoint); + expect(readNationActorActiveActionPoints(core.before, 1)).toBe(initialPoint); expect(reference.execution.outcome).toMatchObject({ completed: true }); expect(core.execution.outcome).toMatchObject({ requestedAction: action, @@ -1902,7 +1918,7 @@ integration('nation volunteer-recruitment constraints, creation values, RNG, and expect(readNumericField(created, 'killTurn')).toBeGreaterThanOrEqual(64); expect(readNumericField(created, 'killTurn')).toBeLessThanOrEqual(70); } - const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); expect(addedLogs.map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining('의병모집 발동')]) ); @@ -2965,7 +2981,7 @@ integration('nation seizure zero target balance parity', () => { request as unknown as Record ); const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceLogs = reference.after.logs.slice(reference.before.logs.length); + const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs); expect(reference.execution.outcome).toMatchObject({ completed: true }); expect(core.execution.outcome).toMatchObject({ @@ -3227,7 +3243,7 @@ integration('nation material aid resource boundaries', () => { request as unknown as Record ); const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceLogs = reference.after.logs.slice(reference.before.logs.length); + const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs); expect(referenceLogs.map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining('쌀0를 지원')]) @@ -3394,7 +3410,7 @@ integration('nation material aid accumulated assistance and officer logs', () => request as unknown as Record ); const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceLogs = reference.after.logs.slice(reference.before.logs.length); + const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs); const sourceOfficerLog = { generalId: 3, text: expect.stringContaining('타국으로 금100 쌀200을 지원했습니다.'), @@ -3574,7 +3590,7 @@ integration('nation population move value and resource boundaries', () => { } if (amount === 0) { const zeroMoveText = '인구 0명을 옮겼습니다.'; - expect(reference.after.logs.slice(reference.before.logs.length).map((entry) => entry.text)).toEqual( + expect(addedReferenceLogs(reference.before, reference.after.logs).map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining(zeroMoveText)]) ); expect(core.after.logs.map((entry) => entry.text)).toEqual( @@ -4629,7 +4645,7 @@ integration('nation random capital constraints, candidates, RNG, and city reset readNumericField(readGeneralMeta(coreGeneralBefore), 'inherit_active_action') ); const noCandidateText = '이동할 수 있는 도시가 없습니다.'; - expect(reference.after.logs.slice(reference.before.logs.length).map((entry) => entry.text)).toEqual( + expect(addedReferenceLogs(reference.before, reference.after.logs).map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining(noCandidateText)]) ); expect(core.after.logs.map((entry) => entry.text)).toEqual( @@ -5055,7 +5071,7 @@ integration('nation mobilize-people target, delay, and city-effect boundaries', if (expectedWall !== undefined) { expect(readNumericField(cityAfter, 'wall')).toBe(expectedWall); } - const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); expect(addedLogs.map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining('백성동원 발동')]) ); @@ -5349,7 +5365,7 @@ integration('nation degrade-relations target, diplomacy, front, and cooldown bou expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront); expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront); - const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); expect(addedLogs.map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining('이호경식 발동')]) ); @@ -5656,7 +5672,7 @@ integration('nation surprise-attack target, diplomacy-term, front, and cooldown expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront); expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront); - const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); expect(addedLogs.map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining('급습 발동')]) ); @@ -5964,7 +5980,7 @@ integration('nation desperate-survival multistep, diplomacy, effects, and cooldo expect(readNumericField(foreignAfter, 'atmos')).toBe(readNumericField(foreignBefore, 'atmos')); if (completed) { - const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); expect(addedLogs.map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining('필사즉생 발동')]) ); @@ -6284,7 +6300,7 @@ integration('nation deception target, multistep, movement, RNG, and cooldown bou ); if (completed) { - const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); expect(addedLogs.map((entry) => entry.text)).toEqual( expect.arrayContaining([expect.stringContaining('허보 발동')]) ); @@ -6735,7 +6751,7 @@ integration('nation scorched-earth constraints, multistep, city values, officers expect( readNumericField(auxAfter, 'did_특성초토화') - readNumericField(auxBefore, 'did_특성초토화') ).toBe(readNumericField(cityBefore, 'level') >= 8 ? 1 : 0); - expect(snapshot.after.logs.slice(snapshot.before.logs.length).map((entry) => entry.text)).toEqual( + expect(addedReferenceLogs(snapshot.before, snapshot.after.logs).map((entry) => entry.text)).toEqual( expect.arrayContaining([ expect.stringContaining('초토화했습니다'), expect.stringContaining('초토화 명령'), diff --git a/tools/integration-tests/test/turnSnapshotComparator.test.ts b/tools/integration-tests/test/turnSnapshotComparator.test.ts index e1f64f78..6affcda0 100644 --- a/tools/integration-tests/test/turnSnapshotComparator.test.ts +++ b/tools/integration-tests/test/turnSnapshotComparator.test.ts @@ -198,6 +198,116 @@ describe('turn snapshot differential comparator', () => { ); }); + it('keys multiple cooldowns for one owner by owner and action name', () => { + const referenceBefore = snapshot('ref', { + world: { + year: 183, + month: 1, + tickMinutes: 10, + turnTime: '0183-01-01T00:00:00.000Z', + isUnited: 0, + generalCooldowns: [ + { generalId: 1, actionName: '일반 행동', nextAvailableTurn: 0 }, + { generalId: 1, actionName: '특수 행동', nextAvailableTurn: 0 }, + ], + nationCooldowns: [ + { nationId: 1, actionName: '피장파장', nextAvailableTurn: 0 }, + { nationId: 1, actionName: '이호경식', nextAvailableTurn: 0 }, + ], + }, + }); + const coreBefore = snapshot('core2026', { + world: { + year: 183, + month: 1, + tickMinutes: 10, + turnTime: '0183-01-01T00:00:00.000Z', + isUnited: 0, + generalCooldowns: [ + { generalId: 1, actionName: '특수 행동', nextAvailableTurn: 0 }, + { generalId: 1, actionName: '일반 행동', nextAvailableTurn: 0 }, + ], + nationCooldowns: [ + { nationId: 1, actionName: '이호경식', nextAvailableTurn: 0 }, + { nationId: 1, actionName: '피장파장', nextAvailableTurn: 0 }, + ], + }, + }); + const reference = snapshot('ref', { + world: { + year: 183, + month: 1, + tickMinutes: 10, + turnTime: '0183-01-01T00:00:00.000Z', + isUnited: 0, + generalCooldowns: [ + { generalId: 1, actionName: '일반 행동', nextAvailableTurn: 3 }, + { generalId: 1, actionName: '특수 행동', nextAvailableTurn: 7 }, + ], + nationCooldowns: [ + { nationId: 1, actionName: '피장파장', nextAvailableTurn: 9 }, + { nationId: 1, actionName: '이호경식', nextAvailableTurn: 11 }, + ], + }, + }); + const core = snapshot('core2026', { + world: { + year: 183, + month: 1, + tickMinutes: 10, + turnTime: '0183-01-01T00:00:00.000Z', + isUnited: 0, + generalCooldowns: [ + { generalId: 1, actionName: '특수 행동', nextAvailableTurn: 7 }, + { generalId: 1, actionName: '일반 행동', nextAvailableTurn: 3 }, + ], + nationCooldowns: [ + { nationId: 1, actionName: '이호경식', nextAvailableTurn: 11 }, + { nationId: 1, actionName: '피장파장', nextAvailableTurn: 9 }, + ], + }, + }); + + expect(compareTurnSnapshots(reference, core)).toEqual([]); + expect(compareTurnSnapshotDeltas(referenceBefore, reference, coreBefore, core)).toEqual([]); + + const mutant = snapshot('core2026', { + ...core, + world: { + ...core.world, + nationCooldowns: [ + { nationId: 1, actionName: '이호경식', nextAvailableTurn: 11 }, + { nationId: 1, actionName: '피장파장', nextAvailableTurn: 10 }, + ], + }, + }); + expect(compareTurnSnapshots(reference, mutant)).toContainEqual({ + path: 'world.nationCooldowns[1:피장파장].nextAvailableTurn', + reference: 9, + core: 10, + }); + expect(compareTurnSnapshotDeltas(referenceBefore, reference, coreBefore, mutant)).toEqual([ + { + path: 'world.nationCooldowns[1:피장파장].nextAvailableTurn', + reference: 9, + core: 10, + }, + ]); + + const duplicate = snapshot('ref', { + world: { + ...reference.world, + nationCooldowns: [ + { nationId: 1, actionName: '피장파장', nextAvailableTurn: 9 }, + { nationId: 1, actionName: '피장파장', nextAvailableTurn: 10 }, + ], + }, + }); + expect(() => compareTurnSnapshots(duplicate, core)).toThrowError( + 'Duplicate semantic entity key "1:피장파장" at "world.nationCooldowns": indexes 0 and 1' + ); + }); + it('reports exact changed paths for general and nation command state', () => { const reference = snapshot('ref', { diplomacy: [{ fromNationId: 1, toNationId: 2, state: 1, term: 24 }],