diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 1c107783..0ab01117 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -148,10 +148,16 @@ const zScenarioMeta = z.object({ ignoreDefaultEvents: z.boolean(), }); -const parseScenarioMeta = (meta: JsonRecord): ScenarioMeta | undefined => { +const parseScenarioMeta = (meta: JsonRecord, worldConfig: JsonRecord): ScenarioMeta | undefined => { const raw = meta.scenarioMeta; const parsed = zScenarioMeta.safeParse(raw); - return parsed.success ? parsed.data : undefined; + if (!parsed.success) { + return undefined; + } + // 시나리오 원본 메타는 보존하되 실행 시에는 설치한 NPC 상성 설정을 우선한다. + // 기존 기수도 재로드하면 가상 모드의 랜덤 임관이 연의 분기로 들어가지 않는다. + const fiction = worldConfig.fiction; + return fiction === 0 || fiction === 1 ? { ...parsed.data, fiction } : parsed.data; }; const parseLegacyLastTurnTime = (meta: JsonRecord): Date | null => { @@ -513,7 +519,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) const unitSetName = scenarioConfig.environment?.unitSet ?? 'che'; const unitSet = await loadUnitSetDefinitionByName(unitSetName, options.unitSetOptions); - const scenarioMeta = parseScenarioMeta(meta); + const scenarioMeta = parseScenarioMeta(meta, worldConfig); const lastTurnTick = worldState.lastTurnTick === null diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 86932a8a..696b24ce 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -1,6 +1,14 @@ -import { GameClock } from '@sammo-ts/common'; +import { GameClock, asRecord } from '@sammo-ts/common'; import { createGamePostgresConnector } from '@sammo-ts/infra'; -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; +import { + ActionDefinition as RandomAppointmentAction, + actionContextBuilder, + type RandomAppointmentResolveContext, +} from '@sammo-ts/logic/actions/turn/general/che_랜덤임관.js'; +import type { ActionContextWorldRef } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js'; import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js'; import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js'; @@ -95,6 +103,97 @@ const canRun = await canConnectToDatabase(databaseUrl); const describeDb = describe.runIf(canRun); describeDb('scenario database seed', () => { + test.each([ + { fiction: 1, expectedHistorical: false }, + { fiction: 0, expectedHistorical: true }, + { fiction: undefined, expectedHistorical: true }, + ])('reloads installed fiction=$fiction into NPC random appointment', async ({ fiction, expectedHistorical }) => { + await seedScenarioToDatabase({ + scenarioId: 1031, + databaseUrl, + resetTables: true, + now: new Date('2030-01-01T00:00:00.000Z'), + installOptions: { fiction }, + }); + const connector = createGamePostgresConnector({ url: databaseUrl }); + try { + await connector.connect(); + const persisted = await connector.prisma.worldState.findFirstOrThrow(); + expect(asRecord(persisted.config).fiction).toBe(fiction); + // 설치 설정은 실행 시 우선하되 원본 시나리오 메타를 덮어쓰지 않는다. + expect(asRecord(asRecord(persisted.meta).scenarioMeta).fiction).toBe(0); + + const loaded = await loadTurnWorldFromDatabase({ databaseUrl }); + expect(loaded.snapshot.scenarioMeta?.fiction).toBe(fiction ?? 0); + expect(asRecord(asRecord(loaded.state.meta).scenarioMeta).fiction).toBe(0); + + const nations = loaded.snapshot.nations.filter((nation) => nation.id > 0).slice(0, 2); + expect(nations).toHaveLength(2); + const sourceActor = loaded.snapshot.generals.find((general) => general.nationId === 0)!; + expect(sourceActor).toBeDefined(); + const actor = { ...sourceActor, npcState: 2, meta: { ...sourceActor.meta, affinity: 0 } }; + const chiefs = nations.map((nation) => { + const chief = loaded.snapshot.generals.find( + (general) => general.nationId === nation.id && general.officerLevel === 12 + )!; + expect(chief).toBeDefined(); + return { ...chief, meta: { ...chief.meta, affinity: 75 } }; + }); + const generals = [actor, ...chiefs]; + const worldRef: ActionContextWorldRef = { + listGenerals: () => generals, + listNations: () => nations, + listCities: () => loaded.snapshot.cities, + listTroops: () => [], + listDiplomacy: () => [], + getDiplomacyEntry: () => null, + getGeneralById: (id) => generals.find((general) => general.id === id) ?? null, + getNationById: (id) => nations.find((nation) => nation.id === id) ?? null, + getCityById: (id) => loaded.snapshot.cities.find((city) => city.id === id) ?? null, + getTroopById: () => null, + }; + const rng = { + nextFloat1: vi.fn(() => 0), + nextBool: () => false, + nextInt: (min: number) => min, + }; + const context = actionContextBuilder( + { general: actor, rng }, + { + // 초기 정원 제한이 풀린 후의 두 후보로 두 분기의 결과를 구분한다. + world: { ...loaded.state, currentYear: 200 }, + scenarioConfig: loaded.snapshot.scenarioConfig, + scenarioMeta: loaded.snapshot.scenarioMeta, + worldRef, + actionArgs: {}, + createGeneralId: () => 0, + createNationId: () => 0, + seedBase: 'installed-fiction', + } + ) as ReturnType & RandomAppointmentResolveContext; + expect(context.historicalNpcAffinityMode).toBe(expectedHistorical); + const action = new RandomAppointmentAction( + buildCommandEnv(loaded.snapshot.scenarioConfig, loaded.snapshot.unitSet) + ); + const result = action.resolve({ ...context, addLog: () => {} } as RandomAppointmentResolveContext, {}); + // Ref: 가상은 첫 가중치 구간(U=0), 연의는 누적 상성 75→0인 두 번째 국가. + const expectedNation = nations[expectedHistorical ? 1 : 0]!; + expect(result.effects).toContainEqual( + expect.objectContaining({ + type: 'general:patch', + patch: expect.objectContaining({ nationId: expectedNation.id }), + }) + ); + expect(rng.nextFloat1).toHaveBeenCalledTimes(expectedHistorical ? 2 : 1); + // 로딩/판정이 저장된 원본 메타에 쓰기를 발생시키지 않는지도 확인한다. + const after = await connector.prisma.worldState.findFirstOrThrow(); + expect(after.config).toEqual(persisted.config); + expect(after.meta).toEqual(persisted.meta); + } finally { + await connector.disconnect(); + } + }); + test.each([ { sync: true, turnMinutes: 60, hour: 10, month: 10, yearOffset: -1 }, { sync: true, turnMinutes: 60, hour: 1, month: 1, yearOffset: 0 },