From c63a49bd07ddbe33e8bf13bd475728a47e60ccc8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 23 Aug 2026 21:48:29 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=B0=A8?= =?UTF-8?q?=EB=93=B1=20=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0=EC=99=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B7=B8=EB=9E=98=ED=94=84=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다. --- .../src/messages/diplomaticResponse.ts | 3 +- app/game-api/src/router/turns/index.ts | 25 +- app/game-api/src/turns/commandInput.ts | 50 +- app/game-api/src/turns/commandTable.ts | 74 +-- app/game-api/test/commandInput.test.ts | 47 ++ app/game-api/test/commandTable.test.ts | 87 ++- app/game-engine/src/turn/databaseHooks.ts | 13 +- app/game-engine/src/turn/inMemoryWorld.ts | 10 +- app/game-engine/src/turn/rankData.ts | 26 +- .../src/turn/reservedTurnHandler.ts | 136 ++++- .../src/turn/turnCommandProfile.ts | 25 +- app/game-engine/src/turn/turnDaemon.ts | 13 +- .../auctionFinalizerCompatibility.test.ts | 22 +- .../test/generalPoolSameTurn.test.ts | 26 +- .../generalTurnLegacyCompatibility.test.ts | 45 ++ .../test/helpers/turnTestHarness.ts | 1 + .../test/myInformationCommands.test.ts | 28 +- .../test/nationCollapseOnConquest.test.ts | 3 + .../test/npcGeneralDomesticTurn.test.ts | 3 + app/game-engine/test/rankData.test.ts | 40 +- .../test/scenarioCommandProfile.test.ts | 281 +++++++++ .../test/uniqueLotteryCommand.test.ts | 14 +- .../turn-state-differential-testing.md | 26 +- packages/logic/src/actions/engine.ts | 11 +- .../logic/src/actions/turn/actionContext.ts | 8 + .../logic/src/actions/turn/commandProfile.ts | 126 +++- .../logic/src/actions/turn/executionHelper.ts | 5 + .../src/actions/turn/general/che_강행.ts | 1 + .../src/actions/turn/general/che_거병.ts | 9 +- .../src/actions/turn/general/che_군량매매.ts | 4 +- .../src/actions/turn/general/che_등용.ts | 64 +- .../src/actions/turn/general/che_등용수락.ts | 34 +- .../src/actions/turn/general/che_모반시도.ts | 15 +- .../src/actions/turn/general/che_선양.ts | 15 +- .../src/actions/turn/general/che_은퇴.ts | 2 +- .../src/actions/turn/general/che_이동.ts | 15 +- .../src/actions/turn/general/che_인재탐색.ts | 1 + .../actions/turn/general/che_장수대상임관.ts | 1 + .../src/actions/turn/general/che_증여.ts | 1 + .../src/actions/turn/general/che_집합.ts | 1 + .../src/actions/turn/general/che_첩보.ts | 9 +- .../src/actions/turn/general/che_출병.ts | 50 +- .../src/actions/turn/general/che_하야.ts | 7 +- .../src/actions/turn/general/che_해산.ts | 17 +- .../logic/src/actions/turn/nation/che_감축.ts | 4 +- .../logic/src/actions/turn/nation/che_급습.ts | 29 +- .../logic/src/actions/turn/nation/che_몰수.ts | 18 +- .../actions/turn/nation/che_무작위수도이전.ts | 5 +- .../src/actions/turn/nation/che_물자원조.ts | 3 + .../logic/src/actions/turn/nation/che_발령.ts | 1 + .../src/actions/turn/nation/che_백성동원.ts | 16 +- .../actions/turn/nation/che_부대탈퇴지시.ts | 3 +- .../src/actions/turn/nation/che_불가침제의.ts | 9 +- .../actions/turn/nation/che_불가침파기제의.ts | 9 +- .../src/actions/turn/nation/che_선전포고.ts | 11 +- .../logic/src/actions/turn/nation/che_수몰.ts | 3 + .../src/actions/turn/nation/che_의병모집.ts | 7 +- .../src/actions/turn/nation/che_이호경식.ts | 29 +- .../src/actions/turn/nation/che_종전제의.ts | 9 +- .../logic/src/actions/turn/nation/che_증축.ts | 4 +- .../logic/src/actions/turn/nation/che_천도.ts | 4 +- .../src/actions/turn/nation/che_초토화.ts | 4 +- .../logic/src/actions/turn/nation/che_포상.ts | 1 + .../src/actions/turn/nation/che_피장파장.ts | 14 +- .../src/actions/turn/nation/che_필사즉생.ts | 10 +- .../logic/src/actions/turn/nation/che_허보.ts | 31 +- .../src/actions/turn/nation/cr_인구이동.ts | 2 +- .../logic/src/diplomacy/instantResponse.ts | 7 + packages/logic/src/logging/actionLogger.ts | 52 +- packages/logic/src/logging/types.ts | 2 + packages/logic/src/messages/message.ts | 38 +- packages/logic/src/messages/scoutMessage.ts | 72 +++ packages/logic/src/rewards/uniqueLottery.ts | 11 +- packages/logic/src/war/aftermath.ts | 197 ++++-- packages/logic/src/war/engine.ts | 28 +- packages/logic/src/war/legacyFlushSequence.ts | 39 ++ packages/logic/src/war/types.ts | 10 + .../actions/turn/appointmentLogFormat.test.ts | 3 + .../test/actions/turn/executionHelper.test.ts | 39 +- .../generalCommandLogFormatParity.test.ts | 166 ++++++ .../nationCapitalCommandLogParity.test.ts | 312 ++++++++++ .../turn/nationCommandLogParity.test.ts | 267 +++++++++ .../nationDeceptionCommandLogParity.test.ts | 402 +++++++++++++ .../test/actions/turn/nationMissing.test.ts | 17 +- .../turn/nationVolunteerRecruit.test.ts | 7 +- .../turn/talentScoutGeneralPool.test.ts | 1 + .../test/diplomacyInstantResponse.test.ts | 4 + packages/logic/test/dispatchWarAction.test.ts | 66 ++- packages/logic/test/loggingEntries.test.ts | 92 ++- packages/logic/test/message.test.ts | 39 +- .../test/scenarios/che_몰수_message.test.ts | 69 +++ .../general/che_등용_message.test.ts | 26 +- .../scenarios/general/che_등용수락.test.ts | 54 ++ .../test/scenarios/general/che_이동.test.ts | 130 ++-- .../test/scenarios/general/che_하야.test.ts | 56 ++ .../general/migratedGeneralCommands.test.ts | 231 ++++++++ .../scenarios/general_commands_new.test.ts | 4 +- .../logic/test/turnCommandProfile.test.ts | 79 +++ packages/logic/test/warAftermath.test.ts | 90 ++- packages/logic/test/warEngine.test.ts | 59 ++ tools/compare-command-logs.ignore.json | 6 +- tools/conditional-integration-registry.tsv | 2 + .../src/turn-differential/canonical.ts | 313 +++++++++- .../src/turn-differential/compare.ts | 88 ++- .../coreCommandPersistenceFixture.ts | 211 +++++++ .../src/turn-differential/coreCommandTrace.ts | 429 ++++++++++++-- .../src/turn-differential/databaseSnapshot.ts | 177 +++++- .../turn-differential/fullLifecycleFixture.ts | 213 +++++++ .../turn-differential/fullLifecycleTrace.ts | 32 + .../src/turn-differential/logProjection.ts | 214 +++++++ .../turn-differential/messageProjection.ts | 266 +++++++++ .../turn-differential/referenceSnapshot.ts | 21 +- .../src/turn-differential/trace.ts | 21 +- .../test/coreCommandTraceClock.test.ts | 252 ++++++++ ...DiplomacyCoreReference.integration.test.ts | 559 ++++++++++++++++++ ...tantDiplomacyReference.integration.test.ts | 5 +- .../liveSortiePersistence.integration.test.ts | 161 +---- ...rnCommandCoreReference.integration.test.ts | 105 +++- ...rnCommandFullLifecycle.integration.test.ts | 102 ++++ ...llLifecyclePersistence.integration.test.ts | 550 +++++++++++++++++ ...rnCommandGeneralMatrix.integration.test.ts | 203 ++++++- ...urnCommandNationMatrix.integration.test.ts | 166 +++++- .../test/turnLogProjection.test.ts | 124 ++++ .../test/turnMessageProjection.test.ts | 254 ++++++++ .../turnSnapshotCanonicalCoverage.test.ts | 509 ++++++++++++++++ .../test/turnSnapshotComparator.test.ts | 113 +++- ...rnSnapshotCoreDatabase.integration.test.ts | 42 ++ tools/run-conditional-integration.sh | 36 +- 128 files changed, 8615 insertions(+), 848 deletions(-) create mode 100644 app/game-engine/test/scenarioCommandProfile.test.ts create mode 100644 packages/logic/src/messages/scoutMessage.ts create mode 100644 packages/logic/src/war/legacyFlushSequence.ts create mode 100644 packages/logic/test/actions/turn/generalCommandLogFormatParity.test.ts create mode 100644 packages/logic/test/actions/turn/nationCapitalCommandLogParity.test.ts create mode 100644 packages/logic/test/actions/turn/nationCommandLogParity.test.ts create mode 100644 packages/logic/test/actions/turn/nationDeceptionCommandLogParity.test.ts create mode 100644 packages/logic/test/scenarios/che_몰수_message.test.ts create mode 100644 packages/logic/test/scenarios/general/che_하야.test.ts create mode 100644 packages/logic/test/turnCommandProfile.test.ts create mode 100644 tools/integration-tests/src/turn-differential/coreCommandPersistenceFixture.ts create mode 100644 tools/integration-tests/src/turn-differential/fullLifecycleFixture.ts create mode 100644 tools/integration-tests/src/turn-differential/fullLifecycleTrace.ts create mode 100644 tools/integration-tests/src/turn-differential/logProjection.ts create mode 100644 tools/integration-tests/src/turn-differential/messageProjection.ts create mode 100644 tools/integration-tests/test/coreCommandTraceClock.test.ts create mode 100644 tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts create mode 100644 tools/integration-tests/test/turnCommandFullLifecycle.integration.test.ts create mode 100644 tools/integration-tests/test/turnCommandFullLifecyclePersistence.integration.test.ts create mode 100644 tools/integration-tests/test/turnLogProjection.test.ts create mode 100644 tools/integration-tests/test/turnMessageProjection.test.ts create mode 100644 tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts diff --git a/app/game-api/src/messages/diplomaticResponse.ts b/app/game-api/src/messages/diplomaticResponse.ts index ec3a6172..a638c9c5 100644 --- a/app/game-api/src/messages/diplomaticResponse.ts +++ b/app/game-api/src/messages/diplomaticResponse.ts @@ -7,6 +7,7 @@ import { finalizeLogEntry, LogFormat, MESSAGE_MAILBOX_NATIONAL_BASE, + orderLegacyActionLoggerFlush, resolveInstantDiplomacyResponse, sendMessage, type GeneralActionEffect, @@ -121,7 +122,7 @@ const persistEffects = async ( logs.push(effect.entry); } } - await persistLogs(db, logs, year, month, at); + await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at); }; const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise => { diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index b6a20aba..7622c507 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -13,8 +13,9 @@ import { } from '../../turns/commandTable.js'; import { loadMapDefinitionByName } from '../../maps/mapDefinition.js'; import { + assertReservedTurnActionAvailable, buildEquipmentTradeItemOptions, - parseReservedTurnArgs, + parseRegisteredTurnArgs, TURN_COMMAND_NATION_COLORS, type TurnCommandInputOptions, } from '../../turns/commandInput.js'; @@ -65,7 +66,7 @@ const buildBulkEntrySchema = (turnList: z.ZodType) => const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => { try { - return await parseReservedTurnArgs(scope, action, args); + return await parseRegisteredTurnArgs(scope, action, args); } catch (error) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -75,6 +76,22 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg } }; +const assertScenarioCommandAvailable = async ( + scope: 'general' | 'nation', + action: string, + worldState: WorldStateRow +): Promise => { + try { + await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const); + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: error instanceof Error ? error.message : 'Unavailable turn command.', + cause: error, + }); + } +}; + const mutateReservedTurns = async (mutation: () => Promise): Promise => { try { return await mutation(); @@ -413,6 +430,7 @@ export const turnsRouter = router({ const general = await getOwnedGeneral(ctx, input.generalId); const args = await parseCommandArgs('general', input.action, input.args); const worldState = await getReservationWorldState(ctx); + await assertScenarioCommandAvailable('general', input.action, worldState); await assertReservedTurnPermission(worldState, general, 'general', input.action, args); const snapshot = await mutateReservedTurns(() => @@ -476,6 +494,7 @@ export const turnsRouter = router({ ); const worldState = await getReservationWorldState(ctx); for (const update of updates) { + await assertScenarioCommandAvailable('general', update.action, worldState); await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args); } const snapshot = await mutateReservedTurns(() => @@ -515,6 +534,7 @@ export const turnsRouter = router({ } const args = await parseCommandArgs('nation', input.action, input.args); const worldState = await getReservationWorldState(ctx); + await assertScenarioCommandAvailable('nation', input.action, worldState); await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); const snapshot = await mutateReservedTurns(() => @@ -628,6 +648,7 @@ export const turnsRouter = router({ ); const worldState = await getReservationWorldState(ctx); for (const update of updates) { + await assertScenarioCommandAvailable('nation', update.action, worldState); await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); } const snapshot = await mutateReservedTurns(() => diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 51c446c2..87ed7a98 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -1,4 +1,6 @@ import { + isGeneralTurnCommandKey, + isNationTurnCommandKey, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs, type GeneralTurnCommandSpec, @@ -9,7 +11,7 @@ import type { ItemModule } from '@sammo-ts/logic/items/types.js'; import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { z } from 'zod'; -import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; +import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; export type TurnCommandOptionValue = string | number; @@ -345,22 +347,35 @@ export const buildTurnCommandInputFields = ( return Object.entries(properties).map(([key, schema]) => buildField(key, schema, required.has(key))); }; -export const loadTurnCommandSpecs = async () => { - const profile = await loadTurnCommandProfile(); +export const loadTurnCommandSpecs = async (scenarioConst?: unknown) => { + const resolution = await loadScenarioTurnCommandProfile({ scenarioConst }); + const profile = resolution.profile; const [general, nation] = await Promise.all([ loadGeneralTurnCommandSpecs(profile.general), loadNationTurnCommandSpecs(profile.nation), ]); - return { general, nation }; + return { + general, + nation, + generalGroups: resolution.generalGroups, + nationGroups: resolution.nationGroups, + }; }; -export const parseReservedTurnArgs = async ( +export const parseRegisteredTurnArgs = async ( scope: 'general' | 'nation', action: string, rawArgs: unknown ): Promise> => { - const specs = await loadTurnCommandSpecs(); - const spec = specs[scope].find((entry) => entry.key === action); + const specs = + scope === 'general' + ? isGeneralTurnCommandKey(action) + ? await loadGeneralTurnCommandSpecs([action]) + : [] + : isNationTurnCommandKey(action) + ? await loadNationTurnCommandSpecs([action]) + : []; + const spec = specs[0]; if (!spec) { throw new Error(`Unknown ${scope} turn command: ${action}`); } @@ -369,3 +384,24 @@ export const parseReservedTurnArgs = async ( } return spec.argsSchema.parse(rawArgs); }; + +export const assertReservedTurnActionAvailable = async ( + scope: 'general' | 'nation', + action: string, + scenarioConst?: unknown +): Promise => { + const specs = await loadTurnCommandSpecs(scenarioConst); + if (!specs[scope].some((entry) => entry.key === action)) { + throw new Error(`Unknown ${scope} turn command: ${action}`); + } +}; + +export const parseReservedTurnArgs = async ( + scope: 'general' | 'nation', + action: string, + rawArgs: unknown, + scenarioConst?: unknown +): Promise> => { + await assertReservedTurnActionAvailable(scope, action, scenarioConst); + return parseRegisteredTurnArgs(scope, action, rawArgs); +}; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 3032c99e..aedf737b 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -142,15 +142,6 @@ const REF_GENERAL_COMMAND_GROUPS = [ commands: ReadonlyArray; }>; -const REF_GENERAL_CATEGORY_ORDER = new Map( - REF_GENERAL_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const) -); -const REF_GENERAL_COMMAND_POSITION = new Map( - REF_GENERAL_COMMAND_GROUPS.flatMap(({ category, commands }) => - commands.map((command, index) => [command, { category, index }] as const) - ) -); - const REF_NATION_COMMAND_GROUPS = [ { category: '휴식', @@ -190,15 +181,6 @@ const REF_NATION_COMMAND_GROUPS = [ commands: ReadonlyArray; }>; -const REF_NATION_CATEGORY_ORDER = new Map( - REF_NATION_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const) -); -const REF_NATION_COMMAND_POSITION = new Map( - REF_NATION_COMMAND_GROUPS.flatMap(({ category, commands }) => - commands.map((command, index) => [command, { category, index }] as const) - ) -); - const INPUT_REQUIREMENT_KINDS = new Set([ 'destGeneral', 'destCity', @@ -746,34 +728,22 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat })); }; -const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] => - entries - .map((entry, profileIndex) => { - const refPosition = REF_GENERAL_COMMAND_POSITION.get(entry.definition.key as GeneralTurnCommandKey); - return { - entry: refPosition ? { ...entry, category: refPosition.category } : entry, - categoryIndex: - REF_GENERAL_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER, - commandIndex: refPosition?.index ?? profileIndex, - profileIndex, - }; - }) - .sort( - (left, right) => - left.categoryIndex - right.categoryIndex || - left.commandIndex - right.commandIndex || - left.profileIndex - right.profileIndex - ) - .map(({ entry }) => entry); +type CommandGroupLayout = ReadonlyArray<{ category: string; commands: ReadonlyArray }>; -const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[] => - entries +const projectCommandGroups = (entries: CommandEntry[], layout: CommandGroupLayout): CommandEntry[] => { + const categoryOrder = new Map(layout.map(({ category }, index) => [category, index] as const)); + const commandPosition = new Map( + layout.flatMap(({ category, commands }) => + commands.map((command, index) => [command, { category, index }] as const) + ) + ); + + return entries .map((entry, profileIndex) => { - const refPosition = REF_NATION_COMMAND_POSITION.get(entry.definition.key as NationTurnCommandKey); + const refPosition = commandPosition.get(entry.definition.key); return { entry: refPosition ? { ...entry, category: refPosition.category } : entry, - categoryIndex: - REF_NATION_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER, + categoryIndex: categoryOrder.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER, commandIndex: refPosition?.index ?? profileIndex, profileIndex, }; @@ -785,6 +755,7 @@ const projectRefNationCommandGroups = (entries: CommandEntry[]): CommandEntry[] left.profileIndex - right.profileIndex ) .map(({ entry }) => entry); +}; export const buildTurnCommandTable = async (options: { worldState: WorldStateRow; @@ -814,7 +785,13 @@ export const buildTurnCommandTable = async (options: { }; const env = buildCommandEnv(options.worldState); - const { general: generalSpecs, nation: nationSpecs } = await loadTurnCommandSpecs(); + const scenarioConst = asRecord(options.worldState.config).const; + const { + general: generalSpecs, + nation: nationSpecs, + generalGroups, + nationGroups, + } = await loadTurnCommandSpecs(scenarioConst); const generalEntries = buildEntries(env, generalSpecs, { foundingAvailable: options.realNationCount === undefined @@ -824,8 +801,12 @@ export const buildTurnCommandTable = async (options: { const nationEntries = buildEntries(env, nationSpecs); return { - general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view), - nation: buildGroups(projectRefNationCommandGroups(nationEntries), ctx, view), + general: buildGroups( + projectCommandGroups(generalEntries, generalGroups ?? REF_GENERAL_COMMAND_GROUPS), + ctx, + view + ), + nation: buildGroups(projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), ctx, view), inputOptions: options.inputOptions ?? { cities: [], nations: [], @@ -850,7 +831,8 @@ export const evaluateReservedTurnPermission = async (options: { action: string; args: Record; }): Promise => { - const specs = await loadTurnCommandSpecs(); + const scenarioConst = asRecord(options.worldState.config).const; + const specs = await loadTurnCommandSpecs(scenarioConst); const spec = specs[options.scope].find((entry) => entry.key === options.action); if (!spec) { throw new Error(`Unknown ${options.scope} turn command: ${options.action}`); diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index 1a8216e3..f01f3d49 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -4,6 +4,7 @@ import { loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs, } from '@sammo-ts/logic'; +import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js'; import { describe, expect, it } from 'vitest'; import { @@ -98,6 +99,52 @@ describe('turn command argument input', () => { await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command'); }); + it('accepts and rejects reserved commands from the real 904/905/910/912 world config', async () => { + const scenarioConsts = Object.fromEntries( + await Promise.all( + [904, 905, 910, 912].map(async (scenarioId) => [ + scenarioId, + (await loadScenarioDefinitionById(scenarioId)).config.const, + ]) + ) + ) as Record>; + + await expect(parseReservedTurnArgs('general', 'che_거병', {}, scenarioConsts[904])).rejects.toThrow( + 'Unknown general turn command: che_거병' + ); + await expect( + parseReservedTurnArgs('nation', 'che_선전포고', { destNationId: 2 }, scenarioConsts[904]) + ).rejects.toThrow('Unknown nation turn command: che_선전포고'); + + await expect( + parseReservedTurnArgs( + 'general', + 'che_무작위건국', + { nationName: '신국', nationType: 'che_도적', colorType: 1 }, + scenarioConsts[905] + ) + ).resolves.toEqual({ nationName: '신국', nationType: 'che_도적', colorType: 1 }); + await expect(parseReservedTurnArgs('nation', 'che_무작위수도이전', {}, scenarioConsts[905])).resolves.toEqual( + {} + ); + await expect(parseReservedTurnArgs('general', 'cr_맹훈련', {}, scenarioConsts[905])).rejects.toThrow( + 'Unknown general turn command: cr_맹훈련' + ); + + await expect(parseReservedTurnArgs('general', 'cr_맹훈련', {}, scenarioConsts[910])).resolves.toEqual({}); + await expect( + parseReservedTurnArgs('nation', 'cr_인구이동', { destCityId: 7, amount: 1234 }, scenarioConsts[910]) + ).resolves.toEqual({ destCityId: 7, amount: 1234 }); + await expect(parseReservedTurnArgs('nation', 'che_무작위수도이전', {}, scenarioConsts[910])).rejects.toThrow( + 'Unknown nation turn command: che_무작위수도이전' + ); + + await expect(parseReservedTurnArgs('nation', 'event_대검병연구', {}, scenarioConsts[912])).resolves.toEqual({}); + await expect(parseReservedTurnArgs('nation', 'cr_인구이동', {}, scenarioConsts[912])).rejects.toThrow( + 'Unknown nation turn command: cr_인구이동' + ); + }); + it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => { const items = buildEquipmentTradeItemOptions({ configConst: {}, diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 0634464a..2e940baf 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../src/context.js'; +import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js'; import type { GeneralActionModule, MapDefinition, UnitSetDefinition } from '@sammo-ts/logic'; import { buildRecruitmentCommandInfo, buildTurnCommandTable } from '../src/turns/commandTable.js'; -const buildWorldState = (joinMode = 'full'): WorldStateRow => +const buildWorldState = (joinMode = 'full', constOverrides: Record = {}): WorldStateRow => ({ id: 1, scenarioCode: 'default', @@ -17,6 +18,7 @@ const buildWorldState = (joinMode = 'full'): WorldStateRow => baseGold: 1000, baseRice: 1000, develCost: 100, + ...constOverrides, }, }, meta: { @@ -190,6 +192,89 @@ describe('buildTurnCommandTable', () => { }); }); + it('projects scenario-specific command categories instead of the default profile', async () => { + const table = await buildTurnCommandTable({ + worldState: buildWorldState('full', { + availableGeneralCommand: { + 개인: ['휴식'], + 내정: ['che_물자조달'], + 군사: ['cr_맹훈련'], + }, + availableChiefCommand: { + 휴식: ['휴식'], + 특수: ['cr_인구이동'], + 연구: ['event_대검병연구', 'event_화륜차연구'], + }, + }), + general: buildGeneral(), + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + expect(table.general.map(({ category }) => category)).toEqual(['개인', '내정', '군사']); + expect(table.general.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([ + '휴식', + 'che_물자조달', + 'cr_맹훈련', + ]); + expect(table.nation.map(({ category }) => category)).toEqual(['휴식', '특수', '연구']); + expect(table.nation.flatMap(({ values }) => values.map(({ key }) => key))).toEqual([ + '휴식', + 'cr_인구이동', + 'event_대검병연구', + 'event_화륜차연구', + ]); + }); + + it('projects the real 904/905/910/912 world command profiles into the API table', async () => { + const buildScenarioTable = async (scenarioId: number) => { + const scenario = await loadScenarioDefinitionById(scenarioId); + return buildTurnCommandTable({ + worldState: buildWorldState('full', scenario.config.const), + general: buildGeneral(), + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + }; + const commandCategory = (groups: Awaited>['general'], key: string) => + groups.find((group) => group.values.some((command) => command.key === key))?.category; + const nationCommandCategory = (groups: Awaited>['nation'], key: string) => + groups.find((group) => group.values.some((command) => command.key === key))?.category; + const [scenario904, scenario905, scenario910, scenario912] = await Promise.all( + [904, 905, 910, 912].map(buildScenarioTable) + ); + + expect(commandCategory(scenario904.general, 'che_물자조달')).toBe('내정'); + expect(commandCategory(scenario904.general, 'che_거병')).toBeUndefined(); + expect(nationCommandCategory(scenario904.nation, 'che_피장파장')).toBe('기타'); + expect(nationCommandCategory(scenario904.nation, 'che_선전포고')).toBeUndefined(); + expect(nationCommandCategory(scenario904.nation, 'che_부대탈퇴지시')).toBeUndefined(); + + expect(commandCategory(scenario905.general, 'che_무작위건국')).toBe('국가'); + expect(nationCommandCategory(scenario905.nation, 'che_무작위수도이전')).toBe('기타'); + + expect(commandCategory(scenario910.general, 'cr_맹훈련')).toBe('군사'); + expect(commandCategory(scenario910.general, 'cr_건국')).toBe('국가'); + expect(nationCommandCategory(scenario910.nation, 'cr_인구이동')).toBe('특수'); + + expect(nationCommandCategory(scenario912.nation, 'event_대검병연구')).toBe('연구'); + expect( + scenario912.nation.find((group) => group.category === '연구')?.values.map((command) => command.key) + ).toEqual([ + 'event_대검병연구', + 'event_극병연구', + 'event_화시병연구', + 'event_원융노병연구', + 'event_산저병연구', + 'event_음귀병연구', + 'event_무희연구', + 'event_상병연구', + 'event_화륜차연구', + ]); + }); + it('keeps every default general and chief argument command inside the shared frontend field contract', async () => { const table = await buildTurnCommandTable({ worldState: buildWorldState(), diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 31c656c3..215f0f31 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -49,7 +49,7 @@ import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLea import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; import type { TurnGeneral } from './types.js'; -import { buildPersistedRankRows } from './rankData.js'; +import { buildInitialRankRows, buildPersistedRankRows } from './rankData.js'; import { persistUnificationFinalization } from './unificationPersistence.js'; import { buildOldNationArchiveData } from './oldNationArchive.js'; import { persistYearbookSnapshot } from './yearbookPersistence.js'; @@ -769,17 +769,6 @@ const buildPersistedGeneralMeta = ( return asJson(meta); }; -const buildInitialRankRows = ( - general: ReturnType['generals'][number] -): Array<{ generalId: number; nationId: number; type: string; value: number }> => - buildPersistedRankRows(general).map((row) => ({ - ...row, - nationId: 0, - // Ref Join은 전체 rank_data를 0으로 만든 직후 장수 생성에 사용한 - // 유산 포인트만 inherit_spent_dyn에 반영한다. - value: row.type === 'inherit_spent_dyn' ? row.value : 0, - })); - const RANK_DATA_UPSERT_BATCH_SIZE = 1_000; const upsertRankRows = async ( diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index be1ba617..6511d529 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1933,9 +1933,17 @@ export class InMemoryTurnWorld { continue; } delete conflict[key]; + // Ref decodes a non-empty JSON object into a PHP array. Removing + // its last nation key and encoding that value persists `[]`, not + // `{}`. Preserve that observable storage shape until the next + // world load (where an empty conflict is normalized for logic). + const persistedConflict = + Object.keys(conflict).length === 0 + ? ([] as unknown as City['conflict']) + : (conflict as City['conflict']); this.cities.set(city.id, { ...city, - conflict: conflict as City['conflict'], + conflict: persistedConflict, }); this.dirtyCityIds.add(city.id); } diff --git a/app/game-engine/src/turn/rankData.ts b/app/game-engine/src/turn/rankData.ts index 1ddeaf11..f281fc2b 100644 --- a/app/game-engine/src/turn/rankData.ts +++ b/app/game-engine/src/turn/rankData.ts @@ -64,12 +64,34 @@ export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRa }); }; +/** + * Ref GeneralBuilder/Join initializes every rank row in nation 0 with value 0. + * The one exception is a user-creation inheritance debit already carried in + * `inherit_spent_dyn`. Keep this persistence boundary shared by the database + * hooks and differential projection. + */ +export const buildInitialRankRows = (general: RankedGeneralState): PersistedRankRow[] => + buildPersistedRankRows(general).map((row) => ({ + ...row, + nationId: 0, + value: row.type === 'inherit_spent_dyn' ? row.value : 0, + })); + +export const buildLegacyComparableInitialRankRows = ( + general: RankedGeneralState +): Array => { + const legacyTypes = new Set(LEGACY_RANK_DATA_TYPES); + return buildInitialRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } => + legacyTypes.has(row.type) + ); +}; + export const buildLegacyComparableRankRows = ( general: RankedGeneralState ): Array => { const legacyTypes = new Set(LEGACY_RANK_DATA_TYPES); - return buildPersistedRankRows(general).filter( - (row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type) + return buildPersistedRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } => + legacyTypes.has(row.type) ); }; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index deaea81c..e6647b04 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -36,6 +36,7 @@ import { getNextTurnAt, getBillByLevel, LEGACY_DEFAULT_MAX_LEVEL, + orderLegacyActionLoggerFlush, type ItemModule, type UniqueLotteryRunner, } from '@sammo-ts/logic'; @@ -121,6 +122,42 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([ 'che_전투태세', ]); +// 아래 Ref 커맨드는 성공 로그보다 addExperience/addDedication을 먼저 호출한다. +// 이 차이는 같은 ActionLogger의 GENERAL/ACTION 버퍼 안에서 보이며, 등용수락은 +// 메시지 즉시 실행과 예약 턴 차등 fixture 모두 같은 순서를 사용한다. +const LEGACY_PROGRESSION_BEFORE_ACTION_LOGS = new Set([ + 'che_등용수락', + 'che_감축', + 'che_국기변경', + 'che_국호변경', + 'che_무작위수도이전', + 'che_증축', + 'che_천도', + 'che_초토화', + 'cr_인구이동', + 'event_극병연구', + 'event_대검병연구', + 'event_무희연구', + 'event_산저병연구', + 'event_상병연구', + 'event_원융노병연구', + 'event_음귀병연구', + 'event_화륜차연구', + 'event_화시병연구', +]); + +const orderLegacyCommandLogs = ( + actionKey: string, + actionLogs: readonly LogEntryDraft[], + progressionLogs: readonly LogEntryDraft[], + postProgressionLogs: readonly LogEntryDraft[] +): LogEntryDraft[] => + orderLegacyActionLoggerFlush( + LEGACY_PROGRESSION_BEFORE_ACTION_LOGS.has(actionKey) + ? [...progressionLogs, ...actionLogs, ...postProgressionLogs] + : [...actionLogs, ...progressionLogs, ...postProgressionLogs] + ); + export const applyLegacyGeneralProgression = ( general: TurnGeneral, previousGeneral: TurnGeneral, @@ -152,36 +189,49 @@ export const applyLegacyGeneralProgression = ( actionKey === 'che_선양' || actionKey === 'che_출병' || actionKey === 'che_물자조달'; - if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) { + const preservesResolvedProcurementLevel = actionKey === 'che_물자조달'; + if ( + preservesResolvedProcurementLevel || + (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) + ) { const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0); const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel); - meta.explevel = expLevel; - if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) { - const josaRo = JosaUtil.pick(String(expLevel), '로'); + const nextExpLevel = preservesResolvedProcurementLevel ? actionResolvedExpLevel : expLevel; + meta.explevel = nextExpLevel; + if ( + nextExpLevel !== previousExpLevel && + (preservesResolvedProcurementLevel || actionResolvedExpLevel !== nextExpLevel) + ) { + const josaRo = JosaUtil.pick(String(nextExpLevel), '로'); logs.push( createGeneralActionLog( general.id, - expLevel > previousExpLevel - ? `Lv ${expLevel}${josaRo} 레벨업!` - : `Lv ${expLevel}${josaRo} 레벨다운!`, + nextExpLevel > previousExpLevel + ? `Lv ${nextExpLevel}${josaRo} 레벨업!` + : `Lv ${nextExpLevel}${josaRo} 레벨다운!`, { format: LogFormat.PLAIN } ) ); } } - if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) { + if ( + preservesResolvedProcurementLevel || + (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) + ) { const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0); - meta.dedlevel = dedicationLevel; - if (dedicationLevel !== previousDedicationLevel) { + const actionResolvedDedicationLevel = readMetaNumber(general.meta, 'dedlevel', previousDedicationLevel); + const nextDedicationLevel = preservesResolvedProcurementLevel ? actionResolvedDedicationLevel : dedicationLevel; + meta.dedlevel = nextDedicationLevel; + if (nextDedicationLevel !== previousDedicationLevel) { const dedicationLevelText = - dedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - dedicationLevel + 1}품관`; - const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US'); + nextDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - nextDedicationLevel + 1}품관`; + const billText = getBillByLevel(nextDedicationLevel).toLocaleString('en-US'); const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); const josaRoBill = JosaUtil.pick(billText, '로'); logs.push( createGeneralActionLog( general.id, - dedicationLevel > previousDedicationLevel + nextDedicationLevel > previousDedicationLevel ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, { format: LogFormat.PLAIN } @@ -778,8 +828,14 @@ const createGeneralActionLog = ( const resolveDefinition = ( actionKey: string, definitions: Map, - fallback: GeneralActionDefinition -): GeneralActionDefinition => definitions.get(actionKey) ?? fallback; + kind: 'general' | 'nation' +): GeneralActionDefinition => { + const definition = definitions.get(actionKey); + if (!definition) { + throw new Error(`Unknown reserved ${kind} turn command: ${actionKey}`); + } + return definition; +}; export const createReservedTurnHandler = async (options: { reservedTurns: InMemoryReservedTurnStore; @@ -790,6 +846,8 @@ export const createReservedTurnHandler = async (options: { getWorld: () => InMemoryTurnWorld | null; commandProfile?: TurnCommandProfile; commandEnv?: TurnCommandEnv; + now?: () => Date; + messageSharedIconBaseUrl?: string; commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil; getAdditionalOccupiedUniqueItemKeys?: () => Iterable; calculateNpcNationFinance?: ( @@ -957,6 +1015,9 @@ export const createReservedTurnHandler = async (options: { let currentGeneral = context.general; let currentCity = context.city; let currentNation = context.nation ?? null; + // Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다. + // 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다. + const legacyStaticNationName = currentNation?.name ?? '재야'; const runAction = ( kind: 'nation' | 'general', @@ -973,7 +1034,7 @@ export const createReservedTurnHandler = async (options: { completed: boolean; blockedReason?: string; } => { - const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition); + const resolvedDefinition = resolveDefinition(command.action, definitionMap, kind); const rawArgs = extractArgsRecord(command.args); const parsedArgs = resolvedDefinition.parseArgs(rawArgs); let definition = resolvedDefinition; @@ -1098,12 +1159,15 @@ export const createReservedTurnHandler = async (options: { time: actionTime, maxTechLevel: env.maxTechLevel, uniqueLottery, + legacyStaticNationName, }; let specificContext = buildActionContext( actionKey, baseContext, { world: context.world, + gameNow: options.now?.() ?? currentGeneral.turnTime, + messageSharedIconBaseUrl: options.messageSharedIconBaseUrl, scenarioConfig: options.scenarioConfig, scenarioMeta: options.scenarioMeta, map: options.map, @@ -1280,15 +1344,24 @@ export const createReservedTurnHandler = async (options: { }; } } + const progressionLogs: LogEntryDraft[] = []; if (!resolution.alternative && !usedFallback && resolution.completed) { currentGeneral = applyLegacyGeneralProgression( currentGeneral, generalBeforeExecution, actionKey, env, - logs + progressionLogs ); } + logs.push( + ...orderLegacyCommandLogs( + actionKey, + resolution.logs, + progressionLogs, + resolution.postProgressionLogs + ) + ); if ( !resolution.alternative && kind === 'nation' && @@ -1418,7 +1491,6 @@ export const createReservedTurnHandler = async (options: { }; } - logs.push(...resolution.logs); for (const nationId of resolution.destroyedNationIds ?? []) { destroyedNationIds.add(nationId); } @@ -1535,10 +1607,9 @@ export const createReservedTurnHandler = async (options: { if (resolution.created?.generals) { const newGenerals = resolution.created.generals as TurnGeneral[]; createdGenerals.push(...newGenerals); - if (worldOverlay) { - for (const general of newGenerals) { - worldOverlay.syncGeneral(general); - } + for (const general of newGenerals) { + worldOverlay?.syncGeneral(general); + options.reservedTurns.ensureGeneralTurns(general.id); } } if (resolution.created?.nations) { @@ -1990,7 +2061,7 @@ export const createReservedTurnHandler = async (options: { src: messageTarget, dest: messageTarget, text: npcMessage, - time: new Date(context.world.lastTurnTime), + time: options.now?.() ?? new Date(context.world.lastTurnTime), validUntil: new Date('9999-12-31T00:00:00.000Z'), option: {}, }); @@ -2438,6 +2509,7 @@ export const createImmediateGeneralActionExecutor = async (options: { }, maxTechLevel: env.maxTechLevel, uniqueLottery, + legacyStaticNationName: nation?.name ?? '재야', }; const actionContext = buildActionContext( @@ -2476,7 +2548,10 @@ export const createImmediateGeneralActionExecutor = async (options: { ); if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) { - for (const log of resolution.logs) { + for (const log of orderLegacyActionLoggerFlush([ + ...resolution.logs, + ...resolution.postProgressionLogs, + ])) { options.world.pushLog(log, general.turnTime); } return { ok: false, reason: '가까운 아국 도시가 없습니다.' }; @@ -2514,7 +2589,11 @@ export const createImmediateGeneralActionExecutor = async (options: { }, }; } - if (input.actionKey === 'che_거병') { + // Ref's immediate uprising and recruitment-accept commands both + // finish their actor addExperience/addDedication calls before the + // actor logger is applied. Keep the same level/rank state and logs + // outside the ordinary reserved-turn lifecycle. + if (input.actionKey === 'che_거병' || input.actionKey === 'che_등용수락') { nextGeneral = applyLegacyGeneralProgression( { ...nextGeneral, @@ -2566,7 +2645,12 @@ export const createImmediateGeneralActionExecutor = async (options: { for (const troopId of resolution.deletedTroopIds ?? []) { options.world.removeTroop(troopId); } - for (const log of [...resolution.logs, ...progressionLogs]) { + for (const log of orderLegacyCommandLogs( + input.actionKey, + resolution.logs, + progressionLogs, + resolution.postProgressionLogs + )) { options.world.pushLog(log, general.turnTime); } options.world.updateGeneral(input.generalId, nextGeneral); diff --git a/app/game-engine/src/turn/turnCommandProfile.ts b/app/game-engine/src/turn/turnCommandProfile.ts index 91136b67..81eda27c 100644 --- a/app/game-engine/src/turn/turnCommandProfile.ts +++ b/app/game-engine/src/turn/turnCommandProfile.ts @@ -1,7 +1,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic'; +import { + parseTurnCommandProfile, + resolveScenarioTurnCommandProfile, + type ScenarioTurnCommandProfileResolution, + type TurnCommandProfile, +} from '@sammo-ts/logic'; import { resolveWorkspaceRoot } from '../paths.js'; @@ -10,6 +15,7 @@ const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands export interface TurnCommandProfileOptions { filePath?: string; + scenarioConst?: unknown; } const readCommandProfile = async (filePath: string): Promise => { @@ -17,14 +23,13 @@ const readCommandProfile = async (filePath: string): Promise return parseTurnCommandProfile(JSON.parse(raw) as unknown); }; -export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise => { +export const loadScenarioTurnCommandProfile = async ( + options?: TurnCommandProfileOptions +): Promise => { const filePath = options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH; - try { - return await readCommandProfile(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return DEFAULT_TURN_COMMAND_PROFILE; - } - throw error; - } + const fallback = await readCommandProfile(filePath); + return resolveScenarioTurnCommandProfile(options?.scenarioConst, fallback); }; + +export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise => + (await loadScenarioTurnCommandProfile(options)).profile; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index c02fa627..d1a5c43a 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -663,11 +663,10 @@ const createTurnDaemonRuntimeWithLease = async ( }); const commandProfile = options.commandProfile ?? - (options.commandProfilePath - ? await loadTurnCommandProfile({ - filePath: options.commandProfilePath, - }) - : await loadTurnCommandProfile()); + (await loadTurnCommandProfile({ + ...(options.commandProfilePath ? { filePath: options.commandProfilePath } : {}), + scenarioConst: snapshot.scenarioConfig.const, + })); let worldRef: InMemoryTurnWorld | null = null; let redisConnector: RedisConnector | null = null; const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); @@ -730,6 +729,10 @@ const createTurnDaemonRuntimeWithLease = async ( map: snapshot.map, unitSet: snapshot.unitSet, getWorld: () => worldRef, + now: () => { + const wallNow = new Date(clock.nowMs()); + return worldRef?.getGameNow(wallNow) ?? wallNow; + }, commandProfile, commandEnv: monthlyCommandEnv, calculateNpcNationFinance: (financeWorld, nation, currentMonth) => diff --git a/app/game-engine/test/auctionFinalizerCompatibility.test.ts b/app/game-engine/test/auctionFinalizerCompatibility.test.ts index 71430579..12cc6add 100644 --- a/app/game-engine/test/auctionFinalizerCompatibility.test.ts +++ b/app/game-engine/test/auctionFinalizerCompatibility.test.ts @@ -375,7 +375,7 @@ describe('unique auction inheritance log compatibility', () => { ); }); - it('builds all four Ref award logs with the original formats and labels', () => { + it('builds all four Ref award logs in the original flush order with the original formats and labels', () => { const bidder = { id: 7, name: '관우', @@ -390,13 +390,6 @@ describe('unique auction inheritance log compatibility', () => { itemRawName: '칠성검', }) ).toEqual([ - expect.objectContaining({ - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.MONTH, - generalId: 7, - text: '칠성검(+12)을 습득했습니다!', - }), expect.objectContaining({ scope: LogScope.GENERAL, category: LogCategory.HISTORY, @@ -405,10 +398,11 @@ describe('unique auction inheritance log compatibility', () => { text: '칠성검(+12)을 습득', }), expect.objectContaining({ - scope: LogScope.SYSTEM, - category: LogCategory.SUMMARY, + scope: LogScope.GENERAL, + category: LogCategory.ACTION, format: LogFormat.MONTH, - text: '관우가 칠성검(+12)을 습득했습니다!', + generalId: 7, + text: '칠성검(+12)을 습득했습니다!', }), expect.objectContaining({ scope: LogScope.SYSTEM, @@ -416,6 +410,12 @@ describe('unique auction inheritance log compatibility', () => { format: LogFormat.YEAR_MONTH, text: '【보물수배】관우가 칠성검(+12)을 습득했습니다!', }), + expect.objectContaining({ + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + text: '관우가 칠성검(+12)을 습득했습니다!', + }), ]); }); diff --git a/app/game-engine/test/generalPoolSameTurn.test.ts b/app/game-engine/test/generalPoolSameTurn.test.ts index 7b6b999e..dfbe86cf 100644 --- a/app/game-engine/test/generalPoolSameTurn.test.ts +++ b/app/game-engine/test/generalPoolSameTurn.test.ts @@ -194,7 +194,7 @@ describe('scenario general pool within one reserved turn', () => { state: buildState(), schedule, map, - reservedTurnStoreOptions: { maxGeneralTurns: 10, maxNationTurns: 12 }, + reservedTurnStoreOptions: { maxGeneralTurns: 30, maxNationTurns: 12 }, commandRngFactory: ({ actionKey }) => actionKey === 'che_의병모집' ? new RandUtil(new SequenceRNG([0, 0.26, 0.51, 0.76])) @@ -210,5 +210,29 @@ describe('scenario general pool within one reserved turn', () => { expect(created.map((general) => general.npcState).sort()).toEqual([3, 4, 4, 4]); expect(claims.every(Boolean)).toBe(true); expect(new Set(claims.map((claim) => claim?.poolEntryId))).toEqual(new Set([1, 2, 3, 4])); + + const createdIds = created.map((general) => general.id); + expect(harness.reservedTurnStore.peekDirtyState().generalInitializationIds).toEqual(createdIds); + for (const generalId of createdIds) { + expect(harness.reservedTurnStore.getGeneralTurns(generalId)).toEqual( + Array.from({ length: 30 }, () => ({ action: '휴식', args: {} })) + ); + } + + await harness.reservedTurnStore.flushChanges(); + + const persistedRows = harness.mockPrisma.generalTurn.createMany.mock.calls.flatMap(([input]) => input.data); + const persistedCreatedRows = persistedRows.filter((row) => createdIds.includes(row.generalId)); + expect(persistedCreatedRows).toHaveLength(createdIds.length * 30); + for (const generalId of createdIds) { + expect(persistedCreatedRows.filter((row) => row.generalId === generalId)).toEqual( + Array.from({ length: 30 }, (_, turnIdx) => ({ + generalId, + turnIdx, + actionCode: '휴식', + arg: {}, + })) + ); + } }); }); diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index 02ad0bbe..81ff443e 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -193,6 +193,34 @@ describe('legacy general-turn execution contract', () => { expect(resolved.meta).toMatchObject({ explevel: 25, dedlevel: 8 }); }); + it('preserves procurement-computed levels while emitting their Ref progression logs', () => { + const previous = makeGeneral({ + experience: 995, + dedication: 899, + meta: { killturn: 24, explevel: 9, dedlevel: 3 }, + }); + const afterProcurement = makeGeneral({ + experience: 1_005, + dedication: 901, + meta: { killturn: 24, explevel: 10, dedlevel: 4 }, + }); + const logs: Array<{ text: string }> = []; + + const resolved = applyLegacyGeneralProgression( + afterProcurement, + previous, + 'che_물자조달', + { maxStatLevel: 255, maxDedicationLevel: 30 } as never, + logs as never + ); + + expect(resolved.meta).toMatchObject({ explevel: 10, dedlevel: 4 }); + expect(logs.map((entry) => entry.text)).toEqual([ + 'Lv 10으로 레벨업!', + '27품관으로 승급하여 봉록이 1,200으로 상승했습니다!', + ]); + }); + it('quantizes integer general columns at each in-memory DB mutation boundary', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot(makeGeneral()), @@ -218,6 +246,23 @@ describe('legacy general-turn execution contract', () => { }); }); + it('fails closed instead of silently resting on an unknown queued command', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot(makeGeneral()), + state: makeState(), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'unknown-command', args: {} }; + const beforeGeneral = structuredClone(harness.world.getGeneralById(1)); + const beforeTurns = structuredClone(harness.reservedTurnStore.getGeneralTurns(1)); + + await expect(harness.runOneTick()).rejects.toThrow('Unknown reserved general turn command: unknown-command'); + expect(harness.world.getGeneralById(1)).toEqual(beforeGeneral); + expect(harness.reservedTurnStore.getGeneralTurns(1)).toEqual(beforeTurns); + expect(harness.world.peekDirtyState()).toMatchObject({ logs: [], messages: [] }); + }); + it('keeps fractional nation rewards in the same general object until the following command is persisted', async () => { const twoCityMap = { ...map, diff --git a/app/game-engine/test/helpers/turnTestHarness.ts b/app/game-engine/test/helpers/turnTestHarness.ts index c03ecf43..b77d5f74 100644 --- a/app/game-engine/test/helpers/turnTestHarness.ts +++ b/app/game-engine/test/helpers/turnTestHarness.ts @@ -200,6 +200,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => return { world, worldRef, + mockPrisma, reservedTurnStore, handler, processor, diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts index 8bb47870..3b0d728c 100644 --- a/app/game-engine/test/myInformationCommands.test.ts +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; -import type { MapDefinition, ScenarioEffectKey, TurnSchedule } from '@sammo-ts/logic'; +import { + LogCategory, + LogFormat, + LogScope, + type MapDefinition, + type ScenarioEffectKey, + type TurnSchedule, +} from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; @@ -562,6 +569,25 @@ describe('my information world commands', () => { experience: recruiter.experience + 100, dedication: recruiter.dedication + 100, }); + const actionLogs = fixture.world + .consumeDirtyState() + .logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION); + expect(actionLogs.map((log) => log.text)).toEqual([ + expect.stringContaining('레벨업'), + expect.stringContaining('승급'), + expect.stringContaining('망명하여 수도로'), + expect.stringContaining('레벨업'), + expect.stringContaining('승급'), + expect.stringContaining('등용에 성공했습니다.'), + ]); + expect(actionLogs.map((log) => log.format)).toEqual([ + LogFormat.PLAIN, + LogFormat.PLAIN, + LogFormat.MONTH, + LogFormat.PLAIN, + LogFormat.PLAIN, + LogFormat.MONTH, + ]); }); it('preserves the Ref uprising precheck order and messages after the game starts', async () => { diff --git a/app/game-engine/test/nationCollapseOnConquest.test.ts b/app/game-engine/test/nationCollapseOnConquest.test.ts index b391312e..2c95d570 100644 --- a/app/game-engine/test/nationCollapseOnConquest.test.ts +++ b/app/game-engine/test/nationCollapseOnConquest.test.ts @@ -90,6 +90,8 @@ describe('도시 점령 시 국가 멸망 처리', () => { strongFrontCity.supplyState = 1; const conflictCity = cities.find((city) => city.id === 3)!; conflictCity.conflict = { 2: 100, 1: 50 }; + const emptiedConflictCity = cities.find((city) => city.id === 4)!; + emptiedConflictCity.conflict = { 2: 25 }; const unitSet: UnitSetDefinition = { id: 'test_unit_set', @@ -291,6 +293,7 @@ describe('도시 점령 시 국가 멸망 처리', () => { expect(world.listEvents('destroy_nation')).toEqual([]); expect(world.getState().meta.block_change_scout).toBeUndefined(); expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 }); + expect(world.getCityById(emptiedConflictCity.id)?.conflict).toEqual([]); const updatedWeakGeneral = world.getGeneralById(weakGeneral.id); expect(updatedWeakGeneral?.nationId).toBe(0); diff --git a/app/game-engine/test/npcGeneralDomesticTurn.test.ts b/app/game-engine/test/npcGeneralDomesticTurn.test.ts index 2260a50a..a16ea720 100644 --- a/app/game-engine/test/npcGeneralDomesticTurn.test.ts +++ b/app/game-engine/test/npcGeneralDomesticTurn.test.ts @@ -251,6 +251,7 @@ describe('NPC 일반 내정 턴', () => { }); await reservedTurnStore.loadAll(); + const logicalGameNow = addMinutes(mockDate, 3); const wrapper = { world: null as InMemoryTurnWorld | null }; const handler = await createReservedTurnHandler({ reservedTurns: reservedTurnStore, @@ -259,6 +260,7 @@ describe('NPC 일반 내정 턴', () => { map: MINIMAL_MAP as any, unitSet: snapshot.unitSet, getWorld: () => wrapper.world, + now: () => logicalGameNow, }); const world = new InMemoryTurnWorld(state, snapshot, { @@ -296,6 +298,7 @@ describe('NPC 일반 내정 턴', () => { msgType: 'public', text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다', src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }), + time: logicalGameNow, }) ); }); diff --git a/app/game-engine/test/rankData.test.ts b/app/game-engine/test/rankData.test.ts index bd0c589a..fbdb9ecd 100644 --- a/app/game-engine/test/rankData.test.ts +++ b/app/game-engine/test/rankData.test.ts @@ -3,6 +3,8 @@ import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common'; import { applyPersistedRankRowsToMeta, + buildInitialRankRows, + buildLegacyComparableInitialRankRows, buildLegacyComparableRankRows, buildPersistedRankRows, rankMetaKey, @@ -34,13 +36,39 @@ describe('rank data projection', () => { { generalId: 7, nationId: 2, type: 'dex1', value: 12 }, ]) ); - expect(buildLegacyComparableRankRows({ - id: 7, + expect( + buildLegacyComparableRankRows({ + id: 7, + nationId: 2, + experience: 10.5, + dedication: 20.49, + meta: {}, + }) + ).toHaveLength(LEGACY_RANK_DATA_TYPES.length); + + const initialRows = buildInitialRankRows({ + id: 8, nationId: 2, - experience: 10.5, - dedication: 20.49, - meta: {}, - })).toHaveLength(LEGACY_RANK_DATA_TYPES.length); + experience: 100, + dedication: 200, + meta: { rank_warnum: 9, inherit_spent_dyn: 7 }, + }); + expect(initialRows).toEqual( + expect.arrayContaining([ + { generalId: 8, nationId: 0, type: 'experience', value: 0 }, + { generalId: 8, nationId: 0, type: 'warnum', value: 0 }, + { generalId: 8, nationId: 0, type: 'inherit_spent_dyn', value: 7 }, + ]) + ); + expect( + buildLegacyComparableInitialRankRows({ + id: 8, + nationId: 2, + experience: 100, + dedication: 200, + meta: {}, + }) + ).toHaveLength(LEGACY_RANK_DATA_TYPES.length); }); it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => { diff --git a/app/game-engine/test/scenarioCommandProfile.test.ts b/app/game-engine/test/scenarioCommandProfile.test.ts new file mode 100644 index 00000000..d159d533 --- /dev/null +++ b/app/game-engine/test/scenarioCommandProfile.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest'; + +import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js'; +import { loadScenarioTurnCommandProfile } from '../src/turn/turnCommandProfile.js'; + +type CommandGroupManifest = ReadonlyArray<{ + category: string; + commands: readonly string[]; +}>; + +const defaultGeneralProfileManifest = [ + 'che_거병', + 'che_임관', + 'che_장수대상임관', + 'che_랜덤임관', + 'che_귀환', + 'che_건국', + 'che_훈련', + 'che_단련', + 'che_숙련전환', + 'che_사기진작', + 'che_요양', + 'che_견문', + 'che_은퇴', + 'che_내정특기초기화', + 'che_전투특기초기화', + 'che_장비매매', + 'che_출병', + 'che_주민선정', + 'che_정착장려', + 'che_농지개간', + 'che_상업투자', + 'che_기술연구', + 'che_치안강화', + 'che_수비강화', + 'che_성벽보수', + 'che_선동', + 'che_탈취', + 'che_파괴', + 'che_화계', + 'che_집합', + 'che_인재탐색', + 'che_등용', + 'che_징병', + 'che_모병', + 'che_소집해제', + 'che_첩보', + 'che_군량매매', + 'che_물자조달', + 'che_증여', + 'che_헌납', + 'che_이동', + 'che_강행', + 'che_하야', + 'che_선양', + 'che_해산', + '휴식', +] as const; + +const generalPersonalCommands = [ + '휴식', + 'che_요양', + 'che_단련', + 'che_숙련전환', + 'che_견문', + 'che_은퇴', + 'che_장비매매', + 'che_군량매매', + 'che_내정특기초기화', + 'che_전투특기초기화', +] as const; +const generalDomesticCommands = [ + 'che_농지개간', + 'che_상업투자', + 'che_기술연구', + 'che_수비강화', + 'che_성벽보수', + 'che_치안강화', + 'che_정착장려', + 'che_주민선정', + 'che_물자조달', +] as const; +const generalMilitaryCommands = [ + 'che_징병', + 'che_모병', + 'che_훈련', + 'che_사기진작', + 'che_출병', + 'che_집합', + 'che_소집해제', + 'che_첩보', +] as const; +const generalPersonnelCommands = ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_랜덤임관'] as const; +const generalSchemeCommands = ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'] as const; +const nationDiplomacyCommands = [ + 'che_물자원조', + 'che_불가침제의', + 'che_선전포고', + 'che_종전제의', + 'che_불가침파기제의', +] as const; + +const scenarioProfileManifest: Record< + number, + { generalGroups: CommandGroupManifest | null; nationGroups: CommandGroupManifest } +> = { + 904: { + generalGroups: [ + { category: '개인', commands: generalPersonalCommands }, + { category: '내정', commands: generalDomesticCommands }, + { category: '군사', commands: generalMilitaryCommands }, + { category: '인사', commands: generalPersonnelCommands }, + { category: '계략', commands: generalSchemeCommands }, + { category: '국가', commands: ['che_증여', 'che_헌납', 'che_하야'] }, + ], + nationGroups: [ + { category: '휴식', commands: ['휴식'] }, + { category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] }, + { category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] }, + { + category: '전략', + commands: [ + 'che_필사즉생', + 'che_백성동원', + 'che_수몰', + 'che_허보', + 'che_의병모집', + 'che_이호경식', + 'che_급습', + ], + }, + { category: '기타', commands: ['che_피장파장', 'che_국기변경', 'che_국호변경'] }, + ], + }, + 905: { + generalGroups: [ + { category: '개인', commands: generalPersonalCommands }, + { category: '내정', commands: generalDomesticCommands }, + { category: '군사', commands: generalMilitaryCommands }, + { category: '인사', commands: generalPersonnelCommands }, + { category: '계략', commands: generalSchemeCommands }, + { + category: '국가', + commands: ['che_증여', 'che_헌납', 'che_하야', 'che_거병', 'che_무작위건국', 'che_선양', 'che_해산'], + }, + ], + nationGroups: [ + { category: '휴식', commands: ['휴식'] }, + { category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] }, + { category: '외교', commands: nationDiplomacyCommands }, + { category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] }, + { + category: '전략', + commands: [ + 'che_필사즉생', + 'che_백성동원', + 'che_수몰', + 'che_허보', + 'che_의병모집', + 'che_이호경식', + 'che_급습', + 'che_피장파장', + ], + }, + { category: '기타', commands: ['che_국기변경', 'che_국호변경', 'che_무작위수도이전'] }, + ], + }, + 910: { + generalGroups: [ + { category: '개인', commands: generalPersonalCommands }, + { category: '내정', commands: generalDomesticCommands }, + { + category: '군사', + commands: [ + 'che_징병', + 'che_모병', + 'che_훈련', + 'che_사기진작', + 'cr_맹훈련', + 'che_출병', + 'che_집합', + 'che_소집해제', + 'che_첩보', + ], + }, + { category: '인사', commands: generalPersonnelCommands }, + { category: '계략', commands: generalSchemeCommands }, + { + category: '국가', + commands: ['che_증여', 'che_헌납', 'che_하야', 'che_거병', 'cr_건국', 'che_선양', 'che_해산'], + }, + ], + nationGroups: [ + { category: '휴식', commands: ['휴식'] }, + { category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] }, + { category: '외교', commands: nationDiplomacyCommands }, + { category: '특수', commands: ['che_초토화', 'che_천도', 'cr_인구이동'] }, + { + category: '전략', + commands: [ + 'che_필사즉생', + 'che_백성동원', + 'che_수몰', + 'che_허보', + 'che_의병모집', + 'che_이호경식', + 'che_급습', + ], + }, + { category: '기타', commands: ['che_피장파장', 'che_국기변경', 'che_국호변경'] }, + ], + }, + 912: { + generalGroups: null, + nationGroups: [ + { category: '휴식', commands: ['휴식'] }, + { category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] }, + { category: '외교', commands: nationDiplomacyCommands }, + { category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] }, + { + category: '전략', + commands: [ + 'che_필사즉생', + 'che_백성동원', + 'che_수몰', + 'che_허보', + 'che_의병모집', + 'che_이호경식', + 'che_급습', + 'che_피장파장', + ], + }, + { category: '기타', commands: ['che_국기변경', 'che_국호변경'] }, + { + category: '연구', + commands: [ + 'event_대검병연구', + 'event_극병연구', + 'event_화시병연구', + 'event_원융노병연구', + 'event_산저병연구', + 'event_음귀병연구', + 'event_무희연구', + 'event_상병연구', + 'event_화륜차연구', + ], + }, + ], + }, +}; + +const loadScenarioProfile = async (scenarioId: number) => { + const scenario = await loadScenarioDefinitionById(scenarioId); + return loadScenarioTurnCommandProfile({ scenarioConst: scenario.config.const }); +}; + +describe('scenario command profile resources', () => { + it('fails closed when the configured base profile file is missing', async () => { + await expect( + loadScenarioTurnCommandProfile({ filePath: '/tmp/core2026-command-profile-does-not-exist.json' }) + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it.each(Object.entries(scenarioProfileManifest))( + 'preserves scenario %s general/chief group order and the exact flattened product profile', + async (scenarioId, manifest) => { + const result = await loadScenarioProfile(Number(scenarioId)); + const expectedGeneral = manifest.generalGroups + ? manifest.generalGroups.flatMap((group) => group.commands) + : defaultGeneralProfileManifest; + const expectedNation = manifest.nationGroups.flatMap((group) => group.commands); + + expect(result.generalGroups).toEqual(manifest.generalGroups); + expect(result.nationGroups).toEqual(manifest.nationGroups); + expect(result.profile.general).toEqual(expectedGeneral); + expect(result.profile.nation).toEqual(expectedNation); + expect(new Set(result.profile.general).size).toBe(result.profile.general.length); + expect(new Set(result.profile.nation).size).toBe(result.profile.nation.length); + } + ); +}); diff --git a/app/game-engine/test/uniqueLotteryCommand.test.ts b/app/game-engine/test/uniqueLotteryCommand.test.ts index e293455b..6b0b7642 100644 --- a/app/game-engine/test/uniqueLotteryCommand.test.ts +++ b/app/game-engine/test/uniqueLotteryCommand.test.ts @@ -39,7 +39,11 @@ const buildGeneral = (id: number): TurnGeneral => ({ describe('unique lottery on general commands', () => { it('awards a unique item for eligible commands', async () => { const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; - const generals = [buildGeneral(1)]; + const lotteryGeneral = buildGeneral(1); + lotteryGeneral.experience = 995; + lotteryGeneral.dedication = 899; + lotteryGeneral.meta = { killturn: 24, explevel: 9, dedlevel: 3 }; + const generals = [lotteryGeneral]; const snapshot: TurnWorldSnapshot = { generals: generals as any, cities: [ @@ -172,6 +176,14 @@ describe('unique lottery on general commands', () => { expect(result.general?.role.items.weapon).toBe('che_무기_12_칠성검'); const logTexts = (result.logs ?? []).map((entry) => entry.text); expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true); + const actionIndex = logTexts.findIndex((text) => text.includes('훈련')); + const levelIndex = logTexts.findIndex((text) => text.includes('레벨업')); + const dedicationIndex = logTexts.findIndex((text) => text.includes('승급')); + const uniqueIndex = logTexts.findIndex((text) => text.includes('습득했습니다')); + expect([actionIndex, levelIndex, dedicationIndex, uniqueIndex].every((index) => index >= 0)).toBe(true); + expect(actionIndex).toBeLessThan(levelIndex); + expect(levelIndex).toBeLessThan(dedicationIndex); + expect(dedicationIndex).toBeLessThan(uniqueIndex); }); it('does not award a unique item reserved by an active auction', async () => { diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 3cb3f34d..d46e50d3 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -54,8 +54,16 @@ pnpm check:legacy:nation 즉시 외교 3종의 core log는 명령 파일이 아니라 공통 `packages/logic/src/diplomacy/instantResponse.ts`에서 생성됩니다. 정적 log 비교의 이 예외는 명령별 allowlist로 제한하며, -`instantDiplomacyReference.integration.test.ts`에서 실제 ref/core 결과를 -별도로 비교합니다. +`instantDiplomacyCoreReference.integration.test.ts`에서 실제 Ref API entry와 Core +router 결과를 별도로 비교합니다. + +장수·수뇌 registry 전수 성공 case는 각각 +`turnCommandGeneralMatrix.integration.test.ts`와 +`turnCommandNationMatrix.integration.test.ts`가 registry와 exact-set으로 닫습니다. +두 matrix의 `includeLifecycle`은 요청 scope의 queue shift와 tail lifecycle까지이며, +같은 actor의 수뇌→장수 제품 outer loop를 뜻하지 않습니다. 결합 outer lifecycle은 +`turnCommandFullLifecycle.integration.test.ts`, 실제 PostgreSQL flush/reload는 +`turnCommandFullLifecyclePersistence.integration.test.ts`가 대표 fixture로 검증합니다. 기본 integration: @@ -82,17 +90,25 @@ pnpm --filter @sammo-ts/integration-tests test:integration Canonical 비교값은 의미 단위로 정규화합니다. -- general: 위치, 소속, 자원, 병력, 능력치, 경험·공헌, penalty, aux, turn time -- nation: 자원, level, 수도, 외교, 정책, aux +- general: 위치, 소속, owner identity, 자원, 병력, 능력치, 경험·공헌·rank mirror, + affinity·NPC origin/state, penalty, aux, turn time +- nation: 자원, level, 수도, 외교, 정책, flag·spy·rate·bill·secret limit, 저장 `gennum`, aux - city: 소유권, 인구·내정·방어, conflict, supply와 인접 상태 +- troop과 실행 중 새로 생긴 general/city/nation/troop의 created-entity closure - queue: command, args, term, next available, revision -- output: 개인·국가·역사 log, message, 공개 문구와 error +- output: `general_record`/`world_history`별 물리 순서, log year·month·format·text, + strict message payload·option·icon·lifetime·unread timeline, 공개 문구와 error - execution: RNG trace, handler 순서, input event와 checkpoint DB auto ID, 생성 시각처럼 의미 없는 차이는 comparator에서 이름과 이유를 명시합니다. 의미 field를 ignore하거나 숫자 허용 범위를 넓혀 mismatch를 숨기지 않습니다. +JSON missing·`{}`·`[]`는 snapshot 원형에서 구분합니다. 일반 message option 부재의 +Ref `[]`와 Core `{}`만 의미상 같게 보며, actionable diplomacy의 `option=null` sentinel은 +부재로 합치지 않습니다. Ref log prefix와 Core format은 독립적으로 해석하고, 서로 다른 +`ActionLogger::flush()`/`applyDB()` 경계는 고유 `legacyFlushGroup`으로 보존합니다. + ## RNG Gameplay RNG는 `LiteHashDRBG`, `RNG`, `RandUtil` 경로를 추적합니다. diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 08de4950..4f6f5129 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -28,12 +28,15 @@ export interface GeneralActionResolveContext< rng: RandomGenerator; city?: City; nation?: Nation | null; + /** Ref General이 한 장수 lifecycle 동안 유지하는 정적 국가 조회값. */ + legacyStaticNationName?: string; addLog(message: string, options?: Partial>): void; + addPostProgressionLog?(message: string, options?: Partial>): void; } export type GeneralActionResolveInputContext = Omit< GeneralActionResolveContext, - 'addLog' + 'addLog' | 'addPostProgressionLog' >; export interface TurnScheduleContext { @@ -125,6 +128,7 @@ export interface GeneralActionResolution { completed: boolean; nextTurnAt: Date; logs: LogEntryDraft[]; + postProgressionLogs: LogEntryDraft[]; effects: GeneralActionEffect[]; destroyedNationIds?: NationId[]; created?: { @@ -216,6 +220,7 @@ export const createLogEffect = (message: string, options: Partial { const logs: LogEntryDraft[] = []; + const postProgressionLogs: LogEntryDraft[] = []; const accumulator: ActionResolutionAccumulator = { createdGenerals: [], createdNations: [], @@ -372,6 +378,7 @@ export const resolveGeneralAction = , (draft) => { const addLog = createActionLogSink(context, logs); + const addPostProgressionLog = createActionLogSink(context, postProgressionLogs); outcome = resolver.resolve( { @@ -381,6 +388,7 @@ export const resolveGeneralAction = , args ); @@ -405,6 +413,7 @@ export const resolveGeneralAction = = Record> { world: ActionContextWorldState; + /** Ref Message::sendRaw stamps the current logical game tick, not the actor turn time. */ + gameNow?: Date; + /** Differential fixtures may point shared message icons at the Ref asset origin. */ + messageSharedIconBaseUrl?: string; scenarioConfig: ScenarioConfig; scenarioMeta?: ScenarioMeta; map?: MapDefinition; diff --git a/packages/logic/src/actions/turn/commandProfile.ts b/packages/logic/src/actions/turn/commandProfile.ts index 58bd4090..8d52917f 100644 --- a/packages/logic/src/actions/turn/commandProfile.ts +++ b/packages/logic/src/actions/turn/commandProfile.ts @@ -1,6 +1,6 @@ import { GENERAL_TURN_COMMAND_KEYS, isGeneralTurnCommandKey, type GeneralTurnCommandKey } from './general/index.js'; import { NATION_TURN_COMMAND_KEYS, isNationTurnCommandKey, type NationTurnCommandKey } from './nation/index.js'; -import { asStringArray } from '@sammo-ts/common'; +import { asStringArray, isRecord } from '@sammo-ts/common'; import { TurnCommandProfileInputSchema } from '../../resources/turnCommandSchema.js'; export interface TurnCommandProfile { @@ -8,31 +8,43 @@ export interface TurnCommandProfile { nation: NationTurnCommandKey[]; } -const asStringArrayOrNull = (value: unknown): string[] | null => { - if (!Array.isArray(value)) { - return null; - } - const list = asStringArray(value); - return list.length > 0 ? list : null; -}; +export interface TurnCommandGroup { + category: string; + commands: Key[]; +} + +export interface ScenarioTurnCommandProfileResolution { + profile: TurnCommandProfile; + generalGroups: Array> | null; + nationGroups: Array> | null; +} const parseKeyList = (options: { raw: unknown; - defaults: T[]; isKey: (value: string) => value is T; label: string; }): T[] => { - const rawList = asStringArrayOrNull(options.raw); - if (!rawList) { - return options.defaults; + if (!Array.isArray(options.raw) || options.raw.length === 0) { + throw new Error(`${options.label} command profile must be a non-empty array.`); } const parsed: T[] = []; - for (const value of rawList) { + const seen = new Set(); + for (const value of asStringArray(options.raw)) { if (!options.isKey(value)) { throw new Error(`Unknown ${options.label} command key: ${value}`); } + if (seen.has(value)) { + throw new Error(`Duplicate ${options.label} command key: ${value}`); + } + seen.add(value); parsed.push(value); } + if (parsed.length !== options.raw.length) { + throw new Error(`${options.label} command profile contains a non-string key.`); + } + if (!parsed.includes('휴식' as T)) { + throw new Error(`${options.label} command profile must include 휴식.`); + } return parsed; }; @@ -41,27 +53,101 @@ export const DEFAULT_TURN_COMMAND_PROFILE: TurnCommandProfile = { nation: [...NATION_TURN_COMMAND_KEYS], }; -export const parseTurnCommandProfile = ( - raw: unknown, - fallback: TurnCommandProfile = DEFAULT_TURN_COMMAND_PROFILE -): TurnCommandProfile => { +export const parseTurnCommandProfile = (raw: unknown): TurnCommandProfile => { const parsed = TurnCommandProfileInputSchema.safeParse(raw); if (!parsed.success) { - return fallback; + throw new Error(`Invalid turn command profile: ${parsed.error.message}`); } const data = parsed.data; return { general: parseKeyList({ raw: data.general, - defaults: fallback.general, isKey: isGeneralTurnCommandKey, label: 'general', }), nation: parseKeyList({ raw: data.nation, - defaults: fallback.nation, isKey: isNationTurnCommandKey, label: 'nation', }), }; }; + +const parseScenarioCommandGroups = (options: { + raw: unknown; + isKey: (value: string) => value is Key; + label: string; +}): Array> | null => { + if (options.raw === undefined || options.raw === null) { + return null; + } + if (!isRecord(options.raw)) { + throw new Error(`Scenario ${options.label} command groups must be an object.`); + } + + const groups: Array> = []; + const seen = new Set(); + for (const [category, rawCommands] of Object.entries(options.raw)) { + if (!category.trim()) { + throw new Error(`Scenario ${options.label} command category must be non-empty.`); + } + if (!Array.isArray(rawCommands)) { + throw new Error(`Scenario ${options.label} command category ${category} must be an array.`); + } + const commands: Key[] = []; + for (const value of asStringArray(rawCommands)) { + if (!options.isKey(value)) { + throw new Error(`Unknown scenario ${options.label} command key: ${value}`); + } + if (seen.has(value)) { + throw new Error(`Duplicate scenario ${options.label} command key: ${value}`); + } + seen.add(value); + commands.push(value); + } + if (commands.length !== rawCommands.length) { + throw new Error(`Scenario ${options.label} command category ${category} contains a non-string key.`); + } + groups.push({ category, commands }); + } + + const flattened = groups.flatMap((group) => group.commands); + if (!flattened.includes('휴식' as Key)) { + throw new Error(`Scenario ${options.label} command groups must include 휴식.`); + } + return groups; +}; + +/** + * Ref replaces GameConst::$availableGeneralCommand/$availableChiefCommand with + * the scenario const when present. Resolve that same public/executable profile + * here so the API and daemon cannot silently use the default profile instead. + */ +export const resolveScenarioTurnCommandProfile = ( + scenarioConst: unknown, + fallback: TurnCommandProfile +): ScenarioTurnCommandProfileResolution => { + if (scenarioConst !== undefined && scenarioConst !== null && !isRecord(scenarioConst)) { + throw new Error('Scenario const must be an object.'); + } + const config = isRecord(scenarioConst) ? scenarioConst : {}; + const generalGroups = parseScenarioCommandGroups({ + raw: config.availableGeneralCommand, + isKey: isGeneralTurnCommandKey, + label: 'general', + }); + const nationGroups = parseScenarioCommandGroups({ + raw: config.availableChiefCommand, + isKey: isNationTurnCommandKey, + label: 'nation', + }); + + return { + profile: { + general: generalGroups ? generalGroups.flatMap((group) => group.commands) : [...fallback.general], + nation: nationGroups ? nationGroups.flatMap((group) => group.commands) : [...fallback.nation], + }, + generalGroups, + nationGroups, + }; +}; diff --git a/packages/logic/src/actions/turn/executionHelper.ts b/packages/logic/src/actions/turn/executionHelper.ts index 0afa6796..2dc73a04 100644 --- a/packages/logic/src/actions/turn/executionHelper.ts +++ b/packages/logic/src/actions/turn/executionHelper.ts @@ -24,6 +24,7 @@ export const processGeneralActionWithFallback = async 0) { loopLimit--; @@ -32,6 +33,7 @@ export const processGeneralActionWithFallback = async 0) { resolution.logs.unshift(...accumulatedLogs); } + if (accumulatedPostProgressionLogs.length > 0) { + resolution.postProgressionLogs.unshift(...accumulatedPostProgressionLogs); + } return resolution; } diff --git a/packages/logic/src/actions/turn/general/che_강행.ts b/packages/logic/src/actions/turn/general/che_강행.ts index 680d5ddb..28d8854b 100644 --- a/packages/logic/src/actions/turn/general/che_강행.ts +++ b/packages/logic/src/actions/turn/general/che_강행.ts @@ -131,6 +131,7 @@ export class ActionResolver< category: LogCategory.ACTION, format: LogFormat.PLAIN, generalId: target.id, + legacyFlushGroup: -1, }) ); } diff --git a/packages/logic/src/actions/turn/general/che_거병.ts b/packages/logic/src/actions/turn/general/che_거병.ts index 89543cf1..a4cf20f4 100644 --- a/packages/logic/src/actions/turn/general/che_거병.ts +++ b/packages/logic/src/actions/turn/general/che_거병.ts @@ -140,13 +140,8 @@ export class ActionDefinition< category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, }); - context.addLog(`${general.name}${josaYi} ${cityName}에서 거병`, { - scope: LogScope.NATION, - nationId: newNationId, - category: LogCategory.HISTORY, - format: LogFormat.YEAR_MONTH, - }); - + // Ref queues the national history entry on the actor logger created + // while nationID is still 0, so ActionLogger::flush discards it. tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME, diff --git a/packages/logic/src/actions/turn/general/che_군량매매.ts b/packages/logic/src/actions/turn/general/che_군량매매.ts index 564534e8..22f1d992 100644 --- a/packages/logic/src/actions/turn/general/che_군량매매.ts +++ b/packages/logic/src/actions/turn/general/che_군량매매.ts @@ -105,7 +105,7 @@ export class ActionDefinition< `군량 ${Math.round(buyAmount).toLocaleString()}을 사서 자금 ${Math.round( sellAmount ).toLocaleString()}을 썼습니다.`, - { format: LogFormat.PLAIN } + { format: LogFormat.MONTH } ); } else { const sellAmount = Math.min(args.amount, general.rice); @@ -118,7 +118,7 @@ export class ActionDefinition< `군량 ${Math.round(sellAmount).toLocaleString()}을 팔아 자금 ${Math.round( buyAmount ).toLocaleString()}을 얻었습니다.`, - { format: LogFormat.PLAIN } + { format: LogFormat.MONTH } ); } diff --git a/packages/logic/src/actions/turn/general/che_등용.ts b/packages/logic/src/actions/turn/general/che_등용.ts index 98af1266..fa71c053 100644 --- a/packages/logic/src/actions/turn/general/che_등용.ts +++ b/packages/logic/src/actions/turn/general/che_등용.ts @@ -17,13 +17,13 @@ import type { GeneralActionResolver, GeneralActionEffect, } from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect, createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogScope } from '@sammo-ts/logic/logging/types.js'; +import { createGeneralPatchEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory } from '@sammo-ts/logic/logging/types.js'; import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { JosaUtil } from '@sammo-ts/common'; import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; @@ -33,6 +33,7 @@ export interface EmployResolveContext< destGeneral?: General; env?: TurnCommandEnv; messageTime: Date; + messageSharedIconBaseUrl?: string; } const ACTION_NAME = '등용'; @@ -88,10 +89,9 @@ export class ActionResolver< > implements GeneralActionResolver { readonly key = ACTION_KEY; - resolve(context: GeneralActionResolveContext, args: EmployArgs): GeneralActionOutcome { + resolve(context: GeneralActionResolveContext, _args: EmployArgs): GeneralActionOutcome { const ctx = context as EmployResolveContext; const general = ctx.general; - const { destGeneralId } = args; const destGeneral = ctx.destGeneral; if (!destGeneral) { @@ -137,45 +137,19 @@ export class ActionResolver< const destNation = ctx.worldView ?.listNations?.() .find((candidate) => candidate.id === destGeneral.nationId); - const josaRo = JosaUtil.pick(ctx.nation.name, '로'); - effects.push( - createMessageEffect({ - msgType: 'private', - src: { - generalId: general.id, - generalName: general.name, - nationId: ctx.nation.id, - nationName: ctx.nation.name, - color: ctx.nation.color, - icon: '', - }, - dest: { - generalId: destGeneral.id, - generalName: destGeneral.name, - nationId: destGeneral.nationId, - nationName: destNation?.name ?? '재야', - color: destNation?.color ?? '#000000', - icon: '', - }, - text: `${ctx.nation.name}${josaRo} 망명 권유 서신`, - time: ctx.messageTime, - validUntil: new Date('9999-12-31T12:59:59.000Z'), - option: { action: 'scout' }, - }) - ); + const message = buildScoutMessageDraft({ + srcGeneral: general, + destGeneral, + srcNation: ctx.nation, + destNation: destNation ?? null, + time: ctx.messageTime, + ...(ctx.messageSharedIconBaseUrl ? { sharedIconBaseUrl: ctx.messageSharedIconBaseUrl } : {}), + }); + if (message) { + effects.push(createMessageEffect(message)); + } } - effects.push( - createLogEffect( - `${general.name}(${ctx.nation?.name ?? '재야'})로 부터 등용 권유 서신이 도착했습니다.`, - { - scope: LogScope.GENERAL, - generalId: destGeneralId, - category: LogCategory.ACTION, - } - ) - ); - return { effects }; } } @@ -249,10 +223,12 @@ export const actionContextBuilder = (base: ActionContextBase, options: ActionCon ...base, destGeneral, env: options.scenarioConfig.const as unknown as TurnCommandEnv, + messageSharedIconBaseUrl: options.messageSharedIconBaseUrl, messageTime: - (base.general as General & { turnTime?: Date }).turnTime instanceof Date + options.gameNow ?? + ((base.general as General & { turnTime?: Date }).turnTime instanceof Date ? (base.general as General & { turnTime: Date }).turnTime - : options.world.lastTurnTime, + : options.world.lastTurnTime), }; }; diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index abcf9a69..4a2ebb1a 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -86,14 +86,14 @@ export class ActionResolver< // Self Log context.addLog(`${destNationName}${josaRo} 망명하여 수도로 이동합니다.`, { category: LogCategory.ACTION, - format: LogFormat.PLAIN, + format: LogFormat.MONTH, }); // Global Log context.addLog(`${generalName}${josaYi} ${destNationName}${josaRo} 망명하였습니다.`, { scope: LogScope.SYSTEM, category: LogCategory.SUMMARY, - format: LogFormat.PLAIN, + format: LogFormat.MONTH, }); // 2. Recruiter Rewards @@ -143,6 +143,7 @@ export class ActionResolver< generalId: destGeneral.id, category: LogCategory.ACTION, format: LogFormat.PLAIN, + legacyFlushGroup: 1, } ) ); @@ -164,6 +165,7 @@ export class ActionResolver< generalId: destGeneral.id, category: LogCategory.ACTION, format: LogFormat.PLAIN, + legacyFlushGroup: 1, } ) ); @@ -283,18 +285,22 @@ export class ActionResolver< category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, }); - context.addLog(`${generalName} 등용에 성공했습니다.`, { - scope: LogScope.GENERAL, - generalId: destGeneral.id, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - }); - context.addLog(`${generalName} 등용에 성공`, { - scope: LogScope.GENERAL, - generalId: destGeneral.id, - category: LogCategory.HISTORY, - format: LogFormat.YEAR_MONTH, - }); + effects.push( + createLogEffect(`${generalName} 등용에 성공했습니다.`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + legacyFlushGroup: 1, + }), + createLogEffect(`${generalName} 등용에 성공`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + legacyFlushGroup: 1, + }) + ); const deletedTroopIds: number[] = []; if (general.troopId === general.id) { diff --git a/packages/logic/src/actions/turn/general/che_모반시도.ts b/packages/logic/src/actions/turn/general/che_모반시도.ts index 2b27aef0..6bd55a15 100644 --- a/packages/logic/src/actions/turn/general/che_모반시도.ts +++ b/packages/logic/src/actions/turn/general/che_모반시도.ts @@ -14,11 +14,7 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } from '@sammo-ts/logic/actions/engine.js'; -import { - createGeneralPatchEffect, - createLogEffect, - createNationPatchEffect, -} from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralPatchEffect, createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; @@ -76,11 +72,13 @@ export class ActionDefinition< { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, } ); context.addLog(`${general.name}${josaYi} ${lord.name}에게서 군주자리를 찬탈`, { scope: LogScope.NATION, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, }); context.addLog('모반에 성공했습니다.', { scope: LogScope.GENERAL, @@ -90,6 +88,7 @@ export class ActionDefinition< context.addLog(`모반으로 ${nation.name}의 군주자리를 찬탈`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, }); effects.push( @@ -97,7 +96,8 @@ export class ActionDefinition< scope: LogScope.GENERAL, generalId: lord.id, category: LogCategory.ACTION, - format: LogFormat.PLAIN, + format: LogFormat.MONTH, + legacyFlushGroup: 1, }), createLogEffect( `${general.name}의 모반으로 인해 ${nation.name}의 군주자리를 박탈당함`, @@ -105,7 +105,8 @@ export class ActionDefinition< scope: LogScope.GENERAL, generalId: lord.id, category: LogCategory.HISTORY, - format: LogFormat.PLAIN, + format: LogFormat.YEAR_MONTH, + legacyFlushGroup: 1, } ), createGeneralPatchEffect( diff --git a/packages/logic/src/actions/turn/general/che_선양.ts b/packages/logic/src/actions/turn/general/che_선양.ts index 4f00c176..f02f052e 100644 --- a/packages/logic/src/actions/turn/general/che_선양.ts +++ b/packages/logic/src/actions/turn/general/che_선양.ts @@ -7,11 +7,7 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } from '@sammo-ts/logic/actions/engine.js'; -import { - createGeneralPatchEffect, - createLogEffect, - createNationPatchEffect, -} from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralPatchEffect, createLogEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import { z } from 'zod'; @@ -107,11 +103,13 @@ export class ActionDefinition< { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, } ); context.addLog(`${general.name}${josaYi} ${destGeneral.name}에게 선양`, { scope: LogScope.NATION, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, }); context.addLog(`${destGeneral.name}에게 군주의 자리를 물려줍니다.`, { scope: LogScope.GENERAL, @@ -121,6 +119,7 @@ export class ActionDefinition< context.addLog(`${nation.name}의 군주자리를 ${destGeneral.name}에게 선양`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, }); effects.push( @@ -128,13 +127,15 @@ export class ActionDefinition< scope: LogScope.GENERAL, generalId: destGeneral.id, category: LogCategory.ACTION, - format: LogFormat.PLAIN, + format: LogFormat.MONTH, + legacyFlushGroup: 1, }), createLogEffect(`${nation.name}의 군주자리를 물려 받음`, { scope: LogScope.GENERAL, generalId: destGeneral.id, category: LogCategory.HISTORY, - format: LogFormat.PLAIN, + format: LogFormat.YEAR_MONTH, + legacyFlushGroup: 1, }), createGeneralPatchEffect( { diff --git a/packages/logic/src/actions/turn/general/che_은퇴.ts b/packages/logic/src/actions/turn/general/che_은퇴.ts index 1b3f7889..0848a320 100644 --- a/packages/logic/src/actions/turn/general/che_은퇴.ts +++ b/packages/logic/src/actions/turn/general/che_은퇴.ts @@ -60,7 +60,7 @@ export class ActionResolver< context.addLog(`${general.name}${josaYi} 은퇴하고 그 자손이 유지를 이어받았습니다.`, { scope: LogScope.SYSTEM, category: LogCategory.SUMMARY, - format: LogFormat.RAWTEXT, + format: LogFormat.MONTH, }); context.addLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.', { category: LogCategory.ACTION, diff --git a/packages/logic/src/actions/turn/general/che_이동.ts b/packages/logic/src/actions/turn/general/che_이동.ts index 8ffcde56..2a19eb96 100644 --- a/packages/logic/src/actions/turn/general/che_이동.ts +++ b/packages/logic/src/actions/turn/general/che_이동.ts @@ -13,8 +13,8 @@ import type { GeneralActionResolver, GeneralActionEffect, } from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; @@ -113,6 +113,17 @@ export class ActionResolver< target.id ) ); + if (!isSelf) { + effects.push( + createLogEffect(`방랑군 세력이 ${destCityName}${josaRo} 이동했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: target.id, + format: LogFormat.PLAIN, + legacyFlushGroup: -1, + }) + ); + } } return { effects }; diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts index 920ca320..276a90ba 100644 --- a/packages/logic/src/actions/turn/general/che_인재탐색.ts +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -493,6 +493,7 @@ export class ActionResolver< const meta: GeneralMeta = { killturn, npcType: NPC_TYPE, + npc_org: NPC_TYPE, explevel: 0, dedlevel: 1, crewTypeId: this.env.defaultCrewTypeId, diff --git a/packages/logic/src/actions/turn/general/che_장수대상임관.ts b/packages/logic/src/actions/turn/general/che_장수대상임관.ts index b2247af4..f10b45ba 100644 --- a/packages/logic/src/actions/turn/general/che_장수대상임관.ts +++ b/packages/logic/src/actions/turn/general/che_장수대상임관.ts @@ -179,6 +179,7 @@ export class ActionDefinition< context.addLog(`${destNation.name}에 임관`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, }); context.addLog(`${general.name}${josaYi} ${destNation.name}임관했습니다.`, { scope: LogScope.SYSTEM, diff --git a/packages/logic/src/actions/turn/general/che_증여.ts b/packages/logic/src/actions/turn/general/che_증여.ts index c8209529..18763e13 100644 --- a/packages/logic/src/actions/turn/general/che_증여.ts +++ b/packages/logic/src/actions/turn/general/che_증여.ts @@ -128,6 +128,7 @@ export class ActionDefinition< category: LogCategory.ACTION, generalId: destGeneral.id, format: LogFormat.PLAIN, + legacyFlushGroup: 1, }), createLogEffect(`${destGeneral.name}에게 ${resName} ${amountText}을 증여했습니다.`, { scope: LogScope.GENERAL, diff --git a/packages/logic/src/actions/turn/general/che_집합.ts b/packages/logic/src/actions/turn/general/che_집합.ts index 81a48569..a63dfd03 100644 --- a/packages/logic/src/actions/turn/general/che_집합.ts +++ b/packages/logic/src/actions/turn/general/che_집합.ts @@ -72,6 +72,7 @@ export class ActionDefinition< category: LogCategory.ACTION, format: LogFormat.PLAIN, generalId: member.id, + legacyFlushGroup: -1, }) ); } diff --git a/packages/logic/src/actions/turn/general/che_첩보.ts b/packages/logic/src/actions/turn/general/che_첩보.ts index ccd8c7ba..b1762178 100644 --- a/packages/logic/src/actions/turn/general/che_첩보.ts +++ b/packages/logic/src/actions/turn/general/che_첩보.ts @@ -215,7 +215,7 @@ export class ActionResolver< : '↓미미'; ctx.addLog(`【${destNation.name}】아국대비기술:${techText}`, { category: LogCategory.ACTION, - format: LogFormat.RAWTEXT, + format: LogFormat.MONTH, }); } } else if (distance === 2) { @@ -317,12 +317,7 @@ export class ActionDefinition< return [notOccupiedDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; } - formatConstraintFailure( - reason: string, - _ctx: ConstraintContext, - args: SpyArgs, - view: StateView - ): string | null { + formatConstraintFailure(reason: string, _ctx: ConstraintContext, args: SpyArgs, view: StateView): string | null { return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location'); } diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index 08bee508..01df8565 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -21,6 +21,7 @@ import { createCityPatchEffect, createDiplomacyPatchEffect, createGeneralPatchEffect, + createMessageEffect, createNationPatchEffect, } from '@sammo-ts/logic/actions/engine.js'; import { JosaUtil, LiteHashDRBG } from '@sammo-ts/common'; @@ -30,6 +31,7 @@ import type { GeneralTurnCommandSpec } from './index.js'; import type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '@sammo-ts/logic/war/types.js'; import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js'; import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js'; +import { LegacyWarLogFlushSequence } from '@sammo-ts/logic/war/legacyFlushSequence.js'; import type { WarActionModule } from '@sammo-ts/logic/war/actions.js'; import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; @@ -63,6 +65,8 @@ export interface DispatchResolveContext< seedBase: string; warConfig: WarEngineConfig; aftermathConfig: WarAftermathConfig; + messageTime: Date; + messageSharedIconBaseUrl?: string; } export const orderDefenderGenerals = ( @@ -70,6 +74,7 @@ export const orderDefenderGenerals = ( ): General[] => [...generals].sort((left, right) => left.id - right.id); const ACTION_NAME = '출병'; +const LEGACY_SORTIE_FLUSH_GROUP_START = Number.MIN_SAFE_INTEGER; const ARGS_SCHEMA = z.object({ destCityId: z.number(), }); @@ -512,16 +517,24 @@ export class ActionDefinition< }; } + // Ref che_출병은 명령 내부에서도 General::applyDB()/ActionLogger::flush()를 + // 여러 번 수행한다. 외부 TurnExecutionHelper progression(group 0)과 + // 섞이지 않도록 명령 내부 epoch은 작은 음수부터 단조 증가시킨다. + const legacyFlushSequence = new LegacyWarLogFlushSequence(LEGACY_SORTIE_FLUSH_GROUP_START); + const preWarFlushGroup = legacyFlushSequence.claimGroup()!; + if (finalTargetCity.id !== destCity.id) { const josaRo = JosaUtil.pick(finalTargetCity.name, '로'); const josaUl = JosaUtil.pick(destCity.name, '을'); if (minDist === 0) { context.addLog( - `${finalTargetCity.name}${josaRo} 가기 위해 ${destCity.name}${josaUl} 거쳐야 합니다.` + `${finalTargetCity.name}${josaRo} 가기 위해 ${destCity.name}${josaUl} 거쳐야 합니다.`, + { legacyFlushGroup: preWarFlushGroup } ); } else { context.addLog( - `${finalTargetCity.name}${josaRo} 가는 도중 ${destCity.name}${josaUl} 거치기로 합니다.` + `${finalTargetCity.name}${josaRo} 가는 도중 ${destCity.name}${josaUl} 거치기로 합니다.`, + { legacyFlushGroup: preWarFlushGroup } ); } } @@ -614,6 +627,7 @@ export class ActionDefinition< })), defenderCity, defenderNation, + legacyFlushSequence, ...(shouldTraceWar ? { trace: (event) => { @@ -635,7 +649,9 @@ export class ActionDefinition< unitSet, config: context.aftermathConfig, time, + messageTime: context.messageTime, hiddenSeed: context.seedBase, + legacyFlushSequence, generalActionModules: this.generalModules, calcNationTechGain: ({ nation, baseGain }) => { const module = this.nationTraitModules.get(nation.typeCode); @@ -644,6 +660,7 @@ export class ActionDefinition< baseGain ); }, + ...(context.messageSharedIconBaseUrl ? { messageSharedIconBaseUrl: context.messageSharedIconBaseUrl } : {}), ...(this.trace ? { trace: this.trace } : {}), }); @@ -676,12 +693,22 @@ export class ActionDefinition< } const effects: Array> = []; + // processWar() 반환 후 StaticEvent/unique 로직이 끝나면 Ref line 259의 + // actor applyDB가 실행된다. 이 epoch은 command 반환 후 outer progression과 별개다. + const finalActorFlushGroup = legacyFlushSequence.claimGroup()!; for (const entry of battle.logs) { effects.push({ type: 'log', entry }); } for (const entry of aftermath.logs) { - effects.push({ type: 'log', entry }); + effects.push({ + type: 'log', + entry: + entry.legacyFlushGroup === undefined ? { ...entry, legacyFlushGroup: finalActorFlushGroup } : entry, + }); + } + for (const message of aftermath.conquest?.messages ?? []) { + effects.push(createMessageEffect(message)); } const generalPatches = new Map>(); @@ -745,7 +772,20 @@ export class ActionDefinition< ); } - tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + const addFinalActorLog: NonNullable = (message, options = {}) => { + const sink = context.addPostProgressionLog ?? context.addLog; + sink(message, { + ...options, + legacyFlushGroup: finalActorFlushGroup, + }); + }; + tryApplyUniqueLottery( + { + ...context, + addPostProgressionLog: addFinalActorLog, + }, + { acquireType: '아이템', reason: ACTION_NAME } + ); return { effects, @@ -792,6 +832,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { seedBase: options.seedBase, warConfig, aftermathConfig, + messageTime: options.gameNow ?? base.general.turnTime, + ...(options.messageSharedIconBaseUrl ? { messageSharedIconBaseUrl: options.messageSharedIconBaseUrl } : {}), }; }; diff --git a/packages/logic/src/actions/turn/general/che_하야.ts b/packages/logic/src/actions/turn/general/che_하야.ts index 300d52a9..53509667 100644 --- a/packages/logic/src/actions/turn/general/che_하야.ts +++ b/packages/logic/src/actions/turn/general/che_하야.ts @@ -65,7 +65,6 @@ export class ActionResolver< // Penalty const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0; - const belong = typeof general.meta.belong === 'number' ? general.meta.belong : 0; const maxBelong = typeof general.meta.max_belong === 'number' ? general.meta.max_belong : 0; const penaltyRatio = betrayal * 0.1; const nextExp = Math.round(general.experience * (1 - penaltyRatio)); @@ -83,7 +82,7 @@ export class ActionResolver< context.addLog(`${general.name}${josaYi} ${nation.name}에서 하야했습니다.`, { scope: LogScope.SYSTEM, category: LogCategory.SUMMARY, - format: LogFormat.RAWTEXT, + format: LogFormat.MONTH, }); effects.push( @@ -101,7 +100,9 @@ export class ActionResolver< ...general.meta, betray: Math.min(9, betrayal + 1), belong: 0, - ...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}), + // Ref resets belong before max_belong is refreshed here. + // Preserve that ordering, including its legacy behavior. + ...(general.npcState < 2 ? { max_belong: Math.max(0, maxBelong) } : {}), makelimit: 12, officer_city: 0, permission: 'normal', diff --git a/packages/logic/src/actions/turn/general/che_해산.ts b/packages/logic/src/actions/turn/general/che_해산.ts index c26cc17e..3d47bf4e 100644 --- a/packages/logic/src/actions/turn/general/che_해산.ts +++ b/packages/logic/src/actions/turn/general/che_해산.ts @@ -23,8 +23,7 @@ import type { GeneralTurnCommandSpec } from './index.js'; const ACTION_NAME = '해산'; const ACTION_KEY = 'che_해산'; -const readMetaNumber = (value: unknown): number => - typeof value === 'number' && Number.isFinite(value) ? value : 0; +const readMetaNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0); export interface DisbandFactionArgs {} @@ -77,7 +76,13 @@ export class ActionDefinition< const defaultGold = this.env.defaultNpcGold > 0 ? this.env.defaultNpcGold : 1000; const defaultRice = this.env.defaultNpcRice > 0 ? this.env.defaultNpcRice : 1000; - const nationGenerals = context.nationGenerals ?? []; + const nationGenerals = [...(context.nationGenerals ?? [])].sort((left, right) => { + const leftIsActor = left.id === general.id; + const rightIsActor = right.id === general.id; + if (leftIsActor !== rightIsActor) return leftIsActor ? 1 : -1; + return left.id - right.id; + }); + const nonActorCount = nationGenerals.filter((targetGeneral) => targetGeneral.id !== general.id).length; for (const targetGeneral of nationGenerals) { const isActor = targetGeneral.id === general.id; const belong = readMetaNumber(targetGeneral.meta.belong); @@ -148,6 +153,7 @@ export class ActionDefinition< context.addLog(`${nation.name}${josaUl} 해산`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, }); const josaUn = JosaUtil.pick(nation.name, '은'); @@ -159,19 +165,22 @@ export class ActionDefinition< format: LogFormat.YEAR_MONTH, }) ); - for (const targetGeneral of nationGenerals) { + for (const [targetIndex, targetGeneral] of nationGenerals.entries()) { + const legacyFlushGroup = targetGeneral.id === general.id ? undefined : targetIndex - nonActorCount; effects.push( createLogEffect(`${nation.name}${josaNationYi} 멸망했습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.PLAIN, generalId: targetGeneral.id, + ...(legacyFlushGroup === undefined ? {} : { legacyFlushGroup }), }), createLogEffect(`${nation.name}${josaNationYi} 멸망`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, generalId: targetGeneral.id, + ...(legacyFlushGroup === undefined ? {} : { legacyFlushGroup }), }) ); } diff --git a/packages/logic/src/actions/turn/nation/che_감축.ts b/packages/logic/src/actions/turn/nation/che_감축.ts index b0a59f26..5abf65de 100644 --- a/packages/logic/src/actions/turn/nation/che_감축.ts +++ b/packages/logic/src/actions/turn/nation/che_감축.ts @@ -185,8 +185,8 @@ export class ActionDefinition< `${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, } ), // Global History Log diff --git a/packages/logic/src/actions/turn/nation/che_급습.ts b/packages/logic/src/actions/turn/nation/che_급습.ts index 0235b9a8..08c47616 100644 --- a/packages/logic/src/actions/turn/nation/che_급습.ts +++ b/packages/logic/src/actions/turn/nation/che_급습.ts @@ -115,28 +115,29 @@ export class ActionResolver< }), ]; - for (const target of context.friendlyGenerals) { - if (target.id === general.id) { - continue; - } + const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id); + const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1); + for (const [index, target] of friendlyTargets.entries()) { effects.push( createLogEffect(broadcastMessage, { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + index, }) ); } const destBroadcast = `아국에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 발동되었습니다.`; - for (const target of context.destNationGenerals) { + for (const [index, target] of context.destNationGenerals.entries()) { effects.push( createLogEffect(destBroadcast, { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index, }) ); } @@ -148,12 +149,15 @@ export class ActionResolver< strategic_cmd_limit: globalDelay, }; effects.push( - createLogEffect(broadcastMessage, { - scope: LogScope.NATION, - category: LogCategory.HISTORY, - nationId: nation.id, - format: LogFormat.YEAR_MONTH, - }) + createLogEffect( + `${generalName}${generalJosa} ${destNationName}${ACTION_NAME}${actionJosa} 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + } + ) ); } effects.push( @@ -163,7 +167,8 @@ export class ActionResolver< scope: LogScope.NATION, category: LogCategory.HISTORY, nationId: context.destNation.id, - format: LogFormat.PLAIN, + format: LogFormat.YEAR_MONTH, + legacyFlushGroup: -1, } ) ); diff --git a/packages/logic/src/actions/turn/nation/che_몰수.ts b/packages/logic/src/actions/turn/nation/che_몰수.ts index d1ecb761..d13a011a 100644 --- a/packages/logic/src/actions/turn/nation/che_몰수.ts +++ b/packages/logic/src/actions/turn/nation/che_몰수.ts @@ -30,6 +30,7 @@ import { clamp } from 'es-toolkit'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; import { normalizeResourceActionAmount } from '../resourceAmount.js'; +import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js'; const ARGS_SCHEMA = z.object({ isGold: z.boolean(), @@ -43,6 +44,7 @@ export interface SeizureResolveContext< > extends GeneralActionResolveContext { destGeneral: General; messageTime: Date; + messageSharedIconBaseUrl?: string; } const ACTION_NAME = '몰수'; @@ -67,16 +69,6 @@ const pickLegacyNpcMessage = (rng: GeneralActionResolveContext['rng']): string = return NPC_SEIZURE_MESSAGES[index]!; }; -const resolveGeneralIcon = (general: General): string => { - const runtimePicture = (general as General & { picture?: unknown }).picture; - const rawPicture = runtimePicture ?? general.meta.picture; - const picture = - (typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number' - ? String(rawPicture) - : 'default.jpg'; - return `https://sam-image.hided.net/icons/${picture}`; -}; - export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition> { @@ -170,6 +162,7 @@ export class ActionDefinition< generalId: destGeneral.id, category: LogCategory.ACTION, format: LogFormat.PLAIN, + legacyFlushGroup: 1, }), ]; @@ -183,7 +176,7 @@ export class ActionDefinition< nationId: nation.id, nationName: nation.name, color: nation.color, - icon: resolveGeneralIcon(destGeneral), + icon: resolveMessageTargetIcon(destGeneral, context.messageSharedIconBaseUrl), }; effects.push( createMessageEffect({ @@ -214,7 +207,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, op return { ...base, destGeneral, - messageTime: base.general.turnTime, + messageSharedIconBaseUrl: options.messageSharedIconBaseUrl, + messageTime: options.gameNow ?? base.general.turnTime, }; }; diff --git a/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts index db5530e5..66cc0fc2 100644 --- a/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts +++ b/packages/logic/src/actions/turn/nation/che_무작위수도이전.ts @@ -148,8 +148,8 @@ export class ActionDefinition< `${generalName}${josaYi} ${destCityName}${josaRo} 수도 이전하였습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, } ), // Global History Log @@ -202,6 +202,7 @@ export class ActionDefinition< category: LogCategory.ACTION, generalId: targetGeneral.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, }) ); } diff --git a/packages/logic/src/actions/turn/nation/che_물자원조.ts b/packages/logic/src/actions/turn/nation/che_물자원조.ts index 3f82cc34..32c84817 100644 --- a/packages/logic/src/actions/turn/nation/che_물자원조.ts +++ b/packages/logic/src/actions/turn/nation/che_물자원조.ts @@ -199,6 +199,7 @@ export class ActionDefinition< nationId: destNation.id, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, + legacyFlushGroup: 1, } ), // General Action Log @@ -222,6 +223,7 @@ export class ActionDefinition< category: LogCategory.ACTION, generalId: chief.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, }) ); } @@ -234,6 +236,7 @@ export class ActionDefinition< category: LogCategory.ACTION, generalId: chief.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, }) ); } diff --git a/packages/logic/src/actions/turn/nation/che_발령.ts b/packages/logic/src/actions/turn/nation/che_발령.ts index d5ef6e5e..cad06c48 100644 --- a/packages/logic/src/actions/turn/nation/che_발령.ts +++ b/packages/logic/src/actions/turn/nation/che_발령.ts @@ -128,6 +128,7 @@ export class ActionResolver< category: LogCategory.ACTION, generalId: destGeneral.id, format: LogFormat.MONTH, + legacyFlushGroup: 1, }) ); effects.push( diff --git a/packages/logic/src/actions/turn/nation/che_백성동원.ts b/packages/logic/src/actions/turn/nation/che_백성동원.ts index bbde89ba..e8ebcb45 100644 --- a/packages/logic/src/actions/turn/nation/che_백성동원.ts +++ b/packages/logic/src/actions/turn/nation/che_백성동원.ts @@ -113,6 +113,7 @@ export class ActionResolver< category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, }) ); } @@ -136,12 +137,15 @@ export class ActionResolver< strategic_cmd_limit: globalDelay, }; effects.push( - createLogEffect(broadcastMessage, { - scope: LogScope.NATION, - category: LogCategory.HISTORY, - nationId: nation.id, - format: LogFormat.YEAR_MONTH, - }) + createLogEffect( + `${generalName}${generalJosa} ${cityName}${ACTION_NAME}을 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + } + ) ); } diff --git a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts index dd0030ce..a4030c58 100644 --- a/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts +++ b/packages/logic/src/actions/turn/nation/che_부대탈퇴지시.ts @@ -90,8 +90,9 @@ export class ActionDefinition< createLogEffect(`${general.name}에게 부대 탈퇴를 지시 받았습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, - format: LogFormat.PLAIN, + format: LogFormat.MONTH, generalId: destGeneral.id, + legacyFlushGroup: 1, }) ); diff --git a/packages/logic/src/actions/turn/nation/che_불가침제의.ts b/packages/logic/src/actions/turn/nation/che_불가침제의.ts index 5c46ee30..2edd4221 100644 --- a/packages/logic/src/actions/turn/nation/che_불가침제의.ts +++ b/packages/logic/src/actions/turn/nation/che_불가침제의.ts @@ -19,6 +19,7 @@ import type { NationTurnCommandSpec } from './index.js'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; import { resolveDiplomacyMessageValidMinutes } from '../../../diplomacy/messageValidity.js'; +import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js'; const ARGS_SCHEMA = z.object({ destNationId: z.number().int().positive(), @@ -33,6 +34,7 @@ interface NonAggressionProposalContext< destNation: Nation; messageValidMinutes: number; messageTime: Date; + messageSharedIconBaseUrl?: string; } const ACTION_NAME = '불가침 제의'; @@ -161,7 +163,7 @@ export class ActionDefinition< nationId: nation.id, nationName: nation.name, color: nation.color, - icon: '', + icon: resolveMessageTargetIcon(general, context.messageSharedIconBaseUrl), }, dest: { generalId: 0, @@ -169,7 +171,7 @@ export class ActionDefinition< nationId: destNation.id, nationName: destNation.name, color: destNation.color, - icon: '', + icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl), }, text: `${nation.name}${josaWa} ${args.year}년 ${args.month}월까지 불가침 제의 서신`, time: context.messageTime, @@ -195,7 +197,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, destNation, currentYear: options.world.currentYear, currentMonth: options.world.currentMonth, - messageTime: base.general.turnTime, + messageSharedIconBaseUrl: options.messageSharedIconBaseUrl, + messageTime: options.gameNow ?? base.general.turnTime, }; }; diff --git a/packages/logic/src/actions/turn/nation/che_수몰.ts b/packages/logic/src/actions/turn/nation/che_수몰.ts index a07c9c6b..313279cb 100644 --- a/packages/logic/src/actions/turn/nation/che_수몰.ts +++ b/packages/logic/src/actions/turn/nation/che_수몰.ts @@ -127,6 +127,7 @@ export class ActionResolver< category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, }) ); } @@ -138,6 +139,7 @@ export class ActionResolver< category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, }) ); } @@ -180,6 +182,7 @@ export class ActionResolver< category: LogCategory.HISTORY, nationId: context.destNation.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, } ) ); diff --git a/packages/logic/src/actions/turn/nation/che_의병모집.ts b/packages/logic/src/actions/turn/nation/che_의병모집.ts index dc1eccfe..6d09aab0 100644 --- a/packages/logic/src/actions/turn/nation/che_의병모집.ts +++ b/packages/logic/src/actions/turn/nation/che_의병모집.ts @@ -376,7 +376,7 @@ export class ActionResolver< const actionName = ACTION_NAME; context.addLog(`${actionName} 발동!`); - context.addLog(`${actionName} 발동`, { + context.addLog(`${actionName}${actionJosa} 발동`, { category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, }); @@ -416,6 +416,7 @@ export class ActionResolver< category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, }) ); } @@ -518,6 +519,9 @@ export class ActionResolver< const meta: GeneralMeta = { killturn, npcType: NPC_TYPE, + npc_org: NPC_TYPE, + explevel: 0, + dedlevel: 1, crewTypeId: this.env.defaultCrewTypeId, dex1: dex[0], dex2: dex[1], @@ -567,6 +571,7 @@ export class ActionResolver< }), turnTime, ...(turnTick === undefined ? {} : { turnTick }), + affinity, bornYear: birthYear, deadYear: deathYear, imageServer: candidate.imageServer ?? 0, diff --git a/packages/logic/src/actions/turn/nation/che_이호경식.ts b/packages/logic/src/actions/turn/nation/che_이호경식.ts index d51b72ad..687f49b8 100644 --- a/packages/logic/src/actions/turn/nation/che_이호경식.ts +++ b/packages/logic/src/actions/turn/nation/che_이호경식.ts @@ -122,28 +122,29 @@ export class ActionResolver< }), ]; - for (const target of context.friendlyGenerals) { - if (target.id === general.id) { - continue; - } + const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id); + const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1); + for (const [index, target] of friendlyTargets.entries()) { effects.push( createLogEffect(broadcastMessage, { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + index, }) ); } const destBroadcast = `${nationName}${nationJosa} 아국에 ${ACTION_NAME}${actionJosa} 발동하였습니다.`; - for (const target of context.destNationGenerals) { + for (const [index, target] of context.destNationGenerals.entries()) { effects.push( createLogEffect(destBroadcast, { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index, }) ); } @@ -155,12 +156,15 @@ export class ActionResolver< strategic_cmd_limit: globalDelay, }; effects.push( - createLogEffect(broadcastMessage, { - scope: LogScope.NATION, - category: LogCategory.HISTORY, - nationId: nation.id, - format: LogFormat.YEAR_MONTH, - }) + createLogEffect( + `${generalName}${generalJosa} ${destNationName}${ACTION_NAME}${actionJosa} 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + } + ) ); } effects.push( @@ -170,7 +174,8 @@ export class ActionResolver< scope: LogScope.NATION, category: LogCategory.HISTORY, nationId: context.destNation.id, - format: LogFormat.PLAIN, + format: LogFormat.YEAR_MONTH, + legacyFlushGroup: -1, } ) ); diff --git a/packages/logic/src/actions/turn/nation/che_종전제의.ts b/packages/logic/src/actions/turn/nation/che_종전제의.ts index 1cf4d4db..fd6d9d12 100644 --- a/packages/logic/src/actions/turn/nation/che_종전제의.ts +++ b/packages/logic/src/actions/turn/nation/che_종전제의.ts @@ -18,6 +18,7 @@ import type { NationTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { resolveMessageTargetIcon } from '@sammo-ts/logic/messages/message.js'; const ARGS_SCHEMA = z.object({ destNationId: z.number().int().positive(), @@ -30,6 +31,7 @@ interface StopWarProposalContext< destNation: Nation; messageValidMinutes: number; messageTime: Date; + messageSharedIconBaseUrl?: string; } const ACTION_NAME = '종전 제의'; @@ -81,7 +83,7 @@ export class ActionDefinition< nationId: nation.id, nationName: nation.name, color: nation.color, - icon: '', + icon: resolveMessageTargetIcon(general, context.messageSharedIconBaseUrl), }, dest: { generalId: 0, @@ -89,7 +91,7 @@ export class ActionDefinition< nationId: destNation.id, nationName: destNation.name, color: destNation.color, - icon: '', + icon: resolveMessageTargetIcon(null, context.messageSharedIconBaseUrl), }, text: `${nation.name}의 종전 제의 서신`, time: context.messageTime, @@ -115,7 +117,8 @@ export const actionContextBuilder: ActionContextBuilder = ( return { ...base, destNation, - messageTime: base.general.turnTime, + messageSharedIconBaseUrl: options.messageSharedIconBaseUrl, + messageTime: options.gameNow ?? base.general.turnTime, messageValidMinutes: Math.max(30, Math.floor((options.world.tickSeconds / 60) * 3)), }; }; diff --git a/packages/logic/src/actions/turn/nation/che_증축.ts b/packages/logic/src/actions/turn/nation/che_증축.ts index 099917ce..c2f83c44 100644 --- a/packages/logic/src/actions/turn/nation/che_증축.ts +++ b/packages/logic/src/actions/turn/nation/che_증축.ts @@ -175,8 +175,8 @@ export class ActionDefinition< `${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, } ), // Global History Log diff --git a/packages/logic/src/actions/turn/nation/che_천도.ts b/packages/logic/src/actions/turn/nation/che_천도.ts index 2382d37e..10e2f81a 100644 --- a/packages/logic/src/actions/turn/nation/che_천도.ts +++ b/packages/logic/src/actions/turn/nation/che_천도.ts @@ -224,8 +224,8 @@ export class ActionDefinition< `${generalName}${josaYi} ${destCityName}${josaRo} ${ACTION_NAME}를 명령하였습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, } ), // Global History Log diff --git a/packages/logic/src/actions/turn/nation/che_초토화.ts b/packages/logic/src/actions/turn/nation/che_초토화.ts index 291a3da5..f1c16e32 100644 --- a/packages/logic/src/actions/turn/nation/che_초토화.ts +++ b/packages/logic/src/actions/turn/nation/che_초토화.ts @@ -204,8 +204,8 @@ export class ActionDefinition< `${generalName}${josaYi} ${destCityName}${josaUl} ${ACTION_NAME}하였습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, } ), createLogEffect( diff --git a/packages/logic/src/actions/turn/nation/che_포상.ts b/packages/logic/src/actions/turn/nation/che_포상.ts index bbce9e7e..6dc30d21 100644 --- a/packages/logic/src/actions/turn/nation/che_포상.ts +++ b/packages/logic/src/actions/turn/nation/che_포상.ts @@ -138,6 +138,7 @@ export class ActionResolver< category: LogCategory.ACTION, generalId: context.destGeneral.id, format: LogFormat.PLAIN, + legacyFlushGroup: 1, }) ); effects.push( diff --git a/packages/logic/src/actions/turn/nation/che_피장파장.ts b/packages/logic/src/actions/turn/nation/che_피장파장.ts index 255c5db8..2302dce4 100644 --- a/packages/logic/src/actions/turn/nation/che_피장파장.ts +++ b/packages/logic/src/actions/turn/nation/che_피장파장.ts @@ -177,27 +177,28 @@ export class ActionResolver< '이' )} 발동되었습니다.`; - for (const target of context.friendlyGenerals) { - if (target.id === general.id) { - continue; - } + const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id); + const firstLegacyFlushGroup = -(friendlyTargets.length + context.destNationGenerals.length + 1); + for (const [index, target] of friendlyTargets.entries()) { effects.push( createLogEffect(broadcastMessage, { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + index, }) ); } - for (const target of context.destNationGenerals) { + for (const [index, target] of context.destNationGenerals.entries()) { effects.push( createLogEffect(destBroadcastMessage, { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index, }) ); } @@ -232,7 +233,8 @@ export class ActionResolver< scope: LogScope.NATION, category: LogCategory.HISTORY, nationId: destNation.id, - format: LogFormat.PLAIN, + format: LogFormat.YEAR_MONTH, + legacyFlushGroup: -1, } ) ); diff --git a/packages/logic/src/actions/turn/nation/che_필사즉생.ts b/packages/logic/src/actions/turn/nation/che_필사즉생.ts index 53c78931..279c3bdd 100644 --- a/packages/logic/src/actions/turn/nation/che_필사즉생.ts +++ b/packages/logic/src/actions/turn/nation/che_필사즉생.ts @@ -112,10 +112,9 @@ export class ActionResolver< general.atmos = selfPatch.atmos; } - for (const target of context.nationGenerals) { - if (target.id === general.id) { - continue; - } + const nationTargets = context.nationGenerals.filter((target) => target.id !== general.id); + const firstLegacyFlushGroup = -nationTargets.length; + for (const [index, target] of nationTargets.entries()) { const patch = updateTrainAtmos(target); if (patch) { effects.push(createGeneralPatchEffect(patch, target.id)); @@ -126,6 +125,7 @@ export class ActionResolver< category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + index, }) ); } @@ -137,7 +137,7 @@ export class ActionResolver< strategic_cmd_limit: globalDelay, }; effects.push( - createLogEffect(broadcastMessage, { + createLogEffect(`${generalName}${generalJosa} ${ACTION_NAME}을 발동`, { scope: LogScope.NATION, category: LogCategory.HISTORY, nationId: nation.id, diff --git a/packages/logic/src/actions/turn/nation/che_허보.ts b/packages/logic/src/actions/turn/nation/che_허보.ts index 47ca047b..89728030 100644 --- a/packages/logic/src/actions/turn/nation/che_허보.ts +++ b/packages/logic/src/actions/turn/nation/che_허보.ts @@ -127,21 +127,25 @@ export class ActionResolver< const effects: Array> = []; - for (const target of context.friendlyGenerals) { - if (target.id === general.id) { - continue; - } + const friendlyTargets = context.friendlyGenerals.filter((target) => target.id !== general.id); + const firstLegacyFlushGroup = -( + friendlyTargets.length + + context.destCityGenerals.length + + (context.destNation ? 1 : 0) + ); + for (const [index, target] of friendlyTargets.entries()) { effects.push( createLogEffect(broadcastMessage, { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + index, }) ); } - for (const target of context.destCityGenerals) { + for (const [index, target] of context.destCityGenerals.entries()) { const moveCityId = pickMoveCityId(context.rng, context.destCity.id, context.destNationSupplyCities); effects.push( createLogEffect(destBroadcastMessage, { @@ -149,6 +153,7 @@ export class ActionResolver< category: LogCategory.ACTION, generalId: target.id, format: LogFormat.PLAIN, + legacyFlushGroup: firstLegacyFlushGroup + friendlyTargets.length + index, }) ); if (moveCityId !== target.cityId) { @@ -163,12 +168,15 @@ export class ActionResolver< strategic_cmd_limit: globalDelay, }; effects.push( - createLogEffect(broadcastMessage, { - scope: LogScope.NATION, - category: LogCategory.HISTORY, - nationId: nation.id, - format: LogFormat.YEAR_MONTH, - }) + createLogEffect( + `${generalName}${generalJosa} ${cityName}${ACTION_NAME}를 발동`, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: nation.id, + format: LogFormat.YEAR_MONTH, + } + ) ); } @@ -181,6 +189,7 @@ export class ActionResolver< category: LogCategory.HISTORY, nationId: context.destNation.id, format: LogFormat.PLAIN, + legacyFlushGroup: -1, } ) ); diff --git a/packages/logic/src/actions/turn/nation/cr_인구이동.ts b/packages/logic/src/actions/turn/nation/cr_인구이동.ts index e55b7e29..1149c639 100644 --- a/packages/logic/src/actions/turn/nation/cr_인구이동.ts +++ b/packages/logic/src/actions/turn/nation/cr_인구이동.ts @@ -101,7 +101,7 @@ export class ActionDefinition< const amount = clamp(args.amount, 0, available); const cost = calcCost(this.env.develCost, args.amount); - const amountText = amount.toLocaleString(); + const amountText = String(amount); const destCityName = destCity.name; const josaRo = JosaUtil.pick(destCityName, '로'); diff --git a/packages/logic/src/diplomacy/instantResponse.ts b/packages/logic/src/diplomacy/instantResponse.ts index f804c78f..b5191bf8 100644 --- a/packages/logic/src/diplomacy/instantResponse.ts +++ b/packages/logic/src/diplomacy/instantResponse.ts @@ -157,6 +157,7 @@ const buildNonAggressionEffects = ( category: LogCategory.HISTORY, generalId: context.proposer.id, format: LogFormat.YEAR_MONTH, + legacyFlushGroup: 1, } ), createLogEffect( @@ -166,6 +167,7 @@ const buildNonAggressionEffects = ( category: LogCategory.ACTION, generalId: context.proposer.id, format: LogFormat.PLAIN, + legacyFlushGroup: 1, } ), ]; @@ -226,12 +228,14 @@ const buildCancelNonAggressionEffects = ${actorNationName}${actorNationJosaWa}의 불가침 파기에 성공했습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: context.proposer.id, format: LogFormat.PLAIN, + legacyFlushGroup: 1, }), ]; }; @@ -297,18 +301,21 @@ const buildStopWarEffects = ( generalId: context.proposer.id, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, + legacyFlushGroup: 1, }), createLogEffect(`${actorNationName}${actorNationJosaWa} 종전에 성공했습니다.`, { scope: LogScope.GENERAL, generalId: context.proposer.id, category: LogCategory.ACTION, format: LogFormat.PLAIN, + legacyFlushGroup: 1, }), createLogEffect(`${actorNationName}${actorNationJosaWa} 종전`, { scope: LogScope.NATION, nationId: proposerNation.id, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, + legacyFlushGroup: 1, }), ]; }; diff --git a/packages/logic/src/logging/actionLogger.ts b/packages/logic/src/logging/actionLogger.ts index 8b1baa2f..16fcb7f6 100644 --- a/packages/logic/src/logging/actionLogger.ts +++ b/packages/logic/src/logging/actionLogger.ts @@ -1,5 +1,55 @@ import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from './types.js'; +const legacyFlushBucket = (entry: LogEntryDraft): number => { + if (entry.scope === LogScope.GENERAL) { + switch (entry.category) { + case LogCategory.HISTORY: + return 0; + case LogCategory.ACTION: + return 1; + case LogCategory.BATTLE_BRIEF: + return 2; + case LogCategory.BATTLE_DETAIL: + return 3; + } + } + if (entry.scope === LogScope.NATION && entry.category === LogCategory.HISTORY) { + return 4; + } + if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY) { + return 5; + } + if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY) { + return 6; + } + return 7; +}; + +const orderLegacyFlushGroup = (entries: readonly LogEntryDraft[]): LogEntryDraft[] => { + const buckets = Array.from({ length: 8 }, () => [] as LogEntryDraft[]); + for (const entry of entries) { + buckets[legacyFlushBucket(entry)]!.push(entry); + } + return buckets.flat(); +}; + +/** Ref ActionLogger별 flush 순서와 그 안의 대상/분류별 버퍼 순서를 보존한다. */ +export const orderLegacyActionLoggerFlush = (entries: readonly LogEntryDraft[]): LogEntryDraft[] => { + const groups = new Map(); + for (const entry of entries) { + const group = entry.legacyFlushGroup ?? 0; + const groupedEntries = groups.get(group); + if (groupedEntries) { + groupedEntries.push(entry); + } else { + groups.set(group, [entry]); + } + } + return [...groups.entries()] + .sort(([left], [right]) => left - right) + .flatMap(([, groupedEntries]) => orderLegacyFlushGroup(groupedEntries)); +}; + export class ActionLogger { private readonly generalId: number | undefined; private readonly nationId: number | undefined; @@ -13,7 +63,7 @@ export class ActionLogger { // 장수/국가/전역 로그를 한번에 모아두고 외부에서 저장한다. public flush(): LogEntryDraft[] { const items = this.logs.splice(0, this.logs.length); - return items; + return orderLegacyActionLoggerFlush(items); } public rollback(): LogEntryDraft[] { diff --git a/packages/logic/src/logging/types.ts b/packages/logic/src/logging/types.ts index fbd3afa1..a7d5ec24 100644 --- a/packages/logic/src/logging/types.ts +++ b/packages/logic/src/logging/types.ts @@ -33,6 +33,8 @@ export interface LogEntryDraft { month?: number; /** 로그를 만든 논리 게임 시각. 생략하면 flush context의 시각을 사용한다. */ occurredAt?: Date; + /** 하나의 명령에서 별도 Ref ActionLogger로 저장한 순서. 작은 그룹을 먼저 flush한다. */ + legacyFlushGroup?: number; } export interface LogEntryRecord { diff --git a/packages/logic/src/messages/message.ts b/packages/logic/src/messages/message.ts index 2564e81b..203db4e3 100644 --- a/packages/logic/src/messages/message.ts +++ b/packages/logic/src/messages/message.ts @@ -2,6 +2,33 @@ export type MessageType = 'public' | 'private' | 'national' | 'diplomacy'; export const MESSAGE_MAILBOX_PUBLIC = 9999; export const MESSAGE_MAILBOX_NATIONAL_BASE = 9000; +export const DEFAULT_MESSAGE_SHARED_ICON_BASE_URL = 'https://sam-image.hided.net/icons'; + +export interface MessageIconSource { + picture?: unknown; + imageServer?: unknown; + meta?: Record; +} + +/** + * Match Ref GetImageURL for message payloads. Shared icons are absolute; + * user icons retain the legacy d_pic marker consumed by the frontend. + */ +export const resolveMessageTargetIcon = ( + source: MessageIconSource | null = null, + sharedIconBaseUrl = DEFAULT_MESSAGE_SHARED_ICON_BASE_URL +): string => { + const rawPicture = source?.picture ?? source?.meta?.picture; + const picture = + (typeof rawPicture === 'string' && rawPicture.trim() !== '') || typeof rawPicture === 'number' + ? String(rawPicture) + : 'default.jpg'; + const imageServer = source?.imageServer ?? source?.meta?.imageServer; + if (typeof imageServer === 'number' && imageServer !== 0) { + return `d_pic/${picture}`; + } + return `${sharedIconBaseUrl.replace(/\/+$/u, '')}/${picture}`; +}; export interface MessageTarget { generalId: number; @@ -80,7 +107,7 @@ const buildPayload = (draft: MessageDraft, optionOverride?: MessageOption | null src: draft.src, dest: draft.dest, text: draft.text, - option: optionOverride ?? draft.option ?? {}, + option: optionOverride !== undefined ? optionOverride : (draft.option ?? {}), }); const buildRecord = ( @@ -110,15 +137,18 @@ const buildRecord = ( }; }; -const buildSenderOption = (draft: MessageDraft, receiverId: number): MessageOption => { +const buildSenderOption = (draft: MessageDraft, receiverId: number): MessageOption | null => { const option = { ...(draft.option ?? {}), receiverMessageID: receiverId, }; if (draft.msgType === 'diplomacy' && 'action' in option) { - const { action: _action, ...rest } = option; - return rest; + // Ref Message::sendToSender temporarily replaces the entire actionable + // diplomacy option with null. Keeping year/month/deletable or the + // receiver row id would make the sender copy actionable in a way Ref is + // deliberately not. + return null; } return option; diff --git a/packages/logic/src/messages/scoutMessage.ts b/packages/logic/src/messages/scoutMessage.ts new file mode 100644 index 00000000..7052e85a --- /dev/null +++ b/packages/logic/src/messages/scoutMessage.ts @@ -0,0 +1,72 @@ +import { JosaUtil } from '@sammo-ts/common'; + +import type { General, Nation } from '@sammo-ts/logic/domain/entities.js'; +import { resolveMessageTargetIcon, type MessageDraft } from './message.js'; + +type ScoutGeneral = Pick; +type ScoutNation = Pick; + +export interface ScoutMessageDraftInput { + srcGeneral: ScoutGeneral; + destGeneral: ScoutGeneral; + srcNation: ScoutNation | null; + destNation: ScoutNation | null; + time: Date; + sharedIconBaseUrl?: string; +} + +/** + * Pure equivalent of Ref ScoutMessage::buildScoutMessage(). Ref constructs + * both targets without a general picture, so MessageTarget supplies the shared + * default icon, and every caller persists the receiver copy only via send(true). + */ +export const buildScoutMessageDraft = (input: ScoutMessageDraftInput): MessageDraft | null => { + const { srcGeneral, destGeneral, srcNation, destNation } = input; + if ( + srcGeneral.id === destGeneral.id || + destGeneral.officerLevel === 12 || + srcGeneral.nationId === 0 || + srcGeneral.nationId === destGeneral.nationId || + !srcNation || + srcNation.id !== srcGeneral.nationId + ) { + return null; + } + + const resolvedDestNation = + destGeneral.nationId === 0 + ? { id: 0, name: '재야', color: '#000000' } + : destNation?.id === destGeneral.nationId + ? destNation + : null; + if (!resolvedDestNation) { + return null; + } + + const josaRo = JosaUtil.pick(srcNation.name, '로'); + const icon = resolveMessageTargetIcon(null, input.sharedIconBaseUrl); + return { + msgType: 'private', + src: { + generalId: srcGeneral.id, + generalName: srcGeneral.name, + nationId: srcGeneral.nationId, + nationName: srcNation.name, + color: srcNation.color, + icon, + }, + dest: { + generalId: destGeneral.id, + generalName: destGeneral.name, + nationId: destGeneral.nationId, + nationName: resolvedDestNation.name, + color: resolvedDestNation.color, + icon, + }, + text: `${srcNation.name}${josaRo} 망명 권유 서신`, + time: input.time, + validUntil: new Date('9999-12-31T12:59:59.000Z'), + option: { action: 'scout' }, + sendDestOnly: true, + }; +}; diff --git a/packages/logic/src/rewards/uniqueLottery.ts b/packages/logic/src/rewards/uniqueLottery.ts index 61301a40..8a22475a 100644 --- a/packages/logic/src/rewards/uniqueLottery.ts +++ b/packages/logic/src/rewards/uniqueLottery.ts @@ -334,27 +334,28 @@ const applyUniqueItemGain = ${itemName}${josaUl} 습득했습니다!`, { + addLog(`${itemName}${josaUl} 습득했습니다!`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.MONTH, }); - context.addLog(`${itemName}${josaUl} 습득`, { + addLog(`${itemName}${josaUl} 습득`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, }); - context.addLog(`${generalName}${josaYi} ${itemName}${josaUl} 습득했습니다!`, { + addLog(`${generalName}${josaYi} ${itemName}${josaUl} 습득했습니다!`, { scope: LogScope.SYSTEM, category: LogCategory.SUMMARY, format: LogFormat.MONTH, }); - context.addLog( + addLog( `【${acquireType}】${nationName}${generalName}${josaYi} ${itemName}${josaUl} 습득했습니다!`, { scope: LogScope.SYSTEM, @@ -376,6 +377,6 @@ export const tryApplyUniqueLottery = { +const updateLegacyProgressionLevels = (general: General, logger: ActionLogger): void => { + const previousExpLevel = getMetaNumber(general.meta, 'explevel', 0); + const previousDedLevel = getMetaNumber(general.meta, 'dedlevel', 0); const expLevel = general.experience < 1_000 ? Math.trunc(general.experience / 100) : Math.trunc(Math.sqrt(general.experience / 10)); - general.meta.explevel = clamp(expLevel, 0, LEGACY_DEFAULT_MAX_LEVEL); - general.meta.dedlevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_LEVEL); + const nextExpLevel = clamp(expLevel, 0, LEGACY_DEFAULT_MAX_LEVEL); + const nextDedLevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_LEVEL); + general.meta.explevel = nextExpLevel; + general.meta.dedlevel = nextDedLevel; + + if (nextExpLevel !== previousExpLevel) { + const josaRo = JosaUtil.pick(String(nextExpLevel), '로'); + logger.pushGeneralActionLog( + nextExpLevel > previousExpLevel + ? `Lv ${nextExpLevel}${josaRo} 레벨업!` + : `Lv ${nextExpLevel}${josaRo} 레벨다운!`, + LogFormat.PLAIN + ); + } + + if (nextDedLevel !== previousDedLevel) { + const dedicationLevelText = nextDedLevel === 0 ? '무품관' : `${MAX_DEDICATION_LEVEL - nextDedLevel + 1}품관`; + const billText = (nextDedLevel * 200 + 400).toLocaleString('en-US'); + const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); + const josaRoBill = JosaUtil.pick(billText, '로'); + logger.pushGeneralActionLog( + nextDedLevel > previousDedLevel + ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` + : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, + LogFormat.PLAIN + ); + } }; const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) => boolean): WarUnitReport | null => { @@ -207,16 +236,19 @@ const findNextCapital = ( })[0]!.city; }; -const pushLoggers = (loggers: ActionLogger[], logs: LogEntryDraft[]): void => { - for (const logger of loggers) { - logs.push(...logger.flush()); - } +const pushLogger = ( + logger: ActionLogger, + logs: LogEntryDraft[], + legacyFlushSequence: LegacyWarLogFlushSequence +): void => { + logs.push(...legacyFlushSequence.flush(logger)); }; // 도시 점령 이후의 국가 붕괴/수도 이동/도시 리셋 처리. const resolveConquerCity = ( input: WarAftermathInput, - rng: RandUtil + rng: RandUtil, + legacyFlushSequence: LegacyWarLogFlushSequence ): ConquerCityOutcome => { const { attackerNation, defenderNation, defenderCity, cities, generals, config } = input; const attacker = input.battle.attacker; @@ -257,12 +289,11 @@ const resolveConquerCity = ( `${attackerGeneralName}${josaYiGen} ${defenderNationDecoration} ${cityName} ${josaUl} 점령` ); - if (defenderNationId) { - const defenderNationLogger = new ActionLogger({ nationId: defenderNationId }); + const defenderNationLogger = defenderNationId ? new ActionLogger({ nationId: defenderNationId }) : null; + if (defenderNationLogger) { defenderNationLogger.pushNationHistoryLog( `${attackerNationName}${attackerGeneralName}에 의해 ${cityName}${josaYiCity} 함락` ); - pushLoggers([defenderNationLogger], logs); } const defenderCityCount = defenderNationId ? cities.filter((city) => city.nationId === defenderNationId).length : 0; @@ -270,32 +301,44 @@ const resolveConquerCity = ( let collapseRewardGold = 0; let collapseRewardRice = 0; + const messages: ConquerCityOutcome['messages'] = []; const ruinedNpcJoinPlans: ConquerCityOutcome['ruinedNpcJoinPlans'] = []; // 국가 붕괴 시 자원 손실과 포상 정산. if (nationCollapsed && defenderNation) { - const defenderGenerals = generals - .filter((general) => general.nationId === defenderNationId) - .sort((lhs, rhs) => { - // deleteNation() reads the non-lord rows in primary-key order, - // then appends the lord object to the returned PHP array. - const lhsIsLord = lhs.id === defenderNation.chiefGeneralId; - const rhsIsLord = rhs.id === defenderNation.chiefGeneralId; - if (lhsIsLord !== rhsIsLord) { - return lhsIsLord ? 1 : -1; - } - return lhs.id - rhs.id; - }); + const defenderNationGenerals = generals.filter((general) => general.nationId === defenderNationId); + const collapseLord = + defenderNationGenerals.find((general) => general.officerLevel === 12) ?? + (defenderNation.chiefGeneralId === null + ? undefined + : defenderNationGenerals.find((general) => general.id === defenderNation.chiefGeneralId)); + if (!collapseLord) { + throw new Error(`Collapsed nation ${defenderNationId} has no lord general.`); + } + const collapseLordId = collapseLord.id; + const defenderGenerals = defenderNationGenerals.sort((lhs, rhs) => { + // deleteNation() reads the non-lord rows in primary-key order, + // then appends the lord object to the returned PHP array. + const lhsIsLord = lhs.id === collapseLordId; + const rhsIsLord = rhs.id === collapseLordId; + if (lhsIsLord !== rhsIsLord) { + return lhsIsLord ? 1 : -1; + } + return lhs.id - rhs.id; + }); let totalGoldLoss = 0; let totalRiceLoss = 0; const defenderNationJosaUl = JosaUtil.pick(defenderNationName, '을'); const defenderNationJosaUn = JosaUtil.pick(defenderNationName, '은'); const defenderNationJosaYi = JosaUtil.pick(defenderNationName, '이'); + // Ref는 도시 수비 장수들의 onArbitraryAction/applyDB 뒤 이 순서로 + // defender nation logger와 attacker logger를 각각 flush한다. + if (defenderNationLogger) { + pushLogger(defenderNationLogger, logs, legacyFlushSequence); + } attackerLogger.pushNationHistoryLog(`${defenderNationName}${defenderNationJosaUl} 정복`); - attackerLogger.pushGlobalHistoryLog( - `【멸망】${defenderNationName}${defenderNationJosaUn} 멸망했습니다.` - ); + pushLogger(attackerLogger, logs, legacyFlushSequence); for (const general of defenderGenerals) { // Legacy Util::toInt truncates these losses rather than rounding. @@ -305,7 +348,6 @@ const resolveConquerCity = ( general.rice = clampMin(general.rice - loseRice, 0); general.experience = round(general.experience * 0.9); general.dedication = round(general.dedication * 0.5); - updateLegacyProgressionLevels(general); totalGoldLoss += loseGold; totalRiceLoss += loseRice; @@ -323,13 +365,46 @@ const resolveConquerCity = ( `도주하며 금${loseGold} 쌀${loseRice}을 분실했습니다.`, LogFormat.PLAIN ); - pushLoggers([generalLogger], logs); + // Ref calls addExperience()/addDedication() after the loss action + // and before this former general's applyDB(), so any level/rank + // notices belong to the same logger/flush epoch. + updateLegacyProgressionLevels(general, generalLogger); + if (general.id === collapseLordId) { + // deleteNation()은 멸망 전역사를 군주의 logger에 먼저 넣고, + // 군주가 배열의 마지막에서 applyDB될 때 같은 epoch으로 저장한다. + generalLogger.pushGlobalHistoryLog( + `【멸망】${defenderNationName}${defenderNationJosaUn} 멸망했습니다.` + ); + } + pushLogger(generalLogger, logs, legacyFlushSequence); affectedGenerals.add(general); if (config.joinMode !== 'onlyRandom') { - // Ref attempts to build/send a scout message after every loss. - // Message availability does not affect this draw. - rng.nextBool(0.5); + // deleteNation() has already persisted every former member as + // an unaffiliated officer before this draw and message build. + // The snapshot in the receiver-only ScoutMessage must + // therefore be neutral even though Core removes the nation + // after applying the command outcome. + if (rng.nextBool(0.5)) { + const message = buildScoutMessageDraft({ + srcGeneral: attacker, + destGeneral: { + id: general.id, + name: general.name, + nationId: 0, + officerLevel: 0, + }, + srcNation: attackerNation, + destNation: null, + time: input.messageTime, + ...(input.messageSharedIconBaseUrl + ? { sharedIconBaseUrl: input.messageSharedIconBaseUrl } + : {}), + }); + if (message) { + messages.push(message); + } + } const eligibleNpc = general.npcState >= 2 && general.npcState <= 8 && general.npcState !== 5; if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) { @@ -365,7 +440,7 @@ const resolveConquerCity = ( nationId: attackerNation.id, }); chiefLogger.pushGeneralActionLog(resourceLog, LogFormat.PLAIN); - pushLoggers([chiefLogger], logs); + pushLogger(chiefLogger, logs, legacyFlushSequence); } defenderNation.meta.collapsed = true; @@ -402,9 +477,11 @@ const resolveConquerCity = ( }); defenderLogger.pushGeneralActionLog(moveLog, LogFormat.PLAIN); if (general.officerLevel >= 5) { - defenderLogger.pushGeneralActionLog(gatherLog, LogFormat.PLAIN); + // Ref omits the explicit PLAIN argument for the chief + // gathering notice, so ActionLogger's MONTH format applies. + defenderLogger.pushGeneralActionLog(gatherLog); } - pushLoggers([defenderLogger], logs); + pushLogger(defenderLogger, logs, legacyFlushSequence); general.atmos = round(general.atmos * 0.8); if (general.officerLevel >= 5) { @@ -422,12 +499,13 @@ const resolveConquerCity = ( ? attackerNation : (input.nations.find((nation) => nation.id === conquerNationId) ?? attackerNation); + let conquerNationLogger: ActionLogger | null = null; if (conquerNationId === attackerNation.id) { attacker.cityId = defenderCity.id; affectedGenerals.add(attacker); } else { const conquerNationName = conquerNation.name; - const conquerNationLogger = new ActionLogger({ nationId: conquerNationId }); + conquerNationLogger = new ActionLogger({ nationId: conquerNationId }); const josaUl = JosaUtil.pick(cityName, '을'); const josaYi = JosaUtil.pick(conquerNationName, '이'); @@ -440,7 +518,6 @@ const resolveConquerCity = ( attackerLogger.pushNationHistoryLog( `${cityName}${josaUl} ${conquerNationName}양도` ); - pushLoggers([conquerNationLogger], logs); } // 점령 후 도시 상태를 방어 기본 상태로 되돌린다. @@ -465,7 +542,16 @@ const resolveConquerCity = ( affectedCities.add(defenderCity); affectedNations.add(conquerNation); - pushLoggers([attackerLogger], logs); + // 비멸망 경로의 defender/conquer logger는 ConquerCity() 함수가 끝날 때 + // 생성 순서대로 destruct/flush된다. attacker logger는 외부 General이 + // 소유하므로 여기서 그룹을 소비하지 않고 che_출병 line 259 epoch으로 넘긴다. + if (!nationCollapsed && defenderNationLogger) { + pushLogger(defenderNationLogger, logs, legacyFlushSequence); + } + if (conquerNationLogger) { + pushLogger(conquerNationLogger, logs, legacyFlushSequence); + } + logs.push(...attackerLogger.flush()); return { conquerNationId, @@ -476,6 +562,7 @@ const resolveConquerCity = ( nations: Array.from(affectedNations), cities: Array.from(affectedCities), generals: Array.from(affectedGenerals), + messages, ruinedNpcJoinPlans, }; }; @@ -484,6 +571,7 @@ export const resolveWarAftermath = ): WarAftermathOutcome => { const logs: LogEntryDraft[] = []; + const legacyFlushSequence = input.legacyFlushSequence ?? new LegacyWarLogFlushSequence(); const diplomacyDeltas: WarDiplomacyDelta[] = []; const affectedNations = new Set(); const affectedCities = new Set(); @@ -591,13 +679,19 @@ export const resolveWarAftermath = - general.nationId !== 0 && - general.nationId === input.defenderCity.nationId && - general.cityId === input.defenderCity.id - ); + const cityDefenders = input.generals + .filter( + (general) => + general.nationId !== 0 && + general.nationId === input.defenderCity.nationId && + general.cityId === input.defenderCity.id + ) + .sort((left, right) => left.id - right.id); for (const general of cityDefenders) { + const generalLogger = new ActionLogger({ + generalId: general.id, + nationId: general.nationId, + }); pipeline.dispatch( { general, @@ -610,25 +704,20 @@ export const resolveWarAftermath = input.nations, }, log: { - push: (text) => - logs.push({ - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.MONTH, - generalId: general.id, - nationId: general.nationId, - text, - }), + push: (text) => generalLogger.pushGeneralActionLog(text, LogFormat.MONTH), }, }, createGeneralActionEvent('city.conquered', { attacker: input.battle.attacker, }) ); + // Ref는 ID 오름차순 city defender마다 onArbitraryAction 직후 + // General::applyDB()를 호출한다. 로그가 없어도 epoch은 하나 소비한다. + pushLogger(generalLogger, logs, legacyFlushSequence); affectedGenerals.add(general); } - conquest = resolveConquerCity(input, rng); + conquest = resolveConquerCity(input, rng, legacyFlushSequence); logs.push(...conquest.logs); conquest.nations.forEach((nation) => affectedNations.add(nation)); diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index 1efcd8a6..c74db278 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -3,7 +3,7 @@ import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js'; import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; -import { LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; import { buildCrewTypeIndex as buildCrewTypeDefinitionIndex } from '@sammo-ts/logic/world/unitSet.js'; import { WarActionPipeline, type WarActionModule } from './actions.js'; import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js'; @@ -16,6 +16,7 @@ import type { WarGeneralInput, WarUnitReport, } from './types.js'; +import { LegacyWarLogFlushSequence } from './legacyFlushSequence.js'; import { getMetaNumber } from './utils.js'; import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js'; @@ -240,20 +241,13 @@ const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTra }; }; -const flushLoggers = (loggers: ActionLogger[]): Array[number]> => { - const logs: Array[number]> = []; - for (const logger of loggers) { - logs.push(...logger.flush()); - } - return logs; -}; - export const resolveWarBattle = ( input: WarBattleInput ): WarBattleOutcome => { // process_war.php 전투 루프를 순수 로직으로 이식한다. const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? '')); const loggerFactory = input.loggerFactory ?? defaultLoggerFactory; + const legacyFlushSequence = input.legacyFlushSequence ?? new LegacyWarLogFlushSequence(); const triggerRegistry: WarTriggerRegistry = { ...createCrewTypeWarTriggerRegistry(), ...(input.triggerRegistry ?? {}), @@ -340,11 +334,21 @@ export const resolveWarBattle = | null, + prevDefender: WarUnit | null, reqNext: boolean ): WarUnit | null => { + if (prevDefender instanceof WarUnitGeneral) { + // Ref getNextDefender()는 다음 상대를 고르기 전에 직전 수비 장수의 + // General::applyDB()를 호출해 그 logger만 먼저 flush한다. + logs.push(...legacyFlushSequence.flush(prevDefender.getLogger())); + } else if (prevDefender instanceof WarUnitCity) { + // WarUnitCity::applyDB()는 도시 logger를 rollback하므로 저장하지 않는다. + legacyFlushSequence.discard(prevDefender.getLogger()); + } + if (!reqNext) { return null; } @@ -698,9 +702,11 @@ export const resolveWarBattle = unit.getLogger())]); + // processWar()는 마지막 수비자 applyDB 뒤 공격자 applyDB를 별도 epoch으로 수행한다. + logs.push(...legacyFlushSequence.flush(attackerLogger)); const reports: WarUnitReport[] = [ resolveUnitReport(attackerUnit), diff --git a/packages/logic/src/war/legacyFlushSequence.ts b/packages/logic/src/war/legacyFlushSequence.ts new file mode 100644 index 00000000..726efc83 --- /dev/null +++ b/packages/logic/src/war/legacyFlushSequence.ts @@ -0,0 +1,39 @@ +import { orderLegacyActionLoggerFlush } from '@sammo-ts/logic/logging/actionLogger.js'; +import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; +import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; + +/** + * Ref의 한 `ActionLogger::flush()`/`General::applyDB()` 경계를 로그 정렬 그룹 하나로 보존한다. + * 시작값을 생략하면 그룹 표식 없이 logger별 bucket 순서만 유지한다. + */ +export class LegacyWarLogFlushSequence { + public constructor(private nextLegacyFlushGroup?: number) {} + + public claimGroup(): number | undefined { + if (this.nextLegacyFlushGroup === undefined) { + return undefined; + } + const group = this.nextLegacyFlushGroup; + this.nextLegacyFlushGroup += 1; + return group; + } + + public flush(logger: ActionLogger): LogEntryDraft[] { + return this.flushEntries(logger.flush()); + } + + public flushEntries(entries: readonly LogEntryDraft[]): LogEntryDraft[] { + const ordered = orderLegacyActionLoggerFlush(entries); + const group = this.claimGroup(); + if (group === undefined) { + return ordered; + } + return ordered.map((entry) => ({ ...entry, legacyFlushGroup: group })); + } + + /** WarUnitCity::applyDB()처럼 logger 내용을 버리지만 flush epoch은 소비하는 경계. */ + public discard(logger: ActionLogger): void { + logger.rollback(); + this.claimGroup(); + } +} diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts index 0ddda6dc..ee03a100 100644 --- a/packages/logic/src/war/types.ts +++ b/packages/logic/src/war/types.ts @@ -4,10 +4,12 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; +import type { MessageDraft } from '@sammo-ts/logic/messages/message.js'; import type { TracePort } from '@sammo-ts/logic/ports/trace.js'; import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; import type { WarActionModule } from './actions.js'; import type { WarTriggerRegistry } from './triggers.js'; +import type { LegacyWarLogFlushSequence } from './legacyFlushSequence.js'; export interface WarArmTypes { footman?: number; @@ -58,6 +60,8 @@ export interface WarBattleInput ActionLogger; + /** Ref processWar의 logger/applyDB epoch을 명령 전체에서 이어 주는 순서 cursor. */ + legacyFlushSequence?: LegacyWarLogFlushSequence; trace?: (event: WarBattleTraceEvent) => void; } @@ -177,6 +181,7 @@ export interface ConquerCityOutcome[]; + messages: MessageDraft[]; ruinedNpcJoinPlans: RuinedNpcJoinPlan[]; } @@ -192,9 +197,14 @@ export interface WarAftermathInput | null | undefined>; + /** 전투에서 시작한 Ref logger/applyDB epoch 순서를 점령 후처리까지 이어 간다. */ + legacyFlushSequence?: LegacyWarLogFlushSequence; calcNationTechGain?: (context: WarAftermathTechContext) => number; trace?: TracePort; } diff --git a/packages/logic/test/actions/turn/appointmentLogFormat.test.ts b/packages/logic/test/actions/turn/appointmentLogFormat.test.ts index 7418b217..5e415ec0 100644 --- a/packages/logic/test/actions/turn/appointmentLogFormat.test.ts +++ b/packages/logic/test/actions/turn/appointmentLogFormat.test.ts @@ -116,5 +116,8 @@ describe('appointment global summary log format', () => { ); expectMonthlySummary(logs); + expect( + logs.find((entry) => entry.scope === LogScope.GENERAL && entry.category === LogCategory.HISTORY)?.format + ).toBe(LogFormat.YEAR_MONTH); }); }); diff --git a/packages/logic/test/actions/turn/executionHelper.test.ts b/packages/logic/test/actions/turn/executionHelper.test.ts index 9e5142be..e3203cd1 100644 --- a/packages/logic/test/actions/turn/executionHelper.test.ts +++ b/packages/logic/test/actions/turn/executionHelper.test.ts @@ -27,18 +27,21 @@ describe('processGeneralActionWithFallback', () => { const fallbackResolver: GeneralActionResolver = { key: 'fallbackCmd', - resolve: () => ({ - effects: [ - { - type: 'log', - entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' }, - } as any, - ], - alternative: { - commandKey: 'alternativeCmd', - args: { foo: 'bar' }, - }, - }), + resolve: (context) => { + context.addPostProgressionLog?.('Primary post-progression'); + return { + effects: [ + { + type: 'log', + entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' }, + } as any, + ], + alternative: { + commandKey: 'alternativeCmd', + args: { foo: 'bar' }, + }, + }; + }, }; const alternativeResolver: GeneralActionResolver = { @@ -100,18 +103,8 @@ describe('processGeneralActionWithFallback', () => { mockLoader ); - // It should eventually execute alternativeResolver - // BUT resolveGeneralAction creates a FRESH resolution from the FINAL resolver. - // It does NOT merge logs currently. (As per my implementation comment) - // Wait, did I implement log merging? No. - // I implemented a simple loop that re-runs `resolveGeneralAction`. - // So the final resolution comes from `alternativeResolver`. - - // Let's verify what we expect. - // If we want legacy parity, we might expect logs from the first attempt too. - // But for now, let's verify the loop works. - expect(resolution.logs).toHaveLength(2); // 'Primary failed' + 'Alternative executed...' + expect(resolution.postProgressionLogs.map((entry) => entry.text)).toEqual(['Primary post-progression']); expect(resolution.alternative).toBeUndefined(); // The final one succeeded expect(mockLoader.load).toHaveBeenCalledWith('alternativeCmd'); }); diff --git a/packages/logic/test/actions/turn/generalCommandLogFormatParity.test.ts b/packages/logic/test/actions/turn/generalCommandLogFormatParity.test.ts new file mode 100644 index 00000000..3cdc8b7f --- /dev/null +++ b/packages/logic/test/actions/turn/generalCommandLogFormatParity.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import type { City, General, Nation } from '../../../src/domain/entities.js'; +import type { GeneralActionResolveContext } from '../../../src/actions/engine.js'; +import { ActionDefinition as TradeAction } from '../../../src/actions/turn/general/che_군량매매.js'; +import { ActionResolver as ResignAction } from '../../../src/actions/turn/general/che_하야.js'; +import { ActionResolver as RetireAction } from '../../../src/actions/turn/general/che_은퇴.js'; +import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; +import { finalizeLogEntry } from '../../../src/logging/entries.js'; +import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js'; + +const makeGeneral = (overrides: Partial = {}): General => + ({ + id: 1, + name: '검증장수', + nationId: 2, + cityId: 3, + troopId: 0, + npcState: 0, + officerLevel: 1, + experience: 100, + dedication: 100, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 1, + train: 100, + atmos: 100, + injury: 0, + age: 60, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: {}, + ...overrides, + }) as General; + +const nation = { + id: 2, + name: '검증국', + color: '#ff0000', + capitalCityId: 3, + chiefGeneralId: 1, + gold: 10_000, + rice: 10_000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: { gennum: 1 }, +} satisfies Nation; + +const city = { + id: 3, + name: '검증도시', + nationId: nation.id, + level: 1, + state: 0, + population: 20_000, + populationMax: 50_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + defence: 300, + defenceMax: 1_000, + wall: 300, + wallMax: 1_000, + supplyState: 1, + frontState: 0, + meta: { trade: 100 }, +} satisfies City; + +const createLogSink = + (logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] => + (text, options = {}) => { + const entry: LogEntryDraft = { + scope: options.scope ?? LogScope.GENERAL, + category: options.category ?? LogCategory.ACTION, + text, + ...options, + }; + if (entry.scope === LogScope.GENERAL && entry.generalId === undefined) { + entry.generalId = 1; + } + logs.push(entry); + }; + +const expectMonthlyPersistence = (entry: LogEntryDraft, legacyWrongFormat: LogFormat): void => { + expect(entry.format).toBe(LogFormat.MONTH); + + const persisted = finalizeLogEntry(entry, { year: 186, month: 9 }); + const mutant = finalizeLogEntry({ ...entry, format: legacyWrongFormat }, { year: 186, month: 9 }); + + expect(persisted?.text).toMatch(/^●<\/>9월:/u); + expect(mutant?.text).not.toBe(persisted?.text); + expect(mutant?.text).not.toMatch(/^●<\/>9월:/u); +}; + +describe('general command Ref log format parity', () => { + it.each([ + { buyRice: true, amount: 100 }, + { buyRice: false, amount: 100 }, + ])('keeps che_군량매매 action logs on the Ref monthly format for $buyRice', (args) => { + const logs: LogEntryDraft[] = []; + const action = new TradeAction(); + + action.resolve( + { + general: makeGeneral(), + city, + nation: { ...nation }, + rng: { nextFloat1: () => 0 }, + addLog: createLogSink(logs), + } as unknown as Parameters[0], + args + ); + + expect(logs).toHaveLength(1); + expectMonthlyPersistence(logs[0]!, LogFormat.PLAIN); + }); + + it('keeps the che_하야 system summary on the Ref monthly format', () => { + const logs: LogEntryDraft[] = []; + const action = new ResignAction({ defaultNpcGold: 1_000, defaultNpcRice: 1_000 } as TurnCommandEnv); + + action.resolve( + { + general: makeGeneral(), + nation, + troopMembers: [], + rng: {}, + addLog: createLogSink(logs), + } as unknown as Parameters[0], + {} + ); + + const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY); + expect(summary).toBeDefined(); + expectMonthlyPersistence(summary!, LogFormat.RAWTEXT); + }); + + it('keeps the che_은퇴 system summary on the Ref monthly format', () => { + const logs: LogEntryDraft[] = []; + const action = new RetireAction(); + + action.resolve( + { + general: makeGeneral(), + rng: {}, + addLog: createLogSink(logs), + } as unknown as Parameters[0], + {} + ); + + const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY); + expect(summary).toBeDefined(); + expectMonthlyPersistence(summary!, LogFormat.RAWTEXT); + }); +}); diff --git a/packages/logic/test/actions/turn/nationCapitalCommandLogParity.test.ts b/packages/logic/test/actions/turn/nationCapitalCommandLogParity.test.ts new file mode 100644 index 00000000..6141f68d --- /dev/null +++ b/packages/logic/test/actions/turn/nationCapitalCommandLogParity.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest'; +import type { City, General, GeneralTriggerState, Nation } from '../../../src/domain/entities.js'; +import { + resolveGeneralAction, + type GeneralActionResolveInputContext, + type GeneralActionResolver, +} from '../../../src/actions/engine.js'; +import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js'; +import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js'; +import { ActionDefinition as ReduceCityAction } from '../../../src/actions/turn/nation/che_감축.js'; +import { ActionDefinition as RandomMoveCapitalAction } from '../../../src/actions/turn/nation/che_무작위수도이전.js'; +import { ActionDefinition as ScorchedEarthAction } from '../../../src/actions/turn/nation/che_초토화.js'; +import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; +import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js'; +import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '../../../src/logging/types.js'; +import type { TurnSchedule } from '../../../src/turn/calendar.js'; +import type { MapDefinition } from '../../../src/world/types.js'; + +const ENV: TurnCommandEnv = { + develCost: 100, + trainDelta: 30, + atmosDelta: 30, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 0.1, + sabotageDefenceCoefByGeneralCount: 0.1, + sabotageDamageMin: 10, + sabotageDamageMax: 30, + openingPartYear: 3, + maxGeneral: 500, + defaultNpcGold: 1_000, + defaultNpcRice: 1_000, + defaultCrewTypeId: 1_100, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + initialNationGenLimit: 10, + maxTechLevel: 12, + baseGold: 1_000, + baseRice: 2_000, + maxResourceActionAmount: 10_000, +}; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 60 }] }; +const rng = { + nextFloat1: () => 0, + nextBool: () => false, + nextInt: () => 0, +}; + +const makeGeneral = (id: number, name = '운영자'): General => ({ + id, + name, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 1_000, + dedication: 1_000, + officerLevel: id === 1 ? 12 : 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 1_100, + train: 100, + atmos: 100, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, betray: 0 }, +}); + +const makeNation = (): Nation => ({ + id: 1, + name: '위', + color: '#111111', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 1_000_000, + rice: 1_000_000, + power: 1_000, + level: 1, + typeCode: 'che_명가', + meta: { capset: 0, can_무작위수도이전: 1, surlimit: 0 }, +}); + +const makeCity = (id: number, name: string, nationId: number): City => ({ + id, + name, + nationId, + level: 5, + state: 0, + population: 200_000, + populationMax: 300_000, + agriculture: 3_000, + agricultureMax: 4_000, + commerce: 3_000, + commerceMax: 4_000, + security: 3_000, + securityMax: 4_000, + supplyState: 1, + frontState: 0, + defence: 3_000, + defenceMax: 4_000, + wall: 3_000, + wallMax: 4_000, + conflict: {}, + meta: { trust: 80, trade: 100 }, +}); + +const mapStats = { + population: 200_000, + agriculture: 3_000, + commerce: 3_000, + security: 3_000, + defence: 3_000, + wall: 3_000, +}; + +const map: MapDefinition = { + id: 'nation-capital-log-parity', + name: '국가 명령 로그', + cities: [ + { + id: 1, + name: '허창', + level: 5, + region: 1, + position: { x: 0, y: 0 }, + connections: [2], + initial: mapStats, + max: mapStats, + }, + { + id: 2, + name: '낙양', + level: 5, + region: 1, + position: { x: 1, y: 0 }, + connections: [1], + initial: mapStats, + max: mapStats, + }, + ], +}; + +const resolveLogs = ( + resolver: GeneralActionResolver, + context: GeneralActionResolveInputContext & Record, + args: Args +): LogEntryDraft[] => + orderLegacyActionLoggerFlush( + resolveGeneralAction(resolver, context, { now: new Date('2026-08-23T00:00:00.000Z'), schedule }, args).logs + ); + +const projectLogs = (logs: readonly LogEntryDraft[]) => + logs.map((log) => [ + log.scope, + log.category, + log.generalId ?? null, + log.nationId ?? null, + log.format, + log.legacyFlushGroup ?? 0, + log.text, + ]); + +const expectedActorLoggerFlush = (params: { + actorId?: number; + nationId?: number; + generalHistory: string; + generalAction: string; + nationHistory: string; + globalHistory: string; + globalSummary: string; +}) => [ + [LogScope.GENERAL, LogCategory.HISTORY, params.actorId ?? 1, null, LogFormat.YEAR_MONTH, 0, params.generalHistory], + [LogScope.GENERAL, LogCategory.ACTION, params.actorId ?? 1, null, LogFormat.MONTH, 0, params.generalAction], + [LogScope.NATION, LogCategory.HISTORY, null, params.nationId ?? 1, LogFormat.YEAR_MONTH, 0, params.nationHistory], + [LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0, params.globalHistory], + [LogScope.SYSTEM, LogCategory.SUMMARY, null, null, LogFormat.MONTH, 0, params.globalSummary], +]; + +describe('nation capital command Ref ActionLogger parity', () => { + it('che_천도', () => { + const general = makeGeneral(1); + const nation = makeNation(); + const capitalCity = makeCity(1, '허창', 1); + const destCity = makeCity(2, '낙양', 1); + const logs = resolveLogs( + new MoveCapitalAction(ENV), + { general, city: capitalCity, nation, destCity, map, nationCities: [capitalCity, destCity], rng }, + { destCityID: destCity.id } + ); + + expect(projectLogs(logs)).toEqual( + expectedActorLoggerFlush({ + generalHistory: '낙양으로 천도명령', + generalAction: '낙양으로 천도했습니다.', + nationHistory: '운영자가 낙양으로 천도 명령', + globalHistory: '【천도】낙양으로 천도하였습니다.', + globalSummary: '운영자가 낙양으로 천도를 명령하였습니다.', + }) + ); + }); + + it.each([ + { + action: '증축', + resolver: new ExpandCityAction(ENV), + globalHistoryPrefix: '【증축】', + }, + { + action: '감축', + resolver: new ReduceCityAction(ENV), + globalHistoryPrefix: '【감축】', + }, + ])('che_$action', ({ action, resolver, globalHistoryPrefix }) => { + const general = makeGeneral(1); + const nation = makeNation(); + const capitalCity = makeCity(1, '낙양', 1); + const logs = resolveLogs(resolver, { general, city: capitalCity, nation, capitalCity, rng }, {}); + + expect(projectLogs(logs)).toEqual( + expectedActorLoggerFlush({ + generalHistory: `낙양${action}`, + generalAction: `낙양을 ${action}했습니다.`, + nationHistory: `운영자가 낙양${action}`, + globalHistory: `${globalHistoryPrefix}낙양${action}하였습니다.`, + globalSummary: `운영자가 낙양${action}하였습니다.`, + }) + ); + }); + + it('che_무작위수도이전', () => { + const general = makeGeneral(1); + const follower = makeGeneral(2, '부하'); + const nation = makeNation(); + const capitalCity = makeCity(1, '허창', 1); + const destCity = makeCity(2, '낙양', 0); + const logs = resolveLogs( + new RandomMoveCapitalAction(), + { + general, + city: capitalCity, + nation, + neutralCandidateCities: [destCity], + nationGenerals: [general, follower], + oldCapitalCity: capitalCity, + rng, + }, + {} + ); + + expect(projectLogs(logs)).toEqual([ + [ + LogScope.GENERAL, + LogCategory.ACTION, + follower.id, + null, + LogFormat.PLAIN, + -1, + '국가 수도를 낙양으로 옮겼습니다.', + ], + ...expectedActorLoggerFlush({ + generalHistory: '낙양으로 무작위 수도 이전', + generalAction: '낙양으로 국가를 옮겼습니다.', + nationHistory: '운영자가 낙양으로 무작위 수도 이전', + globalHistory: + '【무작위 수도 이전】낙양으로 수도 이전하였습니다.', + globalSummary: '운영자가 낙양으로 수도 이전하였습니다.', + }), + ]); + }); + + it('che_초토화', () => { + const general = makeGeneral(1); + const follower = makeGeneral(2, '부하'); + const nation = makeNation(); + const capitalCity = makeCity(1, '허창', 1); + const destCity = makeCity(2, '낙양', 1); + const logs = resolveLogs( + new ScorchedEarthAction(), + { + general, + city: capitalCity, + nation, + destCity, + destNation: nation, + friendlyGenerals: [general, follower], + rng, + }, + { destCityId: destCity.id } + ); + + expect(projectLogs(logs)).toEqual( + expectedActorLoggerFlush({ + generalHistory: '낙양초토화 명령', + generalAction: '낙양을 초토화했습니다.', + nationHistory: '운영자가 낙양초토화 명령', + globalHistory: '【초토화】낙양초토화하였습니다.', + globalSummary: '운영자가 낙양초토화하였습니다.', + }) + ); + }); +}); diff --git a/packages/logic/test/actions/turn/nationCommandLogParity.test.ts b/packages/logic/test/actions/turn/nationCommandLogParity.test.ts new file mode 100644 index 00000000..68fe2c06 --- /dev/null +++ b/packages/logic/test/actions/turn/nationCommandLogParity.test.ts @@ -0,0 +1,267 @@ +import { ConstantRNG, RandUtil } from '@sammo-ts/common'; +import { describe, expect, it } from 'vitest'; + +import type { City, General, Nation } from '../../../src/domain/entities.js'; +import { resolveGeneralAction, type TurnScheduleContext } from '../../../src/actions/engine.js'; +import { ActionDefinition as TroopKickAction } from '../../../src/actions/turn/nation/che_부대탈퇴지시.js'; +import { ActionDefinition as PopulationMoveAction } from '../../../src/actions/turn/nation/cr_인구이동.js'; +import { ActionResolver as MobilizePeopleAction } from '../../../src/actions/turn/nation/che_백성동원.js'; +import { + ActionResolver as VolunteerRecruitAction, + type VolunteerRecruitEnvironment, +} from '../../../src/actions/turn/nation/che_의병모집.js'; +import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; +import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js'; +import { finalizeLogEntry } from '../../../src/logging/entries.js'; +import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js'; + +const scheduleContext: TurnScheduleContext = { + now: new Date('2026-08-23T00:00:00.000Z'), + schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] }, +}; + +const makeGeneral = (overrides: Partial = {}): General => + ({ + id: 1, + name: '군주', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + officerLevel: 12, + experience: 1_000, + dedication: 1_000, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 1, + train: 100, + atmos: 100, + injury: 0, + age: 30, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: {}, + ...overrides, + }) as General; + +const makeNation = (overrides: Partial = {}): Nation => ({ + id: 1, + name: '검증국', + color: '#ff0000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 10_000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: { gennum: 2, strategic_cmd_limit: 0 }, + ...overrides, +}); + +const makeCity = (overrides: Partial = {}): City => ({ + id: 1, + name: '성도', + nationId: 1, + level: 1, + state: 0, + population: 50_000, + populationMax: 100_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + defence: 300, + defenceMax: 1_000, + wall: 300, + wallMax: 1_000, + supplyState: 1, + frontState: 0, + meta: {}, + ...overrides, +}); + +const makeRng = (): RandUtil => new RandUtil(new ConstantRNG(0)); + +const expectMonthlyPersistence = (entry: LogEntryDraft, wrongFormat: LogFormat): void => { + expect(entry.format).toBe(LogFormat.MONTH); + const persisted = finalizeLogEntry(entry, { year: 186, month: 9 }); + const mutant = finalizeLogEntry({ ...entry, format: wrongFormat }, { year: 186, month: 9 }); + + expect(persisted?.text).toMatch(/^●<\/>9월:/u); + expect(mutant?.text).not.toBe(persisted?.text); + expect(mutant?.text).not.toMatch(/^●<\/>9월:/u); +}; + +describe('nation command Ref log parity', () => { + it('flushes che_부대탈퇴지시 actor then target with the Ref monthly format', () => { + const actor = makeGeneral(); + const target = makeGeneral({ id: 2, name: '부대원', troopId: 3 }); + const resolution = resolveGeneralAction( + new TroopKickAction(), + { + general: actor, + nation: makeNation(), + city: makeCity(), + destGeneral: target, + rng: makeRng(), + } as never, + scheduleContext, + { destGeneralId: target.id } + ); + const logs = orderLegacyActionLoggerFlush(resolution.logs); + + expect(logs).toHaveLength(2); + expect(logs.map((entry) => entry.generalId)).toEqual([actor.id, target.id]); + expect(logs.map((entry) => entry.text)).toEqual([ + '부대원에게 부대 탈퇴를 지시했습니다.', + '군주에게 부대 탈퇴를 지시 받았습니다.', + ]); + expect(logs[1]?.legacyFlushGroup).toBe(1); + expectMonthlyPersistence(logs[1]!, LogFormat.PLAIN); + }); + + it('keeps cr_인구이동 population text ungrouped like PHP integer interpolation', () => { + const actor = makeGeneral(); + const source = makeCity(); + const destination = makeCity({ id: 2, name: '락양', population: 10_000 }); + const resolution = resolveGeneralAction( + new PopulationMoveAction({ develCost: 100, baseGold: 1_000, baseRice: 1_000 } as TurnCommandEnv), + { + general: actor, + nation: makeNation(), + city: source, + destCity: destination, + destNation: makeNation(), + rng: makeRng(), + } as never, + scheduleContext, + { destCityId: destination.id, amount: 10_000 } + ); + const [entry] = resolution.logs; + + expect(entry).toMatchObject({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: actor.id, + format: LogFormat.MONTH, + text: '락양으로 인구 10000명을 옮겼습니다.', + }); + expect(entry?.text).not.toBe('락양으로 인구 10,000명을 옮겼습니다.'); + }); + + it('keeps che_백성동원 notification and history streams distinct in Ref flush order', () => { + const actor = makeGeneral(); + const target = makeGeneral({ id: 2, name: '동료' }); + const nation = makeNation(); + const destination = makeCity(); + const resolution = resolveGeneralAction( + new MobilizePeopleAction([], 10), + { + general: actor, + nation, + city: makeCity(), + destCity: destination, + friendlyGenerals: [actor, target], + rng: makeRng(), + } as never, + scheduleContext, + { destCityId: destination.id } + ); + const logs = orderLegacyActionLoggerFlush(resolution.logs); + + expect(logs.map((entry) => [entry.scope, entry.category, entry.generalId, entry.nationId])).toEqual([ + [LogScope.GENERAL, LogCategory.ACTION, target.id, undefined], + [LogScope.GENERAL, LogCategory.HISTORY, actor.id, undefined], + [LogScope.GENERAL, LogCategory.ACTION, actor.id, undefined], + [LogScope.NATION, LogCategory.HISTORY, undefined, nation.id], + ]); + expect(logs[0]).toMatchObject({ + text: '군주가 성도백성동원을 하였습니다.', + format: LogFormat.PLAIN, + legacyFlushGroup: -1, + }); + expect(logs[3]).toMatchObject({ + text: '군주가 성도백성동원을 발동', + format: LogFormat.YEAR_MONTH, + }); + expect(logs[3]?.text).not.toBe(logs[0]?.text); + }); + + it('preserves che_의병모집 actor history markup and Ref flush order', () => { + const actor = makeGeneral(); + const target = makeGeneral({ id: 2, name: '동료' }); + const nation = makeNation(); + const environment: VolunteerRecruitEnvironment = { + openingPartYear: 0, + initialNationGenLimit: 10, + defaultNpcGold: 1_000, + defaultNpcRice: 1_000, + defaultCrewTypeId: 1, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + createCountBase: 0, + createCountDivisor: 8, + }; + const resolution = resolveGeneralAction( + new VolunteerRecruitAction([], environment), + { + general: actor, + nation, + city: makeCity(), + rng: makeRng(), + currentYear: 190, + currentMonth: 1, + startYear: 180, + centennialRules: { + defaultStatMin: 15, + defaultStatMax: 80, + defaultStatTotal: 165, + maxStatLevel: 255, + defaultSpecialDomestic: null, + dexLimit: 1_000_000, + }, + centennialNpcDexTargetRatio: 0.4, + averageNationGeneralCount: 0, + nationAverageStats: { leadership: 50, strength: 50, intelligence: 50 }, + nationAverageExperience: 0, + nationAverageDedication: 0, + nationAverageDex: [100, 100, 100, 100, 100], + friendlyGenerals: [actor, target], + createGeneralId: () => 3, + turnTermSeconds: 60, + turnTimeBase: new Date('0190-01-01T00:00:00.000Z'), + ticksPerSecond: 1, + } as never, + scheduleContext, + {} + ); + const logs = orderLegacyActionLoggerFlush(resolution.logs); + + expect(logs.map((entry) => [entry.scope, entry.category, entry.generalId, entry.nationId])).toEqual([ + [LogScope.GENERAL, LogCategory.ACTION, target.id, undefined], + [LogScope.GENERAL, LogCategory.HISTORY, actor.id, undefined], + [LogScope.GENERAL, LogCategory.ACTION, actor.id, undefined], + [LogScope.NATION, LogCategory.HISTORY, undefined, nation.id], + ]); + expect(logs[0]).toMatchObject({ + text: '군주가 의병모집을 발동하였습니다.', + format: LogFormat.PLAIN, + legacyFlushGroup: -1, + }); + expect(logs[1]).toMatchObject({ + text: '의병모집을 발동', + format: LogFormat.YEAR_MONTH, + }); + expect(logs[1]?.text).not.toBe('의병모집 발동'); + }); +}); diff --git a/packages/logic/test/actions/turn/nationDeceptionCommandLogParity.test.ts b/packages/logic/test/actions/turn/nationDeceptionCommandLogParity.test.ts new file mode 100644 index 00000000..a5d420c1 --- /dev/null +++ b/packages/logic/test/actions/turn/nationDeceptionCommandLogParity.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from 'vitest'; + +import type { RandomGenerator } from '@sammo-ts/common'; + +import type { GeneralActionOutcome, GeneralActionResolveContext } from '../../../src/actions/engine.js'; +import type { City, General, Nation } from '../../../src/domain/entities.js'; +import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js'; +import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js'; +import { + ActionResolver as DegradeRelationsResolver, + type DegradeRelationsResolveContext, +} from '../../../src/actions/turn/nation/che_이호경식.js'; +import { ActionResolver as RaidResolver, type RaidResolveContext } from '../../../src/actions/turn/nation/che_급습.js'; +import { + ActionResolver as LastStandResolver, + type DesperateFightResolveContext, +} from '../../../src/actions/turn/nation/che_필사즉생.js'; +import { + ActionResolver as DeceptionResolver, + type DeceptionResolveContext, +} from '../../../src/actions/turn/nation/che_허보.js'; +import { + ActionResolver as CounterStrategyResolver, + type CounterStrategyResolveContext, +} from '../../../src/actions/turn/nation/che_피장파장.js'; + +const rng: RandomGenerator = { + nextFloat1: () => 0.5, + nextBool: () => false, + nextInt: (minInclusive) => minInclusive, +}; + +const buildGeneral = (id: number, nationId: number, cityId: number, name: string): General => ({ + id, + name, + nationId, + cityId, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 100, + dedication: 100, + officerLevel: id === 1 ? 12 : 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 1, + train: 80, + atmos: 80, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, +}); + +const buildNation = (id: number, name: string, chiefGeneralId: number | null): Nation => ({ + id, + name, + color: '#000000', + capitalCityId: id * 100, + chiefGeneralId, + gold: 10_000, + rice: 10_000, + power: 100, + level: 1, + typeCode: 'test', + meta: { gennum: 3, strategic_cmd_limit: 0 }, +}); + +const buildCity = (id: number, nationId: number, name: string): City => ({ + id, + name, + nationId, + level: 1, + state: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 300, + defenceMax: 1_000, + wall: 300, + wallMax: 1_000, + meta: {}, +}); + +const buildFixture = () => { + const actor = buildGeneral(1, 10, 100, '가람'); + const friendlyTargets = [buildGeneral(2, 10, 100, '아군일'), buildGeneral(3, 10, 100, '아군이')]; + const destTargets = [buildGeneral(4, 20, 200, '적군일'), buildGeneral(5, 20, 200, '적군이')]; + return { + actor, + friendlyTargets, + destTargets, + nation: buildNation(10, '촉', actor.id), + destNation: buildNation(20, '위', destTargets[0]!.id), + destCity: buildCity(200, 20, '업'), + safeDestCity: buildCity(201, 20, '평원'), + }; +}; + +const createActorLogSink = (actorId: number, logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] => { + return (text, options = {}) => { + const entry: LogEntryDraft = { + scope: options.scope ?? LogScope.GENERAL, + category: options.category ?? LogCategory.ACTION, + text, + format: options.format ?? LogFormat.MONTH, + ...options, + }; + if (entry.scope === LogScope.GENERAL && entry.generalId === undefined) { + entry.generalId = actorId; + } + logs.push(entry); + }; +}; + +const collectLogs = ( + actorId: number, + resolve: (addLog: GeneralActionResolveContext['addLog']) => GeneralActionOutcome +): LogEntryDraft[] => { + const logs: LogEntryDraft[] = []; + const outcome = resolve(createActorLogSink(actorId, logs)); + for (const effect of outcome.effects) { + if (effect.type === 'log') { + logs.push(effect.entry); + } + } + return logs; +}; + +interface RefFlushExpectation { + actorId: number; + sourceNationId: number; + destNationId?: number; + friendlyTargetIds: number[]; + destTargetIds: number[]; + friendlyText: string; + destText?: string; + destNationText?: string; + destNationFormat?: LogFormat; + actorHistoryText: string; + actorActionText: string; + sourceNationText: string; +} + +const projectLog = (entry: LogEntryDraft) => ({ + scope: entry.scope, + category: entry.category, + owner: + entry.generalId !== undefined + ? `general:${entry.generalId}` + : entry.nationId !== undefined + ? `nation:${entry.nationId}` + : 'none', + text: entry.text, + format: entry.format, + group: entry.legacyFlushGroup ?? 0, +}); + +const expectRefFlush = (logs: LogEntryDraft[], expected: RefFlushExpectation): void => { + const internalEpochCount = + expected.friendlyTargetIds.length + expected.destTargetIds.length + (expected.destNationText ? 1 : 0); + const firstInternalGroup = -internalEpochCount; + const expectedLogs = [ + ...expected.friendlyTargetIds.map((generalId, index) => ({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + owner: `general:${generalId}`, + text: expected.friendlyText, + format: LogFormat.PLAIN, + group: firstInternalGroup + index, + })), + ...expected.destTargetIds.map((generalId, index) => ({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + owner: `general:${generalId}`, + text: expected.destText, + format: LogFormat.PLAIN, + group: firstInternalGroup + expected.friendlyTargetIds.length + index, + })), + ...(expected.destNationText + ? [ + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + owner: `nation:${expected.destNationId}`, + text: expected.destNationText, + format: expected.destNationFormat, + group: -1, + }, + ] + : []), + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + owner: `general:${expected.actorId}`, + text: expected.actorHistoryText, + format: LogFormat.YEAR_MONTH, + group: 0, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + owner: `general:${expected.actorId}`, + text: expected.actorActionText, + format: LogFormat.MONTH, + group: 0, + }, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + owner: `nation:${expected.sourceNationId}`, + text: expected.sourceNationText, + format: LogFormat.YEAR_MONTH, + group: 0, + }, + ]; + + expect(orderLegacyActionLoggerFlush(logs).map(projectLog)).toEqual(expectedLogs); +}; + +describe('nation deception command Ref log parity', () => { + it('preserves che_이호경식 logger epochs, texts, categories, and formats', () => { + const fixture = buildFixture(); + const logs = collectLogs(fixture.actor.id, (addLog) => + new DegradeRelationsResolver([]).resolve( + { + general: fixture.actor, + nation: fixture.nation, + destNation: fixture.destNation, + diplomacy: { state: 0, term: 3 }, + reverseDiplomacy: { state: 0, term: 3 }, + friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets], + destNationGenerals: fixture.destTargets, + rng, + addLog, + } satisfies DegradeRelationsResolveContext, + { destNationId: fixture.destNation.id } + ) + ); + + expectRefFlush(logs, { + actorId: fixture.actor.id, + sourceNationId: fixture.nation.id, + destNationId: fixture.destNation.id, + friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id), + destTargetIds: fixture.destTargets.map((general) => general.id), + friendlyText: '가람이 이호경식을 발동하였습니다.', + destText: '이 아국에 이호경식을 발동하였습니다.', + destNationText: '가람이 아국에 이호경식을 발동', + destNationFormat: LogFormat.YEAR_MONTH, + actorHistoryText: '이호경식을 발동', + actorActionText: '이호경식 발동!', + sourceNationText: '가람이 이호경식을 발동', + }); + }); + + it('preserves che_급습 logger epochs, texts, categories, and formats', () => { + const fixture = buildFixture(); + const logs = collectLogs(fixture.actor.id, (addLog) => + new RaidResolver([]).resolve( + { + general: fixture.actor, + nation: fixture.nation, + destNation: fixture.destNation, + diplomacy: { state: 1, term: 18 }, + reverseDiplomacy: { state: 1, term: 18 }, + friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets], + destNationGenerals: fixture.destTargets, + rng, + addLog, + } satisfies RaidResolveContext, + { destNationId: fixture.destNation.id } + ) + ); + + expectRefFlush(logs, { + actorId: fixture.actor.id, + sourceNationId: fixture.nation.id, + destNationId: fixture.destNation.id, + friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id), + destTargetIds: fixture.destTargets.map((general) => general.id), + friendlyText: '가람이 급습을 발동하였습니다.', + destText: '아국에 급습이 발동되었습니다.', + destNationText: '가람이 아국에 급습을 발동', + destNationFormat: LogFormat.YEAR_MONTH, + actorHistoryText: '급습을 발동', + actorActionText: '급습 발동!', + sourceNationText: '가람이 급습을 발동', + }); + }); + + it('preserves che_필사즉생 target applyDB epochs before the actor logger', () => { + const fixture = buildFixture(); + const logs = collectLogs(fixture.actor.id, (addLog) => + new LastStandResolver([]).resolve( + { + general: fixture.actor, + nation: fixture.nation, + nationGenerals: [fixture.actor, ...fixture.friendlyTargets], + rng, + addLog, + } satisfies DesperateFightResolveContext, + {} + ) + ); + + expectRefFlush(logs, { + actorId: fixture.actor.id, + sourceNationId: fixture.nation.id, + friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id), + destTargetIds: [], + friendlyText: '가람이 필사즉생을 발동하였습니다.', + actorHistoryText: '필사즉생을 발동', + actorActionText: '필사즉생 발동!', + sourceNationText: '가람이 필사즉생을 발동', + }); + }); + + it('preserves che_허보 per-general applyDB epochs and plain target-nation history', () => { + const fixture = buildFixture(); + const deceptionRng: RandomGenerator = { ...rng, nextInt: () => 1 }; + const logs = collectLogs(fixture.actor.id, (addLog) => + new DeceptionResolver([]).resolve( + { + general: fixture.actor, + nation: fixture.nation, + destNation: fixture.destNation, + destCity: fixture.destCity, + destCityGenerals: fixture.destTargets, + friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets], + destNationSupplyCities: [fixture.destCity, fixture.safeDestCity], + rng: deceptionRng, + addLog, + } satisfies DeceptionResolveContext, + { destCityId: fixture.destCity.id } + ) + ); + + expectRefFlush(logs, { + actorId: fixture.actor.id, + sourceNationId: fixture.nation.id, + destNationId: fixture.destNation.id, + friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id), + destTargetIds: fixture.destTargets.map((general) => general.id), + friendlyText: '가람이 허보를 발동하였습니다.', + destText: '상대의 허보에 당했다!', + destNationText: '가람이 아국의 허보를 발동', + destNationFormat: LogFormat.PLAIN, + actorHistoryText: '허보를 발동', + actorActionText: '허보 발동!', + sourceNationText: '가람이 허보를 발동', + }); + }); + + it('preserves che_피장파장 logger epochs and year-month target-nation history', () => { + const fixture = buildFixture(); + const logs = collectLogs(fixture.actor.id, (addLog) => + new CounterStrategyResolver([]).resolve( + { + general: fixture.actor, + nation: fixture.nation, + destNation: fixture.destNation, + friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets], + destNationGenerals: fixture.destTargets, + currentYearMonth: 2_231, + rng, + addLog, + } satisfies CounterStrategyResolveContext, + { destNationId: fixture.destNation.id, commandType: 'che_허보' } + ) + ); + + expectRefFlush(logs, { + actorId: fixture.actor.id, + sourceNationId: fixture.nation.id, + destNationId: fixture.destNation.id, + friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id), + destTargetIds: fixture.destTargets.map((general) => general.id), + friendlyText: '가람이 허보 전략의 피장파장을 발동하였습니다.', + destText: '아국에 허보 전략의 피장파장이 발동되었습니다.', + destNationText: '가람이 아국에 허보 피장파장을 발동', + destNationFormat: LogFormat.YEAR_MONTH, + actorHistoryText: '허보 피장파장을 발동', + actorActionText: '허보 전략의 피장파장 발동!', + sourceNationText: '가람이 허보 피장파장을 발동', + }); + }); +}); diff --git a/packages/logic/test/actions/turn/nationMissing.test.ts b/packages/logic/test/actions/turn/nationMissing.test.ts index 6089ca95..9337a06b 100644 --- a/packages/logic/test/actions/turn/nationMissing.test.ts +++ b/packages/logic/test/actions/turn/nationMissing.test.ts @@ -3,6 +3,7 @@ import type { City, General, Nation } from '../../../src/domain/entities.js'; import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; import { evaluateConstraints } from '../../../src/constraints/evaluate.js'; import { resolveGeneralAction } from '../../../src/actions/engine.js'; +import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js'; import type { MapDefinition } from '../../../src/world/types.js'; import type { TurnSchedule } from '../../../src/turn/calendar.js'; import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; @@ -355,6 +356,8 @@ describe('Nation Missing Actions', () => { expect(definition.parseArgs({ destNationId: 2, amountList: [-1, 10] })).toBeNull(); const general = buildGeneral(1, 1, 1); + const sourceChief = buildGeneral(2, 1, 1, 'SourceChief'); + const destChief = buildGeneral(3, 2, 2, 'DestChief'); const nation = { ...buildNation(1), gold: 1000, rice: 1000 }; const destNation = { ...buildNation(2), gold: 100, rice: 100 }; const resolution = resolveGeneralAction( @@ -364,8 +367,8 @@ describe('Nation Missing Actions', () => { city: buildCity(1, 1), nation, destNation, - friendlyChiefs: [general], - destNationChiefs: [], + friendlyChiefs: [general, sourceChief], + destNationChiefs: [destChief], rng: {} as any, addLog: () => {}, } as any, @@ -388,6 +391,16 @@ describe('Nation Missing Actions', () => { }), }), }); + + const orderedLogs = orderLegacyActionLoggerFlush(resolution.logs); + expect(orderedLogs.map((log) => log.legacyFlushGroup ?? 0)).toEqual([-1, -1, 0, 0, 0, 0, 0, 1]); + expect(orderedLogs.slice(0, 2).map((log) => log.generalId)).toEqual([sourceChief.id, destChief.id]); + expect(orderedLogs.at(-1)).toEqual( + expect.objectContaining({ + nationId: destNation.id, + legacyFlushGroup: 1, + }) + ); }); it('che_초토화: blocks when diplomacy limit exists', () => { diff --git a/packages/logic/test/actions/turn/nationVolunteerRecruit.test.ts b/packages/logic/test/actions/turn/nationVolunteerRecruit.test.ts index b3aef5e5..08205048 100644 --- a/packages/logic/test/actions/turn/nationVolunteerRecruit.test.ts +++ b/packages/logic/test/actions/turn/nationVolunteerRecruit.test.ts @@ -109,16 +109,21 @@ describe('nation volunteer recruitment lifespan', () => { return; } - const created = createdEffect.general as General & { bornYear?: number; deadYear?: number }; + const created = createdEffect.general as General & { affinity?: number; bornYear?: number; deadYear?: number }; expect(created).toMatchObject({ name: 'ⓖ장수', + affinity: 1, bornYear: 170, deadYear: 200, experience: 2_000, dedication: 2_000, meta: { + affinity: 1, birthYear: 170, deathYear: 200, + npc_org: 4, + explevel: 0, + dedlevel: 1, }, }); }); diff --git a/packages/logic/test/actions/turn/talentScoutGeneralPool.test.ts b/packages/logic/test/actions/turn/talentScoutGeneralPool.test.ts index 405832d7..71a2b869 100644 --- a/packages/logic/test/actions/turn/talentScoutGeneralPool.test.ts +++ b/packages/logic/test/actions/turn/talentScoutGeneralPool.test.ts @@ -130,6 +130,7 @@ describe('talent scout scenario general pool', () => { imageServer: 1, role: { specialDomestic: null, specialWar: null }, meta: { + npc_org: 3, dex1: 12, dex2: 24, dex3: 36, diff --git a/packages/logic/test/diplomacyInstantResponse.test.ts b/packages/logic/test/diplomacyInstantResponse.test.ts index e8ac917c..6587d4b1 100644 --- a/packages/logic/test/diplomacyInstantResponse.test.ts +++ b/packages/logic/test/diplomacyInstantResponse.test.ts @@ -73,6 +73,7 @@ describe('instant diplomatic response parity', () => { const logs = result.effects.filter((effect) => effect.type === 'log'); expect(logs).toHaveLength(4); expect(logs.map((effect) => effect.entry.generalId)).toEqual([11, 11, 22, 22]); + expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 1, 1]); }); it('counts an accepted pact down through every month and returns both directions to trade', () => { @@ -123,6 +124,7 @@ describe('instant diplomatic response parity', () => { expect(result.actionKey).toBe('che_불가침파기수락'); expect(result.refreshFront).toBe(false); expect(logs).toHaveLength(6); + expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 0, 0, 1, 1]); expect(logs).toContainEqual( expect.objectContaining({ entry: expect.objectContaining({ @@ -135,6 +137,7 @@ describe('instant diplomatic response parity', () => { it('creates both nation histories and requests front refresh for stop-war', () => { const result = resolveInstantDiplomacyResponse(context, { action: 'stopWar' }); + const logs = result.effects.filter((effect) => effect.type === 'log'); const nationLogIds = result.effects.flatMap((effect) => effect.type === 'log' && effect.entry.scope === LogScope.NATION ? [effect.entry.nationId] : [] ); @@ -142,6 +145,7 @@ describe('instant diplomatic response parity', () => { expect(result.actionKey).toBe('che_종전수락'); expect(result.refreshFront).toBe(true); expect(nationLogIds).toEqual([1, 2]); + expect(logs.map((effect) => effect.entry.legacyFlushGroup ?? 0)).toEqual([0, 0, 0, 0, 0, 1, 1, 1]); }); it('recomputes only requested nation fronts with legacy priority', () => { diff --git a/packages/logic/test/dispatchWarAction.test.ts b/packages/logic/test/dispatchWarAction.test.ts index cbb086d7..46f8e76b 100644 --- a/packages/logic/test/dispatchWarAction.test.ts +++ b/packages/logic/test/dispatchWarAction.test.ts @@ -7,6 +7,9 @@ import type { DispatchResolveContext } from '../src/actions/turn/general/che_출 import type { TurnSchedule } from '../src/turn/calendar.js'; import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js'; import type { UnitSetDefinition } from '../src/world/types.js'; +import type { ItemModule } from '../src/items/types.js'; +import { orderLegacyActionLoggerFlush } from '../src/logging/actionLogger.js'; +import { LogCategory, LogFormat, LogScope } from '../src/logging/types.js'; const buildGeneral = (id: number, nationId: number, cityId: number): General => ({ id, @@ -175,6 +178,19 @@ const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 60 }], }; +const uniqueItem: ItemModule = { + key: 'test_unique', + name: '테스트 유니크', + rawName: '테스트 유니크', + info: 'test', + slot: 'item', + cost: null, + buyable: false, + consumable: false, + reqSecu: 0, + unique: true, +}; + describe('che_출병', () => { it('runs war battle and emits patches/logs', () => { const attackerNation = buildNation(1); @@ -183,14 +199,18 @@ describe('che_출병', () => { const defenderCity = buildCity(2, defenderNation.id); const neutralCity = buildCity(3, 0); const attacker = buildGeneral(1, attackerNation.id, attackerCity.id); + attacker.officerLevel = 12; attacker.turnTime = new Date('2000-01-01T00:00:00Z'); const defender = buildGeneral(2, defenderNation.id, defenderCity.id); + defender.officerLevel = 12; defender.crew = 0; defenderCity.defence = 0; defenderCity.wall = 0; const definition = new ActionDefinition(); - const context: Omit = { + const context: Omit & { + uniqueLottery: () => ItemModule; + } = { general: attacker, city: attackerCity, nation: attackerNation, @@ -246,9 +266,12 @@ describe('che_출병', () => { month: 1, startYear: 180, }, - seedBase: 'test-seed', + seedBase: 'test-seed-2', warConfig, aftermathConfig, + messageTime: new Date('2000-01-01T00:00:00.000Z'), + messageSharedIconBaseUrl: 'https://ref.example/image/icons', + uniqueLottery: () => uniqueItem, }; const resolution = resolveGeneralAction( definition, @@ -278,6 +301,44 @@ describe('che_출병', () => { effect.destNationId === defenderNation.id ) ).toBe(true); + expect(resolution.effects.find((effect) => effect.type === 'message:add')).toEqual({ + type: 'message:add', + draft: expect.objectContaining({ + msgType: 'private', + dest: expect.objectContaining({ + generalId: defender.id, + nationId: 0, + nationName: '재야', + icon: 'https://ref.example/image/icons/default.jpg', + }), + text: 'Nation1로 망명 권유 서신', + option: { action: 'scout' }, + sendDestOnly: true, + }), + }); + + const uniqueGroups = new Set(resolution.postProgressionLogs.map((log) => log.legacyFlushGroup)); + expect(uniqueGroups.size).toBe(1); + const [internalFinalGroup] = uniqueGroups; + expect(internalFinalGroup).toBeTypeOf('number'); + expect(internalFinalGroup).toBeLessThan(0); + expect(resolution.logs.find((log) => log.text.includes('정복으로 금'))?.legacyFlushGroup).toBe( + internalFinalGroup + ); + + const outerProgressionLog = { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + generalId: attacker.id, + text: 'outer progression', + } as const; + const ordered = orderLegacyActionLoggerFlush([ + ...resolution.logs, + ...resolution.postProgressionLogs, + outerProgressionLog, + ]); + expect(ordered.at(-1)?.text).toBe(outerProgressionLog.text); }); it('orders equal-priority defender inputs by general number before the stable battle sort', () => { @@ -347,6 +408,7 @@ describe('che_출병', () => { seedBase: 'route-layer-seed', warConfig, aftermathConfig, + messageTime: new Date('2000-01-01T00:00:00.000Z'), }; const resolution = resolveGeneralAction( diff --git a/packages/logic/test/loggingEntries.test.ts b/packages/logic/test/loggingEntries.test.ts index 7dd3e543..12e9e9d9 100644 --- a/packages/logic/test/loggingEntries.test.ts +++ b/packages/logic/test/loggingEntries.test.ts @@ -1,6 +1,96 @@ import { describe, expect, it } from 'vitest'; -import { finalizeLogEntry, LogCategory, LogFormat, LogScope } from '../src/index.js'; +import { + ActionLogger, + finalizeLogEntry, + LogCategory, + LogFormat, + LogScope, + orderLegacyActionLoggerFlush, +} from '../src/index.js'; + +describe('ActionLogger', () => { + it('flushes Ref category buffers in physical persistence order', () => { + const logger = new ActionLogger({ generalId: 7, nationId: 3 }); + + logger.pushGlobalActionLog('global action'); + logger.pushGlobalHistoryLog('global history'); + logger.pushNationHistoryLog('nation history'); + logger.pushGeneralActionLog(['first action', 'second action']); + logger.pushGeneralHistoryLog('general history'); + + expect(logger.flush().map((entry) => entry.text)).toEqual([ + 'general history', + 'first action', + 'second action', + 'nation history', + 'global history', + 'global action', + ]); + }); + + it('keeps a separately flushed logger after every category of the earlier logger', () => { + expect( + orderLegacyActionLoggerFlush([ + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: 1, + text: 'actor nation history', + }, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: 2, + text: 'destination nation history', + legacyFlushGroup: 1, + }, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: 'global history', + }, + ]).map((entry) => entry.text) + ).toEqual(['actor nation history', 'global history', 'destination nation history']); + }); + + it('preserves early, actor, and destructor flush stages before applying category buckets', () => { + const ordered = orderLegacyActionLoggerFlush([ + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: 1, + text: 'actor nation history', + }, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: 2, + text: 'destructor nation history', + legacyFlushGroup: 1, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 7, + text: 'early chief action', + legacyFlushGroup: -1, + }, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: 'actor global history', + }, + ]); + + expect(ordered.map((entry) => entry.text)).toEqual([ + 'early chief action', + 'actor nation history', + 'actor global history', + 'destructor nation history', + ]); + }); +}); describe('finalizeLogEntry', () => { it('uses an explicit draft year and month for pre-month logs', () => { diff --git a/packages/logic/test/message.test.ts b/packages/logic/test/message.test.ts index e59f0de7..d1593351 100644 --- a/packages/logic/test/message.test.ts +++ b/packages/logic/test/message.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, + resolveMessageTargetIcon, sendMessage, type MessageDraft, type MessageRecordDraft, @@ -88,7 +89,7 @@ describe('sendMessage', () => { expect(store.records[0]!.draft.mailbox).toBe(MESSAGE_MAILBOX_PUBLIC); }); - it('removes diplomacy action from sender copy', async () => { + it('clears the entire actionable diplomacy option from the sender copy', async () => { const store = new InMemoryMessageStore(); const draft = buildDraft({ msgType: 'diplomacy', @@ -98,8 +99,38 @@ describe('sendMessage', () => { await sendMessage(store, draft); - const senderPayload = store.records[1]!.draft.payload.option ?? {}; - expect(senderPayload).not.toHaveProperty('action'); - expect(senderPayload).toMatchObject({ payload: 1 }); + expect(store.records[1]!.draft.payload.option).toBeNull(); + }); + + it('can persist only the receiver copy like Ref Message::send(true)', async () => { + const store = new InMemoryMessageStore(); + const draft = buildDraft({ sendDestOnly: true }); + + const result = await sendMessage(store, draft); + + expect(result).toEqual({ receiverId: 1 }); + expect(store.records).toHaveLength(1); + expect(store.records[0]!.draft.mailbox).toBe(draft.dest.generalId); + }); +}); + +describe('resolveMessageTargetIcon', () => { + it('uses the product shared origin by default and accepts an explicit differential origin', () => { + expect(resolveMessageTargetIcon()).toBe('https://sam-image.hided.net/icons/default.jpg'); + expect(resolveMessageTargetIcon(null, 'https://dev-sam-ref.hided.net/image/icons/')).toBe( + 'https://dev-sam-ref.hided.net/image/icons/default.jpg' + ); + }); + + it('keeps a non-default shared picture and legacy user-icon marker visible', () => { + expect( + resolveMessageTargetIcon( + { picture: '장수/관우.png', imageServer: 0 }, + 'https://dev-sam-ref.hided.net/image/icons' + ) + ).toBe('https://dev-sam-ref.hided.net/image/icons/장수/관우.png'); + expect(resolveMessageTargetIcon({ picture: 'users/custom.webp', imageServer: 1 })).toBe( + 'd_pic/users/custom.webp' + ); }); }); diff --git a/packages/logic/test/scenarios/che_몰수_message.test.ts b/packages/logic/test/scenarios/che_몰수_message.test.ts new file mode 100644 index 00000000..add0846b --- /dev/null +++ b/packages/logic/test/scenarios/che_몰수_message.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { ActionDefinition } from '../../src/actions/turn/nation/che_몰수.js'; + +describe('che_몰수 NPC message icon', () => { + it('uses the target general non-default shared picture for both payload targets', () => { + const actor = { + id: 1, + name: '집행자', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 12, + role: { personality: null, specialDomestic: null, specialWar: null, items: {} }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + }; + const target = { + ...actor, + id: 3, + name: '몰수NPC', + gold: 100, + npcState: 2, + picture: 'npc/custom.png', + imageServer: 0, + }; + const definition = new ActionDefinition({ + npcSeizureMessageProb: 1, + maxResourceActionAmount: 1_000_000, + } as never); + + const result = definition.resolve( + { + general: actor, + nation: { id: 1, name: '아국', color: '#123456', gold: 0, rice: 0 }, + destGeneral: target, + messageTime: new Date('0190-01-01T00:00:00.000Z'), + messageSharedIconBaseUrl: 'https://ref.example/image/icons', + rng: { + nextBool: () => true, + nextInt: () => 0, + }, + } as never, + { isGold: true, amount: 100, destGeneralID: target.id } + ); + + expect(result.effects).toContainEqual( + expect.objectContaining({ + type: 'message:add', + draft: expect.objectContaining({ + src: expect.objectContaining({ icon: 'https://ref.example/image/icons/npc/custom.png' }), + dest: expect.objectContaining({ icon: 'https://ref.example/image/icons/npc/custom.png' }), + }), + }) + ); + }); +}); diff --git a/packages/logic/test/scenarios/general/che_등용_message.test.ts b/packages/logic/test/scenarios/general/che_등용_message.test.ts index 23d9fb0a..f1e58c01 100644 --- a/packages/logic/test/scenarios/general/che_등용_message.test.ts +++ b/packages/logic/test/scenarios/general/che_등용_message.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { ActionResolver } from '../../../src/actions/turn/general/che_등용.js'; +import { ActionResolver, actionContextBuilder } from '../../../src/actions/turn/general/che_등용.js'; describe('che_등용 recruitment message', () => { it('queues the Ref scout prompt with sender and receiver snapshots', () => { @@ -30,6 +30,23 @@ describe('che_등용 recruitment message', () => { const sourceNation = { id: 1, name: '위', color: '#ffffff' }; const destinationNation = { id: 2, name: '촉', color: '#000000' }; const messageTime = new Date('0200-01-01T00:10:00.000Z'); + const actorTurnTime = new Date('0200-01-01T00:00:00.000Z'); + + const builtContext = actionContextBuilder( + { + general: { ...general, turnTime: actorTurnTime }, + nation: sourceNation, + rng: {}, + } as never, + { + gameNow: messageTime, + messageSharedIconBaseUrl: 'https://ref.example/image/icons', + actionArgs: { destGeneralId: destination.id }, + worldRef: { getGeneralById: () => destination }, + scenarioConfig: { const: {} }, + } as never + ); + expect(builtContext).toMatchObject({ messageTime }); const result = new ActionResolver().resolve( { @@ -54,7 +71,7 @@ describe('che_등용 recruitment message', () => { nationId: sourceNation.id, nationName: sourceNation.name, color: sourceNation.color, - icon: '', + icon: 'https://sam-image.hided.net/icons/default.jpg', }, dest: { generalId: destination.id, @@ -62,13 +79,16 @@ describe('che_등용 recruitment message', () => { nationId: destinationNation.id, nationName: destinationNation.name, color: destinationNation.color, - icon: '', + icon: 'https://sam-image.hided.net/icons/default.jpg', }, text: '위로 망명 권유 서신', time: messageTime, validUntil: new Date('9999-12-31T12:59:59.000Z'), option: { action: 'scout' }, + sendDestOnly: true, }, }); + expect(result.effects.filter((effect) => effect.type === 'log')).toEqual([]); + expect(logs).toHaveLength(1); }); }); diff --git a/packages/logic/test/scenarios/general/che_등용수락.test.ts b/packages/logic/test/scenarios/general/che_등용수락.test.ts index 1fcd82a4..9aaf08e8 100644 --- a/packages/logic/test/scenarios/general/che_등용수락.test.ts +++ b/packages/logic/test/scenarios/general/che_등용수락.test.ts @@ -5,6 +5,9 @@ import { InMemoryWorld, TestGameRunner } from '../../testEnv.js'; import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js'; import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; +import { resolveGeneralAction } from '../../../src/actions/engine.js'; +import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js'; +import { LogCategory, LogFormat, LogScope } from '../../../src/logging/types.js'; const MOCK_SCENARIO_BASE = { title: 'Test', @@ -224,6 +227,57 @@ describe('che_등용수락', () => { const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js'); + const orderingResolution = resolveGeneralAction( + commandSpec.createDefinition(systemEnv), + { + general: structuredClone(neutralGen), + city: structuredClone(world.getCity(neutralGen.cityId)), + nation: null, + destNation: structuredClone(nation2), + destGeneral: structuredClone(recruiterGen), + rng: {} as any, + addLog: () => {}, + } as any, + { + now: new Date('2026-08-23T00:00:00.000Z'), + schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] }, + }, + { destNationId: 2, destGeneralId: 2 } + ); + const recruiterLogs = orderLegacyActionLoggerFlush(orderingResolution.logs).filter( + (log) => log.generalId === recruiterGen.id + ); + expect(recruiterLogs.map((log) => [log.category, log.legacyFlushGroup])).toEqual([ + [LogCategory.HISTORY, 1], + [LogCategory.ACTION, 1], + [LogCategory.ACTION, 1], + [LogCategory.ACTION, 1], + ]); + expect(recruiterLogs.map((log) => log.text)).toEqual([ + expect.stringContaining('등용에 성공'), + expect.stringContaining('레벨업'), + expect.stringContaining('승급'), + expect.stringContaining('등용에 성공했습니다.'), + ]); + expect(recruiterLogs.map((log) => log.format)).toEqual([ + LogFormat.YEAR_MONTH, + LogFormat.PLAIN, + LogFormat.PLAIN, + LogFormat.MONTH, + ]); + expect( + orderingResolution.logs.find( + (log) => + log.scope === LogScope.GENERAL && + log.category === LogCategory.ACTION && + log.text.includes('망명하여 수도로') + )?.format + ).toBe(LogFormat.MONTH); + expect( + orderingResolution.logs.find((log) => log.scope === LogScope.SYSTEM && log.category === LogCategory.SUMMARY) + ?.format + ).toBe(LogFormat.MONTH); + await runner.runTurn([ { generalId: neutralGen.id, diff --git a/packages/logic/test/scenarios/general/che_이동.test.ts b/packages/logic/test/scenarios/general/che_이동.test.ts index 8a8916db..d5900d10 100644 --- a/packages/logic/test/scenarios/general/che_이동.test.ts +++ b/packages/logic/test/scenarios/general/che_이동.test.ts @@ -3,6 +3,8 @@ import type { General, Nation } from '../../../src/domain/entities.js'; import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js'; import { InMemoryWorld, TestGameRunner } from '../../testEnv.js'; import type { MapDefinition } from '../../../src/world/types.js'; +import { resolveGeneralAction } from '../../../src/actions/engine.js'; +import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js'; const MOCK_SCENARIO_BASE = { title: 'Test', @@ -56,6 +58,49 @@ const LINEAR_MAP: MapDefinition = { defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, }; +const buildGeneral = (id: number, name: string): General => ({ + id, + name, + nationId: 1, + cityId: 101, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 10, + age: 20, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, +}); + +const buildNation = (level = 1): Nation => ({ + id: 1, + name: 'MyNation', + color: '#000', + capitalCityId: 101, + chiefGeneralId: 1, + gold: 0, + rice: 0, + power: 0, + level, + typeCode: 'che_def', + meta: {}, +}); + describe('che_이동', () => { it('applies movement side effects (gold/atmos/exp/leadership_exp)', async () => { const bootstrapResult = buildScenarioBootstrap({ @@ -66,47 +111,8 @@ describe('che_이동', () => { const world = new InMemoryWorld(bootstrapResult.snapshot); const runner = new TestGameRunner(world, 200, 1); - const general: General = { - id: 1, - name: 'Mover', - nationId: 1, - cityId: 101, - troopId: 0, - stats: { leadership: 50, strength: 50, intelligence: 50 }, - experience: 0, - dedication: 0, - officerLevel: 1, - role: { - personality: null, - specialDomestic: null, - specialWar: null, - items: { horse: null, weapon: null, book: null, item: null }, - }, - injury: 0, - gold: 1000, - rice: 1000, - crew: 0, - crewTypeId: 0, - train: 0, - atmos: 10, - age: 20, - npcState: 0, - triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, - meta: { killturn: 24 }, - }; - const nation: Nation = { - id: 1, - name: 'MyNation', - color: '#000', - capitalCityId: 101, - chiefGeneralId: 1, - gold: 0, - rice: 0, - power: 0, - level: 1, - typeCode: 'che_def', - meta: {}, - }; + const general = buildGeneral(1, 'Mover'); + const nation = buildNation(); world.snapshot.generals.push(general); world.snapshot.nations.push(nation); @@ -134,4 +140,46 @@ describe('che_이동', () => { expect(updated?.experience).toBe(50); expect(updated?.meta.leadership_exp).toBe(1); }); + + it('logs roaming followers before the actor logger flush', async () => { + const leader = { ...buildGeneral(1, 'Leader'), officerLevel: 12 }; + const follower = buildGeneral(2, 'Follower'); + const nation = buildNation(0); + const moveDef = (await import('../../../src/actions/turn/general/che_이동.js')).commandSpec.createDefinition( + {} as any + ); + + const resolution = resolveGeneralAction( + moveDef, + { + general: leader, + nation, + moveGenerals: [leader, follower], + map: LINEAR_MAP, + develCost: 100, + rng: {} as any, + addLog: () => {}, + } as any, + { + now: new Date('2026-08-23T00:00:00.000Z'), + schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] }, + }, + { destCityId: 102 } + ); + + expect(resolution.patches?.generals).toContainEqual({ + id: follower.id, + patch: expect.objectContaining({ cityId: 102 }), + }); + const orderedLogs = orderLegacyActionLoggerFlush(resolution.logs); + expect(orderedLogs.map((log) => log.generalId)).toEqual([follower.id, leader.id]); + expect(orderedLogs[0]).toEqual( + expect.objectContaining({ + text: '방랑군 세력이 City2로 이동했습니다.', + generalId: follower.id, + legacyFlushGroup: -1, + }) + ); + expect(orderedLogs[1]?.legacyFlushGroup).toBeUndefined(); + }); }); diff --git a/packages/logic/test/scenarios/general/che_하야.test.ts b/packages/logic/test/scenarios/general/che_하야.test.ts new file mode 100644 index 00000000..bcf28c1b --- /dev/null +++ b/packages/logic/test/scenarios/general/che_하야.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { ActionResolver } from '../../../src/actions/turn/general/che_하야.js'; + +describe('che_하야 Ref ordering', () => { + it('resets belong before refreshing max_belong', () => { + const general = { + id: 1, + name: '하야자', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + experience: 100, + dedication: 100, + officerLevel: 5, + gold: 100, + rice: 100, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + injury: 0, + age: 30, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + role: { items: {} }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { belong: 10 }, + }; + const nation = { + id: 1, + name: '소속국', + gold: 1_000, + rice: 1_000, + meta: { gennum: 1 }, + }; + + const result = new ActionResolver({ defaultNpcGold: 1_000, defaultNpcRice: 1_000 } as never).resolve( + { + general, + nation, + troopMembers: [], + addLog: () => {}, + } as never, + {} + ); + const generalPatch = result.effects.find( + (effect) => effect.type === 'general:patch' && effect.targetId === general.id + ); + + expect(generalPatch).toMatchObject({ + type: 'general:patch', + patch: { meta: { belong: 0, max_belong: 0 } }, + }); + }); +}); diff --git a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts index e26b4fc5..8915ac63 100644 --- a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts +++ b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts @@ -6,12 +6,16 @@ import { MINIMAL_MAP } from '../../fixtures/minimalMap.js'; import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../../../src/actions/turn/commandEnv.js'; import { commandSpec as rebellionSpec } from '../../../src/actions/turn/general/che_모반시도.js'; import { commandSpec as abdicationSpec } from '../../../src/actions/turn/general/che_선양.js'; +import { commandSpec as uprisingSpec } from '../../../src/actions/turn/general/che_거병.js'; import { commandSpec as giftSpec } from '../../../src/actions/turn/general/che_증여.js'; import { commandSpec as disbandSpec } from '../../../src/actions/turn/general/che_해산.js'; import { commandSpec as foundNationSpec } from '../../../src/actions/turn/general/cr_건국.js'; import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js'; import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js'; +import { resolveGeneralAction } from '../../../src/actions/engine.js'; +import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js'; +import { LogCategory, LogFormat, LogScope } from '../../../src/logging/types.js'; const SYSTEM_ENV: TurnCommandEnv = { develCost: 100, @@ -168,6 +172,31 @@ const makeSnapshot = (params: { initialEvents: [], }); +const resolveForLogOrdering = ( + resolver: any, + general: General, + city: City, + nation: Nation | null, + args: unknown, + context: Record +) => + resolveGeneralAction( + resolver, + { + general, + city, + nation, + rng: {} as any, + addLog: () => {}, + ...context, + } as any, + { + now: new Date('2026-08-23T00:00:00.000Z'), + schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] }, + }, + args + ); + describe('migrated general commands', () => { it('che_모반시도: 군주를 찬탈한다', async () => { const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 }); @@ -319,6 +348,208 @@ describe('migrated general commands', () => { expect(world.getNation(1)!.meta.collapsed).toBe(true); }); + it('별도 General logger를 actor applyDB 전후 순서로 flush한다', () => { + const city = makeCity({ id: 1, nationId: 1 }); + const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1 }); + + const rebel = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2 }); + const displacedLord = makeGeneral({ + id: 1, + nationId: 1, + cityId: 1, + name: '군주', + officerLevel: 12, + experience: 1000, + }); + const rebellionLogs = orderLegacyActionLoggerFlush( + resolveForLogOrdering( + rebellionSpec.createDefinition(SYSTEM_ENV), + rebel, + city, + nation, + {}, + { + nationGenerals: [displacedLord, rebel], + } + ).logs + ); + expect( + rebellionLogs + .filter((log) => log.generalId === displacedLord.id) + .map((log) => [log.category, log.legacyFlushGroup]) + ).toEqual([ + [LogCategory.HISTORY, 1], + [LogCategory.ACTION, 1], + ]); + + const abdicator = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12 }); + const recipient = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자' }); + const abdicationLogs = orderLegacyActionLoggerFlush( + resolveForLogOrdering( + abdicationSpec.createDefinition(SYSTEM_ENV), + abdicator, + city, + nation, + { destGeneralID: recipient.id }, + { destGeneral: recipient } + ).logs + ); + expect( + abdicationLogs + .filter((log) => log.generalId === recipient.id) + .map((log) => [log.category, log.legacyFlushGroup]) + ).toEqual([ + [LogCategory.HISTORY, 1], + [LogCategory.ACTION, 1], + ]); + + const giver = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 }); + const receiver = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 }); + const giftLogs = orderLegacyActionLoggerFlush( + resolveForLogOrdering( + giftSpec.createDefinition(SYSTEM_ENV), + giver, + city, + nation, + { isGold: true, amount: 500, destGeneralID: receiver.id }, + { destGeneral: receiver } + ).logs + ); + expect(giftLogs.map((log) => [log.generalId, log.legacyFlushGroup ?? 0])).toEqual([ + [giver.id, 0], + [receiver.id, 1], + ]); + + const disbandActor = makeGeneral({ id: 10, nationId: 1, cityId: 1, name: '방랑군주', officerLevel: 12 }); + const member2 = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '부하2' }); + const member3 = makeGeneral({ id: 3, nationId: 1, cityId: 1, name: '부하3' }); + const wanderingNation = makeNation({ + id: 1, + name: '방랑군', + chiefGeneralId: disbandActor.id, + capitalCityId: 1, + level: 0, + typeCode: 'None', + }); + const disbandLogs = orderLegacyActionLoggerFlush( + resolveForLogOrdering( + disbandSpec.createDefinition(SYSTEM_ENV), + disbandActor, + city, + wanderingNation, + {}, + { + nationGenerals: [disbandActor, member3, member2], + nationCities: [city], + currentYearMonth: 201 * 12 + 2 - 1, + initYearMonth: 201 * 12 + 1 - 1, + } + ).logs + ); + const nonActorDestructionLogs = disbandLogs.filter( + (log) => + log.scope === LogScope.GENERAL && log.generalId !== disbandActor.id && log.text.includes('멸망') + ); + expect(nonActorDestructionLogs.map((log) => [log.generalId, log.category, log.legacyFlushGroup])).toEqual([ + [member2.id, LogCategory.HISTORY, -2], + [member2.id, LogCategory.ACTION, -2], + [member3.id, LogCategory.HISTORY, -1], + [member3.id, LogCategory.ACTION, -1], + ]); + const actorLogs = disbandLogs.filter( + (log) => log.scope === LogScope.GENERAL && log.generalId === disbandActor.id + ); + expect(actorLogs).toHaveLength(4); + expect(actorLogs.every((log) => (log.legacyFlushGroup ?? 0) === 0)).toBe(true); + }); + + it('선양·모반시도의 Ref logger format과 거병 중립 nation flush 계약을 보존한다', () => { + const city = makeCity({ id: 1, nationId: 1 }); + const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1 }); + const projectRoute = (logs: ReturnType) => + logs.map((log) => [ + log.scope, + log.category, + log.generalId ?? null, + log.nationId ?? null, + log.format, + log.legacyFlushGroup ?? 0, + ]); + + const rebel = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2 }); + const displacedLord = makeGeneral({ + id: 1, + nationId: 1, + cityId: 1, + name: '군주', + officerLevel: 12, + experience: 1_000, + }); + const rebellionLogs = orderLegacyActionLoggerFlush( + resolveForLogOrdering( + rebellionSpec.createDefinition(SYSTEM_ENV), + rebel, + city, + nation, + {}, + { nationGenerals: [displacedLord, rebel] } + ).logs + ); + expect(projectRoute(rebellionLogs)).toEqual([ + [LogScope.GENERAL, LogCategory.HISTORY, rebel.id, null, LogFormat.YEAR_MONTH, 0], + [LogScope.GENERAL, LogCategory.ACTION, rebel.id, null, LogFormat.MONTH, 0], + [LogScope.NATION, LogCategory.HISTORY, null, nation.id, LogFormat.YEAR_MONTH, 0], + [LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0], + [LogScope.GENERAL, LogCategory.HISTORY, displacedLord.id, null, LogFormat.YEAR_MONTH, 1], + [LogScope.GENERAL, LogCategory.ACTION, displacedLord.id, null, LogFormat.MONTH, 1], + ]); + + const abdicator = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12 }); + const recipient = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자' }); + const abdicationLogs = orderLegacyActionLoggerFlush( + resolveForLogOrdering( + abdicationSpec.createDefinition(SYSTEM_ENV), + abdicator, + city, + nation, + { destGeneralID: recipient.id }, + { destGeneral: recipient } + ).logs + ); + expect(projectRoute(abdicationLogs)).toEqual([ + [LogScope.GENERAL, LogCategory.HISTORY, abdicator.id, null, LogFormat.YEAR_MONTH, 0], + [LogScope.GENERAL, LogCategory.ACTION, abdicator.id, null, LogFormat.MONTH, 0], + [LogScope.NATION, LogCategory.HISTORY, null, nation.id, LogFormat.YEAR_MONTH, 0], + [LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0], + [LogScope.GENERAL, LogCategory.HISTORY, recipient.id, null, LogFormat.YEAR_MONTH, 1], + [LogScope.GENERAL, LogCategory.ACTION, recipient.id, null, LogFormat.MONTH, 1], + ]); + + const founder = makeGeneral({ id: 7, nationId: 0, cityId: 1, name: '운영자' }); + const uprisingLogs = orderLegacyActionLoggerFlush( + resolveForLogOrdering( + uprisingSpec.createDefinition(SYSTEM_ENV), + founder, + city, + null, + {}, + { + createNationId: () => 2, + listNations: () => [], + scenarioId: 1, + baseRice: 1_000, + } + ).logs + ); + expect(projectRoute(uprisingLogs)).toEqual([ + [LogScope.GENERAL, LogCategory.HISTORY, founder.id, null, LogFormat.YEAR_MONTH, 0], + [LogScope.GENERAL, LogCategory.ACTION, founder.id, null, LogFormat.MONTH, 0], + [LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0], + [LogScope.SYSTEM, LogCategory.SUMMARY, null, null, LogFormat.MONTH, 0], + ]); + expect(uprisingLogs.some((log) => log.scope === LogScope.NATION)).toBe(false); + }); + it('cr_건국: 국가 정보를 건국 상태로 갱신한다', async () => { const lord = makeGeneral({ id: 1, diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index 545983e0..00aeeffd 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -390,7 +390,9 @@ describe('General Commands New Scenario', () => { const g1_after_resign = world.getGeneral(1)!; expect(g1_after_resign.nationId).toBe(0); - expect(g1_after_resign.meta.max_belong).toBe(18); + // Ref clears belong before refreshing max_belong, so the existing + // historical maximum is retained instead of the pre-resign belong. + expect(g1_after_resign.meta.max_belong).toBe(12); // 6. Retire (Needs age >= 60) // Manually set age diff --git a/packages/logic/test/turnCommandProfile.test.ts b/packages/logic/test/turnCommandProfile.test.ts new file mode 100644 index 00000000..ab1560ee --- /dev/null +++ b/packages/logic/test/turnCommandProfile.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseTurnCommandProfile, + resolveScenarioTurnCommandProfile, + type TurnCommandProfile, +} from '../src/actions/turn/commandProfile.js'; + +const fallback: TurnCommandProfile = { + general: ['휴식', 'che_훈련'], + nation: ['휴식', 'che_발령'], +}; + +describe('scenario turn command profile', () => { + it('fails closed for malformed, duplicate, or rest-less base profiles', () => { + expect(() => parseTurnCommandProfile({ general: ['휴식'], nation: '휴식' })).toThrow( + 'Invalid turn command profile' + ); + expect(() => parseTurnCommandProfile({ general: ['휴식', '휴식'], nation: ['휴식'] })).toThrow( + 'Duplicate general command key' + ); + expect(() => parseTurnCommandProfile({ general: ['che_훈련'], nation: ['휴식'] })).toThrow('must include 휴식'); + }); + + it('keeps the base profile when the scenario does not override command groups', () => { + expect(resolveScenarioTurnCommandProfile({}, fallback)).toEqual({ + profile: fallback, + generalGroups: null, + nationGroups: null, + }); + }); + + it('flattens Ref command groups while preserving category and command order', () => { + const result = resolveScenarioTurnCommandProfile( + { + availableGeneralCommand: { + 개인: ['휴식'], + 군사: ['cr_맹훈련', 'che_훈련'], + }, + availableChiefCommand: { + 휴식: ['휴식'], + 연구: ['event_대검병연구'], + }, + }, + fallback + ); + + expect(result.profile).toEqual({ + general: ['휴식', 'cr_맹훈련', 'che_훈련'], + nation: ['휴식', 'event_대검병연구'], + }); + expect(result.generalGroups).toEqual([ + { category: '개인', commands: ['휴식'] }, + { category: '군사', commands: ['cr_맹훈련', 'che_훈련'] }, + ]); + expect(result.nationGroups).toEqual([ + { category: '휴식', commands: ['휴식'] }, + { category: '연구', commands: ['event_대검병연구'] }, + ]); + }); + + it('fails closed for unknown, duplicate, or fallback-less scenario commands', () => { + expect(() => resolveScenarioTurnCommandProfile('invalid', fallback)).toThrow( + 'Scenario const must be an object' + ); + expect(() => + resolveScenarioTurnCommandProfile( + { availableGeneralCommand: { 개인: ['휴식', 'unknown-command'] } }, + fallback + ) + ).toThrow('Unknown scenario general command key'); + expect(() => + resolveScenarioTurnCommandProfile({ availableChiefCommand: { 휴식: ['휴식'], 기타: ['휴식'] } }, fallback) + ).toThrow('Duplicate scenario nation command key'); + expect(() => + resolveScenarioTurnCommandProfile({ availableGeneralCommand: { 군사: ['che_훈련'] } }, fallback) + ).toThrow('must include 휴식'); + }); +}); diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index a2482f95..46dd71b7 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -8,9 +8,12 @@ import type { UnitSetDefinition } from '../src/world/types.js'; import { resolveWarAftermath } from '../src/war/aftermath.js'; import type { WarAftermathConfig } from '../src/war/types.js'; import { LogFormat } from '../src/logging/types.js'; +import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js'; import { buildWarAftermathConfig, buildWarConfig } from '../src/actions/turn/actionContextHelpers.js'; import type { ScenarioConfig } from '../src/scenario/types.js'; +const MESSAGE_TIME = new Date('0185-01-01T00:00:00.000Z'); + const buildUnitSet = (): UnitSetDefinition => ({ id: 'test', name: 'test', @@ -188,6 +191,7 @@ describe('war aftermath', () => { unitSet: buildUnitSet(), config: { ...buildConfig(), maxTechLevel: 15 }, time: { year: 200, month: 1, startYear: 180 }, + messageTime: MESSAGE_TIME, }); expect(defenderNation.rice).toBe(985); @@ -232,6 +236,7 @@ describe('war aftermath', () => { month: 1, startYear: 180, }, + messageTime: MESSAGE_TIME, }); expect(attackerNation.meta.tech).toBe(Math.fround(1000.6)); @@ -271,6 +276,7 @@ describe('war aftermath', () => { unitSet: buildUnitSet(), config: buildConfig(), time: { year: 200, month: 1, startYear: 180 }, + messageTime: MESSAGE_TIME, }); expect(attackerCity.meta.dead).toBe(71); @@ -320,6 +326,7 @@ describe('war aftermath', () => { month: 1, startYear: 180, }, + messageTime: MESSAGE_TIME, }); expect(defenderNation.capitalCityId).toBe(nextCapital.id); @@ -330,6 +337,7 @@ describe('war aftermath', () => { '수뇌는 City3으로 집합되었습니다.', ]) ); + expect(outcome.logs.find((log) => log.text.startsWith('수뇌는'))?.format).toBe(LogFormat.MONTH); }); it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => { @@ -372,6 +380,7 @@ describe('war aftermath', () => { unitSet: buildUnitSet(), config: buildConfig(), time: { year: 200, month: 1, startYear: 180 }, + messageTime: MESSAGE_TIME, }); expect(defenderNation.rice).toBe(6500); @@ -389,6 +398,11 @@ describe('war aftermath', () => { const attacker = buildGeneral(1, 1, 1); attacker.officerLevel = 12; const defender = buildGeneral(2, 2, 2); + defender.officerLevel = 12; + defender.experience = 2_000; + defender.dedication = 2_000; + defender.meta.explevel = 14; + defender.meta.dedlevel = 3; const outcome = resolveWarAftermath({ battle: { @@ -430,14 +444,16 @@ describe('war aftermath', () => { month: 1, startYear: 180, }, + messageTime: MESSAGE_TIME, rng, + legacyFlushSequence: new LegacyWarLogFlushSequence(100), }); expect(outcome.conquest?.nationCollapsed).toBe(true); expect(attackerNation.gold).toBe(3600); expect(attackerNation.rice).toBe(4600); - expect(defender.experience).toBe(90); - expect(defender.dedication).toBe(50); + expect(defender.experience).toBe(1_800); + expect(defender.dedication).toBe(1_000); // Removed nations can remain in old persisted conflict data. They // must never receive ownership during a later conquest. expect(defenderCity.nationId).toBe(attackerNation.id); @@ -447,9 +463,14 @@ describe('war aftermath', () => { 'Nation2를 정복', '【멸망】Nation2멸망했습니다.', 'Nation2멸망했습니다.', + 'Lv 13으로 레벨다운!', + '27품관으로 승급하여 봉록이 1,200으로 상승했습니다!', 'Nation2 정복으로 금2,600 쌀3,600을 획득했습니다.', ]) ); + expect(outcome.logs.filter((log) => log.generalId === defender.id).map((log) => log.legacyFlushGroup)).toEqual([ + 103, 103, 103, 103, 103, + ]); }); it('matches ruined-nation lord ordering and NPC appointment draws', () => { @@ -464,7 +485,7 @@ describe('war aftermath', () => { npc.npcState = 2; const rangeDraws = [0.2, 0.21, 0.4, 0.41]; - const nextBool = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true).mockReturnValueOnce(false); + const nextBool = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValueOnce(false); const rng = { nextRange: vi.fn(() => rangeDraws.shift()!), nextBool, @@ -495,6 +516,8 @@ describe('war aftermath', () => { joinRuinedNpcProbability: 0.1, }, time: { year: 186, month: 1, startYear: 179 }, + messageTime: MESSAGE_TIME, + messageSharedIconBaseUrl: 'https://ref.example/image/icons', rng, }); @@ -503,6 +526,32 @@ describe('war aftermath', () => { expect(lord.gold).toBe(600); expect(lord.rice).toBe(590); expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.5, 0.1, 0.5]); + expect(outcome.conquest?.messages).toEqual([ + { + msgType: 'private', + src: { + generalId: attacker.id, + generalName: attacker.name, + nationId: attackerNation.id, + nationName: attackerNation.name, + color: attackerNation.color, + icon: 'https://ref.example/image/icons/default.jpg', + }, + dest: { + generalId: npc.id, + generalName: npc.name, + nationId: 0, + nationName: '재야', + color: '#000000', + icon: 'https://ref.example/image/icons/default.jpg', + }, + text: 'Nation1로 망명 권유 서신', + time: MESSAGE_TIME, + validUntil: new Date('9999-12-31T12:59:59.000Z'), + option: { action: 'scout' }, + sendDestOnly: true, + }, + ]); expect(outcome.conquest?.ruinedNpcJoinPlans).toEqual([ { generalId: npc.id, destNationId: attackerNation.id, joinTurn: 6 }, ]); @@ -523,10 +572,12 @@ describe('war aftermath', () => { const attackerCity = buildCity(1, 1); const defenderCity = buildCity(2, 2); const attacker = buildGeneral(1, 1, 1); + attacker.officerLevel = 12; const firstDefender = buildGeneral(2, 2, 2); const secondDefender = buildGeneral(3, 2, 2); secondDefender.crew = 0; const elsewhere = buildGeneral(4, 2, 3); + elsewhere.officerLevel = 12; const dispatchOrder: number[] = []; const module: GeneralActionModule = { eventHandlers: { @@ -562,8 +613,10 @@ describe('war aftermath', () => { month: 1, startYear: 180, }, + messageTime: MESSAGE_TIME, rng, generalActionModules: [module], + legacyFlushSequence: new LegacyWarLogFlushSequence(200), }); expect(dispatchOrder).toEqual([firstDefender.id, secondDefender.id]); @@ -585,6 +638,27 @@ describe('war aftermath', () => { LogFormat.MONTH, LogFormat.MONTH, ]); + expect( + outcome.logs.filter((log) => log.text.startsWith('점령 이벤트')).map((log) => log.legacyFlushGroup) + ).toEqual([200, 201]); + expect( + outcome.logs.find((log) => log.nationId === defenderNation.id && log.text.includes('함락')) + ?.legacyFlushGroup + ).toBe(202); + expect( + outcome.logs.find((log) => log.text.includes(`Nation${defenderNation.id}를 정복`))?.legacyFlushGroup + ).toBe(203); + expect( + outcome.logs + .filter((log) => log.text.includes('도주하며')) + .map((log) => [log.generalId, log.legacyFlushGroup]) + ).toEqual([ + [firstDefender.id, 204], + [secondDefender.id, 205], + [elsewhere.id, 206], + ]); + expect(outcome.logs.find((log) => log.text.includes('【멸망】'))?.legacyFlushGroup).toBe(206); + expect(outcome.logs.find((log) => log.text.includes('정복으로 금'))?.legacyFlushGroup).toBeUndefined(); expect(outcome.generals.map((general) => general.id)).toEqual( expect.arrayContaining([firstDefender.id, secondDefender.id]) ); @@ -626,11 +700,21 @@ describe('war aftermath', () => { month: 1, startYear: 180, }, + messageTime: MESSAGE_TIME, + legacyFlushSequence: new LegacyWarLogFlushSequence(300), }); expect(outcome.conquest?.conquerNationId).toBe(4); expect(defenderCity.nationId).toBe(4); expect(defenderCity.meta.conflict_order).toEqual([]); expect(attacker.cityId).toBe(1); + expect(outcome.logs.find((log) => log.nationId === defenderNation.id)?.legacyFlushGroup).toBe(300); + expect(outcome.logs.find((log) => log.nationId === firstNation.id)?.legacyFlushGroup).toBe(301); + expect( + outcome.logs + .filter((log) => log.generalId === attacker.id || log.scope === 'SYSTEM') + .filter((log) => log.text.includes('영토분쟁') || log.text.includes('점령')) + .every((log) => log.legacyFlushGroup === undefined) + ).toBe(true); }); }); diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 8d37a064..06056ccc 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -7,6 +7,7 @@ import type { UnitSetDefinition } from '../src/world/types.js'; import { ActionLogger } from '../src/logging/actionLogger.js'; import { WarActionPipeline } from '../src/war/actions.js'; import { resolveWarBattle } from '../src/war/engine.js'; +import { LegacyWarLogFlushSequence } from '../src/war/legacyFlushSequence.js'; import type { WarEngineConfig } from '../src/war/types.js'; import { WarCrewType } from '../src/war/crewType.js'; import { loadWarTriggerModules } from '../src/war/triggers/index.js'; @@ -535,6 +536,64 @@ describe('war triggers', () => { }); describe('resolveWarBattle', () => { + it('flushes each fought defender before the attacker with a unique legacy epoch', () => { + const attackerNation = buildNation(); + const defenderNation = { ...buildNation(), id: 2, name: 'DefenderNation', capitalCityId: 2 }; + const attackerCity = buildCity(); + const defenderCity = { + ...buildCity(), + id: 2, + name: 'DefenderCity', + nationId: defenderNation.id, + defence: 0, + wall: 0, + }; + const attacker = { ...buildGeneral(100), crew: 10_000, rice: 100_000 }; + const firstDefender = { + ...buildGeneral(10), + id: 2, + name: 'FirstDefender', + nationId: defenderNation.id, + cityId: defenderCity.id, + crew: 1, + }; + const secondDefender = { + ...buildGeneral(10), + id: 3, + name: 'SecondDefender', + nationId: defenderNation.id, + cityId: defenderCity.id, + crew: 1, + }; + const outcome = resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + attacker: { general: attacker, city: attackerCity, nation: attackerNation }, + defenders: [ + { general: firstDefender, city: defenderCity, nation: defenderNation }, + { general: secondDefender, city: defenderCity, nation: defenderNation }, + ], + defenderCity, + defenderNation, + legacyFlushSequence: new LegacyWarLogFlushSequence(100), + }); + + const groupsFor = (generalId: number): number[] => [ + ...new Set( + outcome.logs + .filter((log) => log.generalId === generalId) + .map((log) => log.legacyFlushGroup) + .filter((group): group is number => group !== undefined) + ), + ]; + expect(groupsFor(firstDefender.id)).toEqual([100]); + expect(groupsFor(secondDefender.id)).toEqual([101]); + // group 102 is the city applyDB/rollback epoch. Ref then flushes the attacker. + expect(groupsFor(attacker.id)).toEqual([103]); + }); + it('persists multi-use battle item charges and removes the last charge', async () => { const general = buildGeneral(100); equipNewItem(general, 'item', 'event_충차', { charges: 2 }); diff --git a/tools/compare-command-logs.ignore.json b/tools/compare-command-logs.ignore.json index 464640a3..75289ced 100644 --- a/tools/compare-command-logs.ignore.json +++ b/tools/compare-command-logs.ignore.json @@ -1,8 +1,8 @@ { "SharedTsLogCommands": { - "Nation/che_불가침수락": "logs are emitted by diplomacy/instantResponse.ts and verified by instantDiplomacyReference.integration.test.ts", - "Nation/che_불가침파기수락": "logs are emitted by diplomacy/instantResponse.ts and verified by instantDiplomacyReference.integration.test.ts", - "Nation/che_종전수락": "logs are emitted by diplomacy/instantResponse.ts and verified by instantDiplomacyReference.integration.test.ts" + "Nation/che_불가침수락": "shared resolver logs are dynamically compared through Core messages.respond and Ref DecideMessageResponse by instantDiplomacyCoreReference.integration.test.ts", + "Nation/che_불가침파기수락": "shared resolver logs are dynamically compared through Core messages.respond and Ref DecideMessageResponse by instantDiplomacyCoreReference.integration.test.ts", + "Nation/che_종전수락": "shared resolver logs are dynamically compared through Core messages.respond and Ref DecideMessageResponse by instantDiplomacyCoreReference.integration.test.ts" }, "Global": { "templates": [ diff --git a/tools/conditional-integration-registry.tsv b/tools/conditional-integration-registry.tsv index 16afbded..b682d027 100644 --- a/tools/conditional-integration-registry.tsv +++ b/tools/conditional-integration-registry.tsv @@ -18,3 +18,5 @@ RESERVED_TURN_DATABASE_URL core SELECT_POOL_DATABASE_URL select_pool TURN_DAEMON_LEASE_DATABASE_URL core TURN_DIFFERENTIAL_DATABASE_URL core +TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle +WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index 76249a09..11929486 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -1,9 +1,17 @@ +import { GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common'; + export type CanonicalEngine = 'ref' | 'core2026'; export interface TurnSnapshotSelector { generalIds: number[]; cityIds: number[]; nationIds: number[]; + troopIds?: number[]; + allGenerals?: boolean; + allCities?: boolean; + allNations?: boolean; + allTroops?: boolean; + includeRankMirrors?: boolean; logAfterId?: number; messageAfterId?: number; includeNationHistoryLogs?: boolean; @@ -18,6 +26,7 @@ export interface CanonicalTurnSnapshot { rankData: Array>; cities: Array>; nations: Array>; + troops: Array>; diplomacy: Array>; generalTurns: Array>; nationTurns: Array>; @@ -30,6 +39,39 @@ export interface CanonicalTurnSnapshot { }; } +export interface TurnSnapshotEntityIds { + generalIds: number[]; + cityIds: number[]; + nationIds: number[]; + troopIds: number[]; +} + +const unionEntityIds = (selected: readonly number[] | undefined, created: readonly number[]): number[] => + [...new Set([...(selected ?? []), ...created])].sort((left, right) => left - right); + +/** + * Keep the explicit observation boundary, but extend the after snapshot over + * entities created during the execution. Otherwise a successful create can be + * absent from both the selector query and the resulting differential. + */ +export const closeTurnSnapshotSelectorOverCreatedEntities = ( + selector: TurnSnapshotSelector, + before: TurnSnapshotEntityIds, + after: TurnSnapshotEntityIds +): TurnSnapshotSelector => { + const created = (key: Key): number[] => { + const previous = new Set(before[key]); + return after[key].filter((id) => !previous.has(id)); + }; + return { + ...selector, + generalIds: unionEntityIds(selector.generalIds, created('generalIds')), + cityIds: unionEntityIds(selector.cityIds, created('cityIds')), + nationIds: unionEntityIds(selector.nationIds, created('nationIds')), + troopIds: unionEntityIds(selector.troopIds, created('troopIds')), + }; +}; + export interface CanonicalTurnCommandTrace { schemaVersion: 1; engine: CanonicalEngine; @@ -85,7 +127,191 @@ const readString = (record: Record, key: string): string | null return typeof value === 'string' ? value : null; }; +const readCommandInteger = (value: unknown, field: string, fallback: number | null): number | null => { + if (value === null || value === undefined) { + return fallback; + } + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new Error(`${field} must be a safe integer`); + } + return value; +}; + +const readCommandBoolean = (value: unknown, field: string): boolean => { + if (value === null || value === undefined || value === false || value === 0) { + return false; + } + if (value === true || value === 1) { + return true; + } + throw new Error(`${field} must be a boolean flag`); +}; + +const readCommandOptionalString = (value: unknown, field: string): string | null => { + if (value === null || value === undefined || value === '') { + return null; + } + if (typeof value !== 'string') { + throw new Error(`${field} must be a string`); + } + return value; +}; + +const readCommandValue = ( + fields: Record, + fieldKey: string, + meta: Record, + metaKey = fieldKey +): unknown => (Object.prototype.hasOwnProperty.call(fields, fieldKey) ? fields[fieldKey] : meta[metaKey]); + +const readSafeTick = (value: unknown, field: string): number | null => { + if (value === null || value === undefined) { + return null; + } + const numeric = typeof value === 'bigint' ? Number(value) : value; + if (typeof numeric !== 'number' || !Number.isSafeInteger(numeric)) { + throw new Error(`${field} must be a safe integer`); + } + return numeric; +}; + +export const projectCanonicalTurnOffset = ( + turnTickValue: unknown, + baseTurnTickValue: unknown, + turnSecondsValue: unknown +): { turnSecond: number | null; turnFraction: number | null } => { + const turnTick = readSafeTick(turnTickValue, 'general.turnTick'); + const baseTurnTick = readSafeTick(baseTurnTickValue, 'world.lastTurnTick'); + if (turnTick === null || baseTurnTick === null) { + return { turnSecond: null, turnFraction: null }; + } + const turnSeconds = readCommandInteger(turnSecondsValue, 'world.tickSeconds', null); + if (turnSeconds === null || turnSeconds <= 0 || GAME_TICKS_PER_TURN % turnSeconds !== 0) { + throw new Error('world.tickSeconds must divide the legacy game-turn tick domain'); + } + const ticksPerSecond = GAME_TICKS_PER_TURN / turnSeconds; + const offsetTicks = turnTick - baseTurnTick; + const turnSecond = Math.floor(offsetTicks / ticksPerSecond); + const remainingTicks = offsetTicks - turnSecond * ticksPerSecond; + return { + turnSecond, + turnFraction: Math.floor((remainingTicks * 1_000_000) / ticksPerSecond), + }; +}; + +const projectCanonicalSpyState = (value: unknown): Array<{ cityId: number; remainingTurns: number }> => { + if (value === null || value === undefined) { + return []; + } + if (typeof value !== 'object') { + throw new Error('nation.commandState.spy must be an object'); + } + return Object.entries(value) + .map(([cityIdText, remainingTurns]) => { + const cityId = Number(cityIdText); + if (!Number.isSafeInteger(cityId) || cityId < 1) { + throw new Error(`nation.commandState.spy has an invalid city id: ${cityIdText}`); + } + const turns = readCommandInteger(remainingTurns, `nation.commandState.spy[${cityIdText}]`, null); + if (turns === null) { + throw new Error(`nation.commandState.spy[${cityIdText}] is missing`); + } + return { cityId, remainingTurns: turns }; + }) + .sort((left, right) => left.cityId - right.cityId); +}; + +/** Command-relevant General.aux fields kept outside the intentionally ignored raw meta graph. */ +export const projectCanonicalGeneralCommandState = (metaValue: unknown): Record => { + const meta = asRecord(metaValue); + return { + recruitmentArmType: readCommandInteger(meta.armType, 'general.commandState.recruitmentArmType', null), + }; +}; + +/** Persisted General columns/semantics that commands mutate or initialize. */ +export const projectCanonicalGeneralStoredFields = ( + metaValue: unknown, + fieldsValue: unknown = {} +): Record => { + const meta = asRecord(metaValue); + const fields = asRecord(fieldsValue); + return { + expLevel: readCommandInteger(readCommandValue(fields, 'expLevel', meta, 'explevel'), 'general.expLevel', 0), + dedLevel: readCommandInteger(readCommandValue(fields, 'dedLevel', meta, 'dedlevel'), 'general.dedLevel', 0), + affinity: readCommandInteger(readCommandValue(fields, 'affinity', meta), 'general.affinity', null), + bornYear: readCommandInteger(readCommandValue(fields, 'bornYear', meta, 'birthYear'), 'general.bornYear', null), + deadYear: readCommandInteger(readCommandValue(fields, 'deadYear', meta, 'deathYear'), 'general.deadYear', null), + npcMessage: readCommandOptionalString( + readCommandValue(fields, 'npcMessage', meta, 'text'), + 'general.npcMessage' + ), + npcOriginalState: readCommandInteger( + readCommandValue(fields, 'npcOriginalState', meta, 'npc_org'), + 'general.npcOriginalState', + 0 + ), + turnTick: readSafeTick(readCommandValue(fields, 'turnTick', meta), 'general.turnTick'), + turnSecond: readCommandInteger(fields.turnSecond, 'general.turnSecond', null), + turnFraction: readCommandInteger(fields.turnFraction, 'general.turnFraction', null), + }; +}; + +/** Command-relevant nation aux/spy fields kept outside the intentionally ignored raw meta graph. */ +export const projectCanonicalNationCommandState = ( + metaValue: unknown, + spyValue: unknown = asRecord(metaValue).spy, + fieldsValue: unknown = {} +): Record => { + const meta = asRecord(metaValue); + const fields = asRecord(fieldsValue); + return { + flagChangesRemaining: readCommandInteger(meta.can_국기변경, 'nation.commandState.flagChangesRemaining', 0), + randomCapitalMovesRemaining: readCommandInteger( + meta.can_무작위수도이전, + 'nation.commandState.randomCapitalMovesRemaining', + 0 + ), + spy: projectCanonicalSpyState(spyValue), + collapsed: readCommandBoolean(meta.collapsed, 'nation.commandState.collapsed'), + rate: readCommandInteger(readCommandValue(fields, 'rate', meta), 'nation.commandState.rate', 0), + bill: readCommandInteger(readCommandValue(fields, 'bill', meta), 'nation.commandState.bill', 0), + secretLimit: readCommandInteger( + readCommandValue(fields, 'secretLimit', meta, 'secretlimit'), + 'nation.commandState.secretLimit', + 3 + ), + }; +}; + const serializeDate = (value: Date | null): string | null => value?.toISOString() ?? null; +const messageMailboxNationalBase = 9_000; + +export const CANONICAL_MESSAGE_VALID_UNTIL_INFINITE = 'infinite' as const; + +/** + * Ref persists an unbounded message lifetime as GameClock::MAX_SAFE_TICK, + * while Core's Date fallback persists the legacy year-9999 sentinel. Keep the + * semantic distinction explicit instead of conflating it with null/missing. + */ +export const projectCanonicalMessageValidUntil = ( + value: unknown +): string | typeof CANONICAL_MESSAGE_VALID_UNTIL_INFINITE => { + if (value === CANONICAL_MESSAGE_VALID_UNTIL_INFINITE) { + return value; + } + if (value === null || value === undefined) { + throw new Error('message.validUntil must be a finite timestamp or the infinite sentinel'); + } + const date = value instanceof Date ? value : new Date(String(value)); + if (Number.isNaN(date.getTime())) { + throw new Error(`message.validUntil must be a valid timestamp: ${String(value)}`); + } + if (date.getUTCFullYear() === 9999) { + return CANONICAL_MESSAGE_VALID_UNTIL_INFINITE; + } + return date.toISOString(); +}; export const projectCoreDatabaseSnapshot = (rows: { world: { @@ -93,20 +319,53 @@ export const projectCoreDatabaseSnapshot = (rows: { currentMonth: number; tickSeconds: number; meta: unknown; + gameNow?: Date | string; + lastTurnTick?: bigint | number | null; }; generals: Array>; rankData: Array>; cities: Array>; nations: Array>; + troops: Array>; diplomacy: Array>; generalTurns: Array>; nationTurns: Array>; logs: Array>; + messages: Array>; + messageReadStates?: Array>; + messageInboxRows?: Array>; + messageWatermark?: number; + includeRankMirrors?: boolean; }): CanonicalTurnSnapshot => { const worldMeta = asRecord(rows.world.meta); - const legacyRankTypes = new Set(LEGACY_RANK_DATA_TYPES); + const projectedRankTypes = new Set(rows.includeRankMirrors ? RANK_DATA_TYPES : LEGACY_RANK_DATA_TYPES); + const messageReadStateByGeneralId = new Map( + (rows.messageReadStates ?? []).map((row) => [readNumber(row, 'generalId'), row] as const) + ); + const messageInboxRows = rows.messageInboxRows ?? []; const generals = rows.generals.map((row) => { const meta = asRecord(row.meta); + const turnOffset = projectCanonicalTurnOffset(row.turnTick, rows.world.lastTurnTick, rows.world.tickSeconds); + const generalId = readNumber(row, 'id'); + const nationId = readNumber(row, 'nationId'); + const readState = messageReadStateByGeneralId.get(generalId) ?? {}; + const latestReadPrivateMessageId = readNumber(readState, 'latestPrivateMessage'); + const latestReadDiplomacyMessageId = readNumber(readState, 'latestDiplomacyMessage'); + const diplomacyMailbox = messageMailboxNationalBase + nationId; + const unreadPrivateCount = messageInboxRows.filter( + (message) => + message.type === 'private' && + readNumber(message, 'mailbox') === generalId && + readNumber(message, 'src') !== generalId && + readNumber(message, 'id') > latestReadPrivateMessageId + ).length; + const unreadDiplomacyCount = messageInboxRows.filter( + (message) => + message.type === 'diplomacy' && + readNumber(message, 'mailbox') === diplomacyMailbox && + readNumber(message, 'src') !== diplomacyMailbox && + readNumber(message, 'id') > latestReadDiplomacyMessageId + ).length; return { id: row.id, name: row.name, @@ -136,6 +395,8 @@ export const projectCoreDatabaseSnapshot = (rows: { itemWeapon: row.itemWeapon ?? null, itemBook: row.itemBook ?? null, itemExtra: row.itemExtra ?? null, + picture: row.picture ?? null, + imageServer: readNumber(row, 'imageServer'), injury: row.injury, gold: row.gold, rice: row.rice, @@ -146,10 +407,27 @@ export const projectCoreDatabaseSnapshot = (rows: { age: row.age, npcState: row.npcState, hasOwner: typeof row.userId === 'string' && row.userId.length > 0, + ownerIdentity: typeof row.userId === 'string' && row.userId.length > 0 ? row.userId : null, + messageReadState: { + unreadPrivateCount, + unreadDiplomacyCount, + hasUnreadMessage: unreadPrivateCount + unreadDiplomacyCount > 0, + }, turnTime: row.turnTime instanceof Date ? serializeDate(row.turnTime) : row.turnTime, recentWarTime: row.recentWarTime instanceof Date ? serializeDate(row.recentWarTime) : row.recentWarTime, lastTurn: row.lastTurn, meta, + ...projectCanonicalGeneralStoredFields(meta, { + expLevel: meta.explevel, + dedLevel: meta.dedlevel, + affinity: row.affinity, + bornYear: row.bornYear, + deadYear: row.deadYear, + npcOriginalState: meta.npc_org, + turnTick: row.turnTick, + ...turnOffset, + }), + commandState: projectCanonicalGeneralCommandState(meta), leadershipExp: readNumber(meta, 'leadership_exp'), strengthExp: readNumber(meta, 'strength_exp'), intelExp: readNumber(meta, 'intel_exp'), @@ -215,8 +493,14 @@ export const projectCoreDatabaseSnapshot = (rows: { capitalRevision: readNumber(meta, 'capset'), strategicCommandLimit: readNumber(meta, 'strategic_cmd_limit'), meta, + commandState: projectCanonicalNationCommandState(meta), }; }); + const troops = rows.troops.map((row) => ({ + id: row.troopLeaderId, + nationId: row.nationId, + name: row.name, + })); const diplomacy = rows.diplomacy.map((row) => ({ fromNationId: row.srcNationId, toNationId: row.destNationId, @@ -247,6 +531,18 @@ export const projectCoreDatabaseSnapshot = (rows: { month: row.month, text: row.text, })); + const messages = rows.messages.map((row) => ({ + id: row.id, + mailbox: row.mailbox, + type: row.type, + sourceId: row.src, + destinationId: row.dest, + createdAt: row.time instanceof Date ? serializeDate(row.time) : row.time, + validUntil: projectCanonicalMessageValidUntil( + Object.prototype.hasOwnProperty.call(row, 'effectiveValidUntil') ? row.effectiveValidUntil : row.validUntil + ), + payload: row.message, + })); return { schemaVersion: 1, @@ -255,12 +551,19 @@ export const projectCoreDatabaseSnapshot = (rows: { year: rows.world.currentYear, month: rows.world.currentMonth, tickMinutes: Math.max(1, Math.round(rows.world.tickSeconds / 60)), + lastTurnTick: readSafeTick(rows.world.lastTurnTick, 'world.lastTurnTick'), turnTime: readString(worldMeta, 'lastTurnTime'), + ...(rows.world.gameNow !== undefined + ? { + gameNow: + rows.world.gameNow instanceof Date ? serializeDate(rows.world.gameNow) : rows.world.gameNow, + } + : {}), isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')), }, generals, rankData: rows.rankData - .filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type)) + .filter((row) => typeof row.type === 'string' && projectedRankTypes.has(row.type)) .map((row) => ({ generalId: row.generalId, nationId: row.nationId, @@ -269,16 +572,16 @@ export const projectCoreDatabaseSnapshot = (rows: { })), cities, nations, + troops, diplomacy, generalTurns, nationTurns, logs, - messages: [], + messages, watermarks: { logId: logs.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0), historyLogId: logs.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0), - messageId: 0, + messageId: rows.messageWatermark ?? messages.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0), }, }; }; -import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; diff --git a/tools/integration-tests/src/turn-differential/compare.ts b/tools/integration-tests/src/turn-differential/compare.ts index 2d60c37c..f97d9bf8 100644 --- a/tools/integration-tests/src/turn-differential/compare.ts +++ b/tools/integration-tests/src/turn-differential/compare.ts @@ -13,42 +13,74 @@ export interface SnapshotComparisonOptions { type FlatSnapshot = Map; -const entityKey = (value: Record, index: number): string => { +const flatArray = Symbol('turn-snapshot-array'); +const flatObject = Symbol('turn-snapshot-object'); +const flatMissing = Symbol('turn-snapshot-missing'); + +const publicFlatStates = { + array: Object.freeze({ $snapshotState: 'array' }), + object: Object.freeze({ $snapshotState: 'object' }), + missing: Object.freeze({ $snapshotState: 'missing' }), +} as const; + +interface EntityIdentity { + key: string; + semantic: boolean; +} + +const entityIdentity = (value: Record, index: number): EntityIdentity => { if ( (typeof value.generalId === 'number' || typeof value.generalId === 'string') && typeof value.type === 'string' ) { - return `${String(value.generalId)}:${value.type}`; + return { key: `${String(value.generalId)}:${value.type}`, semantic: true }; } for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) { const candidate = value[key]; if (typeof candidate === 'number' || typeof candidate === 'string') { if (key === 'fromNationId' && value.toNationId !== undefined) { - return `${String(candidate)}->${String(value.toNationId)}`; + return { key: `${String(candidate)}->${String(value.toNationId)}`, semantic: true }; } if (value.turnIndex !== undefined) { - return `${String(candidate)}:${String(value.officerLevel ?? '')}:${String(value.turnIndex)}`; + return { + key: `${String(candidate)}:${String(value.officerLevel ?? '')}:${String(value.turnIndex)}`, + semantic: true, + }; } - return String(candidate); + return { key: String(candidate), semantic: true }; } } - return String(index); + return { key: String(index), semantic: false }; }; const flatten = (value: unknown, path: string, output: FlatSnapshot): void => { if (Array.isArray(value)) { + output.set(path, flatArray); + const semanticKeys = new Map(); value.forEach((entry, index) => { - const key = + const identity = path === 'logs' || path === 'messages' - ? String(index) + ? { key: String(index), semantic: false } : typeof entry === 'object' && entry !== null && !Array.isArray(entry) - ? entityKey(entry as Record, index) - : String(index); - flatten(entry, `${path}[${key}]`, output); + ? entityIdentity(entry as Record, index) + : { key: String(index), semantic: false }; + if (identity.semantic) { + const firstIndex = semanticKeys.get(identity.key); + if (firstIndex !== undefined) { + throw new Error( + `Duplicate semantic entity key ${JSON.stringify(identity.key)} at ${JSON.stringify( + path + )}: indexes ${firstIndex} and ${index}` + ); + } + semanticKeys.set(identity.key, index); + } + flatten(entry, `${path}[${identity.key}]`, output); }); return; } if (typeof value === 'object' && value !== null) { + output.set(path, flatObject); const record = value as Record; for (const key of Object.keys(record).sort()) { flatten(record[key], path ? `${path}.${key}` : key, output); @@ -65,6 +97,22 @@ const canonicalFlatSnapshot = (snapshot: CanonicalTurnSnapshot): FlatSnapshot => return output; }; +const flatValueAt = (snapshot: FlatSnapshot, path: string): unknown => + snapshot.has(path) ? snapshot.get(path) : flatMissing; + +const publicFlatValue = (value: unknown): unknown => { + if (value === flatArray) { + return publicFlatStates.array; + } + if (value === flatObject) { + return publicFlatStates.object; + } + if (value === flatMissing) { + return publicFlatStates.missing; + } + return value; +}; + const valuesEqual = (left: unknown, right: unknown, numericTolerance: number): boolean => { if (typeof left === 'number' && typeof right === 'number') { return Math.abs(left - right) <= numericTolerance; @@ -96,11 +144,11 @@ export const compareTurnSnapshots = ( const paths = [...new Set([...referenceFlat.keys(), ...coreFlat.keys()])].sort(); return paths .filter((path) => !ignored.some((pattern) => pattern.test(path))) - .filter((path) => !valuesEqual(referenceFlat.get(path), coreFlat.get(path), tolerance)) + .filter((path) => !valuesEqual(flatValueAt(referenceFlat, path), flatValueAt(coreFlat, path), tolerance)) .map((path) => ({ path, - reference: referenceFlat.get(path), - core: coreFlat.get(path), + reference: publicFlatValue(flatValueAt(referenceFlat, path)), + core: publicFlatValue(flatValueAt(coreFlat, path)), })); }; @@ -113,15 +161,15 @@ export const buildTurnSnapshotDelta = ( const paths = [...new Set([...beforeFlat.keys(), ...afterFlat.keys()])].sort(); const delta = new Map(); for (const path of paths) { - const previous = beforeFlat.get(path); - const next = afterFlat.get(path); + const previous = flatValueAt(beforeFlat, path); + const next = flatValueAt(afterFlat, path); if (Object.is(previous, next)) { continue; } if (typeof previous === 'number' && typeof next === 'number') { delta.set(path, next - previous); } else { - delta.set(path, { before: previous, after: next }); + delta.set(path, { before: publicFlatValue(previous), after: publicFlatValue(next) }); } } return delta; @@ -141,10 +189,10 @@ export const compareTurnSnapshotDeltas = ( const paths = [...new Set([...referenceDelta.keys(), ...coreDelta.keys()])].sort(); return paths .filter((path) => !ignored.some((pattern) => pattern.test(path))) - .filter((path) => !valuesEqual(referenceDelta.get(path), coreDelta.get(path), tolerance)) + .filter((path) => !valuesEqual(flatValueAt(referenceDelta, path), flatValueAt(coreDelta, path), tolerance)) .map((path) => ({ path, - reference: referenceDelta.get(path), - core: coreDelta.get(path), + reference: publicFlatValue(flatValueAt(referenceDelta, path)), + core: publicFlatValue(flatValueAt(coreDelta, path)), })); }; diff --git a/tools/integration-tests/src/turn-differential/coreCommandPersistenceFixture.ts b/tools/integration-tests/src/turn-differential/coreCommandPersistenceFixture.ts new file mode 100644 index 00000000..4606a264 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/coreCommandPersistenceFixture.ts @@ -0,0 +1,211 @@ +import { buildPersistedRankRows } from '@sammo-ts/game-engine/turn/rankData.js'; +import type { GamePrismaClient, InputJsonValue } from '@sammo-ts/infra'; + +import type { CanonicalTurnSnapshot } from './canonical.js'; +import type { buildCoreTurnCommandWorldInput } from './coreCommandTrace.js'; + +type CoreTurnCommandWorldInput = ReturnType; + +const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; +const nullableCode = (value: string | null | undefined): string => value ?? 'None'; +const turnArgs = (value: unknown): InputJsonValue => + asJson(typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}); + +const createManyIfPresent = async ( + rows: Row[], + createMany: (args: { data: Row[] }) => Promise +): Promise => { + if (rows.length > 0) { + await createMany({ data: rows }); + } +}; + +export const clearCoreTurnCommandPersistenceFixture = async (db: GamePrismaClient): Promise => { + await db.message.deleteMany(); + await db.messageReadState.deleteMany(); + await db.webPushOutbox.deleteMany(); + await db.readModelOutbox.deleteMany(); + await db.readModelRevision.deleteMany(); + await db.logEntry.deleteMany(); + await db.oldNation.deleteMany(); + await db.rankData.deleteMany(); + await db.generalTurn.deleteMany(); + await db.generalTurnRevision.deleteMany(); + await db.nationTurn.deleteMany(); + await db.nationTurnRevision.deleteMany(); + await db.diplomacy.deleteMany(); + await db.general.deleteMany(); + await db.troop.deleteMany(); + await db.city.deleteMany(); + await db.nation.deleteMany(); + await db.worldState.deleteMany(); +}; + +export const seedCoreTurnCommandPersistenceFixture = async ( + db: GamePrismaClient, + input: { + worldInput: CoreTurnCommandWorldInput; + generalTurns: CanonicalTurnSnapshot['generalTurns']; + nationTurns?: CanonicalTurnSnapshot['nationTurns']; + scenarioCode: string; + } +): Promise => { + const { state, snapshot, map } = input.worldInput; + await db.worldState.create({ + data: { + id: state.id, + scenarioCode: input.scenarioCode, + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + config: asJson(snapshot.scenarioConfig), + meta: asJson({ + ...state.meta, + ...(snapshot.scenarioMeta ? { scenarioMeta: snapshot.scenarioMeta } : {}), + }), + }, + }); + + await createManyIfPresent( + snapshot.nations.map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, + gold: nation.gold, + rice: nation.rice, + tech: Number(nation.meta.tech ?? 0), + level: nation.level, + typeCode: nation.typeCode, + meta: asJson(nation.meta), + })), + (args) => db.nation.createMany(args) + ); + await createManyIfPresent( + snapshot.cities.map((city) => { + const definition = map.cities.find((entry) => entry.id === city.id); + return { + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + supplyState: city.supplyState, + frontState: city.frontState, + population: Math.round(city.population), + populationMax: city.populationMax, + agriculture: Math.round(city.agriculture), + agricultureMax: city.agricultureMax, + commerce: Math.round(city.commerce), + commerceMax: city.commerceMax, + security: Math.round(city.security), + securityMax: city.securityMax, + trust: Number(city.meta.trust ?? 0), + trade: Number(city.meta.trade ?? 100), + defence: Math.round(city.defence), + defenceMax: city.defenceMax, + wall: Math.round(city.wall), + wallMax: city.wallMax, + region: definition?.region ?? 0, + conflict: asJson(city.conflict ?? {}), + meta: asJson({ ...city.meta, state: city.state }), + }; + }), + (args) => db.city.createMany(args) + ); + await createManyIfPresent( + snapshot.troops.map((troop) => ({ + troopLeaderId: troop.id, + nationId: troop.nationId, + name: troop.name, + })), + (args) => db.troop.createMany(args) + ); + await createManyIfPresent( + snapshot.generals.map((general) => ({ + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + npcState: general.npcState, + affinity: general.affinity, + bornYear: general.bornYear, + deadYear: general.deadYear, + picture: general.picture, + leadership: Math.round(general.stats.leadership), + strength: Math.round(general.stats.strength), + intel: Math.round(general.stats.intelligence), + injury: Math.round(general.injury), + experience: Math.round(general.experience), + dedication: Math.round(general.dedication), + officerLevel: general.officerLevel, + gold: Math.round(general.gold), + rice: Math.round(general.rice), + crew: Math.round(general.crew), + crewTypeId: general.crewTypeId, + train: Math.round(general.train), + atmos: Math.round(general.atmos), + age: general.age, + startAge: general.startAge, + personalCode: nullableCode(general.role.personality), + specialCode: nullableCode(general.role.specialDomestic), + special2Code: nullableCode(general.role.specialWar), + horseCode: nullableCode(general.role.items.horse), + weaponCode: nullableCode(general.role.items.weapon), + bookCode: nullableCode(general.role.items.book), + itemCode: nullableCode(general.role.items.item), + turnTime: general.turnTime, + recentWarTime: general.recentWarTime, + // Preserve the canonical fixture's container exactly. Ref's + // pre-command last_turn may be an empty object; synthesizing a + // 휴식 command here changes the graph before the lifecycle runs. + lastTurn: asJson(general.lastTurn ?? {}), + meta: asJson(general.meta), + penalty: asJson(general.penalty ?? {}), + })), + (args) => db.general.createMany(args) + ); + await createManyIfPresent( + snapshot.generals.flatMap((general) => + buildPersistedRankRows(general).map((row) => ({ + generalId: row.generalId, + nationId: row.nationId, + type: row.type, + value: row.value, + })) + ), + (args) => db.rankData.createMany(args) + ); + await createManyIfPresent( + snapshot.diplomacy.map((entry) => ({ + srcNationId: entry.fromNationId, + destNationId: entry.toNationId, + stateCode: entry.state, + term: entry.term, + isDead: entry.dead !== 0, + meta: asJson(entry.meta), + })), + (args) => db.diplomacy.createMany(args) + ); + await createManyIfPresent( + input.generalTurns.map((turn) => ({ + generalId: Number(turn.generalId), + turnIdx: Number(turn.turnIndex), + actionCode: String(turn.action), + arg: turnArgs(turn.args), + })), + (args) => db.generalTurn.createMany(args) + ); + await createManyIfPresent( + (input.nationTurns ?? []).map((turn) => ({ + nationId: Number(turn.nationId), + officerLevel: Number(turn.officerLevel), + turnIdx: Number(turn.turnIndex), + actionCode: String(turn.action), + arg: turnArgs(turn.args), + })), + (args) => db.nationTurn.createMany(args) + ); +}; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 4f3f7ec2..73f2fa00 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -1,11 +1,15 @@ -import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; +import { GameClock, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js'; import { GENERAL_TURN_COMMAND_KEYS, + LogFormat, NATION_TURN_COMMAND_KEYS, normalizeScenarioEffect, readLegacyCityTrust, + sendMessage, type MapDefinition, + type MessageDraft, + type MessageRecordDraft, type Nation, type TurnCommandProfile, type UnitSetDefinition, @@ -21,12 +25,25 @@ import type { TurnWorldSnapshot, TurnWorldState, } from '@sammo-ts/game-engine/turn/types.js'; -import { applyPersistedRankRowsToMeta, buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js'; +import { + applyPersistedRankRowsToMeta, + buildInitialRankRows, + buildLegacyComparableInitialRankRows, + buildLegacyComparableRankRows, + buildPersistedRankRows, +} from '@sammo-ts/game-engine/turn/rankData.js'; import { canonicalizeTurnCommandArgs, + closeTurnSnapshotSelectorOverCreatedEntities, + projectCanonicalGeneralCommandState, + projectCanonicalGeneralStoredFields, + projectCanonicalMessageValidUntil, + projectCanonicalNationCommandState, + projectCanonicalTurnOffset, type CanonicalTurnCommandTrace, type CanonicalTurnSnapshot, + type TurnSnapshotEntityIds, } from './canonical.js'; interface GeneralCooldownSelector { @@ -56,6 +73,8 @@ export interface TurnCommandFixtureRequest { hiddenSeed?: string; scenarioEffect?: string | null; staticEventHandlers?: Record; + freezeClock?: boolean; + messageSharedIconBaseUrl?: string; }; isolateWorld?: boolean; generals?: Array>; @@ -73,6 +92,12 @@ export interface TurnCommandFixtureRequest { generalIds?: number[]; cityIds?: number[]; nationIds?: number[]; + troopIds?: number[]; + allGenerals?: boolean; + allCities?: boolean; + allNations?: boolean; + allTroops?: boolean; + includeRankMirrors?: boolean; logAfterId?: number; messageAfterId?: number; includeNationHistoryLogs?: boolean; @@ -164,6 +189,18 @@ const readNullableString = (record: Record, key: string): strin return typeof value === 'string' && value !== '' && value !== 'None' ? value : null; }; +const parseSnapshotDate = (value: unknown, fallback: Date): Date => { + if (typeof value !== 'string') { + return new Date(fallback.getTime()); + } + const mysqlTimestamp = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/.exec(value); + const normalized = mysqlTimestamp + ? `${mysqlTimestamp[1]}-${mysqlTimestamp[2]}-${mysqlTimestamp[3]}T${mysqlTimestamp[4]}:${mysqlTimestamp[5]}:${mysqlTimestamp[6]}.${(mysqlTimestamp[7] ?? '').slice(0, 3).padEnd(3, '0')}Z` + : value; + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? new Date(fallback.getTime()) : parsed; +}; + const toDatabaseInt = (value: number): number => Math.round(value); const COMMANDS_WITH_LEGACY_CORE_ARG_KEYS = new Set([ @@ -186,32 +223,57 @@ export const resolveCoreTurnCommandArgs = (request: TurnCommandFixtureRequest): }; export const createCoreTurnCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandProfile => { + const configuredGeneralActions = (request.setup?.generalTurns ?? []).map((turn) => readString(turn, 'action', '')); + const configuredNationActions = (request.setup?.nationTurns ?? []).map((turn) => readString(turn, 'action', '')); + for (const action of configuredGeneralActions) { + if (!GENERAL_TURN_COMMAND_KEYS.includes(action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) { + throw new Error(`Unknown configured general command: ${action}`); + } + } + for (const action of configuredNationActions) { + if (!NATION_TURN_COMMAND_KEYS.includes(action as (typeof NATION_TURN_COMMAND_KEYS)[number])) { + throw new Error(`Unknown configured nation command: ${action}`); + } + } 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}`); } - const generalActions = [request.action, '휴식', 'che_인재탐색', 'che_해산', 'che_이동'] as Array< - (typeof GENERAL_TURN_COMMAND_KEYS)[number] - >; + const generalActions = [ + request.action, + ...configuredGeneralActions, + '휴식', + 'che_인재탐색', + 'che_해산', + 'che_이동', + ] as Array<(typeof GENERAL_TURN_COMMAND_KEYS)[number]>; return { general: [...new Set(generalActions)], - nation: ['휴식'], + nation: [...new Set(['휴식', ...configuredNationActions])] as Array< + (typeof NATION_TURN_COMMAND_KEYS)[number] + >, }; } 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], '휴식'], + general: [...new Set(['휴식', ...configuredGeneralActions])] as Array< + (typeof GENERAL_TURN_COMMAND_KEYS)[number] + >, + nation: [ + ...new Set([ + request.action as (typeof NATION_TURN_COMMAND_KEYS)[number], + '휴식', + ...configuredNationActions, + ]), + ] as Array<(typeof NATION_TURN_COMMAND_KEYS)[number]>, }; }; const buildGeneral = (row: Record, fallbackTurnTime: Date): TurnGeneral => { const meta = asRecord(row.meta); - const rawTurnTime = row.turnTime; - const parsedTurnTime = typeof rawTurnTime === 'string' ? new Date(rawTurnTime) : fallbackTurnTime; - const turnTime = Number.isNaN(parsedTurnTime.getTime()) ? fallbackTurnTime : parsedTurnTime; + const turnTime = parseSnapshotDate(row.turnTime, fallbackTurnTime); const rawLastTurn = asRecord(row.lastTurn); const lastTurn = typeof rawLastTurn.command === 'string' @@ -256,7 +318,17 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur atmos: readNumber(row, 'atmos'), age: readNumber(row, 'age', 30), npcState: readNumber(row, 'npcState'), - userId: row.hasOwner === true ? 'turn-differential-owner' : null, + ...(typeof row.affinity === 'number' || row.affinity === null ? { affinity: row.affinity } : {}), + ...(typeof row.bornYear === 'number' ? { bornYear: row.bornYear } : {}), + ...(typeof row.deadYear === 'number' ? { deadYear: row.deadYear } : {}), + picture: readNullableString(row, 'picture'), + imageServer: readNumber(row, 'imageServer'), + userId: + typeof row.ownerIdentity === 'string' && row.ownerIdentity.length > 0 + ? row.ownerIdentity + : row.hasOwner === true + ? 'turn-differential-owner' + : null, penalty: row.penalty, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { @@ -275,6 +347,12 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur dex4: readNumber(row, 'dex4', readNumber(meta, 'dex4')), dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')), explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')), + dedlevel: readNumber(row, 'dedLevel', readNumber(meta, 'dedlevel')), + npc_org: readNumber(row, 'npcOriginalState', readNumber(meta, 'npc_org')), + affinity: readNumber(row, 'affinity', readNumber(meta, 'affinity')), + birthYear: readNumber(row, 'bornYear', readNumber(meta, 'birthYear')), + deathYear: readNumber(row, 'deadYear', readNumber(meta, 'deathYear')), + ...(typeof row.npcMessage === 'string' && row.npcMessage !== '' ? { text: row.npcMessage } : {}), betray: readNumber(row, 'betray', readNumber(meta, 'betray')), officerCityId: readNumber( row, @@ -296,6 +374,7 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur block: readNumber(row, 'blockState', readNumber(meta, 'block')), }, ...(lastTurn ? { lastTurn } : {}), + ...(typeof row.turnTick === 'number' ? { turnTick: row.turnTick } : {}), turnTime, recentWarTime: null, }; @@ -304,6 +383,7 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur const buildNation = (row: Record, generals: TurnGeneral[]): Nation => { const id = readNumber(row, 'id'); const meta = asRecord(row.meta); + const commandState = asRecord(row.commandState); const turnLastByOfficerLevel = asRecord(row.turnLastByOfficerLevel); return { id, @@ -330,6 +410,9 @@ const buildNation = (row: Record, generals: TurnGeneral[]): Nat surlimit: readNumber(row, 'diplomacyLimit', readNumber(meta, 'surlimit')), capset: readNumber(row, 'capitalRevision', readNumber(meta, 'capset')), strategic_cmd_limit: readNumber(row, 'strategicCommandLimit', readNumber(meta, 'strategic_cmd_limit')), + rate: readNumber(commandState, 'rate', readNumber(meta, 'rate')), + bill: readNumber(commandState, 'bill', readNumber(meta, 'bill')), + secretlimit: readNumber(commandState, 'secretLimit', readNumber(meta, 'secretlimit', 3)), }, }; }; @@ -342,7 +425,17 @@ export const buildCoreTurnCommandWorldInput = ( ): { 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 calendarFallback = new Date( + `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z` + ); + const turnTime = parseSnapshotDate(referenceBefore.world.turnTime, calendarFallback); + const tickSeconds = readNumber(referenceBefore.world, 'tickMinutes', 10) * 60; + const lastTurnTick = readNumber(referenceBefore.world, 'lastTurnTick'); + // Ref's tick is absolute within GameClock's domain, while turnTime is the + // display date at that tick. Derive the clock epoch instead of treating + // the scenario year/month as the epoch and rewriting 2026 snapshots into + // year 0185 during InMemoryTurnWorld normalization. + const clockBaseTime = GameClock.baseTimeForProjection(turnTime, lastTurnTick, tickSeconds); const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime)); for (const general of generals) { applyPersistedRankRowsToMeta( @@ -437,9 +530,10 @@ export const buildCoreTurnCommandWorldInput = ( environment: { mapName: map.id, unitSet: unitSet.id, - ...(request.setup?.world?.scenarioEffect !== undefined - ? { scenarioEffect: normalizeScenarioEffect(request.setup.world.scenarioEffect) } - : {}), + // worldLoader materializes the optional empty value as null. + // Keep the in-memory fixture on that same product boundary so + // a persistence round trip cannot add a synthetic field. + scenarioEffect: normalizeScenarioEffect(request.setup?.world?.scenarioEffect), }, }, scenarioMeta: { @@ -525,8 +619,13 @@ export const buildCoreTurnCommandWorldInput = ( id: 1, currentYear: year, currentMonth: month, - tickSeconds: readNumber(referenceBefore.world, 'tickMinutes', 10) * 60, + tickSeconds, + lastTurnTick, lastTurnTime: turnTime, + clockBaseTime, + clockTick: lastTurnTick, + clockMode: 'manual', + clockWallAnchor: turnTime, meta: { hiddenSeed: request.setup?.world?.hiddenSeed ?? 'turn-command-differential-seed', killturn: readNumber(referenceBefore.world, 'killTurn', 24), @@ -538,6 +637,11 @@ export const buildCoreTurnCommandWorldInput = ( request.setup?.world?.initYear ?? request.setup?.world?.startYear ?? year ), initMonth: readNumber(referenceBefore.world, 'initMonth', request.setup?.world?.initMonth ?? 1), + differentialGameNow: readString( + referenceBefore.world, + 'gameNow', + readString(referenceBefore.world, 'turnTime', turnTime.toISOString()) + ), }, }, snapshot, @@ -545,19 +649,127 @@ export const buildCoreTurnCommandWorldInput = ( }; }; +interface InMemorySnapshotSelector { + generalIds: Set; + initialGeneralIds: Set; + cityIds: Set; + nationIds: Set; + troopIds: Set; + includeRankMirrors: boolean; + messageReadStateByGeneralId: Map; + generalCooldowns: GeneralCooldownSelector[]; + nationCooldowns: NationCooldownSelector[]; +} + +interface SemanticMessageReadState { + unreadPrivateCount: number; + unreadDiplomacyCount: number; +} + +const readSemanticMessageState = (general: Record): SemanticMessageReadState => { + const state = asRecord(general.messageReadState); + return { + unreadPrivateCount: readNumber(state, 'unreadPrivateCount'), + unreadDiplomacyCount: readNumber(state, 'unreadDiplomacyCount'), + }; +}; + +export const projectCoreMessageReadState = ( + generalId: number, + nationId: number, + messages: CanonicalTurnSnapshot['messages'], + baseline?: SemanticMessageReadState +): SemanticMessageReadState & { hasUnreadMessage: boolean } => { + const startingState = baseline ?? { unreadPrivateCount: 0, unreadDiplomacyCount: 0 }; + const diplomacyMailbox = 9_000 + nationId; + const unreadPrivateCount = + startingState.unreadPrivateCount + + messages.filter( + (message) => + message.type === 'private' && + readNumber(message, 'mailbox') === generalId && + readNumber(message, 'sourceId') !== generalId + ).length; + const unreadDiplomacyCount = + startingState.unreadDiplomacyCount + + messages.filter( + (message) => + message.type === 'diplomacy' && + readNumber(message, 'mailbox') === diplomacyMailbox && + readNumber(message, 'sourceId') !== diplomacyMailbox + ).length; + return { + unreadPrivateCount, + unreadDiplomacyCount, + hasUnreadMessage: unreadPrivateCount + unreadDiplomacyCount > 0, + }; +}; + +const readWorldEntityIds = (world: InMemoryTurnWorld): TurnSnapshotEntityIds => ({ + generalIds: world.listGenerals().map((general) => general.id), + cityIds: world.listCities().map((city) => city.id), + nationIds: world.listNations().map((nation) => nation.id), + troopIds: world.listTroops().map((troop) => troop.id), +}); + +const extendSelectorOverCreatedEntities = ( + selector: InMemorySnapshotSelector, + before: TurnSnapshotEntityIds, + after: TurnSnapshotEntityIds +): void => { + const closed = closeTurnSnapshotSelectorOverCreatedEntities( + { + generalIds: [...selector.generalIds], + cityIds: [...selector.cityIds], + nationIds: [...selector.nationIds], + troopIds: [...selector.troopIds], + }, + before, + after + ); + for (const id of closed.generalIds) selector.generalIds.add(id); + for (const id of closed.cityIds) selector.cityIds.add(id); + for (const id of closed.nationIds) selector.nationIds.add(id); + for (const id of closed.troopIds ?? []) selector.troopIds.add(id); +}; + +export const projectCoreMessageDrafts = async ( + drafts: readonly MessageDraft[], + messageIdWatermark: number +): Promise => { + const records: Array = []; + let nextId = messageIdWatermark; + for (const draft of drafts) { + await sendMessage( + { + insertMessage: async (record) => { + const id = ++nextId; + records.push({ ...record, id }); + return id; + }, + }, + draft, + { sendDestOnly: draft.sendDestOnly } + ); + } + return records.map((record) => ({ + id: record.id, + mailbox: record.mailbox, + type: record.msgType, + sourceId: record.srcId, + destinationId: record.destId, + createdAt: record.time.toISOString(), + validUntil: projectCanonicalMessageValidUntil(record.validUntil), + payload: record.payload, + })); +}; + const projectWorld = ( world: InMemoryTurnWorld, reservedTurns: InMemoryReservedTurnStore, logs: CanonicalTurnSnapshot['logs'], messages: CanonicalTurnSnapshot['messages'], - selector: { - generalIds: Set; - cityIds: Set; - nationIds: Set; - initialGeneralIds: Set; - generalCooldowns: GeneralCooldownSelector[]; - nationCooldowns: NationCooldownSelector[]; - } + selector: InMemorySnapshotSelector ): CanonicalTurnSnapshot => { const state = world.getState(); const generals = world @@ -574,7 +786,6 @@ const projectWorld = ( intelligence: general.stats.intelligence, experience: toDatabaseInt(general.experience), dedication: toDatabaseInt(general.dedication), - expLevel: readNumber(general.meta, 'explevel'), officerLevel: general.officerLevel, officerCityId: readNumber( general.meta, @@ -593,6 +804,8 @@ const projectWorld = ( itemWeapon: general.role.items.weapon, itemBook: general.role.items.book, itemExtra: general.role.items.item, + picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, injury: general.injury, gold: toDatabaseInt(general.gold), rice: toDatabaseInt(general.rice), @@ -603,10 +816,28 @@ const projectWorld = ( age: general.age, npcState: general.npcState, hasOwner: Boolean(general.userId), + ownerIdentity: general.userId ?? null, + messageReadState: projectCoreMessageReadState( + general.id, + general.nationId, + messages, + selector.messageReadStateByGeneralId.get(general.id) + ), turnTime: general.turnTime.toISOString(), recentWarTime: general.recentWarTime?.toISOString() ?? null, - lastTurn: general.lastTurn ?? null, + // Core's persisted JSON column and Ref both represent the + // pre-command state as an empty object. Do not invent a null-only + // in-memory variant at the canonical boundary. + lastTurn: general.lastTurn ?? {}, meta: general.meta, + ...projectCanonicalGeneralStoredFields(general.meta, { + affinity: general.affinity, + bornYear: general.bornYear, + deadYear: general.deadYear, + turnTick: general.turnTick, + ...projectCanonicalTurnOffset(general.turnTick, state.lastTurnTick, state.tickSeconds), + }), + commandState: projectCanonicalGeneralCommandState(general.meta), leadershipExp: toDatabaseInt(readNumber(general.meta, 'leadership_exp')), strengthExp: toDatabaseInt(readNumber(general.meta, 'strength_exp')), intelExp: toDatabaseInt(readNumber(general.meta, 'intel_exp')), @@ -631,7 +862,9 @@ const projectWorld = ( year: state.currentYear, month: state.currentMonth, tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)), + lastTurnTick: state.lastTurnTick, turnTime: state.lastTurnTime.toISOString(), + gameNow: readString(state.meta, 'differentialGameNow', state.lastTurnTime.toISOString()), isUnited: readNumber(state.meta, 'isUnited'), generalCooldowns: selector.generalCooldowns.map(({ generalId, actionName }) => { const general = world.getGeneralById(generalId); @@ -657,9 +890,13 @@ const projectWorld = ( .listGenerals() .filter((general) => selector.generalIds.has(general.id)) .flatMap((general) => - buildLegacyComparableRankRows(general).map((row) => - selector.initialGeneralIds.has(general.id) ? row : { ...row, nationId: 0, value: 0 } - ) + selector.includeRankMirrors + ? selector.initialGeneralIds.has(general.id) + ? buildPersistedRankRows(general) + : buildInitialRankRows(general) + : selector.initialGeneralIds.has(general.id) + ? buildLegacyComparableRankRows(general) + : buildLegacyComparableInitialRankRows(general) ) .map((row) => ({ ...row })), cities: world @@ -706,18 +943,44 @@ const projectWorld = ( tech: readLegacyStoredFloat(readNumber(nation.meta, 'tech')), level: nation.level, typeCode: nation.typeCode, - generalCount: world.listGenerals().filter((general) => general.nationId === nation.id).length, + generalCount: readNumber( + nation.meta, + 'gennum', + world.listGenerals().filter((general) => general.nationId === nation.id).length + ), power: nation.power, war: readNumber(nation.meta, 'war'), diplomacyLimit: readNumber(nation.meta, 'surlimit'), capitalRevision: readNumber(nation.meta, 'capset'), strategicCommandLimit: readNumber(nation.meta, 'strategic_cmd_limit'), meta: nation.meta, + commandState: projectCanonicalNationCommandState(nation.meta), })), + troops: world + .listTroops() + .filter( + (troop) => + selector.troopIds.has(troop.id) || + selector.generalIds.has(troop.id) || + world + .listGenerals() + .some((general) => selector.generalIds.has(general.id) && general.troopId === troop.id) + ) + .map((troop) => ({ ...troop })), diplomacy: world .listDiplomacy() .filter((entry) => selector.nationIds.has(entry.fromNationId) && selector.nationIds.has(entry.toNationId)) - .map((entry) => ({ ...entry })), + // Keep the in-memory adapter on the same canonical boundary as the + // PostgreSQL and Ref adapters. Core's internal `meta` container has + // no Ref diplomacy-table counterpart and must not appear/disappear + // as a synthetic graph mutation. + .map((entry) => ({ + fromNationId: entry.fromNationId, + toNationId: entry.toNationId, + state: entry.state, + term: entry.term, + dead: entry.dead, + })), generalTurns: generals.flatMap((general) => reservedTurns.getGeneralTurns(Number(general.id)).map((turn, turnIndex) => ({ generalId: general.id, @@ -739,7 +1002,11 @@ const projectWorld = ( ), logs, messages, - watermarks: { logId: logs.length, historyLogId: logs.length, messageId: messages.length }, + watermarks: { + logId: logs.reduce((max, row) => Math.max(max, readNumber(row, 'id')), 0), + historyLogId: logs.reduce((max, row) => Math.max(max, readNumber(row, 'id')), 0), + messageId: messages.reduce((max, row) => Math.max(max, readNumber(row, 'id')), 0), + }, }; }; @@ -781,17 +1048,43 @@ export const runCoreTurnCommandTrace = async ( const map = await loadMapDefinitionByName('che'); const worldInput = buildCoreTurnCommandWorldInput(request, referenceBefore, unitSet, map); const { state, snapshot } = worldInput; - const selector = { - generalIds: new Set([ - ...referenceBefore.generals.map((row) => readNumber(row, 'id')), - ...(request.observe?.generalIds ?? []), - ]), - cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), - nationIds: new Set([ - ...referenceBefore.nations.map((row) => readNumber(row, 'id')), - ...(request.observe?.nationIds ?? []), - ]), - initialGeneralIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))), + const selector: InMemorySnapshotSelector = { + generalIds: new Set( + request.observe?.allGenerals + ? snapshot.generals.map((general) => general.id) + : [ + ...referenceBefore.generals.map((row) => readNumber(row, 'id')), + ...(request.observe?.generalIds ?? []), + ] + ), + initialGeneralIds: new Set(snapshot.generals.map((general) => general.id)), + cityIds: new Set( + request.observe?.allCities + ? snapshot.cities.map((city) => city.id) + : [...referenceBefore.cities.map((row) => readNumber(row, 'id')), ...(request.observe?.cityIds ?? [])] + ), + nationIds: new Set( + request.observe?.allNations + ? snapshot.nations.map((nation) => nation.id) + : [ + ...referenceBefore.nations.map((row) => readNumber(row, 'id')), + ...(request.observe?.nationIds ?? []), + ] + ), + troopIds: new Set( + request.observe?.allTroops + ? snapshot.troops.map((troop) => troop.id) + : [ + ...(referenceBefore.troops ?? []).map((row) => readNumber(row, 'id')), + ...(request.observe?.troopIds ?? []), + ] + ), + includeRankMirrors: request.observe?.includeRankMirrors === true, + messageReadStateByGeneralId: new Map( + referenceBefore.generals.map( + (general) => [readNumber(general, 'id'), readSemanticMessageState(general)] as const + ) + ), generalCooldowns: request.observe?.generalCooldowns ?? [], nationCooldowns: request.observe?.nationCooldowns ?? [], }; @@ -818,16 +1111,17 @@ export const runCoreTurnCommandTrace = async ( } let world: InMemoryTurnWorld | null = null; - let resolution: - | { - kind: 'nation' | 'general'; - actionKey: string; - requestedAction: string; - usedFallback: boolean; - blockedReason?: string; - } - | undefined; + type LifecycleResolution = { + kind: 'nation' | 'general'; + actionKey: string; + requestedAction: string; + usedFallback: boolean; + blockedReason?: string; + }; + let resolution: LifecycleResolution | undefined; + const lifecycleResolutions: LifecycleResolution[] = []; const commandRngCalls: RandomCall[] = []; + const gameNow = parseSnapshotDate(referenceBefore.world.gameNow, actor.turnTime); const handler = await createReservedTurnHandler({ reservedTurns, scenarioConfig: snapshot.scenarioConfig, @@ -835,6 +1129,8 @@ export const runCoreTurnCommandTrace = async ( map, unitSet, getWorld: () => world, + now: () => new Date(gameNow.getTime()), + messageSharedIconBaseUrl: request.setup?.world?.messageSharedIconBaseUrl, commandProfile: createCoreTurnCommandProfile(request), commandRngFactory: ({ kind, actionKey, seed }) => { const tracing = new TracingRng(new LiteHashDRBG(seed)); @@ -867,6 +1163,7 @@ export const runCoreTurnCommandTrace = async ( return new RandUtil(new LiteHashDRBG(seed)); }, onActionResolved: (payload) => { + lifecycleResolutions.push(payload); if (payload.kind === request.kind && payload.requestedAction === request.action) { resolution = payload; } @@ -878,9 +1175,12 @@ export const runCoreTurnCommandTrace = async ( }, generalTurnHandler: handler, }); + const initialWorldEntityIds = readWorldEntityIds(world); const before = projectWorld(world, reservedTurns, [], [], selector); world.executeGeneralTurn(actor); + extendSelectorOverCreatedEntities(selector, initialWorldEntityIds, readWorldEntityIds(world)); const dirty = world.peekDirtyState(); + const projectedMessages = await projectCoreMessageDrafts(dirty.messages, referenceBefore.watermarks.messageId); const after = projectWorld( world, reservedTurns, @@ -892,14 +1192,12 @@ export const runCoreTurnCommandTrace = async ( // GENERAL logs that finalizeLogEntry would reject in production. generalId: log.generalId, nationId: log.nationId, - year: state.currentYear, - month: state.currentMonth, + year: log.year ?? state.currentYear, + month: log.month ?? state.currentMonth, + format: log.format ?? LogFormat.RAWTEXT, text: log.text, })), - dirty.messages.map((message, index) => ({ - id: index + 1, - payload: message, - })), + projectedMessages, selector ); @@ -912,7 +1210,18 @@ export const runCoreTurnCommandTrace = async ( action: request.action, args, seedDomain: request.kind === 'general' ? 'generalCommand' : 'nationCommand', - outcome: resolution, + outcome: resolution + ? { + ...resolution, + lifecycleActions: lifecycleResolutions.map((entry) => ({ + kind: entry.kind, + requestedAction: entry.requestedAction, + actionKey: entry.actionKey, + usedFallback: entry.usedFallback, + ...(entry.blockedReason ? { blockedReason: entry.blockedReason } : {}), + })), + } + : resolution, }, before, after, diff --git a/tools/integration-tests/src/turn-differential/databaseSnapshot.ts b/tools/integration-tests/src/turn-differential/databaseSnapshot.ts index 9d34281d..df50af2f 100644 --- a/tools/integration-tests/src/turn-differential/databaseSnapshot.ts +++ b/tools/integration-tests/src/turn-differential/databaseSnapshot.ts @@ -1,6 +1,55 @@ +import { GameClock, MAX_SAFE_GAME_TICK, type GameClockMode } from '@sammo-ts/common'; import { createGamePostgresConnector } from '@sammo-ts/infra'; -import { projectCoreDatabaseSnapshot, type CanonicalTurnSnapshot, type TurnSnapshotSelector } from './canonical.js'; +import { + projectCoreDatabaseSnapshot, + CANONICAL_MESSAGE_VALID_UNTIL_INFINITE, + projectCanonicalMessageValidUntil, + type CanonicalTurnSnapshot, + type TurnSnapshotEntityIds, + type TurnSnapshotSelector, +} from './canonical.js'; + +export const projectEffectiveCoreMessageValidUntil = ( + row: { validUntil: Date | string; validUntilTick?: bigint | number | null }, + clock: GameClock | null +): string | typeof CANONICAL_MESSAGE_VALID_UNTIL_INFINITE => { + if (clock && row.validUntilTick !== null && row.validUntilTick !== undefined) { + const tick = Number(row.validUntilTick); + if (!Number.isSafeInteger(tick)) { + throw new Error( + `message.valid_until_tick is outside the JavaScript safe integer range: ${String(row.validUntilTick)}` + ); + } + if (tick === MAX_SAFE_GAME_TICK) { + return CANONICAL_MESSAGE_VALID_UNTIL_INFINITE; + } + return projectCanonicalMessageValidUntil(clock.tickToDate(tick)); + } + return projectCanonicalMessageValidUntil(row.validUntil); +}; + +export const readCoreDatabaseEntityIds = async (databaseUrl: string): Promise => { + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const db = connector.prisma; + const [generals, cities, nations, troops] = await Promise.all([ + db.general.findMany({ select: { id: true }, orderBy: { id: 'asc' } }), + db.city.findMany({ select: { id: true }, orderBy: { id: 'asc' } }), + db.nation.findMany({ select: { id: true }, orderBy: { id: 'asc' } }), + db.troop.findMany({ select: { troopLeaderId: true }, orderBy: { troopLeaderId: 'asc' } }), + ]); + return { + generalIds: generals.map((row) => row.id), + cityIds: cities.map((row) => row.id), + nationIds: nations.map((row) => row.id), + troopIds: troops.map((row) => row.troopLeaderId), + }; + } finally { + await connector.disconnect(); + } +}; export const readCoreDatabaseSnapshot = async ( databaseUrl: string, @@ -11,60 +60,152 @@ export const readCoreDatabaseSnapshot = async ( try { const db = connector.prisma; const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); - const [generals, rankData, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([ + const [generals, cities, nations] = await Promise.all([ db.general.findMany({ - where: { id: { in: selector.generalIds } }, + ...(selector.allGenerals ? {} : { where: { id: { in: selector.generalIds } } }), orderBy: { id: 'asc' }, }), - db.rankData.findMany({ - where: { generalId: { in: selector.generalIds } }, - orderBy: [{ generalId: 'asc' }, { type: 'asc' }], - }), db.city.findMany({ - where: { id: { in: selector.cityIds } }, + ...(selector.allCities ? {} : { where: { id: { in: selector.cityIds } } }), orderBy: { id: 'asc' }, }), db.nation.findMany({ - where: { id: { in: selector.nationIds } }, + ...(selector.allNations ? {} : { where: { id: { in: selector.nationIds } } }), orderBy: { id: 'asc' }, }), + ]); + const generalIds = generals.map((row) => row.id); + const nationIds = nations.map((row) => row.id); + const wallNow = new Date(); + let currentMessageTime = wallNow; + let currentMessageTick: bigint | null = null; + let gameClock: GameClock | null = null; + if (world.clockBaseTime && world.clockTick !== null && world.clockWallAnchor) { + const mode: GameClockMode = world.clockMode === 'manual' ? 'manual' : 'realtime'; + const storedTick = Number(world.clockTick); + if (!Number.isSafeInteger(storedTick)) { + throw new Error( + `world_state.clock_tick is outside the JavaScript safe integer range: ${world.clockTick}` + ); + } + gameClock = new GameClock({ + baseTime: world.clockBaseTime, + tick: storedTick, + mode, + wallAnchor: world.clockWallAnchor, + turnSeconds: world.tickSeconds, + }); + currentMessageTick = BigInt(gameClock.nowTick(wallNow)); + currentMessageTime = gameClock.tickToDate(Number(currentMessageTick)); + } + const troopIds = new Set([...(selector.troopIds ?? []), ...selector.generalIds]); + for (const general of generals) { + troopIds.add(general.id); + if (general.troopId > 0) { + troopIds.add(general.troopId); + } + } + const [ + rankData, + troops, + diplomacy, + generalTurns, + nationTurns, + logs, + messages, + latestMessage, + messageReadStates, + messageInboxRows, + ] = await Promise.all([ + db.rankData.findMany({ + where: { generalId: { in: generalIds } }, + orderBy: [{ generalId: 'asc' }, { type: 'asc' }], + }), + db.troop.findMany({ + ...(selector.allTroops + ? {} + : { where: { troopLeaderId: { in: [...troopIds].sort((left, right) => left - right) } } }), + orderBy: { troopLeaderId: 'asc' }, + }), db.diplomacy.findMany({ where: { - srcNationId: { in: selector.nationIds }, - destNationId: { in: selector.nationIds }, + srcNationId: { in: nationIds }, + destNationId: { in: nationIds }, }, orderBy: [{ srcNationId: 'asc' }, { destNationId: 'asc' }], }), db.generalTurn.findMany({ - where: { generalId: { in: selector.generalIds } }, + where: { generalId: { in: generalIds } }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], }), db.nationTurn.findMany({ - where: { nationId: { in: selector.nationIds } }, + where: { nationId: { in: nationIds } }, orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }], }), db.logEntry.findMany({ where: { id: { gt: selector.logAfterId ?? 0 }, - OR: [ - { scope: 'SYSTEM' }, - { generalId: { in: selector.generalIds } }, - { nationId: { in: selector.nationIds } }, + OR: [{ scope: 'SYSTEM' }, { generalId: { in: generalIds } }, { nationId: { in: nationIds } }], + }, + orderBy: { id: 'asc' }, + }), + db.message.findMany({ + where: { id: { gt: selector.messageAfterId ?? 0 } }, + orderBy: { id: 'asc' }, + }), + db.message.findFirst({ + select: { id: true }, + orderBy: { id: 'desc' }, + }), + db.messageReadState.findMany({ + where: { generalId: { in: generalIds } }, + orderBy: { generalId: 'asc' }, + }), + db.message.findMany({ + where: { + AND: [ + { + OR: [ + { type: 'private', mailbox: { in: generalIds } }, + { + type: 'diplomacy', + mailbox: { in: nationIds.map((nationId) => 9_000 + nationId) }, + }, + ], + }, + { + OR: [ + ...(currentMessageTick === null + ? [] + : [{ validUntilTick: { not: null, gt: currentMessageTick } }]), + { validUntilTick: null, validUntil: { gt: currentMessageTime } }, + ], + }, ], }, + select: { id: true, mailbox: true, type: true, src: true }, orderBy: { id: 'asc' }, }), ]); return projectCoreDatabaseSnapshot({ - world, + world: { ...world, gameNow: currentMessageTime }, generals, rankData, cities, nations, + troops, diplomacy, generalTurns, nationTurns, logs, + messages: messages.map((row) => ({ + ...row, + effectiveValidUntil: projectEffectiveCoreMessageValidUntil(row, gameClock), + })), + messageReadStates, + messageInboxRows, + messageWatermark: latestMessage?.id ?? 0, + includeRankMirrors: selector.includeRankMirrors, }); } finally { await connector.disconnect(); diff --git a/tools/integration-tests/src/turn-differential/fullLifecycleFixture.ts b/tools/integration-tests/src/turn-differential/fullLifecycleFixture.ts new file mode 100644 index 00000000..7480d89a --- /dev/null +++ b/tools/integration-tests/src/turn-differential/fullLifecycleFixture.ts @@ -0,0 +1,213 @@ +import type { CanonicalTurnSnapshot, TurnSnapshotSelector } from './canonical.js'; +import type { TurnCommandFixtureRequest } from './coreCommandTrace.js'; + +const asRecord = (value: unknown): Record => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; + +const semanticTimestamp = (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 semanticTurnArgs = (value: unknown): unknown => (Array.isArray(value) && value.length === 0 ? {} : value); + +export const fullLifecycleGeneralTurns = Array.from({ length: 30 }, (_, turnIndex) => ({ + generalId: 1, + turnIndex, + action: turnIndex === 0 ? 'che_훈련' : '휴식', + args: {}, +})); + +export const fullLifecycleNationTurns = Array.from({ length: 12 }, (_, turnIndex) => ({ + nationId: 1, + officerLevel: 12, + turnIndex, + action: turnIndex === 0 ? 'che_국호변경' : '휴식', + args: turnIndex === 0 ? { nationName: '수명주기국' } : {}, +})); + +export const fullLifecycleSnapshotSelector: TurnSnapshotSelector = { + generalIds: [1], + cityIds: [3], + nationIds: [1], + allGenerals: true, + allCities: true, + allNations: true, + allTroops: true, + includeRankMirrors: true, + logAfterId: 0, + messageAfterId: 0, + includeNationHistoryLogs: true, + includeGlobalHistoryLogs: true, +}; + +export const fullLifecycleTurnCommandRequest: TurnCommandFixtureRequest = { + kind: 'general', + actorGeneralId: 1, + action: 'che_훈련', + args: {}, + includeLifecycle: true, + setup: { + isolateWorld: true, + world: { + startYear: 180, + year: 190, + month: 1, + hiddenSeed: 'turn-command-full-lifecycle-v1', + freezeClock: true, + }, + nations: [ + { + id: 1, + name: '아국', + color: '#777777', + capitalCityId: 3, + gold: 1_000_000, + rice: 1_000_000, + tech: 1_000, + level: 1, + typeCode: 'che_명가', + generalCount: 1, + meta: { can_국호변경: 1 }, + }, + ], + cities: [ + { + id: 3, + nationId: 1, + level: 5, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + state: 0, + term: 0, + trust: 80, + trade: 100, + }, + ], + generals: [ + { + id: 1, + name: '수명주기장수', + nationId: 1, + cityId: 3, + troopId: 0, + leadership: 90, + strength: 80, + intelligence: 70, + leadershipExp: 0, + strengthExp: 0, + intelExp: 0, + experience: 1_000, + dedication: 1_000, + expLevel: 0, + officerLevel: 12, + officerCityId: 3, + belong: 10, + permission: 'normal', + injury: 0, + age: 30, + gold: 100_000, + rice: 100_000, + crew: 1_000, + crewTypeId: 1_100, + 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: {}, + }, + ], + generalTurns: fullLifecycleGeneralTurns, + nationTurns: fullLifecycleNationTurns, + }, + observe: fullLifecycleSnapshotSelector, +}; + +export const projectFullLifecycleSnapshotGraph = (snapshot: CanonicalTurnSnapshot): Record => { + const general = snapshot.generals.find((entry) => entry.id === 1); + const nation = snapshot.nations.find((entry) => entry.id === 1); + return { + actor: general + ? { + nationId: general.nationId, + cityId: general.cityId, + train: general.train, + atmos: general.atmos, + experience: general.experience, + dedication: general.dedication, + leadershipExp: general.leadershipExp, + expLevel: general.expLevel, + dedLevel: general.dedLevel, + killTurn: general.killTurn, + mySet: general.mySet, + turnTime: semanticTimestamp(general.turnTime), + lastTurn: general.lastTurn, + } + : null, + nation: nation + ? { + name: nation.name, + gold: nation.gold, + rice: nation.rice, + canRename: asRecord(nation.meta).can_국호변경 ?? 0, + } + : null, + actorRankData: snapshot.rankData + .filter((row) => row.generalId === 1) + .sort((left, right) => String(left.type).localeCompare(String(right.type))) + .map((row) => ({ + nationId: row.nationId, + type: row.type, + value: row.value, + })), + generalTurns: snapshot.generalTurns + .filter((turn) => turn.generalId === 1) + .sort((left, right) => Number(left.turnIndex) - Number(right.turnIndex)) + .map((turn) => ({ + turnIndex: turn.turnIndex, + action: turn.action, + args: semanticTurnArgs(turn.args), + })), + nationTurns: snapshot.nationTurns + .filter((turn) => turn.nationId === 1 && turn.officerLevel === 12) + .sort((left, right) => Number(left.turnIndex) - Number(right.turnIndex)) + .map((turn) => ({ + turnIndex: turn.turnIndex, + action: turn.action, + args: semanticTurnArgs(turn.args), + })), + }; +}; + +export const addedFullLifecycleReferenceLogs = ( + before: CanonicalTurnSnapshot, + after: CanonicalTurnSnapshot +): Array> => + after.logs.filter((entry) => { + const scope = String(entry.scope).toLowerCase(); + const category = String(entry.category).toLowerCase(); + const usesWorldHistory = scope === 'nation' || (scope === 'system' && category === 'history'); + const watermark = usesWorldHistory ? before.watermarks.historyLogId : before.watermarks.logId; + return Number(entry.id) > watermark; + }); diff --git a/tools/integration-tests/src/turn-differential/fullLifecycleTrace.ts b/tools/integration-tests/src/turn-differential/fullLifecycleTrace.ts new file mode 100644 index 00000000..4f648aeb --- /dev/null +++ b/tools/integration-tests/src/turn-differential/fullLifecycleTrace.ts @@ -0,0 +1,32 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +import type { CanonicalTurnCommandTrace } from './canonical.js'; + +/** + * Execute the comparison-only wrapper around Ref's real + * TurnExecutionHelper::executeGeneralCommandUntil entry point. + */ +export const runReferenceFullLifecycleTrace = ( + workspaceRoot: string, + request: Record +): CanonicalTurnCommandTrace => { + const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference'); + const appDirectory = path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam')); + const runtimeDirectory = path.join(workspaceRoot, 'ref/sam'); + const runner = process.env.TURN_DIFFERENTIAL_CASE_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, + TURN_DIFFERENTIAL_APP_DIR: appDirectory, + TURN_DIFFERENTIAL_RUNTIME_DIR: runtimeDirectory, + TURN_DIFFERENTIAL_RUNNER_SCRIPT: path.join(appDirectory, 'hwe/compare/turn_full_lifecycle_trace.php'), + }, + }); + return JSON.parse(stdout) as CanonicalTurnCommandTrace; +}; diff --git a/tools/integration-tests/src/turn-differential/logProjection.ts b/tools/integration-tests/src/turn-differential/logProjection.ts new file mode 100644 index 00000000..b91a6182 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/logProjection.ts @@ -0,0 +1,214 @@ +export interface OrderedSemanticLogOptions { + omitRest?: boolean; +} + +type SemanticLogFormat = + | 'rawtext' + | 'plain' + | 'year_month' + | 'year' + | 'month' + | 'event_plain' + | 'event_year_month' + | 'notice' + | 'notice_year_month'; + +interface ParsedStoredLogText { + format: SemanticLogFormat; + text: string; + renderedYear?: number; + renderedMonth?: number; +} + +const normalizeLogBody = (value: unknown): string => + String(value) + .replace(/(.*?)<\/span>/g, '$2') + .replace(/ ?<1>\d{2}:\d{2}<\/\>$/, ''); + +const parseStoredLogText = (value: unknown): ParsedStoredLogText => { + const text = String(value); + const yearMonth = text.match(/^●<\/>(\d+)년 (\d+)월:/u); + if (yearMonth) { + return { + format: 'year_month', + renderedYear: Number(yearMonth[1]), + renderedMonth: Number(yearMonth[2]), + text: text.slice(yearMonth[0].length), + }; + } + const year = text.match(/^●<\/>(\d+)년:/u); + if (year) { + return { + format: 'year', + renderedYear: Number(year[1]), + text: text.slice(year[0].length), + }; + } + const month = text.match(/^●<\/>(\d+)월:/u); + if (month) { + return { + format: 'month', + renderedMonth: Number(month[1]), + text: text.slice(month[0].length), + }; + } + if (text.startsWith('●')) { + return { format: 'plain', text: text.slice('●'.length) }; + } + + const eventYearMonth = text.match(/^◆<\/>(\d+)년 (\d+)월:/u); + if (eventYearMonth) { + return { + format: 'event_year_month', + renderedYear: Number(eventYearMonth[1]), + renderedMonth: Number(eventYearMonth[2]), + text: text.slice(eventYearMonth[0].length), + }; + } + if (text.startsWith('◆')) { + return { format: 'event_plain', text: text.slice('◆'.length) }; + } + + const noticeYearMonth = text.match(/^★<\/>(\d+)년 (\d+)월:/u); + if (noticeYearMonth) { + return { + format: 'notice_year_month', + renderedYear: Number(noticeYearMonth[1]), + renderedMonth: Number(noticeYearMonth[2]), + text: text.slice(noticeYearMonth[0].length), + }; + } + if (text.startsWith('★')) { + return { format: 'notice', text: text.slice('★'.length) }; + } + return { format: 'rawtext', text }; +}; + +const readLogCalendar = (entry: Record, field: 'year' | 'month'): number => { + const value = entry[field]; + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new Error(`log.${field} must be a safe integer`); + } + if (field === 'month' && (value < 1 || value > 12)) { + throw new Error(`log.month must be between 1 and 12: ${value}`); + } + return value; +}; + +const readExplicitLogFormat = (entry: Record): number | null => { + if (!Object.prototype.hasOwnProperty.call(entry, 'format')) { + return null; + } + const value = entry.format; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 8) { + throw new Error(`log.format must be an integer from 0 through 8: ${String(value)}`); + } + return value; +}; + +/** Independent rendering of Core's draft enum into Ref's persisted prefix contract. */ +const renderExplicitLogFormat = (text: string, format: number, year: number, month: number): string => { + switch (format) { + case 0: + return text; + case 1: + return `●${text}`; + case 2: + return `●${year}년 ${month}월:${text}`; + case 3: + return `●${year}년:${text}`; + case 4: + return `●${month}월:${text}`; + case 5: + return `◆${text}`; + case 6: + return `◆${year}년 ${month}월:${text}`; + case 7: + return `★${text}`; + case 8: + return `★${year}년 ${month}월:${text}`; + default: + throw new Error(`unsupported log format: ${format}`); + } +}; + +const projectSemanticLogEntry = (entry: Record): Record => { + const year = readLogCalendar(entry, 'year'); + const month = readLogCalendar(entry, 'month'); + const explicitFormat = readExplicitLogFormat(entry); + const renderedText = + explicitFormat === null + ? String(entry.text) + : renderExplicitLogFormat(String(entry.text), explicitFormat, year, month); + const parsed = parseStoredLogText(renderedText); + if (parsed.renderedYear !== undefined && parsed.renderedYear !== year) { + throw new Error(`stored log year ${parsed.renderedYear} does not match row year ${year}`); + } + if (parsed.renderedMonth !== undefined && parsed.renderedMonth !== month) { + throw new Error(`stored log month ${parsed.renderedMonth} does not match row month ${month}`); + } + return { + scope: String(entry.scope).toLowerCase(), + category: String(entry.category).toLowerCase(), + generalId: Number(entry.generalId) || null, + nationId: Number(entry.nationId) || null, + year, + month, + format: parsed.format, + text: normalizeLogBody(parsed.text), + }; +}; + +export const normalizeStoredTurnLogText = (value: unknown): string => normalizeLogBody(parseStoredLogText(value).text); + +const logStream = (entry: Record): 'general_record' | 'world_history' => { + const scope = String(entry.scope).toLowerCase(); + const category = String(entry.category).toLowerCase(); + // Ref keeps a general's own history rows in general_record. Only nation + // history and global history share world_history's independent ID stream. + return scope === 'nation' || (scope === 'system' && category === 'history') ? 'world_history' : 'general_record'; +}; + +const numericLogId = (entry: Record): number => { + const id = Number(entry.id); + return Number.isFinite(id) ? id : Number.MAX_SAFE_INTEGER; +}; + +/** + * Compare the semantic persisted log graph without erasing write order, + * calendar ownership, or Ref's rendered format prefix. + * + * Ref stores action/summary logs in `general_record` and nation/global history + * in `world_history`. Their numeric IDs are independent, so ordering across + * those tables is not observable. Ordering inside each table is observable and + * is part of the command lifecycle contract. + */ +export const orderedSemanticLogStreams = ( + logs: Array>, + options: OrderedSemanticLogOptions = {} +): string[] => { + const streams = new Map; inputIndex: number }>>(); + logs.forEach((entry, inputIndex) => { + if (options.omitRest && normalizeStoredTurnLogText(entry.text) === '아무것도 실행하지 않았습니다.') { + return; + } + const key = logStream(entry); + const values = streams.get(key) ?? []; + values.push({ entry, inputIndex }); + streams.set(key, values); + }); + + return [...streams.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([stream, values]) => + JSON.stringify({ + stream, + entries: values + .sort( + (left, right) => + numericLogId(left.entry) - numericLogId(right.entry) || left.inputIndex - right.inputIndex + ) + .map(({ entry }) => projectSemanticLogEntry(entry)), + }) + ); +}; diff --git a/tools/integration-tests/src/turn-differential/messageProjection.ts b/tools/integration-tests/src/turn-differential/messageProjection.ts new file mode 100644 index 00000000..1c166f61 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/messageProjection.ts @@ -0,0 +1,266 @@ +import type { CanonicalTurnSnapshot } from './canonical.js'; + +type JsonRecord = Record; + +export interface SemanticTurnMessageTarget { + generalId: number; + generalName: string; + nationId: number; + nationName: string; + color: string; + icon: string; +} + +export type SemanticTurnMessageLifetime = { kind: 'finite'; at: string } | { kind: 'infinite' }; + +export interface SemanticTurnMessage { + mailbox: number; + type: string; + sourceId: number; + destinationId: number; + createdAt: string; + validUntil: SemanticTurnMessageLifetime; + source: SemanticTurnMessageTarget; + destination: SemanticTurnMessageTarget; + text: string; + option: unknown; +} + +export interface StrictTurnMessageTimeline { + beforeGameNow: string; + afterGameNow: string; + messageCreatedAts: string[]; + usesSingleTick: boolean; +} + +export interface SemanticUnreadMessageDelta { + generalId: number; + unreadPrivateBefore: number; + unreadPrivateAfter: number; + unreadPrivateDelta: number; + unreadDiplomacyBefore: number; + unreadDiplomacyAfter: number; + unreadDiplomacyDelta: number; + hadUnreadMessage: boolean; + hasUnreadMessage: boolean; +} + +const asRecord = (value: unknown, field: string): JsonRecord => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value as JsonRecord; +}; + +const readAliasedValue = (record: JsonRecord, aliases: readonly string[], field: string): unknown => { + for (const alias of aliases) { + if (Object.prototype.hasOwnProperty.call(record, alias)) { + return record[alias]; + } + } + throw new Error(`${field} is missing`); +}; + +const readNumber = (record: JsonRecord, aliases: readonly string[], field: string): number => { + const value = readAliasedValue(record, aliases, field); + const number = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(number)) { + throw new Error(`${field} must be a finite number`); + } + return number; +}; + +const readString = (record: JsonRecord, aliases: readonly string[], field: string): string => { + const value = readAliasedValue(record, aliases, field); + if (typeof value !== 'string') { + throw new Error(`${field} must be a string`); + } + return value; +}; + +const normalizeTimestamp = (value: unknown, field = 'createdAt'): string => { + const raw = value instanceof Date ? value.toISOString() : String(value); + const withTimezone = raw.includes('T') ? raw : `${raw.replace(' ', 'T')}Z`; + const millisecondPrecision = withTimezone.replace(/(\.\d{3})\d+(?=(?:Z|[+-]\d{2}:\d{2})$)/u, '$1'); + const timestamp = Date.parse(millisecondPrecision); + if (!Number.isFinite(timestamp)) { + throw new Error(`${field} must be a valid timestamp: ${raw}`); + } + return new Date(timestamp).toISOString(); +}; + +const normalizeMessageLifetime = (value: unknown): SemanticTurnMessageLifetime => { + if (value === 'infinite') { + return { kind: 'infinite' }; + } + if (value === null || value === undefined) { + throw new Error('message.validUntil must be a finite timestamp or the infinite sentinel'); + } + return { kind: 'finite', at: normalizeTimestamp(value, 'message.validUntil') }; +}; + +const normalizeJsonValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(normalizeJsonValue); + } + if (typeof value !== 'object' || value === null) { + return value; + } + const record = value as JsonRecord; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, normalizeJsonValue(record[key])]) + ); +}; + +const normalizeOption = (value: unknown, context: { mailbox: number; type: string; sourceId: number }): unknown => { + if (context.type === 'diplomacy' && context.mailbox === context.sourceId && value === null) { + return { kind: 'actionable-diplomacy-sender-redacted' }; + } + // Ref serializes an empty PHP option array as `[]`, while Core represents + // the same absence of option fields as `{}`. Non-empty arrays and every + // option field remain exact. The actionable diplomacy sender null above is + // a distinct security contract and must not collapse into ordinary absence. + if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) { + return {}; + } + return normalizeJsonValue(value); +}; + +const normalizeTarget = (value: unknown, field: string): SemanticTurnMessageTarget => { + const target = asRecord(value, field); + return { + generalId: readNumber(target, ['generalId', 'id'], `${field}.generalId`), + generalName: readString(target, ['generalName', 'name'], `${field}.generalName`), + nationId: readNumber(target, ['nationId', 'nation_id'], `${field}.nationId`), + nationName: readString(target, ['nationName', 'nation'], `${field}.nationName`), + color: readString(target, ['color'], `${field}.color`), + icon: readString(target, ['icon'], `${field}.icon`), + }; +}; + +export const projectSemanticTurnMessages = ( + messages: CanonicalTurnSnapshot['messages'], + messageAfterId: number +): SemanticTurnMessage[] => + messages + .filter((message) => readNumber(message, ['id'], 'message.id') > messageAfterId) + .map((message) => { + const payload = asRecord(readAliasedValue(message, ['payload'], 'message.payload'), 'message.payload'); + const mailbox = readNumber(message, ['mailbox'], 'message.mailbox'); + const type = readString(message, ['type'], 'message.type'); + const sourceId = readNumber(message, ['sourceId'], 'message.sourceId'); + return { + mailbox, + type, + sourceId, + destinationId: readNumber(message, ['destinationId'], 'message.destinationId'), + createdAt: normalizeTimestamp(readAliasedValue(message, ['createdAt'], 'message.createdAt')), + validUntil: normalizeMessageLifetime(readAliasedValue(message, ['validUntil'], 'message.validUntil')), + source: normalizeTarget( + readAliasedValue(payload, ['src'], 'message.payload.src'), + 'message.payload.src' + ), + destination: normalizeTarget( + readAliasedValue(payload, ['dest'], 'message.payload.dest'), + 'message.payload.dest' + ), + text: readString(payload, ['text'], 'message.payload.text'), + option: normalizeOption(payload.option, { mailbox, type, sourceId }), + }; + }); + +export const projectStrictTurnMessageTimeline = ( + before: CanonicalTurnSnapshot, + after: CanonicalTurnSnapshot, + messageAfterId: number +): StrictTurnMessageTimeline => { + const beforeGameNow = normalizeTimestamp( + readAliasedValue(asRecord(before.world, 'before.world'), ['gameNow'], 'before.world.gameNow') + ); + const afterGameNow = normalizeTimestamp( + readAliasedValue(asRecord(after.world, 'after.world'), ['gameNow'], 'after.world.gameNow') + ); + const messageCreatedAts = projectSemanticTurnMessages(after.messages, messageAfterId).map( + (message) => message.createdAt + ); + return { + beforeGameNow, + afterGameNow, + messageCreatedAts, + usesSingleTick: + afterGameNow === beforeGameNow && messageCreatedAts.every((createdAt) => createdAt === beforeGameNow), + }; +}; + +interface SemanticUnreadState { + unreadPrivateCount: number; + unreadDiplomacyCount: number; + hasUnreadMessage: boolean; +} + +const readUnreadState = (general: JsonRecord): SemanticUnreadState => { + const generalId = readNumber(general, ['id'], 'general.id'); + const state = asRecord(general.messageReadState, `general[${generalId}].messageReadState`); + const hasUnreadMessage = readAliasedValue( + state, + ['hasUnreadMessage'], + `general[${generalId}].messageReadState.hasUnreadMessage` + ); + if (typeof hasUnreadMessage !== 'boolean') { + throw new Error(`general[${generalId}].messageReadState.hasUnreadMessage must be a boolean`); + } + return { + unreadPrivateCount: readNumber( + state, + ['unreadPrivateCount'], + `general[${generalId}].messageReadState.unreadPrivateCount` + ), + unreadDiplomacyCount: readNumber( + state, + ['unreadDiplomacyCount'], + `general[${generalId}].messageReadState.unreadDiplomacyCount` + ), + hasUnreadMessage, + }; +}; + +export const projectSemanticUnreadMessageDeltas = ( + before: CanonicalTurnSnapshot, + after: CanonicalTurnSnapshot +): SemanticUnreadMessageDelta[] => { + const beforeByGeneralId = new Map( + before.generals.map((general) => [readNumber(general, ['id'], 'general.id'), readUnreadState(general)] as const) + ); + const afterByGeneralId = new Map( + after.generals.map((general) => [readNumber(general, ['id'], 'general.id'), readUnreadState(general)] as const) + ); + const generalIds = [...new Set([...beforeByGeneralId.keys(), ...afterByGeneralId.keys()])].sort( + (left, right) => left - right + ); + + return generalIds.map((generalId) => { + const beforeState = beforeByGeneralId.get(generalId) ?? { + unreadPrivateCount: 0, + unreadDiplomacyCount: 0, + hasUnreadMessage: false, + }; + const afterState = afterByGeneralId.get(generalId) ?? { + unreadPrivateCount: 0, + unreadDiplomacyCount: 0, + hasUnreadMessage: false, + }; + return { + generalId, + unreadPrivateBefore: beforeState.unreadPrivateCount, + unreadPrivateAfter: afterState.unreadPrivateCount, + unreadPrivateDelta: afterState.unreadPrivateCount - beforeState.unreadPrivateCount, + unreadDiplomacyBefore: beforeState.unreadDiplomacyCount, + unreadDiplomacyAfter: afterState.unreadDiplomacyCount, + unreadDiplomacyDelta: afterState.unreadDiplomacyCount - beforeState.unreadDiplomacyCount, + hadUnreadMessage: beforeState.hasUnreadMessage, + hasUnreadMessage: afterState.hasUnreadMessage, + }; + }); +}; diff --git a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts index 3cc4c7c5..ae48ff06 100644 --- a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts +++ b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts @@ -117,5 +117,24 @@ export const runReferenceTurnCommandTraceRequest = ( ...referenceRunnerEnvironment(workspaceRoot, stackDirectory), }, }); - return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace); + const raw = JSON.parse(stdout) as CanonicalTurnCommandTrace & { + harness?: { messageSharedIconBaseUrl?: unknown }; + }; + const messageSharedIconBaseUrl = raw.harness?.messageSharedIconBaseUrl; + if (typeof messageSharedIconBaseUrl === 'string' && messageSharedIconBaseUrl !== '') { + const setup = + typeof request.setup === 'object' && request.setup !== null && !Array.isArray(request.setup) + ? (request.setup as Record) + : {}; + const world = + typeof setup.world === 'object' && setup.world !== null && !Array.isArray(setup.world) + ? (setup.world as Record) + : {}; + request.setup = { + ...setup, + world: { ...world, messageSharedIconBaseUrl }, + }; + } + const { harness: _harness, ...trace } = raw; + return withProjectedTraceMeta(trace); }; diff --git a/tools/integration-tests/src/turn-differential/trace.ts b/tools/integration-tests/src/turn-differential/trace.ts index 6fd30b08..b776d49c 100644 --- a/tools/integration-tests/src/turn-differential/trace.ts +++ b/tools/integration-tests/src/turn-differential/trace.ts @@ -1,5 +1,9 @@ -import type { CanonicalTurnCommandTrace, TurnSnapshotSelector } from './canonical.js'; -import { readCoreDatabaseSnapshot } from './databaseSnapshot.js'; +import { + closeTurnSnapshotSelectorOverCreatedEntities, + type CanonicalTurnCommandTrace, + type TurnSnapshotSelector, +} from './canonical.js'; +import { readCoreDatabaseEntityIds, readCoreDatabaseSnapshot } from './databaseSnapshot.js'; export interface CoreTurnTraceRequest { kind: 'general' | 'nation'; @@ -17,10 +21,19 @@ export const captureCoreDatabaseTurnTrace = async ( rng?: CanonicalTurnCommandTrace['rng']; }> ): Promise => { - const before = await readCoreDatabaseSnapshot(databaseUrl, request.observe); + const [before, entityIdsBefore] = await Promise.all([ + readCoreDatabaseSnapshot(databaseUrl, request.observe), + readCoreDatabaseEntityIds(databaseUrl), + ]); const result = await execute(); + const entityIdsAfter = await readCoreDatabaseEntityIds(databaseUrl); + const afterSelector = closeTurnSnapshotSelectorOverCreatedEntities( + request.observe, + entityIdsBefore, + entityIdsAfter + ); const after = await readCoreDatabaseSnapshot(databaseUrl, { - ...request.observe, + ...afterSelector, logAfterId: request.observe.logAfterId ?? before.watermarks.logId, messageAfterId: request.observe.messageAfterId ?? before.watermarks.messageId, }); diff --git a/tools/integration-tests/test/coreCommandTraceClock.test.ts b/tools/integration-tests/test/coreCommandTraceClock.test.ts new file mode 100644 index 00000000..bf6a8fb2 --- /dev/null +++ b/tools/integration-tests/test/coreCommandTraceClock.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'vitest'; +import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic'; +import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js'; + +import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js'; +import { + buildCoreTurnCommandWorldInput, + runCoreTurnCommandTrace, + type TurnCommandFixtureRequest, +} from '../src/turn-differential/coreCommandTrace.js'; + +const cityStats = { + population: 1_000, + agriculture: 100, + commerce: 100, + security: 100, + defence: 100, + wall: 100, +}; + +const map: MapDefinition = { + id: 'clock-projection-test', + name: 'clock projection test', + cities: [ + { + id: 1, + name: '테스트시', + level: 5, + region: 1, + position: { x: 0, y: 0 }, + connections: [], + initial: cityStats, + max: cityStats, + }, + ], +}; + +const unitSet: UnitSetDefinition = { + id: 'clock-projection-test', + name: 'clock projection test', + defaultCrewTypeId: 1100, + crewTypes: [], +}; + +const referenceBefore: CanonicalTurnSnapshot = { + schemaVersion: 1, + engine: 'ref', + world: { + year: 185, + month: 1, + tickMinutes: 60, + lastTurnTick: 24_229_750_000, + // Ref snapshots use MySQL's timezone-less microsecond representation. + turnTime: '2026-08-22 14:02:55.000000', + gameNow: '2026-08-22 14:02:55.000000', + }, + generals: [ + { + id: 1, + name: '장수', + nationId: 0, + cityId: 1, + troopId: 0, + officerLevel: 0, + turnTick: 24_245_250_000, + turnTime: '2026-08-22 14:28:45.000000', + }, + ], + rankData: [], + cities: [{ id: 1, name: '테스트시', nationId: 0, level: 5 }], + nations: [], + troops: [], + diplomacy: [], + generalTurns: [], + nationTurns: [], + logs: [], + messages: [], + watermarks: { logId: 0, historyLogId: 0, messageId: 0 }, +}; + +describe('turn command fixture GameClock projection', () => { + it('preserves Ref absolute dates when materializing persisted ticks', () => { + const request: TurnCommandFixtureRequest = { + kind: 'general', + actorGeneralId: 1, + action: '휴식', + }; + const input = buildCoreTurnCommandWorldInput(request, referenceBefore, unitSet, map); + const world = new InMemoryTurnWorld(input.state, input.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] }, + }); + + expect(world.getState().lastTurnTime.toISOString()).toBe('2026-08-22T14:02:55.000Z'); + expect(world.getGeneralById(1)?.turnTime.toISOString()).toBe('2026-08-22T14:28:45.000Z'); + }); + + it('injects Ref gameNow instead of the later actor turn time into command messages', async () => { + const commandSnapshot: CanonicalTurnSnapshot = { + ...referenceBefore, + world: { + ...referenceBefore.world, + initYear: 180, + initMonth: 1, + develCost: 100, + gameNow: '2026-08-22 14:02:55.123456', + }, + generals: [ + { + ...referenceBefore.generals[0], + nationId: 1, + cityId: 3, + officerLevel: 12, + gold: 100_000, + rice: 100_000, + }, + { + ...referenceBefore.generals[0], + id: 2, + name: '수신자', + nationId: 2, + cityId: 70, + officerLevel: 1, + }, + ], + cities: [ + { + id: 3, + name: '아국도시', + nationId: 1, + level: 5, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + supplyState: 1, + frontState: 0, + state: 0, + trust: 80, + trade: 100, + }, + { + id: 70, + name: '타국도시', + nationId: 2, + level: 5, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + supplyState: 1, + frontState: 0, + state: 0, + trust: 80, + trade: 100, + }, + ], + nations: [ + { + id: 1, + name: '아국', + color: '#111111', + capitalCityId: 3, + gold: 1_000_000, + rice: 1_000_000, + power: 1_000, + level: 1, + typeCode: 'che_중립', + }, + { + id: 2, + name: '타국', + color: '#222222', + capitalCityId: 70, + gold: 1_000_000, + rice: 1_000_000, + power: 1_000, + level: 1, + typeCode: 'che_중립', + }, + ], + }; + const request: TurnCommandFixtureRequest = { + kind: 'general', + actorGeneralId: 1, + action: 'che_등용', + args: { destGeneralID: 2 }, + setup: { + isolateWorld: true, + world: { startYear: 180, year: 185, month: 1, freezeClock: true }, + }, + observe: { generalIds: [1, 2], cityIds: [3, 70], nationIds: [1, 2], messageAfterId: 0 }, + }; + + const trace = await runCoreTurnCommandTrace(request, commandSnapshot); + + expect(trace.after.world.gameNow).toBe('2026-08-22 14:02:55.123456'); + expect(trace.after.messages.map((message) => message.createdAt)).toEqual(['2026-08-22T14:02:55.123Z']); + }); + + it('keeps a stale nation gennum visible instead of masking an update omission with live membership', async () => { + const snapshot: CanonicalTurnSnapshot = { + ...referenceBefore, + generals: Array.from({ length: 4 }, (_, index) => ({ + ...referenceBefore.generals[0], + id: index + 1, + name: `장수${index + 1}`, + nationId: 1, + cityId: 1, + officerLevel: index === 0 ? 12 : 1, + gold: 1_000, + rice: 1_000, + })), + cities: [{ ...referenceBefore.cities[0], nationId: 1 }], + nations: [ + { + id: 1, + name: '위', + color: '#111111', + capitalCityId: 1, + gold: 1_000, + rice: 1_000, + power: 1_000, + level: 1, + typeCode: 'che_중립', + // Simulate a command that created three members but omitted + // the denormalized nation counter update. + generalCount: 1, + }, + ], + }; + + const trace = await runCoreTurnCommandTrace( + { + kind: 'general', + actorGeneralId: 1, + action: '휴식', + observe: { allGenerals: true, allNations: true }, + }, + snapshot + ); + + expect(trace.before.generals.filter((general) => general.nationId === 1)).toHaveLength(4); + expect(trace.before.nations).toEqual([expect.objectContaining({ id: 1, generalCount: 1 })]); + expect(trace.after.nations).toEqual([expect.objectContaining({ id: 1, generalCount: 1 })]); + }); +}); diff --git a/tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts b/tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts new file mode 100644 index 00000000..a4d4c1ef --- /dev/null +++ b/tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts @@ -0,0 +1,559 @@ +import path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { ChangeJournal } from '@sammo-ts/common'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { GameApiContext, GeneralRow } from '../../../app/game-api/src/context.js'; +import { appRouter } from '../../../app/game-api/src/router.js'; + +import { + findTurnDifferentialWorkspaceRoot, + runReferenceTurnCommandTraceRequest, +} from '../src/turn-differential/referenceSnapshot.js'; + +type DiplomacyAction = 'noAggression' | 'cancelNA' | 'stopWar'; + +interface ReferenceExecution { + entryPoint: string; + action: DiplomacyAction; + outcome: { result: boolean; reason: string }; + proposalMessageId: number; + proposalBefore: { validUntilTick: number; payload: unknown }; + proposalAfter: { validUntilTick: number; payload: unknown }; +} + +interface ReferenceTrace { + execution: ReferenceExecution; + before: { + watermarks: { logId: number; messageId: number }; + }; + after: { + diplomacy: Array<{ fromNationId: number; toNationId: number; state: number; term: number }>; + cities: Array<{ id: number; frontState: number }>; + nations: Array<{ id: number; meta: unknown }>; + logs: Array<{ + id: number; + generalId: number | null; + scope: string; + category: string; + text: string; + }>; + messages: Array<{ + id: number; + mailbox: number; + type: string; + sourceId: number; + destinationId: number; + payload: unknown; + }>; + }; +} + +interface CoreMessageRow { + id: number; + mailbox: number; + type: 'national' | 'diplomacy'; + src: number; + dest: number; + time: Date; + valid_until: Date; + message: Record; +} + +interface CoreLogRow { + scope: string; + category: string; + generalId?: number | null; + nationId?: number | null; + text: string; +} + +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 actionCases = [ + { action: 'noAggression' as const, state: 2, reverseState: 2, term: 0 }, + { action: 'cancelNA' as const, state: 7, reverseState: 7, term: 12 }, + { action: 'stopWar' as const, state: 0, reverseState: 1, term: 6 }, +]; + +const target = (id: number, name: string, nationId: number, nationName: string) => ({ + generalId: id, + generalName: name, + nationId, + nationName, + color: '#777777', + icon: '/image/icons/default.jpg', +}); + +const referenceSetup = (testCase: (typeof actionCases)[number]) => ({ + isolateWorld: true, + world: { year: 190, month: 3 }, + nations: [ + { id: 1, name: '수락국', capitalCityId: 1 }, + { + id: 2, + name: '제안국', + capitalCityId: 2, + ...(testCase.action === 'noAggression' ? { nationEnv: { recv_assist: { n1: [1, 37] } } } : {}), + }, + ], + cities: [ + { id: 1, nationId: 1, supplyState: 1, frontState: 1 }, + { id: 2, nationId: 2, supplyState: 1, frontState: 1 }, + ], + generals: [ + { + id: 1, + name: '수락장수', + nationId: 1, + cityId: 1, + officerLevel: 12, + permission: 'normal', + penalty: {}, + }, + { + id: 2, + name: '제안장수', + nationId: 2, + cityId: 2, + officerLevel: 12, + permission: 'normal', + penalty: {}, + }, + ], + diplomacy: [ + { fromNationId: 1, toNationId: 2, state: testCase.state, term: testCase.term }, + { fromNationId: 2, toNationId: 1, state: testCase.reverseState, term: testCase.term }, + ], +}); + +const runReference = (testCase: (typeof actionCases)[number]): ReferenceTrace => { + const sourceRoot = process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot!, 'ref/sam'); + const runner = path.join(sourceRoot, 'hwe/compare/instant_diplomacy_response_trace.php'); + const previousRunner = process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT; + process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT = runner; + try { + return runReferenceTurnCommandTraceRequest(workspaceRoot!, { + actorGeneralId: 1, + proposerGeneralId: 2, + action: testCase.action, + response: true, + ...(testCase.action === 'noAggression' ? { year: 191, month: 2 } : {}), + setup: referenceSetup(testCase), + observe: { + generalIds: [1, 2], + nationIds: [1, 2], + cityIds: [1, 2], + }, + }) as unknown as ReferenceTrace; + } finally { + if (previousRunner === undefined) { + delete process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT; + } else { + process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT = previousRunner; + } + } +}; + +const buildCoreCaller = (testCase: (typeof actionCases)[number]) => { + const actor = { + id: 1, + userId: 'user-1', + name: '수락장수', + nationId: 1, + cityId: 1, + officerLevel: 12, + npcState: 0, + meta: {}, + penalty: {}, + } as GeneralRow; + const proposer = { + ...actor, + id: 2, + userId: 'user-2', + name: '제안장수', + nationId: 2, + cityId: 2, + } as GeneralRow; + const nations = [ + { + id: 1, + name: '수락국', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 0, + rice: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, + }, + { + id: 2, + name: '제안국', + color: '#777777', + capitalCityId: 2, + chiefGeneralId: 2, + gold: 0, + rice: 0, + level: 1, + typeCode: 'che_중립', + meta: testCase.action === 'noAggression' ? { recv_assist: { n1: [1, 37] } } : {}, + }, + ]; + const cities = [ + { id: 1, nationId: 1, supplyState: 1, frontState: 1 }, + { id: 2, nationId: 2, supplyState: 1, frontState: 1 }, + // Ref isolateWorld keeps the remaining map cities as neutral. These two + // adjacent neutral cities are sufficient to exercise SetNationFront's + // peace-time `front = 2` branch for cities 1 and 2. + { id: 9, nationId: 0, supplyState: 0, frontState: 0 }, + { id: 10, nationId: 0, supplyState: 0, frontState: 0 }, + ]; + const diplomacy = [ + { + id: 1, + srcNationId: 1, + destNationId: 2, + stateCode: testCase.state, + term: testCase.term, + }, + { + id: 2, + srcNationId: 2, + destNationId: 1, + stateCode: testCase.reverseState, + term: testCase.term, + }, + ]; + const proposalPayload = { + src: target(2, '제안장수', 2, '제안국'), + dest: target(1, '수락장수', 1, '수락국'), + text: '외교 제안', + option: { + action: testCase.action, + ...(testCase.action === 'noAggression' ? { year: 191, month: 2 } : {}), + }, + }; + const messages: CoreMessageRow[] = [ + { + id: 1, + mailbox: 9001, + type: 'diplomacy', + src: 9002, + dest: 9001, + time: new Date('2026-08-23T00:00:00Z'), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: proposalPayload, + }, + { + id: 2, + mailbox: 9002, + type: 'diplomacy', + src: 9002, + dest: 9001, + time: new Date('2026-08-23T00:00:00Z'), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + ...proposalPayload, + option: null, + }, + }, + ]; + const logs: CoreLogRow[] = []; + const proposalBefore = structuredClone(messages[0]!); + + const findGeneral = (id: number) => (id === actor.id ? actor : id === proposer.id ? proposer : null); + const queryRaw = vi.fn(async (strings: TemplateStringsArray, ...values: unknown[]) => { + const sql = strings.join('?'); + if (sql.includes('FROM message') && sql.includes('WHERE id =')) { + const id = Number(values[0]); + const row = messages.find((message) => message.id === id); + return row && row.valid_until.getTime() > Date.now() ? [row] : []; + } + if (sql.includes('INSERT INTO message')) { + const payload = JSON.parse(String(values[8])) as Record; + const row: CoreMessageRow = { + id: messages.at(-1)!.id + 1, + mailbox: Number(values[0]), + type: values[1] as CoreMessageRow['type'], + src: Number(values[2]), + dest: Number(values[3]), + time: values[4] as Date, + valid_until: values[6] as Date, + message: payload, + }; + messages.push(row); + return [{ id: row.id }]; + } + return []; + }); + + const db = { + general: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => findGeneral(where.id)), + findMany: vi.fn(async () => []), + }, + nation: { + findUnique: vi.fn( + async ({ where }: { where: { id: number } }) => nations.find((nation) => nation.id === where.id) ?? null + ), + findMany: vi.fn(async () => nations), + update: vi.fn(async ({ where, data }: { where: { id: number }; data: { meta?: unknown } }) => { + const nation = nations.find((entry) => entry.id === where.id); + if (nation && data.meta !== undefined) nation.meta = data.meta as typeof nation.meta; + return nation; + }), + }, + city: { + findUnique: vi.fn( + async ({ where }: { where: { id: number } }) => cities.find((city) => city.id === where.id) ?? null + ), + findMany: vi.fn(async () => cities), + update: vi.fn(async ({ where, data }: { where: { id: number }; data: { frontState: number } }) => { + const city = cities.find((entry) => entry.id === where.id); + if (city) city.frontState = data.frontState; + return city; + }), + }, + diplomacy: { + findUnique: vi.fn( + async ({ + where, + }: { + where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } }; + }) => + diplomacy.find( + (entry) => + entry.srcNationId === where.srcNationId_destNationId.srcNationId && + entry.destNationId === where.srcNationId_destNationId.destNationId + ) ?? null + ), + findMany: vi.fn(async () => diplomacy), + update: vi.fn( + async ({ + where, + data, + }: { + where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } }; + data: { stateCode?: number; term?: number }; + }) => { + const entry = diplomacy.find( + (row) => + row.srcNationId === where.srcNationId_destNationId.srcNationId && + row.destNationId === where.srcNationId_destNationId.destNationId + ); + if (entry) { + if (data.stateCode !== undefined) entry.stateCode = data.stateCode; + if (data.term !== undefined) entry.term = data.term; + } + return entry; + } + ), + }, + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 190, + currentMonth: 3, + config: { environment: { mapName: 'che' } }, + clockBaseTime: null, + clockTick: null, + clockMode: null, + clockWallAnchor: null, + tickSeconds: 60, + })), + }, + logEntry: { + createMany: vi.fn(async ({ data }: { data: CoreLogRow[] }) => { + logs.push(...data); + return { count: data.length }; + }), + }, + message: { + updateMany: vi.fn( + async ({ where, data }: { where: { id: { in: number[] } }; data: { validUntil: Date } }) => { + for (const row of messages) { + if (where.id.in.includes(row.id)) row.valid_until = data.validUntil; + } + return { count: where.id.in.length }; + } + ), + }, + $queryRaw: queryRaw, + }; + + const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2027-01-01T00:00:00.000Z', + sessionId: 'session-1', + user: { + id: actor.userId!, + username: 'tester', + displayName: 'Tester', + roles: ['user'], + }, + sanctions: {}, + }; + const context = { + db, + auth, + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + redis: {}, + turnDaemon: {}, + battleSim: {}, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: {}, + flushStore: {}, + gameTokenSecret: 'test-secret', + changeJournal: new ChangeJournal(), + } as unknown as GameApiContext; + + return { + caller: appRouter.createCaller(context), + actor, + nations, + cities, + diplomacy, + logs, + messages, + proposalBefore, + }; +}; + +const normalizeTarget = (value: unknown) => { + const targetValue = (value ?? {}) as Record; + return { + generalId: Number(targetValue.generalId ?? targetValue.id), + generalName: String(targetValue.generalName ?? targetValue.name), + nationId: Number(targetValue.nationId ?? targetValue.nation_id), + nationName: String(targetValue.nationName ?? targetValue.nation), + }; +}; + +const normalizeOption = (value: unknown, proposalId: number) => { + const option = + value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; + return { + ...(option.delete === undefined ? {} : { delete: Number(option.delete) === proposalId ? 'proposal' : 'other' }), + ...(option.silence === undefined ? {} : { silence: option.silence }), + ...(option.deletable === undefined ? {} : { deletable: option.deletable }), + ...(option.receiverMessageID === undefined ? {} : { receiverMessageID: 'receiver-copy' }), + }; +}; + +const normalizeMessages = ( + rows: Array<{ + id: number; + mailbox: number; + type: string; + src?: number; + dest?: number; + sourceId?: number; + destinationId?: number; + message?: unknown; + payload?: unknown; + }>, + proposalId: number +) => + rows.map((row) => { + const payload = (row.payload ?? row.message) as Record; + return { + mailbox: row.mailbox, + type: row.type, + sourceId: Number(row.sourceId ?? row.src), + destinationId: Number(row.destinationId ?? row.dest), + src: normalizeTarget(payload.src), + dest: normalizeTarget(payload.dest), + text: payload.text, + option: normalizeOption(payload.option, proposalId), + }; + }); + +const normalizeLogs = (rows: CoreLogRow[]) => + rows + .filter((row) => row.scope.toLowerCase() === 'general' || row.category.toLowerCase() === 'summary') + .map((row) => ({ + generalId: row.generalId ?? 0, + category: row.category.toLowerCase(), + text: row.text, + })); + +integration('Core tRPC messages.respond and Ref DecideMessageResponse dynamic differential', () => { + it.each(actionCases)('matches the accepted $action response state, logs, and messages', async (testCase) => { + const reference = runReference(testCase); + const core = buildCoreCaller(testCase); + + const result = await core.caller.messages.respond({ + generalId: core.actor.id, + messageId: 1, + response: true, + }); + + expect(reference.execution).toMatchObject({ + entryPoint: 'sammo\\API\\Message\\DecideMessageResponse', + action: testCase.action, + outcome: { result: true }, + }); + expect(result).toEqual({ result: true, reason: 'success' }); + expect( + core.diplomacy.map(({ srcNationId, destNationId, stateCode, term }) => ({ + fromNationId: srcNationId, + toNationId: destNationId, + state: stateCode, + term, + })) + ).toEqual( + reference.after.diplomacy.map(({ fromNationId, toNationId, state, term }) => ({ + fromNationId, + toNationId, + state, + term, + })) + ); + expect(core.cities.filter((city) => city.id <= 2).map(({ id, frontState }) => ({ id, frontState }))).toEqual( + reference.after.cities.map(({ id, frontState }) => ({ id, frontState })) + ); + if (testCase.action === 'noAggression') { + expect(core.nations[1]?.meta).toEqual(reference.after.nations.find((nation) => nation.id === 2)?.meta); + } + + const referenceLogs = reference.after.logs + .filter((log) => log.id > reference.before.watermarks.logId) + .filter((log) => log.scope === 'general' || log.category === 'summary') + .map((log) => ({ + generalId: log.generalId ?? 0, + category: log.category, + text: log.text, + })); + expect(normalizeLogs(core.logs)).toEqual(referenceLogs); + + const referenceResults = reference.after.messages.filter( + (message) => message.id > reference.before.watermarks.messageId + ); + const coreResults = core.messages.filter((message) => message.id > 2); + expect(normalizeMessages(coreResults, 1)).toEqual( + normalizeMessages(referenceResults, reference.execution.proposalMessageId) + ); + + const referenceProposalOption = ( + reference.execution.proposalAfter.payload as { + option?: Record; + } + ).option; + expect(reference.execution.proposalAfter.validUntilTick).toBeLessThan( + reference.execution.proposalBefore.validUntilTick + ); + expect(referenceProposalOption).toMatchObject({ used: true, invalid: true }); + // Ref also annotates the hidden JSON payload. Core's store represents + // the same invalidation by expiring validUntil, which is the predicate + // used by every product message read path. + expect(core.messages[0]?.valid_until.getTime()).toBeLessThan(core.proposalBefore.valid_until.getTime()); + }); +}); diff --git a/tools/integration-tests/test/instantDiplomacyReference.integration.test.ts b/tools/integration-tests/test/instantDiplomacyReference.integration.test.ts index e47842f9..84ee46ad 100644 --- a/tools/integration-tests/test/instantDiplomacyReference.integration.test.ts +++ b/tools/integration-tests/test/instantDiplomacyReference.integration.test.ts @@ -41,7 +41,10 @@ const observe = { const addedLogs = (trace: ReturnType) => trace.after.logs.filter((log) => Number(log.id) > trace.before.watermarks.logId); -integration('legacy instant diplomacy responses', () => { +// This suite intentionally records the Ref command behavior only. The dynamic +// Core-vs-Ref product-path comparison lives in +// instantDiplomacyCoreReference.integration.test.ts. +integration('legacy instant diplomacy command behavior (Ref-only)', () => { it('accepts non-aggression without RNG and copies received assistance', () => { const setup = baseSetup(2, 0); setup.nations[1] = { diff --git a/tools/integration-tests/test/liveSortiePersistence.integration.test.ts b/tools/integration-tests/test/liveSortiePersistence.integration.test.ts index 010a9965..a5a467ff 100644 --- a/tools/integration-tests/test/liveSortiePersistence.integration.test.ts +++ b/tools/integration-tests/test/liveSortiePersistence.integration.test.ts @@ -3,7 +3,6 @@ import path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { asRecord } from '@sammo-ts/common'; -import { buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js'; import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js'; import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js'; import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js'; @@ -23,6 +22,10 @@ import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest, } from '../src/turn-differential/coreCommandTrace.js'; +import { + clearCoreTurnCommandPersistenceFixture, + seedCoreTurnCommandPersistenceFixture, +} from '../src/turn-differential/coreCommandPersistenceFixture.js'; import { findTurnDifferentialWorkspaceRoot, runReferenceTurnCommandTraceRequest, @@ -41,7 +44,6 @@ const turnRunResult = { } as const; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; -const nullableCode = (value: string | null | undefined): string => value ?? 'None'; const assertDedicatedDatabase = (rawUrl: string): void => { const schema = new URL(rawUrl).searchParams.get('schema'); @@ -81,21 +83,7 @@ const readFixture = (fixtureName: string, scenarioEffect?: string): TurnCommandF }; }; -const cleanup = async (db: GamePrismaClient): Promise => { - await db.logEntry.deleteMany(); - await db.oldNation.deleteMany(); - await db.rankData.deleteMany(); - await db.generalTurn.deleteMany(); - await db.generalTurnRevision.deleteMany(); - await db.nationTurn.deleteMany(); - await db.nationTurnRevision.deleteMany(); - await db.diplomacy.deleteMany(); - await db.general.deleteMany(); - await db.troop.deleteMany(); - await db.city.deleteMany(); - await db.nation.deleteMany(); - await db.worldState.deleteMany(); -}; +const cleanup = clearCoreTurnCommandPersistenceFixture; integration('live sortie PostgreSQL persistence retry', () => { let db: GamePrismaClient; @@ -189,138 +177,15 @@ integration('live sortie PostgreSQL persistence retry', () => { const map = await loadMapDefinitionByName('che'); const { state, snapshot } = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map); - await db.worldState.create({ - data: { - id: state.id, - scenarioCode: 'live-sortie-persistence', - currentYear: state.currentYear, - currentMonth: state.currentMonth, - tickSeconds: state.tickSeconds, - config: asJson(snapshot.scenarioConfig), - meta: asJson(state.meta), - }, - }); - await db.nation.createMany({ - data: snapshot.nations.map((nation) => ({ - id: nation.id, - name: nation.name, - color: nation.color, - capitalCityId: nation.capitalCityId, - chiefGeneralId: nation.chiefGeneralId, - gold: nation.gold, - rice: nation.rice, - tech: Number(nation.meta.tech ?? 0), - level: nation.level, - typeCode: nation.typeCode, - meta: asJson(nation.meta), - })), - }); - await db.city.createMany({ - data: snapshot.cities.map((city) => { - const definition = map.cities.find((entry) => entry.id === city.id); - return { - id: city.id, - name: city.name, - level: city.level, - nationId: city.nationId, - supplyState: city.supplyState, - frontState: city.frontState, - population: Math.round(city.population), - populationMax: city.populationMax, - agriculture: Math.round(city.agriculture), - agricultureMax: city.agricultureMax, - commerce: Math.round(city.commerce), - commerceMax: city.commerceMax, - security: Math.round(city.security), - securityMax: city.securityMax, - trust: Number(city.meta.trust ?? 0), - trade: Number(city.meta.trade ?? 100), - defence: Math.round(city.defence), - defenceMax: city.defenceMax, - wall: Math.round(city.wall), - wallMax: city.wallMax, - region: definition?.region ?? 0, - conflict: asJson(city.conflict ?? {}), - meta: asJson({ ...city.meta, state: city.state }), - }; - }), - }); - await db.troop.createMany({ - data: snapshot.troops.map((troop) => ({ - troopLeaderId: troop.id, - nationId: troop.nationId, - name: troop.name, - })), - }); - await db.general.createMany({ - data: snapshot.generals.map((general) => ({ - id: general.id, - userId: general.userId, - name: general.name, - nationId: general.nationId, - cityId: general.cityId, - troopId: general.troopId, - npcState: general.npcState, - affinity: general.affinity, - bornYear: general.bornYear, - deadYear: general.deadYear, - picture: general.picture, - leadership: Math.round(general.stats.leadership), - strength: Math.round(general.stats.strength), - intel: Math.round(general.stats.intelligence), - injury: Math.round(general.injury), - experience: Math.round(general.experience), - dedication: Math.round(general.dedication), - officerLevel: general.officerLevel, - gold: Math.round(general.gold), - rice: Math.round(general.rice), - crew: Math.round(general.crew), - crewTypeId: general.crewTypeId, - train: Math.round(general.train), - atmos: Math.round(general.atmos), - age: general.age, - startAge: general.startAge, - personalCode: nullableCode(general.role.personality), - specialCode: nullableCode(general.role.specialDomestic), - special2Code: nullableCode(general.role.specialWar), - horseCode: nullableCode(general.role.items.horse), - weaponCode: nullableCode(general.role.items.weapon), - bookCode: nullableCode(general.role.items.book), - itemCode: nullableCode(general.role.items.item), - turnTime: general.turnTime, - recentWarTime: general.recentWarTime, - lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), - meta: asJson(general.meta), - penalty: asJson(general.penalty ?? {}), - })), - }); - await db.rankData.createMany({ - data: snapshot.generals.flatMap((general) => - buildLegacyComparableRankRows(general).map((row) => ({ - generalId: row.generalId, - nationId: row.nationId, - type: row.type, - value: row.value, - })) - ), - }); - await db.diplomacy.createMany({ - data: snapshot.diplomacy.map((entry) => ({ - srcNationId: entry.fromNationId, - destNationId: entry.toNationId, - stateCode: entry.state, - term: entry.term, - isDead: entry.dead !== 0, - meta: asJson(entry.meta), - })), - }); - await db.generalTurn.createMany({ - data: snapshot.generals.flatMap((general) => - Array.from({ length: 30 }, (_, turnIdx) => ({ + await seedCoreTurnCommandPersistenceFixture(db, { + worldInput: { state, snapshot, map }, + scenarioCode: 'live-sortie-persistence', + generalTurns: snapshot.generals.flatMap((general) => + Array.from({ length: 30 }, (_, turnIndex) => ({ generalId: general.id, - turnIdx, - actionCode: general.id === request.actorGeneralId && turnIdx === 0 ? request.action : '휴식', - arg: asJson(general.id === request.actorGeneralId && turnIdx === 0 ? coreArgs : {}), + turnIndex, + action: general.id === request.actorGeneralId && turnIndex === 0 ? request.action : '휴식', + args: general.id === request.actorGeneralId && turnIndex === 0 ? coreArgs : {}, })) ), }); diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index 283f449b..eb699cb9 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -5,6 +5,12 @@ 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 { orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js'; +import { + projectSemanticTurnMessages, + projectSemanticUnreadMessageDeltas, + projectStrictTurnMessageTimeline, +} from '../src/turn-differential/messageProjection.js'; import { findTurnDifferentialWorkspaceRoot, runReferenceTurnCommandTraceRequest, @@ -20,7 +26,9 @@ const ignoredLifecyclePaths = [ /^logs/, /^messages/, /^world\.turnTime$/, + /^world\.gameNow$/, /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|mySet)(?:\.|$)/, + /^generals\[1\]\.(?:turnTick|turnSecond|turnFraction)$/, /^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/, ]; @@ -30,6 +38,7 @@ const comparedLifecycleIgnoredPaths = [ /^logs/, /^messages/, /^world\.turnTime$/, + /^world\.gameNow$/, /^generalTurns\[[^\]]+\]\.args(?:\.|$)/, /^generals\[[^\]]+\]\.(?:lastTurn|recentWarTime|turnTime)(?:\.|$)/, /^generals\[[^\]]+\]\.meta(?:\.|$)/, @@ -42,24 +51,7 @@ const timestampMillis = (value: unknown): number => { 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 semanticLogSignatures = (logs: Array>): string[] => orderedSemanticLogStreams(logs); const readFixture = (relativePath: string): TurnCommandFixtureRequest => { const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference'); @@ -74,6 +66,7 @@ const readFixture = (relativePath: string): TurnCommandFixtureRequest => { world: { ...fixture.setup?.world, hiddenSeed: 'turn-command-differential-seed', + freezeClock: true, }, generals: fixture.setup?.generals?.map((general) => ({ ...general, @@ -257,6 +250,25 @@ integration('core ↔ legacy command-boundary differential', () => { }); expect(reference.execution.outcome).toMatchObject({ completed: true }); expect(core.rng).toEqual(reference.rng); + const messageAfterId = reference.before.watermarks.messageId; + const coreMessages = projectSemanticTurnMessages(core.after.messages, messageAfterId); + const referenceMessages = projectSemanticTurnMessages(reference.after.messages, messageAfterId); + const coreMessageTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); + const referenceMessageTimeline = projectStrictTurnMessageTimeline( + reference.before, + reference.after, + messageAfterId + ); + expect({ + messages: coreMessages, + unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), + timeline: coreMessageTimeline, + }).toEqual({ + messages: referenceMessages, + unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), + timeline: referenceMessageTimeline, + }); + expect(referenceMessageTimeline.usesSingleTick).toBe(true); if (request.action === 'che_출병') { const generalLogWatermark = reference.before.watermarks.logId; const historyLogWatermark = reference.before.watermarks.historyLogId; @@ -282,4 +294,61 @@ integration('core ↔ legacy command-boundary differential', () => { }, 120_000 ); + + it('matches the Ref receiver-only scout message on a positive collapse draw', async () => { + const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json'); + request.setup!.world!.hiddenSeed = 'collapse-scout-positive-4'; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + const messageAfterId = reference.before.watermarks.messageId; + const coreMessages = projectSemanticTurnMessages(core.after.messages, messageAfterId); + const referenceMessages = projectSemanticTurnMessages(reference.after.messages, messageAfterId); + const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); + const referenceTimeline = projectStrictTurnMessageTimeline(reference.before, reference.after, messageAfterId); + const coreUnread = projectSemanticUnreadMessageDeltas(core.before, core.after); + const referenceUnread = projectSemanticUnreadMessageDeltas(reference.before, reference.after); + + expect(core.rng).toEqual(reference.rng); + expect(coreMessages).toEqual(referenceMessages); + expect(coreTimeline).toEqual(referenceTimeline); + expect(coreUnread).toEqual(referenceUnread); + expect(referenceTimeline.usesSingleTick).toBe(true); + expect(referenceMessages).toHaveLength(1); + expect(referenceMessages[0]).toMatchObject({ + mailbox: 2, + type: 'private', + sourceId: 1, + destinationId: 2, + createdAt: referenceTimeline.beforeGameNow, + validUntil: { kind: 'infinite' }, + source: { + generalId: 1, + nationId: 1, + nationName: '공격국', + }, + destination: { + generalId: 2, + nationId: 0, + nationName: '재야', + color: '#000000', + }, + text: '공격국으로 망명 권유 서신', + option: { action: 'scout' }, + }); + expect(referenceMessages[0]!.source.icon).toBe(referenceMessages[0]!.destination.icon); + expect(referenceMessages[0]!.source.icon).toMatch(/\/default\.jpg$/u); + expect(referenceUnread.find((entry) => entry.generalId === 2)).toMatchObject({ + unreadPrivateDelta: 1, + hasUnreadMessage: true, + }); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); }); diff --git a/tools/integration-tests/test/turnCommandFullLifecycle.integration.test.ts b/tools/integration-tests/test/turnCommandFullLifecycle.integration.test.ts new file mode 100644 index 00000000..194108fa --- /dev/null +++ b/tools/integration-tests/test/turnCommandFullLifecycle.integration.test.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { createCoreTurnCommandProfile, runCoreTurnCommandTrace } from '../src/turn-differential/coreCommandTrace.js'; +import { runReferenceFullLifecycleTrace } from '../src/turn-differential/fullLifecycleTrace.js'; +import { + addedFullLifecycleReferenceLogs, + fullLifecycleTurnCommandRequest as request, + projectFullLifecycleSnapshotGraph, +} from '../src/turn-differential/fullLifecycleFixture.js'; +import { normalizeStoredTurnLogText, orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js'; +import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js'; + +const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd()); +const referenceSourceRoot = workspaceRoot + ? path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam')) + : null; +const hasFullLifecycleRunner = + referenceSourceRoot !== null && + fs.existsSync(path.join(referenceSourceRoot, 'hwe/compare/turn_full_lifecycle_trace.php')); +const integration = describe.skipIf( + !workspaceRoot || !hasFullLifecycleRunner || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1' +); + +const asRecord = (value: unknown): Record => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; + +describe('full lifecycle fixture profile closure', () => { + it('keeps both the queued nation and general command executable', () => { + const profile = createCoreTurnCommandProfile(request); + + expect(profile.general).toContain('che_훈련'); + expect(profile.nation).toContain('che_국호변경'); + }); +}); + +integration('Ref/Core full reserved-turn lifecycle topology', () => { + it('runs nation then general and persists both queue shifts in the same actor turn', async () => { + const reference = runReferenceFullLifecycleTrace(workspaceRoot!, { + ...request, + generalAction: request.action, + generalArgs: request.args, + nationAction: 'che_국호변경', + nationArgs: { nationName: '수명주기국' }, + } as unknown as Record); + const core = await runCoreTurnCommandTrace(request, reference.before); + + const referencePhases = asRecord(reference.execution.outcome).phases as Array>; + expect(referencePhases.map((entry) => entry.phase)).toEqual([ + 'preprocess', + 'block', + 'nation_command', + 'nation_command_resolved', + 'general_command', + 'general_command_resolved', + 'queues_shifted', + 'turn_state_advanced', + 'persisted', + ]); + expect( + referencePhases + .filter((entry) => entry.phase === 'nation_command' || entry.phase === 'general_command') + .map((entry) => [entry.phase, entry.action]) + ).toEqual([ + ['nation_command', 'che_국호변경'], + ['general_command', 'che_훈련'], + ]); + expect(referencePhases.find((entry) => entry.phase === 'queues_shifted')).toMatchObject({ + generalAction: '휴식', + nationAction: '휴식', + }); + + const coreLifecycleActions = asRecord(core.execution.outcome).lifecycleActions as Array< + Record + >; + expect(coreLifecycleActions.map((entry) => [entry.kind, entry.requestedAction, entry.usedFallback])).toEqual([ + ['nation', 'che_국호변경', false], + ['general', 'che_훈련', false], + ]); + expect(projectFullLifecycleSnapshotGraph(core.after)).toEqual( + projectFullLifecycleSnapshotGraph(reference.after) + ); + + const referenceLogs = addedFullLifecycleReferenceLogs(reference.before, reference.after); + expect(orderedSemanticLogStreams(core.after.logs)).toEqual(orderedSemanticLogStreams(referenceLogs)); + const generalActionTexts = referenceLogs + .filter((entry) => String(entry.category).toLowerCase() === 'action') + .map((entry) => normalizeStoredTurnLogText(entry.text)); + expect(generalActionTexts.findIndex((text) => text.includes('국호를'))).toBeLessThan( + generalActionTexts.findIndex((text) => text.includes('훈련치가')) + ); + + const persisted = referencePhases.find((entry) => entry.phase === 'persisted'); + const referenceActor = reference.after.generals.find((entry) => entry.id === 1); + expect(persisted).toMatchObject({ + killTurn: referenceActor?.killTurn, + mySet: referenceActor?.mySet, + }); + }, 180_000); +}); diff --git a/tools/integration-tests/test/turnCommandFullLifecyclePersistence.integration.test.ts b/tools/integration-tests/test/turnCommandFullLifecyclePersistence.integration.test.ts new file mode 100644 index 00000000..e69e0c92 --- /dev/null +++ b/tools/integration-tests/test/turnCommandFullLifecyclePersistence.integration.test.ts @@ -0,0 +1,550 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { asRecord } from '@sammo-ts/common'; +import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js'; +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 type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '@sammo-ts/game-engine/turn/types.js'; +import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js'; +import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; +import { loadTurnWorldFromDatabase } from '@sammo-ts/game-engine/turn/worldLoader.js'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { + clearCoreTurnCommandPersistenceFixture, + seedCoreTurnCommandPersistenceFixture, +} from '../src/turn-differential/coreCommandPersistenceFixture.js'; +import { + buildCoreTurnCommandWorldInput, + createCoreTurnCommandProfile, + runCoreTurnCommandTrace, +} from '../src/turn-differential/coreCommandTrace.js'; +import { readCoreDatabaseSnapshot } from '../src/turn-differential/databaseSnapshot.js'; +import { + addedFullLifecycleReferenceLogs, + fullLifecycleSnapshotSelector, + fullLifecycleTurnCommandRequest as request, + projectFullLifecycleSnapshotGraph, +} from '../src/turn-differential/fullLifecycleFixture.js'; +import { runReferenceFullLifecycleTrace } from '../src/turn-differential/fullLifecycleTrace.js'; +import { normalizeStoredTurnLogText, orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js'; +import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js'; + +const databaseUrl = process.env.TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL; +const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd()); +const referenceSourceRoot = workspaceRoot + ? path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam')) + : null; +const hasFullLifecycleRunner = + referenceSourceRoot !== null && + fs.existsSync(path.join(referenceSourceRoot, 'hwe/compare/turn_full_lifecycle_trace.php')); +const databaseIntegration = describe.skipIf(!databaseUrl); +const leaseOwner = 'turn-full-lifecycle-persistence-daemon'; +const dedicatedSuffix = 'turn_full_lifecycle_persistence'; + +export const assertDedicatedTurnFullLifecycleDatabase = (rawUrl: string): void => { + const url = new URL(rawUrl); + const schema = url.searchParams.get('schema'); + const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, '')); + if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) { + throw new Error( + `Refusing to mutate non-dedicated turn full-lifecycle database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}` + ); + } +}; + +describe('turn full-lifecycle persistence database guard', () => { + it('rejects a shared database and schema before connecting', () => { + expect(() => + assertDedicatedTurnFullLifecycleDatabase('postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public') + ).toThrow('Refusing to mutate non-dedicated turn full-lifecycle database'); + }); + + it('accepts only an explicitly dedicated schema or database name', () => { + expect(() => + assertDedicatedTurnFullLifecycleDatabase( + 'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_turn_full_lifecycle_persistence' + ) + ).not.toThrow(); + expect(() => + assertDedicatedTurnFullLifecycleDatabase( + 'postgresql://fixture:fixture@127.0.0.1:5432/ci_turn_full_lifecycle_persistence' + ) + ).not.toThrow(); + }); +}); + +databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence', () => { + let db: GamePrismaClient | undefined; + let disconnect: (() => Promise) | undefined; + + beforeAll(async () => { + assertDedicatedTurnFullLifecycleDatabase(databaseUrl!); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await clearCoreTurnCommandPersistenceFixture(db); + }); + + beforeEach(async () => { + if (db) { + await clearCoreTurnCommandPersistenceFixture(db); + } + }); + + afterAll(async () => { + try { + if (db) { + await clearCoreTurnCommandPersistenceFixture(db); + } + } finally { + await disconnect?.(); + } + }); + + it.skipIf(!workspaceRoot || !hasFullLifecycleRunner || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1')( + 'commits nation then general, both queue shifts, ordered logs, and reloadable state in one flush', + async () => { + if (!db) { + throw new Error('fixture database is not connected'); + } + const reference = runReferenceFullLifecycleTrace(workspaceRoot!, { + ...request, + generalAction: request.action, + generalArgs: request.args, + nationAction: 'che_국호변경', + nationArgs: { nationName: '수명주기국' }, + } as unknown as Record); + const expected = await runCoreTurnCommandTrace(request, reference.before); + const unitSet = await loadUnitSetDefinitionByName('che'); + const map = await loadMapDefinitionByName('che'); + const worldInput = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map); + + await seedCoreTurnCommandPersistenceFixture(db, { + worldInput, + scenarioCode: 'turn-full-lifecycle-persistence', + generalTurns: reference.before.generalTurns, + nationTurns: reference.before.nationTurns, + }); + const before = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector); + expect(projectFullLifecycleSnapshotGraph(before)).toEqual( + projectFullLifecycleSnapshotGraph(reference.before) + ); + expect(projectFullLifecycleSnapshotGraph(before)).toEqual( + projectFullLifecycleSnapshotGraph(expected.before) + ); + + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(loaded.snapshot.scenarioConfig).toEqual(worldInput.snapshot.scenarioConfig); + expect(loaded.snapshot.scenarioMeta).toEqual(worldInput.snapshot.scenarioMeta); + const reservedTurns = new InMemoryReservedTurnStore(db, { + maxGeneralTurns: 30, + maxNationTurns: 12, + leaseOwner, + leaseDurationMs: 60_000, + }); + await reservedTurns.loadAll(); + const loadedActor = loaded.snapshot.generals.find((general) => general.id === request.actorGeneralId); + if (!loadedActor) { + throw new Error('fixture actor is missing after database load'); + } + await reservedTurns.prepareTurnsForExecution(loadedActor.id, { + nationId: loadedActor.nationId, + officerLevel: loadedActor.officerLevel, + }); + + const lifecycleActions: Array<{ kind: string; requestedAction: string; usedFallback: boolean }> = []; + let world: InMemoryTurnWorld | null = null; + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig: loaded.snapshot.scenarioConfig, + scenarioMeta: loaded.snapshot.scenarioMeta, + map: loaded.snapshot.map, + unitSet: loaded.snapshot.unitSet, + getWorld: () => world, + now: () => new Date(loaded.state.lastTurnTime), + commandProfile: createCoreTurnCommandProfile(request), + onActionResolved: (entry) => { + lifecycleActions.push({ + kind: entry.kind, + requestedAction: entry.requestedAction, + usedFallback: entry.usedFallback, + }); + }, + }); + world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { + entries: [ + { + startMinute: 0, + tickMinutes: Math.max(1, Math.round(loaded.state.tickSeconds / 60)), + }, + ], + }, + generalTurnHandler: handler, + }); + const actor = world.getGeneralById(request.actorGeneralId); + if (!actor) { + throw new Error('fixture actor is missing from executable world'); + } + world.executeGeneralTurn(actor); + + expect(lifecycleActions).toEqual([ + { kind: 'nation', requestedAction: 'che_국호변경', usedFallback: false }, + { kind: 'general', requestedAction: 'che_훈련', usedFallback: false }, + ]); + expect(reservedTurns.getGeneralTurn(actor.id, 0).action).toBe('휴식'); + expect(reservedTurns.getNationTurn(actor.nationId, actor.officerLevel, 0).action).toBe('휴식'); + const dirtyBeforeFlush = world.peekDirtyState(); + expect(dirtyBeforeFlush.generals.map((entry) => entry.id)).toContain(actor.id); + expect(dirtyBeforeFlush.nations.map((entry) => entry.id)).toContain(actor.nationId); + expect(dirtyBeforeFlush.logs.length).toBeGreaterThan(0); + + const databaseBeforeFlush = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector); + expect(projectFullLifecycleSnapshotGraph(databaseBeforeFlush)).toEqual( + projectFullLifecycleSnapshotGraph(before) + ); + + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + try { + if (!hooks.hooks.flushChanges) { + throw new Error('database turn hooks do not expose flushChanges'); + } + await hooks.hooks.flushChanges({ + lastTurnTime: loaded.state.lastTurnTime.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + } finally { + await hooks.close(); + } + + expect(world.peekDirtyState().logs).toEqual([]); + expect(reservedTurns.peekDirtyState()).toMatchObject({ + generalIds: [], + nationKeys: [], + }); + + const after = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector); + expect(projectFullLifecycleSnapshotGraph(after)).toEqual( + projectFullLifecycleSnapshotGraph(reference.after) + ); + expect(projectFullLifecycleSnapshotGraph(after)).toEqual(projectFullLifecycleSnapshotGraph(expected.after)); + expect(orderedSemanticLogStreams(after.logs)).toEqual( + orderedSemanticLogStreams(addedFullLifecycleReferenceLogs(reference.before, reference.after)) + ); + expect(orderedSemanticLogStreams(after.logs)).toEqual(orderedSemanticLogStreams(expected.after.logs)); + const persistedActionTexts = after.logs + .filter((entry) => String(entry.category).toLowerCase() === 'action') + .map((entry) => normalizeStoredTurnLogText(entry.text)); + expect(persistedActionTexts.findIndex((text) => text.includes('국호를'))).toBeLessThan( + persistedActionTexts.findIndex((text) => text.includes('훈련치가')) + ); + + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const reloadedActor = reloaded.snapshot.generals.find((general) => general.id === request.actorGeneralId); + const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId); + expect(reloadedActor).toMatchObject({ + train: expectedActor?.train, + atmos: expectedActor?.atmos, + experience: expectedActor?.experience, + dedication: expectedActor?.dedication, + }); + expect(asRecord(reloadedActor?.meta)).toMatchObject({ + killturn: expectedActor?.killTurn, + myset: expectedActor?.mySet, + }); + expect(reloaded.snapshot.nations.find((nation) => nation.id === actor.nationId)?.name).toBe('수명주기국'); + + const reloadedReservedTurns = new InMemoryReservedTurnStore(db, { + maxGeneralTurns: 30, + maxNationTurns: 12, + }); + await reloadedReservedTurns.loadAll(); + expect(reloadedReservedTurns.getGeneralTurn(actor.id, 0).action).toBe('휴식'); + expect(reloadedReservedTurns.getNationTurn(actor.nationId, actor.officerLevel, 0).action).toBe('휴식'); + }, + 180_000 + ); + + it('persists thirty resting turns for every command-created volunteer and reloads them', async () => { + if (!db) { + throw new Error('fixture database is not connected'); + } + const map = await loadMapDefinitionByName('che'); + const unitSet = await loadUnitSetDefinitionByName('che'); + const cityDefinition = map.cities.find((city) => city.id === 3) ?? map.cities[0]; + if (!cityDefinition) { + throw new Error('fixture map has no city'); + } + const actorId = 101; + const nationId = 11; + const actionTime = new Date('0190-01-01T00:00:00.000Z'); + const actor: TurnGeneral = { + id: actorId, + userId: null, + name: '영속화의병장', + nationId, + cityId: cityDefinition.id, + troopId: 0, + stats: { leadership: 90, strength: 80, intelligence: 70 }, + experience: 1_000, + dedication: 1_000, + officerLevel: 12, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 100_000, + rice: 100_000, + crew: 1_000, + crewTypeId: 1_100, + train: 50, + atmos: 50, + age: 30, + npcState: 0, + bornYear: 160, + deadYear: 260, + affinity: 50, + picture: 'default.jpg', + imageServer: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 24, + officer_city: cityDefinition.id, + belong: 10, + permission: 'normal', + }, + turnTime: actionTime, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: actionTime, + clockBaseTime: actionTime, + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: actionTime, + lastTurnTick: 0, + meta: { + hiddenSeed: 'turn-command-volunteer-persistence', + killturn: 24, + lastTurnTime: actionTime.toISOString(), + }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + develCost: 100, + openingPartYear: 3, + defaultMaxGeneral: 500, + initialNationGenLimit: 10, + defaultNpcGold: 1_000, + defaultNpcRice: 1_000, + defaultCrewTypeId: 1_100, + retirementYear: 80, + randGenFirstName: ['가'], + randGenMiddleName: [''], + randGenLastName: ['가'], + availablePersonality: ['che_안전'], + }, + environment: { mapName: map.id, unitSet: unitSet.id }, + }, + scenarioMeta: { + title: '명령 생성 장수 예약 턴 영속화', + startYear: 180, + life: null, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }, + map, + unitSet, + nations: [ + { + id: nationId, + name: '의병국', + color: '#777777', + capitalCityId: cityDefinition.id, + chiefGeneralId: actorId, + gold: 1_000_000, + rice: 1_000_000, + power: 0, + level: 1, + typeCode: 'che_명가', + meta: { + gennum: 1, + tech: 1_000, + strategic_cmd_limit: 0, + turn_last_12: { command: '의병모집', arg: {}, term: 2 }, + }, + }, + ], + cities: [ + { + id: cityDefinition.id, + name: cityDefinition.name, + nationId, + level: cityDefinition.level, + state: 0, + population: cityDefinition.initial.population, + populationMax: cityDefinition.max.population, + agriculture: cityDefinition.initial.agriculture, + agricultureMax: cityDefinition.max.agriculture, + commerce: cityDefinition.initial.commerce, + commerceMax: cityDefinition.max.commerce, + security: cityDefinition.initial.security, + securityMax: cityDefinition.max.security, + supplyState: map.defaults?.supplyState ?? 1, + frontState: map.defaults?.frontState ?? 0, + defence: cityDefinition.initial.defence, + defenceMax: cityDefinition.max.defence, + wall: cityDefinition.initial.wall, + wallMax: cityDefinition.max.wall, + conflict: {}, + meta: { + trust: map.defaults?.trust ?? 50, + trade: map.defaults?.trade ?? 100, + region: cityDefinition.region, + }, + }, + ], + generals: [actor], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + await seedCoreTurnCommandPersistenceFixture(db, { + worldInput: { state, snapshot, map }, + scenarioCode: 'turn-command-volunteer-persistence', + generalTurns: Array.from({ length: 30 }, (_, turnIndex) => ({ + generalId: actorId, + turnIndex, + action: '휴식', + args: {}, + })), + nationTurns: Array.from({ length: 12 }, (_, turnIndex) => ({ + nationId, + officerLevel: 12, + turnIndex, + action: turnIndex === 0 ? 'che_의병모집' : '휴식', + args: {}, + })), + }); + + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const reservedTurns = new InMemoryReservedTurnStore(db, { + maxGeneralTurns: 30, + maxNationTurns: 12, + leaseOwner: `${leaseOwner}-volunteer`, + leaseDurationMs: 60_000, + }); + await reservedTurns.loadAll(); + const loadedActor = loaded.snapshot.generals.find((general) => general.id === actorId); + if (!loadedActor) { + throw new Error('volunteer fixture actor is missing after database load'); + } + await reservedTurns.prepareTurnsForExecution(actorId, { nationId, officerLevel: 12 }); + + let world: InMemoryTurnWorld | null = null; + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig: loaded.snapshot.scenarioConfig, + scenarioMeta: loaded.snapshot.scenarioMeta, + map: loaded.snapshot.map, + unitSet: loaded.snapshot.unitSet, + getWorld: () => world, + now: () => new Date(loaded.state.lastTurnTime), + commandProfile: { + general: ['휴식'], + nation: ['che_의병모집', '휴식'], + }, + }); + world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: handler, + }); + const executableActor = world.getGeneralById(actorId); + if (!executableActor) { + throw new Error('volunteer fixture actor is missing from executable world'); + } + world.executeGeneralTurn(executableActor); + + const createdIds = world + .peekDirtyState() + .createdGenerals.map((general) => general.id) + .sort((left, right) => left - right); + expect(createdIds).toEqual([102, 103, 104]); + expect(reservedTurns.peekDirtyState().generalInitializationIds.sort((left, right) => left - right)).toEqual( + createdIds + ); + const restingTurns = Array.from({ length: 30 }, () => ({ action: '휴식', args: {} })); + for (const generalId of createdIds) { + expect(reservedTurns.getGeneralTurns(generalId)).toEqual(restingTurns); + } + + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + try { + if (!hooks.hooks.flushChanges) { + throw new Error('database turn hooks do not expose flushChanges'); + } + await hooks.hooks.flushChanges({ + lastTurnTime: loaded.state.lastTurnTime.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + } finally { + await hooks.close(); + } + + const persistedTurns = await db.generalTurn.findMany({ + where: { generalId: { in: createdIds } }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }); + expect(persistedTurns).toHaveLength(createdIds.length * 30); + for (const generalId of createdIds) { + expect( + persistedTurns + .filter((turn) => turn.generalId === generalId) + .map((turn) => ({ turnIdx: turn.turnIdx, action: turn.actionCode, args: turn.arg })) + ).toEqual(Array.from({ length: 30 }, (_, turnIdx) => ({ turnIdx, action: '휴식', args: {} }))); + } + + const persistedNation = await db.nation.findUnique({ where: { id: nationId }, select: { meta: true } }); + expect(persistedNation?.meta).toMatchObject({ gennum: 4 }); + + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect( + reloaded.snapshot.generals + .filter((general) => createdIds.includes(general.id)) + .map((general) => general.id) + .sort((left, right) => left - right) + ).toEqual(createdIds); + expect(reloaded.snapshot.nations.find((nation) => nation.id === nationId)?.meta).toMatchObject({ gennum: 4 }); + const reloadedReservedTurns = new InMemoryReservedTurnStore(db, { + maxGeneralTurns: 30, + maxNationTurns: 12, + }); + await reloadedReservedTurns.loadAll(); + for (const generalId of createdIds) { + expect(reloadedReservedTurns.getGeneralTurns(generalId)).toEqual(restingTurns); + } + }, 180_000); +}); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 7ace0651..65abf2ce 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { asRecord, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; +import { asRecord, GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; +import { GENERAL_TURN_COMMAND_KEYS } from '@sammo-ts/logic'; import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; +import { + normalizeStoredTurnLogText as normalizeStoredLogText, + orderedSemanticLogStreams, +} from '../src/turn-differential/logProjection.js'; +import { + projectSemanticTurnMessages, + projectSemanticUnreadMessageDeltas, + projectStrictTurnMessageTimeline, +} from '../src/turn-differential/messageProjection.js'; import { findTurnDifferentialWorkspaceRoot, runReferenceTurnCommandTraceRequest, @@ -12,24 +22,8 @@ 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 normalizeStoredLogText = (value: unknown): string => - String(value) - .replace(/^(?:●<\/>|◆<\/>|★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '') - .replace(/ ?<1>\d{2}:\d{2}<\/>$/, ''); - const semanticLogSignatures = (logs: Array>): string[] => - logs - .filter((entry) => normalizeStoredLogText(entry.text) !== '아무것도 실행하지 않았습니다.') - .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(); + orderedSemanticLogStreams(logs, { omitRest: true }); const addedReferenceLogs = ( before: { watermarks: { logId: number; historyLogId: number } }, @@ -51,6 +45,20 @@ const ignoredLifecyclePaths = [ /^logs/, /^messages/, /^world\.turnTime$/, + /^world\.gameNow$/, + /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, + /^generals\[1\]\.(?:turnTick|turnSecond|turnFraction)$/, + /^generals\[[^\]]+\]\.meta(?:\.|$)/, + /^nations\[[^\]]+\]\.meta(?:\.|$)/, +]; + +const successfulLifecycleIgnoredPaths = [ + /^nationTurns/, + /^logs/, + /^messages/, + /^world\.turnTime$/, + /^world\.gameNow$/, + /^generalTurns\[[^\]]+\]\.args(?:\.|$)/, /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, /^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/, @@ -129,6 +137,7 @@ const buildRequest = ( year: 190, month: 1, hiddenSeed: 'turn-command-general-matrix-v1', + freezeClock: true, ...fixturePatches.world, }, nations: [ @@ -230,6 +239,10 @@ const buildRequest = ( ], }, observe: { + allGenerals: true, + allCities: true, + allNations: true, + allTroops: true, generalIds: [1, 2, 3], cityIds: [ 3, @@ -238,6 +251,8 @@ const buildRequest = ( ], nationIds: [1, 2], logAfterId: 0, + includeNationHistoryLogs: true, + includeGlobalHistoryLogs: true, messageAfterId: 0, }, }); @@ -286,6 +301,58 @@ const cases: Array< ], ['che_전투특기초기화', undefined, { specialWar: 'che_귀병', lastTurn: { command: '전투 특기 초기화', term: 1 } }], ['che_장비매매', { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, undefined], + [ + 'che_출병', + { destCityID: 70 }, + { leadership: 100, strength: 100, intelligence: 100, crew: 10_000, train: 100, atmos: 100 }, + { + world: { startYear: 180, year: 185 }, + nations: { 2: { capitalCityId: 71, generalCount: 2 } }, + cities: { 70: { population: 10_000, defence: 1, wall: 1 } }, + generals: { + 2: { + cityId: 71, + officerLevel: 1, + officerCityId: 0, + rice: 10_000, + crew: 0, + train: 0, + atmos: 0, + npcState: 2, + }, + 3: { + nationId: 2, + cityId: 71, + officerLevel: 12, + officerCityId: 71, + rice: 10_000, + crew: 0, + train: 0, + atmos: 0, + npcState: 2, + }, + }, + additionalCities: [ + { + id: 71, + nationId: 2, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 5_000, + wall: 5_000, + supplyState: 1, + frontState: 1, + state: 0, + term: 0, + trust: 80, + trade: 100, + }, + ], + }, + ], ['che_하야', undefined, { officerLevel: 1 }], ['che_은퇴', undefined, { age: 60, lastTurn: { command: '은퇴', term: 1 } }], [ @@ -372,6 +439,7 @@ integration('general command success matrix', () => { '%s matches the legacy state delta and command RNG', async (action, args, actorPatch, fixturePatches) => { const request = buildRequest(action, args, actorPatch, fixturePatches); + request.includeLifecycle = true; const reference = runReferenceTurnCommandTraceRequest( workspaceRoot!, request as unknown as Record @@ -390,14 +458,14 @@ integration('general command success matrix', () => { reference.after, reference.before, reference.before, - { ignoredPathPatterns: ignoredLifecyclePaths } + { ignoredPathPatterns: successfulLifecycleIgnoredPaths } ).filter((entry) => entry.path.startsWith('generals')), coreGeneralDelta: compareTurnSnapshotDeltas( core.before, core.after, core.before, core.before, - { ignoredPathPatterns: ignoredLifecyclePaths } + { ignoredPathPatterns: successfulLifecycleIgnoredPaths } ).filter((entry) => entry.path.startsWith('generals')), referenceGenerals: reference.after.generals, coreGenerals: core.after.generals, @@ -416,31 +484,93 @@ integration('general command success matrix', () => { expect(reference.execution.outcome).toMatchObject({ completed: true }); expect(core.execution.outcome).not.toHaveProperty('blockedReason'); expect(core.rng).toEqual(reference.rng); + + const actorGeneralId = request.actorGeneralId; + const referenceBeforeActor = reference.before.generals.find((general) => general.id === actorGeneralId); + const referenceAfterActor = 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); + const actorTurnAt = (turns: Array>, turnIndex: number) => + turns.find((turn) => turn.generalId === actorGeneralId && turn.turnIndex === turnIndex); + expect(actorTurnAt(reference.before.generalTurns, 0)?.action).toBe(action); + expect(actorTurnAt(core.before.generalTurns, 0)?.action).toBe(action); + if (referenceAfterActor) { + expect(actorTurnAt(reference.after.generalTurns, 0)?.action).toBe('휴식'); + expect(actorTurnAt(core.after.generalTurns, 0)?.action).toBe('휴식'); + expect(Number(referenceAfterActor.turnTick) - Number(referenceBeforeActor?.turnTick)).toBe( + GAME_TICKS_PER_TURN + ); + expect(Number(coreAfterActor?.turnTick) - Number(coreBeforeActor?.turnTick)).toBe(GAME_TICKS_PER_TURN); + } else { + expect(coreAfterActor).toBeUndefined(); + expect(actorTurnAt(reference.after.generalTurns, 0)).toBeUndefined(); + expect(actorTurnAt(core.after.generalTurns, 0)).toBeUndefined(); + } expect( compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, + ignoredPathPatterns: successfulLifecycleIgnoredPaths, }) ).toEqual([]); + + // Logs and messages live outside the generic state-delta graph. + // Assert both for every registered success case so a command cannot + // stay green merely because those paths are excluded above. + expect(semanticLogSignatures(addedReferenceLogs(core.before, core.after.logs))).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + const messageAfterId = reference.before.watermarks.messageId; + const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); + const referenceTimeline = projectStrictTurnMessageTimeline( + reference.before, + reference.after, + messageAfterId + ); + expect({ + unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), + messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), + timeline: coreTimeline, + }).toEqual({ + unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), + messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), + timeline: referenceTimeline, + }); + expect(referenceTimeline.usesSingleTick).toBe(true); if (action === 'che_이동' || action === 'che_강행') { const actionLogSuffix = action === 'che_이동' ? '이동했습니다.' : '강행했습니다.'; - expect( - semanticLogSignatures( - core.after.logs.filter((entry) => String(entry.text).includes(actionLogSuffix)) - ) - ).toEqual( - semanticLogSignatures( - addedReferenceLogs(reference.before, reference.after.logs).filter((entry) => - String(entry.text).includes(actionLogSuffix) - ) - ) - ); expect(core.after.logs.some((entry) => String(entry.text).includes('도시('))).toBe(false); + expect( + addedReferenceLogs(reference.before, reference.after.logs).some((entry) => + String(entry.text).includes(actionLogSuffix) + ) + ).toBe(true); } }, 120_000 ); }); +integration('turn command fixture clock validation', () => { + it('rejects a non-boolean setup.world.freezeClock', () => { + const request = buildRequest('휴식'); + const setup = request.setup!; + const world = setup.world!; + const invalidRequest = { + ...request, + setup: { + ...setup, + world: { + ...world, + freezeClock: 'yes', + }, + }, + }; + + expect(() => + runReferenceTurnCommandTraceRequest(workspaceRoot!, invalidRequest as unknown as Record) + ).toThrow(/setup\.world\.freezeClock must be a boolean/); + }); +}); + type GeneralActiveActionInheritanceCase = { name: string; action: string; @@ -4230,3 +4360,12 @@ integration('general command full-constraint fallback matrix', () => { 120_000 ); }); + +describe('general command success matrix manifest', () => { + it('covers every registered general command exactly once', () => { + const matrixActions = cases.map(([action]) => action); + + expect(new Set(matrixActions).size).toBe(matrixActions.length); + expect([...matrixActions].sort()).toEqual([...GENERAL_TURN_COMMAND_KEYS].sort()); + }); +}); diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index d4f6e043..4cdfa499 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -4,6 +4,15 @@ import { NATION_TURN_COMMAND_KEYS } from '@sammo-ts/logic'; import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; +import { + normalizeStoredTurnLogText as normalizeStoredLogText, + orderedSemanticLogStreams, +} from '../src/turn-differential/logProjection.js'; +import { + projectSemanticTurnMessages, + projectSemanticUnreadMessageDeltas, + projectStrictTurnMessageTimeline, +} from '../src/turn-differential/messageProjection.js'; import { findTurnDifferentialWorkspaceRoot, runReferenceTurnCommandTraceRequest, @@ -35,23 +44,7 @@ const timestampMillis = (value: unknown): number => { return new Date(normalized).getTime(); }; -const normalizeStoredLogText = (value: unknown): string => - String(value) - .replace(/^(?:●<\/>|◆<\/>|★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '') - .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 semanticLogSignatures = (logs: Array>): string[] => orderedSemanticLogStreams(logs); const nationCommandLogs = (logs: Array>): Array> => logs.filter((entry) => normalizeStoredLogText(entry.text) !== '아무것도 실행하지 않았습니다.'); @@ -62,7 +55,9 @@ const ignoredLifecyclePaths = [ /^logs/, /^messages/, /^world\.turnTime$/, + /^world\.gameNow$/, /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, + /^generals\[1\]\.(?:turnTick|turnSecond|turnFraction)$/, /^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta\.(?:turn_last_\d+|next_execute_.+|capset|tech|gennum|war|surlimit|strategic_cmd_limit)(?:\.|$)/, ]; @@ -211,6 +206,7 @@ const buildRequest = ( year: 190, month: 1, hiddenSeed: 'turn-command-nation-matrix-v1', + freezeClock: true, ...fixturePatches.world, }, nations: [ @@ -364,6 +360,10 @@ const buildRequest = ( ], }, observe: { + allGenerals: true, + allCities: true, + allNations: true, + allTroops: true, generalIds: [ 1, 2, @@ -430,6 +430,8 @@ const buildRequest = ( } : {}), logAfterId: 0, + includeNationHistoryLogs: true, + includeGlobalHistoryLogs: true, messageAfterId: 0, }, }); @@ -647,7 +649,10 @@ const cases: NationMatrixCase[] = [ describe('nation command differential coverage manifest', () => { it('keeps one successful Ref/Core case for every registered nation turn command', () => { - expect(new Set(cases.map(([action]) => action))).toEqual(new Set(NATION_TURN_COMMAND_KEYS)); + const matrixActions = cases.map(([action]) => action); + + expect(new Set(matrixActions).size).toBe(matrixActions.length); + expect([...matrixActions].sort()).toEqual([...NATION_TURN_COMMAND_KEYS].sort()); }); }); @@ -732,6 +737,30 @@ integration('nation command success matrix', () => { ignoredPathPatterns: ignoredLifecyclePaths, }) ).toEqual([]); + + // Ref persists logs in two independent ID streams and messages in + // per-mailbox rows, so they are excluded from the state comparator. + // Compare those observable graphs for every registered command. + expect(semanticLogSignatures(nationCommandLogs(addedReferenceLogs(core.before, core.after.logs)))).toEqual( + semanticLogSignatures(nationCommandLogs(addedReferenceLogs(reference.before, reference.after.logs))) + ); + const messageAfterId = reference.before.watermarks.messageId; + const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); + const referenceTimeline = projectStrictTurnMessageTimeline( + reference.before, + reference.after, + messageAfterId + ); + expect({ + unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), + messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), + timeline: coreTimeline, + }).toEqual({ + unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), + messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), + timeline: referenceTimeline, + }); + expect(referenceTimeline.usesSingleTick).toBe(true); const researchConfig = researchConfigs[action]; if (researchConfig) { for (const snapshot of [reference, core]) { @@ -1291,6 +1320,24 @@ integration('nation diplomacy proposal boundary and message parity', () => { }) ).toEqual([]); + const messageAfterId = reference.before.watermarks.messageId; + const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); + const referenceTimeline = projectStrictTurnMessageTimeline( + reference.before, + reference.after, + messageAfterId + ); + expect({ + unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), + messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), + timeline: coreTimeline, + }).toEqual({ + unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), + messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), + timeline: referenceTimeline, + }); + expect(referenceTimeline.usesSingleTick).toBe(true); + const referenceMessages = reference.after.messages.slice(reference.before.messages.length); if (!completed) { expect(referenceMessages).toEqual([]); @@ -1319,10 +1366,12 @@ integration('nation diplomacy proposal boundary and message parity', () => { option: expected.option, }, }); - expect(core.after.messages).toHaveLength(1); - expect(core.after.messages[0]).toMatchObject({ + expect(core.after.messages).toHaveLength(2); + expect(core.after.messages.find((entry) => entry.mailbox === 9002)).toMatchObject({ + type: expected.type, + sourceId: 9001, + destinationId: 9002, payload: { - msgType: expected.type, src: { nationId: 1, nationName: sourceNation?.name }, dest: { nationId: 2, nationName: destinationNation?.name }, text: expected.text, @@ -2819,7 +2868,14 @@ integration('nation seizure NPC public message parity', () => { { isGold: true, amount: 100, destGeneralID: 3 }, { world: { hiddenSeed: 'seizure-message-37' }, - generals: { 3: { name: '몰수NPC', npcState: 2 } }, + generals: { + 3: { + name: '몰수NPC', + npcState: 2, + picture: 'npc/custom.png', + imageServer: 0, + }, + }, } ); const reference = runReferenceTurnCommandTraceRequest( @@ -2835,22 +2891,62 @@ integration('nation seizure NPC public message parity', () => { const referenceMessages = reference.after.messages.slice(reference.before.messages.length); expect(referenceMessages).toHaveLength(1); expect(core.after.messages).toHaveLength(1); + const messageAfterId = reference.before.watermarks.messageId; + const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); + const referenceTimeline = projectStrictTurnMessageTimeline(reference.before, reference.after, messageAfterId); + expect({ + unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), + messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), + timeline: coreTimeline, + }).toEqual({ + unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), + messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), + timeline: referenceTimeline, + }); + expect(referenceTimeline.usesSingleTick).toBe(true); expect(referenceMessages[0]).toMatchObject({ mailbox: 9999, type: 'public', sourceId: 3, destinationId: 9999, payload: { - src: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' }, - dest: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' }, + src: { + id: 3, + name: '몰수NPC', + nation_id: 1, + nation: '아국', + icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png', + }, + dest: { + id: 3, + name: '몰수NPC', + nation_id: 1, + nation: '아국', + icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png', + }, text: NPC_SEIZURE_MESSAGE_TEXT, }, }); expect(core.after.messages[0]).toMatchObject({ + mailbox: 9999, + type: 'public', + sourceId: 3, + destinationId: 9999, payload: { - msgType: 'public', - src: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' }, - dest: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' }, + src: { + generalId: 3, + generalName: '몰수NPC', + nationId: 1, + nationName: '아국', + icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png', + }, + dest: { + generalId: 3, + generalName: '몰수NPC', + nationId: 1, + nationName: '아국', + icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png', + }, text: NPC_SEIZURE_MESSAGE_TEXT, }, }); @@ -2919,13 +3015,27 @@ integration('nation seizure zero target balance parity', () => { expect(core.rng).toEqual(reference.rng); expect(referenceMessages).toHaveLength(1); expect(core.after.messages).toHaveLength(1); + const messageAfterId = reference.before.watermarks.messageId; + const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); + const referenceTimeline = projectStrictTurnMessageTimeline(reference.before, reference.after, messageAfterId); + expect({ + unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), + messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), + timeline: coreTimeline, + }).toEqual({ + unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), + messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), + timeline: referenceTimeline, + }); + expect(referenceTimeline.usesSingleTick).toBe(true); expect(referenceMessages[0]).toMatchObject({ type: 'public', sourceId: 3, payload: { text: NPC_SEIZURE_MESSAGE_TEXT }, }); expect(core.after.messages[0]).toMatchObject({ - payload: { msgType: 'public', text: NPC_SEIZURE_MESSAGE_TEXT }, + type: 'public', + payload: { text: NPC_SEIZURE_MESSAGE_TEXT }, }); expect( compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { diff --git a/tools/integration-tests/test/turnLogProjection.test.ts b/tools/integration-tests/test/turnLogProjection.test.ts new file mode 100644 index 00000000..0f104f5c --- /dev/null +++ b/tools/integration-tests/test/turnLogProjection.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js'; + +const actionLog = (id: number, text: string, overrides: Record = {}): Record => ({ + id, + scope: 'general', + category: 'action', + generalId: 1, + nationId: null, + year: 190, + month: 1, + format: 4, + text, + ...overrides, +}); + +const historyLog = (id: number, text: string, overrides: Record = {}): Record => ({ + id, + scope: 'nation', + category: 'history', + generalId: null, + nationId: 1, + year: 190, + month: 1, + format: 2, + text, + ...overrides, +}); + +const generalHistoryLog = ( + id: number, + text: string, + overrides: Record = {} +): Record => ({ + id, + scope: 'general', + category: 'history', + generalId: 1, + nationId: null, + year: 190, + month: 1, + format: 2, + text, + ...overrides, +}); + +describe('orderedSemanticLogStreams', () => { + it('preserves observable ordering inside general_record', () => { + const expected = orderedSemanticLogStreams([actionLog(1, '명령'), actionLog(2, '능력 상승')]); + const reversed = orderedSemanticLogStreams([actionLog(2, '명령'), actionLog(1, '능력 상승')]); + + expect(reversed).not.toEqual(expected); + }); + + it('keeps general history in general_record so an action/history order mutant fails', () => { + const expected = orderedSemanticLogStreams([generalHistoryLog(1, '장수 역사'), actionLog(2, '장수 행동')]); + const reversed = orderedSemanticLogStreams([actionLog(1, '장수 행동'), generalHistoryLog(2, '장수 역사')]); + + expect(reversed).not.toEqual(expected); + }); + + it('orders each Ref table by its own id and does not invent a cross-table order', () => { + const left = orderedSemanticLogStreams([ + historyLog(4, '국가 기록 둘'), + historyLog(3, '국가 기록 하나'), + actionLog(8, '장수 기록 둘'), + generalHistoryLog(6, '장수 역사'), + actionLog(7, '장수 기록 하나'), + ]); + const right = orderedSemanticLogStreams([ + generalHistoryLog(6, '장수 역사'), + actionLog(7, '장수 기록 하나'), + actionLog(8, '장수 기록 둘'), + historyLog(3, '국가 기록 하나'), + historyLog(4, '국가 기록 둘'), + ]); + + expect(left).toEqual(right); + }); + + it('can omit the lifecycle rest log without omitting other ordered entries', () => { + expect( + orderedSemanticLogStreams([actionLog(1, '아무것도 실행하지 않았습니다.'), actionLog(2, '명령')], { + omitRest: true, + }) + ).toEqual(orderedSemanticLogStreams([actionLog(2, '명령')])); + }); + + it('maps a Core draft format to the same semantic persisted prefix as Ref', () => { + const core = actionLog(1, '명령'); + const reference = actionLog(1, '●1월:명령'); + delete reference.format; + + expect(orderedSemanticLogStreams([core])).toEqual(orderedSemanticLogStreams([reference])); + }); + + it('normalizes the hidden battle seed span with either HTML quote style', () => { + const singleQuoted = actionLog(1, `진격(전투시드: abc)`); + const doubleQuoted = actionLog(1, `진격(전투시드: abc)`); + + expect(orderedSemanticLogStreams([doubleQuoted])).toEqual(orderedSemanticLogStreams([singleQuoted])); + expect( + orderedSemanticLogStreams([actionLog(1, `진격(전투시드: abc)`)]) + ).not.toEqual(orderedSemanticLogStreams([singleQuoted])); + }); + + it.each([ + ['year', { year: 191 }], + ['month', { month: 2 }], + ['format', { format: 1 }], + ])('keeps a %s mutation visible', (_field, overrides) => { + expect(orderedSemanticLogStreams([actionLog(1, '명령', overrides)])).not.toEqual( + orderedSemanticLogStreams([actionLog(1, '명령')]) + ); + }); + + it('rejects a persisted prefix whose calendar disagrees with its row', () => { + const malformed = actionLog(1, '●2월:명령'); + delete malformed.format; + + expect(() => orderedSemanticLogStreams([malformed])).toThrow('stored log month 2 does not match row month 1'); + }); +}); diff --git a/tools/integration-tests/test/turnMessageProjection.test.ts b/tools/integration-tests/test/turnMessageProjection.test.ts new file mode 100644 index 00000000..16612158 --- /dev/null +++ b/tools/integration-tests/test/turnMessageProjection.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; + +import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js'; +import { + projectSemanticTurnMessages, + projectSemanticUnreadMessageDeltas, + projectStrictTurnMessageTimeline, +} from '../src/turn-differential/messageProjection.js'; + +const referenceMessage = (overrides: Record = {}): Record => ({ + id: 41, + mailbox: 2, + type: 'private', + sourceId: 1, + destinationId: 2, + createdAt: '2026-08-23 01:02:03.123456', + validUntil: '2026-08-24 01:02:03.123456', + payload: { + src: { id: 1, name: '보낸이', nation_id: 1, nation: '아국', color: '#112233', icon: '/ref.png' }, + dest: { id: 2, name: '받는이', nation_id: 2, nation: '타국', color: '#445566', icon: '/ref2.png' }, + text: '아국으로 망명 권유 서신', + option: { action: 'scout' }, + }, + ...overrides, +}); + +const coreMessage = (overrides: Record = {}): Record => ({ + id: 41, + mailbox: 2, + type: 'private', + sourceId: 1, + destinationId: 2, + createdAt: '2026-08-23T01:02:03.123Z', + validUntil: '2026-08-24T01:02:03.123Z', + payload: { + src: { + generalId: 1, + generalName: '보낸이', + nationId: 1, + nationName: '아국', + color: '#112233', + icon: '/ref.png', + }, + dest: { + generalId: 2, + generalName: '받는이', + nationId: 2, + nationName: '타국', + color: '#445566', + icon: '/ref2.png', + }, + text: '아국으로 망명 권유 서신', + option: { action: 'scout' }, + }, + ...overrides, +}); + +const snapshot = ( + generals: Array>, + messages: Array> = [], + gameNow = '2026-08-23 01:02:03.123456' +): CanonicalTurnSnapshot => + ({ + world: { gameNow }, + generals, + messages, + }) as unknown as CanonicalTurnSnapshot; + +const general = (id: number, unreadPrivateCount: number, unreadDiplomacyCount: number): Record => ({ + id, + messageReadState: { + unreadPrivateCount, + unreadDiplomacyCount, + hasUnreadMessage: unreadPrivateCount + unreadDiplomacyCount > 0, + }, +}); + +describe('turn message semantic projection', () => { + it('maps Ref and Core target schemas and timestamp precision to the same message', () => { + expect(projectSemanticTurnMessages([coreMessage()], 40)).toEqual( + projectSemanticTurnMessages([referenceMessage()], 40) + ); + }); + + it('treats Ref empty-array and Core empty-object options as the same absence of fields', () => { + const referencePayload = referenceMessage().payload as Record; + const corePayload = coreMessage().payload as Record; + + expect(projectSemanticTurnMessages([coreMessage({ payload: { ...corePayload, option: {} } })], 40)).toEqual( + projectSemanticTurnMessages([referenceMessage({ payload: { ...referencePayload, option: [] } })], 40) + ); + }); + + it.each([ + ['mailbox', { mailbox: 3 }], + ['type', { type: 'diplomacy' }], + ['source', { sourceId: 9 }], + ['destination', { destinationId: 9 }], + ['createdAt', { createdAt: '2026-08-23T01:02:04.123Z' }], + ['validUntil', { validUntil: '2026-08-24T01:02:04.123Z' }], + [ + 'source icon', + { + payload: { + ...(coreMessage().payload as Record), + src: { + ...((coreMessage().payload as Record).src as Record), + icon: '/mutant.png', + }, + }, + }, + ], + [ + 'destination icon', + { + payload: { + ...(coreMessage().payload as Record), + dest: { + ...((coreMessage().payload as Record).dest as Record), + icon: '/mutant.png', + }, + }, + }, + ], + [ + 'text', + { + payload: { + ...(coreMessage().payload as Record), + text: '다른 본문', + }, + }, + ], + [ + 'option', + { + payload: { + ...(coreMessage().payload as Record), + option: { action: 'scout', used: true }, + }, + }, + ], + ])('keeps a %s mutation visible', (_field, overrides) => { + expect(projectSemanticTurnMessages([coreMessage(overrides)], 40)).not.toEqual( + projectSemanticTurnMessages([referenceMessage()], 40) + ); + }); + + it('uses explicit finite/infinite lifetimes and rejects missing or null lifetime', () => { + expect(projectSemanticTurnMessages([referenceMessage({ validUntil: 'infinite' })], 40)[0]?.validUntil).toEqual({ + kind: 'infinite', + }); + expect(projectSemanticTurnMessages([referenceMessage()], 40)[0]?.validUntil).toEqual({ + kind: 'finite', + at: '2026-08-24T01:02:03.123Z', + }); + expect(() => projectSemanticTurnMessages([referenceMessage({ validUntil: null })], 40)).toThrow( + /message\.validUntil must be a finite timestamp or the infinite sentinel/ + ); + const missing = referenceMessage(); + delete missing.validUntil; + expect(() => projectSemanticTurnMessages([missing], 40)).toThrow(/message\.validUntil is missing/); + }); + + it('does not normalize away a sender option difference', () => { + const referenceSender = referenceMessage({ + mailbox: 9001, + type: 'diplomacy', + sourceId: 9001, + destinationId: 9002, + payload: { + ...(referenceMessage().payload as Record), + option: null, + }, + }); + const coreSender = coreMessage({ + mailbox: 9001, + type: 'diplomacy', + sourceId: 9001, + destinationId: 9002, + payload: { + ...(coreMessage().payload as Record), + option: { receiverMessageID: 41 }, + }, + }); + + expect(projectSemanticTurnMessages([coreSender], 40)).not.toEqual( + projectSemanticTurnMessages([referenceSender], 40) + ); + expect(projectSemanticTurnMessages([referenceSender], 40)[0]?.option).toEqual({ + kind: 'actionable-diplomacy-sender-redacted', + }); + for (const option of [undefined, [], {}]) { + const mutantPayload = { + ...(referenceSender.payload as Record), + option, + }; + expect(projectSemanticTurnMessages([{ ...referenceSender, payload: mutantPayload }], 40)).not.toEqual( + projectSemanticTurnMessages([referenceSender], 40) + ); + } + }); + + it('keeps absolute before, after, and message timestamps in the strict single-tick timeline', () => { + const before = snapshot([], []); + const frozenAfter = snapshot([], [referenceMessage()]); + const advancedAfter = snapshot([], [referenceMessage()], '2026-08-23 01:02:03.124456'); + + expect(projectStrictTurnMessageTimeline(before, frozenAfter, 40)).toEqual({ + beforeGameNow: '2026-08-23T01:02:03.123Z', + afterGameNow: '2026-08-23T01:02:03.123Z', + messageCreatedAts: ['2026-08-23T01:02:03.123Z'], + usesSingleTick: true, + }); + expect(projectStrictTurnMessageTimeline(before, advancedAfter, 40)).toEqual({ + beforeGameNow: '2026-08-23T01:02:03.123Z', + afterGameNow: '2026-08-23T01:02:03.124Z', + messageCreatedAts: ['2026-08-23T01:02:03.123Z'], + usesSingleTick: false, + }); + }); + + it('projects explicit private and diplomacy unread deltas', () => { + expect( + projectSemanticUnreadMessageDeltas( + snapshot([general(1, 0, 2), general(2, 0, 0)]), + snapshot([general(1, 1, 2), general(2, 0, 1)]) + ) + ).toEqual([ + { + generalId: 1, + unreadPrivateBefore: 0, + unreadPrivateAfter: 1, + unreadPrivateDelta: 1, + unreadDiplomacyBefore: 2, + unreadDiplomacyAfter: 2, + unreadDiplomacyDelta: 0, + hadUnreadMessage: true, + hasUnreadMessage: true, + }, + { + generalId: 2, + unreadPrivateBefore: 0, + unreadPrivateAfter: 0, + unreadPrivateDelta: 0, + unreadDiplomacyBefore: 0, + unreadDiplomacyAfter: 1, + unreadDiplomacyDelta: 1, + hadUnreadMessage: false, + hasUnreadMessage: true, + }, + ]); + }); +}); diff --git a/tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts b/tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts new file mode 100644 index 00000000..ac44d3a3 --- /dev/null +++ b/tools/integration-tests/test/turnSnapshotCanonicalCoverage.test.ts @@ -0,0 +1,509 @@ +import { GameClock, MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; +import { describe, expect, it } from 'vitest'; + +import { + closeTurnSnapshotSelectorOverCreatedEntities, + projectCoreDatabaseSnapshot, + type CanonicalTurnSnapshot, +} from '../src/turn-differential/canonical.js'; +import { compareTurnSnapshotDeltas, compareTurnSnapshots } from '../src/turn-differential/compare.js'; +import { projectCoreMessageDrafts, projectCoreMessageReadState } from '../src/turn-differential/coreCommandTrace.js'; +import { projectEffectiveCoreMessageValidUntil } from '../src/turn-differential/databaseSnapshot.js'; +import { projectFullLifecycleSnapshotGraph } from '../src/turn-differential/fullLifecycleFixture.js'; + +interface CommandStateFixture { + generalMeta: Record; + generalFields?: Record; + nationMeta: Record; + nationFields?: Record; +} + +const databaseSnapshot = ( + latestReadPrivateMessage = 0, + commandStateFixture?: CommandStateFixture +): CanonicalTurnSnapshot => + projectCoreDatabaseSnapshot({ + world: { + currentYear: 183, + currentMonth: 1, + tickSeconds: 600, + meta: { lastTurnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 }, + gameNow: new Date('0183-01-01T00:10:00.000Z'), + lastTurnTick: 0, + }, + generals: [ + { + id: 1, + name: '조조', + nationId: 1, + cityId: 1, + troopId: 1, + userId: 'owner-a', + meta: commandStateFixture?.generalMeta ?? {}, + penalty: {}, + ...commandStateFixture?.generalFields, + }, + ], + rankData: [], + cities: [], + nations: commandStateFixture + ? [ + { + id: 1, + name: '위', + color: '#111111', + capitalCityId: 1, + gold: 1_000, + rice: 1_000, + tech: 100, + level: 1, + typeCode: 'che_명가', + meta: commandStateFixture.nationMeta, + ...commandStateFixture.nationFields, + }, + ] + : [], + troops: [{ troopLeaderId: 1, nationId: 1, name: '조조군' }], + diplomacy: [], + generalTurns: [], + nationTurns: [], + logs: [], + messages: [ + { + id: 71, + mailbox: 1, + type: 'private', + src: 2, + dest: 1, + time: new Date('0183-01-01T00:10:00.000Z'), + validUntil: new Date('0183-04-01T00:10:00.000Z'), + message: { text: '등용 서신' }, + }, + ], + messageReadStates: [ + { + generalId: 1, + latestPrivateMessage: latestReadPrivateMessage, + latestDiplomacyMessage: 0, + }, + ], + messageInboxRows: [{ id: 71, mailbox: 1, type: 'private', src: 2 }], + messageWatermark: 71, + }); + +describe('turn snapshot canonical blind-spot coverage', () => { + it('projects troop rows and detects a troop mutant', () => { + const reference = databaseSnapshot(); + const core = { + ...databaseSnapshot(), + troops: [{ id: 1, nationId: 1, name: '변조된 부대' }], + }; + + expect(reference.troops).toEqual([{ id: 1, nationId: 1, name: '조조군' }]); + expect(reference.world.gameNow).toBe('0183-01-01T00:10:00.000Z'); + expect(compareTurnSnapshots(reference, core)).toContainEqual({ + path: 'troops[1].name', + reference: '조조군', + core: '변조된 부대', + }); + }); + + it('projects persisted message rows and detects a message mutant', () => { + const reference = databaseSnapshot(); + const core = { + ...databaseSnapshot(), + messages: [{ ...databaseSnapshot().messages[0], sourceId: 9 }], + }; + + expect(reference.messages).toEqual([ + { + id: 71, + mailbox: 1, + type: 'private', + sourceId: 2, + destinationId: 1, + createdAt: '0183-01-01T00:10:00.000Z', + validUntil: '0183-04-01T00:10:00.000Z', + payload: { text: '등용 서신' }, + }, + ]); + expect(reference.watermarks.messageId).toBe(71); + expect(compareTurnSnapshots(reference, core)).toContainEqual({ + path: 'messages[0].sourceId', + reference: 2, + core: 9, + }); + }); + + it('expands a Core message draft into the persisted receiver and sender rows', async () => { + const messages = await projectCoreMessageDrafts( + [ + { + msgType: 'private', + src: { + generalId: 1, + generalName: '조조', + nationId: 1, + nationName: '위', + color: '#111111', + icon: '1.webp', + }, + dest: { + generalId: 2, + generalName: '유비', + nationId: 2, + nationName: '촉', + color: '#222222', + icon: '2.webp', + }, + text: '등용 서신', + time: new Date('0183-01-01T00:10:00.000Z'), + validUntil: new Date('0183-04-01T00:10:00.000Z'), + }, + ], + 70 + ); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + id: 71, + mailbox: 2, + type: 'private', + sourceId: 1, + destinationId: 2, + validUntil: '0183-04-01T00:10:00.000Z', + payload: { text: '등용 서신' }, + }); + expect(messages[1]).toMatchObject({ + id: 72, + mailbox: 1, + validUntil: '0183-04-01T00:10:00.000Z', + payload: { option: { receiverMessageID: 71 } }, + }); + expect(projectCoreMessageReadState(2, 2, messages)).toEqual({ + unreadPrivateCount: 1, + unreadDiplomacyCount: 0, + hasUnreadMessage: true, + }); + }); + + it('uses a persisted validity tick ahead of the Date fallback and keeps infinity explicit', () => { + const clock = new GameClock({ + baseTime: new Date('0183-01-01T00:00:00.000Z'), + tick: 0, + mode: 'manual', + wallAnchor: new Date('0183-01-01T00:00:00.000Z'), + turnSeconds: 600, + }); + const oneMinuteTick = clock.dateToTick(new Date('0183-01-01T00:01:00.000Z')); + + expect( + projectEffectiveCoreMessageValidUntil( + { + validUntil: new Date('0183-01-02T00:00:00.000Z'), + validUntilTick: BigInt(oneMinuteTick), + }, + clock + ) + ).toBe('0183-01-01T00:01:00.000Z'); + expect( + projectEffectiveCoreMessageValidUntil( + { + validUntil: new Date('0183-01-02T00:00:00.000Z'), + validUntilTick: BigInt(MAX_SAFE_GAME_TICK), + }, + clock + ) + ).toBe('infinite'); + expect( + projectEffectiveCoreMessageValidUntil( + { validUntil: new Date('0183-01-02T00:00:00.000Z'), validUntilTick: null }, + clock + ) + ).toBe('0183-01-02T00:00:00.000Z'); + }); + + it('compares stable owner identity instead of only owner presence', () => { + const reference = databaseSnapshot(); + const core = { + ...databaseSnapshot(), + generals: [{ ...databaseSnapshot().generals[0], ownerIdentity: 'owner-b' }], + }; + + expect(reference.generals[0]).toMatchObject({ hasOwner: true, ownerIdentity: 'owner-a' }); + expect(compareTurnSnapshots(reference, core)).toContainEqual({ + path: 'generals[1].ownerIdentity', + reference: 'owner-a', + core: 'owner-b', + }); + }); + + it('detects a mutant that marks a generated incoming message as already read', () => { + const reference = databaseSnapshot(); + const core = databaseSnapshot(71); + + expect(reference.generals[0]?.messageReadState).toEqual({ + unreadPrivateCount: 1, + unreadDiplomacyCount: 0, + hasUnreadMessage: true, + }); + expect(compareTurnSnapshots(reference, core)).toEqual( + expect.arrayContaining([ + { + path: 'generals[1].messageReadState.hasUnreadMessage', + reference: true, + core: false, + }, + { + path: 'generals[1].messageReadState.unreadPrivateCount', + reference: 1, + core: 0, + }, + ]) + ); + }); + + it('closes the after selector over created entities and exposes an omission mutant', () => { + const selector = closeTurnSnapshotSelectorOverCreatedEntities( + { generalIds: [1], cityIds: [1], nationIds: [1], troopIds: [] }, + { generalIds: [1], cityIds: [1], nationIds: [1], troopIds: [] }, + { generalIds: [1, 2], cityIds: [1], nationIds: [1, 2], troopIds: [2] } + ); + expect(selector).toMatchObject({ generalIds: [1, 2], nationIds: [1, 2], troopIds: [2] }); + + const beforeReference = databaseSnapshot(); + const afterReference = { + ...databaseSnapshot(), + generals: [...databaseSnapshot().generals, { id: 2, ownerIdentity: null }], + }; + const beforeCore = databaseSnapshot(); + const afterCore = databaseSnapshot(); + expect(compareTurnSnapshotDeltas(beforeReference, afterReference, beforeCore, afterCore)).not.toEqual([]); + }); + + it('keeps persisted actor rank rows in the full-lifecycle graph and catches an upsert omission', () => { + const snapshot = { + ...databaseSnapshot(), + rankData: [ + { generalId: 1, nationId: 1, type: 'dedication', value: 1_015 }, + { generalId: 1, nationId: 1, type: 'experience', value: 1_015 }, + ], + }; + const omitted = { + ...snapshot, + rankData: snapshot.rankData.filter((row) => row.type !== 'experience'), + }; + + expect(projectFullLifecycleSnapshotGraph(snapshot).actorRankData).toEqual([ + { nationId: 1, type: 'dedication', value: 1_015 }, + { nationId: 1, type: 'experience', value: 1_015 }, + ]); + expect(projectFullLifecycleSnapshotGraph(omitted)).not.toEqual(projectFullLifecycleSnapshotGraph(snapshot)); + }); + + it('projects command-semantic meta outside the ignored raw meta graph and catches omission mutants', () => { + const generalMeta = { + armType: 3, + explevel: 4, + dedlevel: 2, + npc_org: 4, + text: '의병 소개', + }; + const generalFields = { + affinity: 37, + bornYear: 170, + deadYear: 210, + npcState: 4, + turnTick: 1_027_407n, + }; + const nationMeta = { + can_국기변경: 1, + can_무작위수도이전: 1, + spy: { 7: 3 }, + collapsed: true, + rate: 20, + bill: 100, + secretlimit: 3, + }; + const expectedFixture = { generalMeta, generalFields, nationMeta }; + const before = databaseSnapshot(0, { generalMeta: {}, nationMeta: {} }); + const expectedAfter = databaseSnapshot(0, expectedFixture); + + expect(expectedAfter.generals[0]).toMatchObject({ + expLevel: 4, + dedLevel: 2, + affinity: 37, + bornYear: 170, + deadYear: 210, + npcState: 4, + npcOriginalState: 4, + npcMessage: '의병 소개', + turnTick: 1_027_407, + turnSecond: 17, + turnFraction: 123_450, + }); + expect(expectedAfter.generals[0]?.commandState).toEqual({ recruitmentArmType: 3 }); + expect(expectedAfter.nations[0]?.commandState).toEqual({ + flagChangesRemaining: 1, + randomCapitalMovesRemaining: 1, + spy: [{ cityId: 7, remainingTurns: 3 }], + collapsed: true, + rate: 20, + bill: 100, + secretLimit: 3, + }); + + const ignoredRawMeta = [/^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/]; + const mutants: Array<{ path: string; snapshot: CanonicalTurnSnapshot }> = [ + { + path: 'generals[1].commandState.recruitmentArmType', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalMeta: { ...generalMeta, armType: undefined }, + }), + }, + { + path: 'generals[1].expLevel', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalMeta: { ...generalMeta, explevel: 0 }, + }), + }, + { + path: 'generals[1].dedLevel', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalMeta: { ...generalMeta, dedlevel: 0 }, + }), + }, + { + path: 'generals[1].affinity', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalFields: { ...generalFields, affinity: null }, + }), + }, + { + path: 'generals[1].bornYear', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalFields: { ...generalFields, bornYear: 171 }, + }), + }, + { + path: 'generals[1].deadYear', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalFields: { ...generalFields, deadYear: 211 }, + }), + }, + { + path: 'generals[1].npcState', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalFields: { ...generalFields, npcState: 3 }, + }), + }, + { + path: 'generals[1].npcOriginalState', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalMeta: { ...generalMeta, npc_org: undefined }, + }), + }, + { + path: 'generals[1].npcMessage', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalMeta: { ...generalMeta, text: null }, + }), + }, + { + path: 'generals[1].turnSecond', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalFields: { ...generalFields, turnTick: 1_087_407n }, + }), + }, + { + path: 'generals[1].turnFraction', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalFields: { ...generalFields, turnTick: 1_027_408n }, + }), + }, + { + path: 'generals[1].turnTick', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + generalFields: { ...generalFields, turnTick: 1_027_408n }, + }), + }, + { + path: 'nations[1].commandState.flagChangesRemaining', + snapshot: databaseSnapshot(0, { + generalMeta, + generalFields, + nationMeta: { ...nationMeta, can_국기변경: 0 }, + }), + }, + { + path: 'nations[1].commandState.randomCapitalMovesRemaining', + snapshot: databaseSnapshot(0, { + generalMeta, + generalFields, + nationMeta: { ...nationMeta, can_무작위수도이전: 0 }, + }), + }, + { + path: 'nations[1].commandState.spy', + snapshot: databaseSnapshot(0, { + generalMeta, + generalFields, + nationMeta: { ...nationMeta, spy: {} }, + }), + }, + { + path: 'nations[1].commandState.collapsed', + snapshot: databaseSnapshot(0, { + generalMeta, + generalFields, + nationMeta: { ...nationMeta, collapsed: false }, + }), + }, + { + path: 'nations[1].commandState.rate', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + nationMeta: { ...nationMeta, rate: 0 }, + }), + }, + { + path: 'nations[1].commandState.bill', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + nationMeta: { ...nationMeta, bill: 0 }, + }), + }, + { + path: 'nations[1].commandState.secretLimit', + snapshot: databaseSnapshot(0, { + ...expectedFixture, + nationMeta: { ...nationMeta, secretlimit: 2 }, + }), + }, + ]; + + for (const mutant of mutants) { + const differences = compareTurnSnapshotDeltas(before, expectedAfter, before, mutant.snapshot, { + ignoredPathPatterns: ignoredRawMeta, + }); + expect( + differences.some( + (difference) => difference.path === mutant.path || difference.path.startsWith(`${mutant.path}[`) + ), + mutant.path + ).toBe(true); + } + }); +}); diff --git a/tools/integration-tests/test/turnSnapshotComparator.test.ts b/tools/integration-tests/test/turnSnapshotComparator.test.ts index 5d3a6f02..e1f64f78 100644 --- a/tools/integration-tests/test/turnSnapshotComparator.test.ts +++ b/tools/integration-tests/test/turnSnapshotComparator.test.ts @@ -18,6 +18,7 @@ const snapshot = ( rankData: [], cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }], nations: [{ id: 1, gold: 0, rice: 0 }], + troops: [], diplomacy: [], generalTurns: [{ generalId: 1, turnIndex: 0, action: 'che_농지개간', args: null }], nationTurns: [], @@ -89,6 +90,114 @@ describe('turn snapshot differential comparator', () => { ).toEqual([]); }); + it('distinguishes a present empty collection from a missing property', () => { + const reference = snapshot('ref', { + world: { + year: 183, + month: 1, + tickMinutes: 10, + turnTime: '0183-01-01T00:00:00.000Z', + isUnited: 0, + nationCooldowns: [], + generalFlags: {}, + }, + }); + const core = snapshot('core2026'); + + const differences = compareTurnSnapshots(reference, core); + expect(differences).toContainEqual({ + path: 'world.nationCooldowns', + reference: { $snapshotState: 'array' }, + core: { $snapshotState: 'missing' }, + }); + expect(differences).toContainEqual({ + path: 'world.generalFlags', + reference: { $snapshotState: 'object' }, + core: { $snapshotState: 'missing' }, + }); + expect( + compareTurnSnapshots(reference, core, { + ignoredPathPatterns: [/^world\.(?:nationCooldowns|generalFlags)(?:\.|$)/], + }) + ).toEqual([]); + }); + + it('distinguishes an empty JSON object from an empty JSON array', () => { + const reference = snapshot('ref', { + generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1, meta: {} }], + }); + const core = snapshot('core2026', { + generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1, meta: [] }], + }); + + expect(compareTurnSnapshots(reference, core)).toContainEqual({ + path: 'generals[1].meta', + reference: { $snapshotState: 'object' }, + core: { $snapshotState: 'array' }, + }); + }); + + it('distinguishes collection deletion from replacement with an empty collection in deltas', () => { + const beforeRef = snapshot('ref', { + world: { + year: 183, + month: 1, + tickMinutes: 10, + turnTime: '0183-01-01T00:00:00.000Z', + isUnited: 0, + nationCooldowns: [{ nationId: 1, remaining: 2 }], + }, + }); + const afterRef = snapshot('ref'); + const beforeCore = snapshot('core2026', { + world: { + year: 183, + month: 1, + tickMinutes: 10, + turnTime: '0183-01-01T00:00:00.000Z', + isUnited: 0, + nationCooldowns: [{ nationId: 1, remaining: 2 }], + }, + }); + const afterCore = snapshot('core2026', { + world: { + year: 183, + month: 1, + tickMinutes: 10, + turnTime: '0183-01-01T00:00:00.000Z', + isUnited: 0, + nationCooldowns: [], + }, + }); + + expect(compareTurnSnapshotDeltas(beforeRef, afterRef, beforeCore, afterCore)).toContainEqual({ + path: 'world.nationCooldowns', + reference: { + before: { $snapshotState: 'array' }, + after: { $snapshotState: 'missing' }, + }, + core: { $snapshotState: 'missing' }, + }); + }); + + it('fails closed when an entity array repeats a semantic key', () => { + const reference = snapshot('ref', { + cities: [ + { id: 1, nationId: 1, agriculture: 900 }, + { id: 1, nationId: 1, agriculture: 1000 }, + ], + }); + const core = snapshot('core2026', { + cities: [{ id: 1, nationId: 1, agriculture: 1000 }], + }); + + const expectedError = 'Duplicate semantic entity key "1" at "cities": indexes 0 and 1'; + expect(() => compareTurnSnapshots(reference, core)).toThrowError(expectedError); + expect(() => compareTurnSnapshotDeltas(snapshot('ref'), reference, snapshot('core2026'), core)).toThrowError( + expectedError + ); + }); + it('reports exact changed paths for general and nation command state', () => { const reference = snapshot('ref', { diplomacy: [{ fromNationId: 1, toNationId: 2, state: 1, term: 24 }], @@ -154,8 +263,8 @@ describe('turn snapshot differential comparator', () => { expect(compareTurnSnapshotDeltas(beforeRef, afterRef, beforeCore, afterCore)).toContainEqual({ path: 'nations[2].gold', - reference: { before: 0, after: undefined }, - core: undefined, + reference: { before: 0, after: { $snapshotState: 'missing' } }, + core: { $snapshotState: 'missing' }, }); }); }); diff --git a/tools/integration-tests/test/turnSnapshotCoreDatabase.integration.test.ts b/tools/integration-tests/test/turnSnapshotCoreDatabase.integration.test.ts index 2d3d4c9f..a0a06b7f 100644 --- a/tools/integration-tests/test/turnSnapshotCoreDatabase.integration.test.ts +++ b/tools/integration-tests/test/turnSnapshotCoreDatabase.integration.test.ts @@ -12,11 +12,13 @@ const ids = { city: 2_147_000_102, nation: 2_147_000_103, }; +const ownerIdentity = 'turn-differential-database-owner'; integration('core2026 turn state database snapshot adapter', () => { let db: GamePrismaClient; let disconnect: (() => Promise) | undefined; let createdWorldId: number | null = null; + let createdMessageId: number | null = null; beforeAll(async () => { const connector = createGamePostgresConnector({ url: databaseUrl! }); @@ -80,6 +82,7 @@ integration('core2026 turn state database snapshot adapter', () => { await db.general.create({ data: { id: ids.general, + userId: ownerIdentity, name: '비교장수', nationId: ids.nation, cityId: ids.city, @@ -97,9 +100,33 @@ integration('core2026 turn state database snapshot adapter', () => { meta: { killturn: 24, myset: 6, intel_exp: 3 }, }, }); + await db.troop.create({ + data: { + troopLeaderId: ids.general, + nationId: ids.nation, + name: '비교부대', + }, + }); + createdMessageId = ( + await db.message.create({ + data: { + mailbox: ids.general, + type: 'private', + src: ids.general, + dest: ids.general, + time: new Date('0183-01-01T00:01:00.000Z'), + validUntil: new Date('9999-12-31T00:00:00.000Z'), + message: { text: '비교 메시지' }, + }, + }) + ).id; }); afterAll(async () => { + if (createdMessageId !== null) { + await db.message.deleteMany({ where: { id: createdMessageId } }); + } + await db.troop.deleteMany({ where: { troopLeaderId: ids.general } }); await db.general.deleteMany({ where: { id: ids.general } }); await db.city.deleteMany({ where: { id: ids.city } }); await db.nation.deleteMany({ where: { id: ids.nation } }); @@ -114,6 +141,8 @@ integration('core2026 turn state database snapshot adapter', () => { generalIds: [ids.general], cityIds: [ids.city], nationIds: [ids.nation], + troopIds: [ids.general], + messageAfterId: (createdMessageId ?? 1) - 1, }); expect(result.engine).toBe('core2026'); @@ -125,6 +154,7 @@ integration('core2026 turn state database snapshot adapter', () => { intelligence: 80, killTurn: 24, mySet: 6, + ownerIdentity, }) ); expect(result.cities).toContainEqual( @@ -143,6 +173,18 @@ integration('core2026 turn state database snapshot adapter', () => { power: 300, }) ); + expect(result.troops).toContainEqual({ id: ids.general, nationId: ids.nation, name: '비교부대' }); + expect(result.messages).toContainEqual( + expect.objectContaining({ + id: createdMessageId, + mailbox: ids.general, + type: 'private', + sourceId: ids.general, + destinationId: ids.general, + validUntil: 'infinite', + payload: { text: '비교 메시지' }, + }) + ); }); it('captures before/after state around a real database execution boundary', async () => { diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 50a6d1f0..48d1a95b 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -41,7 +41,7 @@ node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag} export CONDITIONAL_INTEGRATION_RUN_ID=$run_id schema_ownership_token="sammo-conditional-integration:$run_id" -supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_live_sortie reference_npc_possession select_pool" +supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_full_lifecycle reference_live_sortie reference_npc_possession select_pool web_push_gateway" term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10} case "$term_grace_seconds" in ''|*[!0-9]*) @@ -60,9 +60,11 @@ create_general_schema=${CREATE_GENERAL_INTEGRATION_SCHEMA:-ci_${run_id}_create_g select_pool_schema=${SELECT_POOL_INTEGRATION_SCHEMA:-ci_${run_id}_select_pool_integration} immediate_action_schema=${IMMEDIATE_ACTION_INTEGRATION_SCHEMA:-ci_${run_id}_immediate_action_integration} gateway_runtime_schema=${GATEWAY_RUNTIME_INTEGRATION_SCHEMA:-ci_${run_id}_gateway_runtime_integration} +web_push_gateway_schema=${WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA:-ci_${run_id}_web_push_integration} read_model_journal_schema=${READ_MODEL_JOURNAL_INTEGRATION_SCHEMA:-ci_${run_id}_read_model_journal_integration} npc_possession_differential_schema=${NPC_POSSESSION_DIFFERENTIAL_SCHEMA:-ci_${run_id}_npc_possession_differential} live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-ci_${run_id}_live_sortie_persistence} +turn_full_lifecycle_schema=${TURN_FULL_LIFECYCLE_PERSISTENCE_SCHEMA:-ci_${run_id}_turn_full_lifecycle_persistence} for schema in \ "$integration_schema" \ @@ -72,9 +74,11 @@ for schema in \ "$select_pool_schema" \ "$immediate_action_schema" \ "$gateway_runtime_schema" \ + "$web_push_gateway_schema" \ "$read_model_journal_schema" \ "$npc_possession_differential_schema" \ - "$live_sortie_schema"; do + "$live_sortie_schema" \ + "$turn_full_lifecycle_schema"; do case "$schema" in ''|[!a-z_]*|*[!a-z0-9_]*) echo "integration schema must be a lowercase PostgreSQL identifier: $schema" >&2 @@ -576,6 +580,20 @@ run_marked_tests app/game-engine \ "$(markers_for_mode gateway_runtime)" \ "gateway_runtime_postgresql" +create_owned_schema "$web_push_gateway_schema" +web_push_gateway_database_url=$(build_database_url "$web_push_gateway_schema") +( + export POSTGRES_SCHEMA=$web_push_gateway_schema + export DATABASE_URL=$web_push_gateway_database_url + export GATEWAY_DATABASE_URL=$web_push_gateway_database_url + pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway +) +export WEB_PUSH_GATEWAY_DATABASE_URL=$web_push_gateway_database_url +export WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA=$web_push_gateway_schema +run_marked_tests app/gateway-api \ + "$(markers_for_mode web_push_gateway)" \ + "web_push_gateway_postgresql" + npc_possession_database_url=$(build_database_url "$npc_possession_schema") ( export POSTGRES_SCHEMA=$npc_possession_schema @@ -653,6 +671,20 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then run_marked_tests tools/integration-tests \ "$(markers_for_mode reference_live_sortie)" \ "live_sortie_postgresql" + + create_owned_schema "$turn_full_lifecycle_schema" + turn_full_lifecycle_database_url=$(build_database_url "$turn_full_lifecycle_schema") + ( + export POSTGRES_SCHEMA=$turn_full_lifecycle_schema + export DATABASE_URL=$turn_full_lifecycle_database_url + pnpm --filter @sammo-ts/infra prisma:db:push:game + ) + export POSTGRES_SCHEMA=$turn_full_lifecycle_schema + export DATABASE_URL=$turn_full_lifecycle_database_url + export TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL=$turn_full_lifecycle_database_url + run_marked_tests tools/integration-tests \ + "$(markers_for_mode reference_full_lifecycle)" \ + "turn_full_lifecycle_postgresql" export POSTGRES_SCHEMA=$integration_schema export DATABASE_URL=$database_url fi