From 38e516de6d659a63745a2c8b24af9dbfaa359a84 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 4 Sep 2026 02:02:36 +0000 Subject: [PATCH] =?UTF-8?q?=ED=94=BC=EC=9E=A5=ED=8C=8C=EC=9E=A5=20?= =?UTF-8?q?=EC=97=B0=EC=86=8D=20=EC=8B=A4=ED=96=89=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B5=EA=B5=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/turn/reservedTurnHandler.ts | 3 +- .../test/nationTurnCompatibility.test.ts | 129 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 1c7b2795..0752bb38 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -44,6 +44,7 @@ import { import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { isEqual } from 'es-toolkit'; import type { ConstraintContext, StateView } from '@sammo-ts/logic'; @@ -385,7 +386,7 @@ const normalizeLastTurn = (value: unknown): LegacyLastTurn => { }; const sameArgs = (left: Record | undefined, right: Record): boolean => - JSON.stringify(left ?? {}) === JSON.stringify(right); + isEqual(left ?? {}, right); const readNextAvailableTurn = (nation: Nation, actionName: string): number | null => { const raw = asRecord(nation.meta)[`next_execute_${actionName}`]; diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts index e085f38e..0bdf3b5b 100644 --- a/app/game-engine/test/nationTurnCompatibility.test.ts +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -39,6 +39,135 @@ const createChief = (userId: string | null): TurnGeneral => ({ }); describe('레거시 사령부 턴 실행 호환성', () => { + it('피장파장은 같은 두 턴의 사기진작과 병행해도 2회째에 완료한다', async () => { + const cities = buildLargeTestCities(); + for (const city of cities) { + city.nationId = city.id === 1 ? 1 : city.id === 2 ? 2 : 0; + } + const chief = createChief('test-user'); + chief.crew = 1_000; + chief.train = 80; + chief.atmos = 80; + const snapshot: TurnWorldSnapshot = { + generals: [chief], + cities, + nations: [ + { + id: 1, + name: '테스트국', + color: '#aa0000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 1_000_000, + rice: 1_000_000, + power: 0, + level: 1, + typeCode: 'large_test_map_def', + meta: { strategic_cmd_limit: 0, gennum: 10 }, + }, + { + id: 2, + name: '상대국', + color: '#0000aa', + capitalCityId: 2, + chiefGeneralId: null, + gold: 1_000_000, + rice: 1_000_000, + power: 0, + level: 1, + typeCode: 'large_test_map_def', + meta: {}, + }, + ], + troops: [], + diplomacy: [ + { fromNationId: 1, toNationId: 2, state: 0, term: 1200, dead: 0, meta: {} }, + { fromNationId: 2, toNationId: 1, state: 0, term: 1200, dead: 0, meta: {} }, + ], + events: [], + initialEvents: [], + map: LARGE_TEST_MAP, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + openingPartYear: 3, + develCost: 10, + baseGold: 1000, + baseRice: 1000, + maxResourceActionAmount: 10000, + maxTechLevel: 12000, + }, + environment: { mapName: 'large_test_map', unitSet: 'default' }, + }, + scenarioMeta: { startYear: 189 } as never, + unitSet: {} as never, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 189, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: mockDate, + meta: { seed: 1 }, + }; + const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + const resolvedActions: Array<{ kind: string; actionKey: string; completed?: boolean }> = []; + const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({ + snapshot, + state, + schedule, + map: LARGE_TEST_MAP, + reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 }, + onActionResolved: (event) => resolvedActions.push(event), + }); + const counterStrategy = { + action: 'che_피장파장', + args: { destNationId: 2, commandType: 'che_허보' }, + }; + reservedTurnStore.getNationTurns(1, 12).splice(0, 2, counterStrategy, counterStrategy); + reservedTurnStore.getGeneralTurns(1).splice( + 0, + 2, + { action: 'che_사기진작', args: {} }, + { action: 'che_사기진작', args: {} } + ); + + await runOneTick(); + expect(world.getNationById(1)!.meta.turn_last_12).toMatchObject({ + command: '피장파장', + term: 1, + }); + expect(resolvedActions).toContainEqual( + expect.objectContaining({ kind: 'nation', actionKey: 'che_피장파장', completed: false }) + ); + expect(resolvedActions).toContainEqual( + expect.objectContaining({ kind: 'general', actionKey: 'che_사기진작', completed: true }) + ); + + // PostgreSQL jsonb는 object key 순서를 보존하지 않는다. 첫 턴의 + // turn_last를 DB에 저장했다가 재적재한 상태를 그대로 재현한다. + const nationAfterFirstTurn = world.getNationById(1)!; + world.updateNation(1, { + meta: { + ...nationAfterFirstTurn.meta, + turn_last_12: { + command: '피장파장', + arg: { commandType: 'che_허보', destNationId: 2 }, + term: 1, + }, + }, + }); + + resolvedActions.length = 0; + await runOneTick(); + expect(resolvedActions).toContainEqual( + expect.objectContaining({ kind: 'nation', actionKey: 'che_피장파장', completed: true }) + ); + expect(world.getNationById(2)!.meta.next_execute_허보).toBe(189 * 12 + 1 + 60); + }); + it.each([ { ownerLabel: '소유 장수', userId: 'test-user', expectedActiveAction: 1 }, { ownerLabel: '무소유 장수', userId: null, expectedActiveAction: undefined },