From 791f6e13953120ad5b8782ced7f811ac99973926 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 08:20:42 +0000 Subject: [PATCH] fix(game): preserve scenario NPC progression --- .../src/scenario/scenarioSeeder.ts | 8 +- app/game-engine/src/turn/databaseHooks.ts | 19 ++- app/game-engine/src/turn/turnDaemon.ts | 22 ++++ .../defaultCommandProfileAiCoverage.test.ts | 28 +++++ packages/logic/src/world/bootstrap.ts | 52 +++++--- packages/logic/test/worldBootstrap.test.ts | 114 +++++++++++++++++- resources/turn-commands/default.json | 13 ++ 7 files changed, 227 insertions(+), 29 deletions(-) create mode 100644 app/game-engine/test/defaultCommandProfileAiCoverage.test.ts diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 1ce594c..05a7d5a 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -205,6 +205,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const generalPoolEntries = targetGeneralPool ? await loadGeneralPoolEntries(targetGeneralPool, options.generalPoolOptions) : []; + const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim(); + const hiddenSeed = + integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex'); const { seed, warnings } = buildScenarioBootstrap({ scenario: scenarioDefinition, @@ -212,6 +215,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom unitSet, options: { includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true, + hiddenSeed, }, }); seed.cities = applyInitialChangeCityEvents(seed.cities, seed.initialEvents); @@ -273,9 +277,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom worldMeta.installCommitSha = install.installCommitSha.trim(); } - const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim(); - worldMeta.hiddenSeed = - integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex'); + worldMeta.hiddenSeed = hiddenSeed; if (install?.preopenAt) { worldMeta.preopenAt = formatDateTime(install.preopenAt); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index fe26274..ca98211 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -553,6 +553,7 @@ export const createDatabaseTurnHooks = async ( profileName?: string; reservedTurns?: InMemoryReservedTurnStore; turnDaemonLease?: DatabaseTurnDaemonLease; + transactionTimeoutMs?: number; } ): Promise => { // 턴 처리 결과를 DB에 반영하는 훅을 만든다. @@ -999,7 +1000,10 @@ export const createDatabaseTurnHooks = async ( if (transaction) { await persist(transaction); } else { - await prisma.$transaction(persist); + await prisma.$transaction( + persist, + options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined + ); } return () => { @@ -1020,11 +1024,14 @@ export const createDatabaseTurnHooks = async ( acknowledge(); }, executeCommand: async (requestId, execute) => { - const committed = await prisma.$transaction(async (transaction) => { - const result = await execute({ db: transaction }); - const acknowledge = await persistChanges(transaction, { requestId, result }); - return { result, acknowledge }; - }); + const committed = await prisma.$transaction( + async (transaction) => { + const result = await execute({ db: transaction }); + const acknowledge = await persistChanges(transaction, { requestId, result }); + return { result, acknowledge }; + }, + options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined + ); committed.acknowledge(); return committed.result; }, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 03d90f8..a9beea6 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -102,6 +102,13 @@ export interface TurnDaemonRuntimeOptions { leaseDurationMs?: number; leaseOwnerId?: string; enableLeaseHeartbeat?: boolean; + /** + * Isolated, single-process fixture acceleration only. Reserved turns are + * loaded once and no concurrent API writer may touch this database. + */ + exclusiveFastForward?: boolean; + databaseTransactionTimeoutMs?: number; + onActionResolved?: NonNullable[0]['onActionResolved']>; } export interface TurnDaemonRuntime { @@ -179,6 +186,9 @@ const createTurnDaemonRuntimeWithLease = async ( databaseFlushEnabled: boolean, turnDaemonLease: DatabaseTurnDaemonLease | null ): Promise => { + if (options.exclusiveFastForward && options.profileName) { + throw new Error('exclusiveFastForward cannot be used with a gateway-managed profile.'); + } // DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다. const { state, snapshot } = await loadTurnWorldFromDatabase({ databaseUrl: options.databaseUrl, @@ -499,6 +509,7 @@ const createTurnDaemonRuntimeWithLease = async ( commandProfile, commandEnv: monthlyCommandEnv, getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys, + onActionResolved: options.onActionResolved, })), calendarHandler: calendarHandler ?? undefined, autoAdvanceDiplomacyMonth: false, @@ -538,10 +549,20 @@ const createTurnDaemonRuntimeWithLease = async ( }); const stateStore = new InMemoryTurnStateStore(world); + let fastForwardPreparedMonth = ''; const processor = new InMemoryTurnProcessor(world, { tickMinutes, beforeExecuteGeneral: reservedTurnStoreHandle ? async (general) => { + if (options.exclusiveFastForward) { + const state = world.getState(); + const monthKey = `${state.currentYear}-${state.currentMonth}`; + if (fastForwardPreparedMonth !== monthKey) { + await refreshOccupiedAuctionUniqueItemKeys(); + fastForwardPreparedMonth = monthKey; + } + return; + } const promises: Promise[] = []; promises.push( reservedTurnStoreHandle.store.prepareTurnsForExecution( @@ -597,6 +618,7 @@ const createTurnDaemonRuntimeWithLease = async ( profileName: options.profileName ?? options.profile, reservedTurns: reservedTurnStoreHandle?.store, turnDaemonLease: turnDaemonLease ?? undefined, + transactionTimeoutMs: options.databaseTransactionTimeoutMs, }); auctionBidder = await createAuctionBidder({ databaseUrl: options.databaseUrl, diff --git a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts new file mode 100644 index 0000000..bad2eae --- /dev/null +++ b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { loadTurnCommandProfile } from '../src/turn/turnCommandProfile.js'; + +const GENERAL_AI_ACTIONS = [ + 'che_군량매매', + 'che_귀환', + 'che_랜덤임관', + 'che_모병', + 'che_물자조달', + 'che_선양', + 'che_소집해제', + 'che_이동', + 'che_정착장려', + 'che_해산', + 'che_헌납', +] as const; + +const NATION_AI_ACTIONS = ['che_몰수', 'che_발령', 'che_선전포고', 'che_천도', 'che_포상'] as const; + +describe('default turn command profile AI coverage', () => { + it('loads every action selected directly by the general and nation AI', async () => { + const profile = await loadTurnCommandProfile(); + + expect(profile.general).toEqual(expect.arrayContaining([...GENERAL_AI_ACTIONS])); + expect(profile.nation).toEqual(expect.arrayContaining([...NATION_AI_ACTIONS])); + }); +}); diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index 01e8c31..c4d1a37 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -31,6 +31,12 @@ export interface ScenarioBootstrapOptions { defaultCrewTypeId?: number; nationTypePrefix?: string; mapDefaults?: Partial; + /** + * Legacy scenario installation uses the world's hidden seed while resolving + * generals whose city is omitted. Keep this input explicit so bootstrap + * remains deterministic in tests and reset operations. + */ + hiddenSeed?: string | number; } export type ScenarioBootstrapWarningCode = @@ -208,11 +214,7 @@ const resolveGeneralBootstrapDisposition = ( return 'active'; }; -const buildDelayedGeneralAction = ( - general: ScenarioGeneral, - nationId: number, - npcType: 2 | 6 -): unknown[] => { +const buildDelayedGeneralAction = (general: ScenarioGeneral, nationId: number, npcType: 2 | 6): unknown[] => { const common = [ general.affinity ?? 0, general.name, @@ -309,6 +311,9 @@ const buildGeneralSeeds = ( nationNameToId: Map, warnings: ScenarioBootstrapWarning[], defaultCrewTypeId: number, + mapCities: MapDefinition['cities'], + nationCityIds: Map, + placementRng: RandUtil, options?: ScenarioBootstrapOptions ): { seeds: GeneralSeed[]; @@ -330,7 +335,14 @@ const buildGeneralSeeds = ( nextId += 1; const nationId = resolveNationId(row.nation, nationNameToId, warnings, row.name); - const cityId = resolveCityId(row.city, cityByName, warnings, row.name); + let cityId = resolveCityId(row.city, cityByName, warnings, row.name); + if (row.city === null) { + const ownedCityIds = nationId > 0 ? (nationCityIds.get(nationId) ?? []) : []; + const candidateCityIds = ownedCityIds.length > 0 ? ownedCityIds : mapCities.map((city) => city.id); + if (candidateCityIds.length > 0) { + cityId = placementRng.choice(candidateCityIds); + } + } const birthYear = resolveBirthYear(row.birthYear, scenario.startYear); const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear); const deathMonth = resolveScenarioGeneralDeathMonth({ @@ -620,6 +632,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB } const mapDefaults = resolveMapDefaults(map, options); + const placementRng = new RandUtil( + new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenarioGeneralCities')) + ); const defaultCrewTypeId = unitSet?.defaultCrewTypeId ?? options?.defaultCrewTypeId ?? DEFAULT_CREWTYPE_ID; const seedCities: CitySeed[] = []; const domainCities: City[] = []; @@ -733,6 +748,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB nationNameToId, warnings, defaultCrewTypeId, + map.cities, + nationCityIds, + placementRng, options ); allGeneralSeeds.push(...generalResult.seeds); @@ -749,6 +767,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB nationNameToId, warnings, defaultCrewTypeId, + map.cities, + nationCityIds, + placementRng, options ); allGeneralSeeds.push(...generalExResult.seeds); @@ -765,20 +786,21 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB nationNameToId, warnings, defaultCrewTypeId, + map.cities, + nationCityIds, + placementRng, options ); allGeneralSeeds.push(...generalNeutralResult.seeds); allGenerals.push(...generalNeutralResult.generals); - const delayedGeneralEvents = Array.from(delayedActionsByBirthYear.entries()).map( - ([birthYear, actions]) => [ - 'Month', - 1_000, - ['Date', '>=', birthYear + ADULT_GENERAL_AGE, 1], - ...actions, - ['DeleteEvent'], - ] - ); + const delayedGeneralEvents = Array.from(delayedActionsByBirthYear.entries()).map(([birthYear, actions]) => [ + 'Month', + 1_000, + ['Date', '>=', birthYear + ADULT_GENERAL_AGE, 1], + ...actions, + ['DeleteEvent'], + ]); const events = [...scenario.events, ...delayedGeneralEvents]; const seed: WorldSeedPayload = { diff --git a/packages/logic/test/worldBootstrap.test.ts b/packages/logic/test/worldBootstrap.test.ts index 3379974..5e34f93 100644 --- a/packages/logic/test/worldBootstrap.test.ts +++ b/packages/logic/test/worldBootstrap.test.ts @@ -142,6 +142,114 @@ describe('scenario bootstrap', () => { expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario'); }); + it('places generals without an explicit city in a deterministic valid city', () => { + const scenario: ScenarioDefinition = { + title: 'Random placement', + startYear: 200, + life: null, + fiction: null, + history: [], + config: { + stat: { total: 100, min: 10, max: 70, npcTotal: 80, npcMax: 60, npcMin: 5, chiefMin: 50 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test-map', unitSet: 'test-unit' }, + }, + nations: [ + { + id: 1, + name: 'TestNation', + color: '#123456', + gold: 5000, + rice: 3000, + infoText: null, + tech: 100, + type: 'Test', + level: 3, + cities: ['Alpha'], + }, + ], + diplomacy: [], + generals: [ + { + affinity: 10, + name: 'NationGeneral', + picture: null, + nation: 1, + city: null, + leadership: 50, + strength: 50, + intelligence: 50, + officerLevel: 1, + birthYear: 180, + deathYear: 240, + personality: null, + special: '', + text: '', + }, + { + affinity: 20, + name: 'NeutralGeneral', + picture: null, + nation: null, + city: null, + leadership: 50, + strength: 50, + intelligence: 50, + officerLevel: 0, + birthYear: 180, + deathYear: 240, + personality: null, + special: '', + text: '', + }, + ], + generalsEx: [], + generalsNeutral: [], + cities: [], + events: [], + initialEvents: [], + ignoreDefaultEvents: false, + }; + const map: MapDefinition = { + id: 'test-map', + name: 'test-map', + cities: [ + { + id: 1, + name: 'Alpha', + level: 5, + region: 1, + position: { x: 0, y: 0 }, + connections: [2], + max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, + initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, + }, + { + id: 2, + name: 'Beta', + level: 5, + region: 1, + position: { x: 1, y: 0 }, + connections: [1], + max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, + initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, + }, + ], + }; + + const first = buildScenarioBootstrap({ scenario, map, options: { hiddenSeed: 'placement-seed' } }); + const second = buildScenarioBootstrap({ scenario, map, options: { hiddenSeed: 'placement-seed' } }); + + expect(first.seed.generals.map((general) => general.cityId)).toEqual( + second.seed.generals.map((general) => general.cityId) + ); + expect(first.seed.generals[0]?.cityId).toBe(1); + expect([1, 2]).toContain(first.seed.generals[1]?.cityId); + expect(first.seed.generals.every((general) => general.cityId > 0)).toBe(true); + }); + it('defers future generals into birth-year registration events and omits expired rows', () => { const general = ( name: string, @@ -192,11 +300,7 @@ describe('scenario bootstrap', () => { }, ], diplomacy: [], - generals: [ - general('현재', 180, 240), - general('미래1', 190, 250, 'TestNation'), - general('만료', 170, 200), - ], + generals: [general('현재', 180, 240), general('미래1', 190, 250, 'TestNation'), general('만료', 170, 200)], generalsEx: [general('미래확장', 190, 260)], generalsNeutral: [general('미래재야', 191, 260, 0)], cities: [], diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index afbda80..141a310 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -2,6 +2,8 @@ "general": [ "che_거병", "che_임관", + "che_랜덤임관", + "che_귀환", "che_건국", "che_훈련", "che_단련", @@ -13,6 +15,7 @@ "che_전투특기초기화", "che_출병", "che_주민선정", + "che_정착장려", "che_농지개간", "che_상업투자", "che_기술연구", @@ -23,13 +26,23 @@ "che_집합", "che_인재탐색", "che_징병", + "che_모병", + "che_소집해제", + "che_군량매매", + "che_물자조달", + "che_헌납", + "che_이동", + "che_선양", + "che_해산", "휴식" ], "nation": [ "휴식", "che_포상", + "che_몰수", "che_부대탈퇴지시", "che_발령", + "che_천도", "che_선전포고", "che_불가침제의", "che_불가침파기제의",