From 5f390a46b90e00effd972afe5552f3c43556c7d7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 21:36:34 +0000 Subject: [PATCH] feat(engine): migrate monthly pre-update boundary --- app/game-engine/src/turn/databaseHooks.ts | 18 ++ .../src/turn/monthlyBoundaryPreHandler.ts | 75 ++++++ .../src/turn/nationTurnMonthlyHandler.ts | 51 +++- .../src/turn/reservedTurnHandler.ts | 3 +- app/game-engine/src/turn/turnDaemon.ts | 12 +- app/game-engine/src/turn/types.ts | 1 + app/game-engine/src/turn/worldLoader.ts | 45 ++-- app/game-engine/src/turn/yearbookHandler.ts | 62 ++--- .../test/monthlyBoundaryPreHandler.test.ts | 173 +++++++++++++ ...BoundaryPrePersistence.integration.test.ts | 240 ++++++++++++++++++ .../test/nationTurnCompatibility.test.ts | 36 ++- packages/infra/src/turnEngineDb.ts | 9 + 12 files changed, 666 insertions(+), 59 deletions(-) create mode 100644 app/game-engine/src/turn/monthlyBoundaryPreHandler.ts create mode 100644 app/game-engine/test/monthlyBoundaryPreHandler.test.ts create mode 100644 app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 5e0e876..4f982be 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -887,6 +887,24 @@ export const createDatabaseTurnHooks = async ( data: buildGeneralUpdate(general), }) ), + ...generals + .filter( + (general) => + typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal) + ) + .map((general) => + prisma.generalAccessLog.upsert({ + where: { generalId: general.id }, + update: { + refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0), + }, + create: { + generalId: general.id, + userId: general.userId ?? null, + refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0), + }, + }) + ), ...cities.map((city) => prisma.city.update({ where: { id: city.id }, diff --git a/app/game-engine/src/turn/monthlyBoundaryPreHandler.ts b/app/game-engine/src/turn/monthlyBoundaryPreHandler.ts new file mode 100644 index 0000000..12446df --- /dev/null +++ b/app/game-engine/src/turn/monthlyBoundaryPreHandler.ts @@ -0,0 +1,75 @@ +import { asRecord } from '@sammo-ts/common'; +import type { TurnCommandEnv } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; + +const CITY_STATE_TRANSITIONS = new Map([ + [31, 0], + [32, 31], + [33, 0], + [34, 33], + [41, 0], + [42, 41], + [43, 42], +]); + +const readFiniteNumber = (value: unknown, fallback = 0): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +}; + +/** + * ref preUpdateMonthly()에서 국가 외 상태를 날짜 변경 전에 갱신한다. + * 연감 저장은 이 handler보다 먼저, MONTH event는 이 handler보다 나중에 + * 실행되도록 turnDaemon의 calendar handler 순서가 계약을 보장한다. + */ +export const createMonthlyBoundaryPreHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + startYear: number; + commandEnv: TurnCommandEnv; +}): TurnCalendarHandler => ({ + beforeMonthChanged: (context) => { + const world = options.getWorld(); + if (!world) { + return; + } + + const develCost = (context.previousYear - options.startYear + 10) * 2; + options.commandEnv.develCost = develCost; + world.updateWorldMeta({ develcost: develCost }); + + for (const general of world.listGenerals()) { + const meta = asRecord(general.meta); + world.updateGeneral(general.id, { + ...(general.refreshScoreTotal === undefined + ? {} + : { refreshScoreTotal: Math.floor(readFiniteNumber(general.refreshScoreTotal) * 0.99) }), + meta: { + ...general.meta, + makelimit: Math.max(0, Math.floor(readFiniteNumber(meta.makelimit)) - 1), + }, + }); + } + + for (const city of world.listCities()) { + const meta = asRecord(city.meta); + const nextTerm = Math.max(0, Math.floor(readFiniteNumber(meta.term)) - 1); + world.updateCity(city.id, { + state: CITY_STATE_TRANSITIONS.get(city.state) ?? city.state, + conflict: nextTerm === 0 ? {} : city.conflict, + meta: { + ...city.meta, + term: nextTerm, + }, + }); + } + }, +}); diff --git a/app/game-engine/src/turn/nationTurnMonthlyHandler.ts b/app/game-engine/src/turn/nationTurnMonthlyHandler.ts index 00ebb82..806fc44 100644 --- a/app/game-engine/src/turn/nationTurnMonthlyHandler.ts +++ b/app/game-engine/src/turn/nationTurnMonthlyHandler.ts @@ -15,11 +15,56 @@ const decrementLimit = (value: unknown): number => { return 0; }; -// ref preUpdateMonthly(): 전략 제한과 외교 제한은 매 월턴마다 1씩 감소한다. +const readRate = (value: unknown): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return 20; +}; + +const readSpyRemain = (value: unknown): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return 0; +}; + +const decrementSpy = (value: unknown): Record => { + let raw: unknown = value; + if (typeof value === 'string') { + try { + raw = JSON.parse(value); + } catch { + raw = {}; + } + } + const result: Record = {}; + for (const [cityId, remain] of Object.entries(asRecord(raw))) { + const numeric = readSpyRemain(remain); + if (numeric > 1) { + result[cityId] = numeric - 1; + } + } + return result; +}; + +// ref preUpdateMonthly(): 국가 제한·세율·첩보는 MONTH action보다 먼저 갱신한다. export const createNationTurnMonthlyHandler = (options: { getWorld: () => InMemoryTurnWorld | null; }): TurnCalendarHandler => ({ - onMonthChanged: () => { + beforeMonthChanged: () => { const world = options.getWorld(); if (!world) { return; @@ -31,6 +76,8 @@ export const createNationTurnMonthlyHandler = (options: { ...nation.meta, strategic_cmd_limit: decrementLimit(meta.strategic_cmd_limit), surlimit: decrementLimit(meta.surlimit), + rate_tmp: readRate(meta.rate), + spy: decrementSpy(meta.spy), }, }); } diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 6f05b7f..00dd074 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -716,6 +716,7 @@ export const createReservedTurnHandler = async (options: { unitSet?: UnitSetDefinition; getWorld: () => InMemoryTurnWorld | null; commandProfile?: TurnCommandProfile; + commandEnv?: TurnCommandEnv; commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil; onActionResolved?: (payload: { kind: 'nation' | 'general'; @@ -728,7 +729,7 @@ export const createReservedTurnHandler = async (options: { aiState?: ReturnType; }) => void; }): Promise => { - const env = buildCommandEnv(options.scenarioConfig, options.unitSet); + const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet); const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const)); if (Object.keys(uniqueConfig.allItems).length === 0) { diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 2d5a598..5133c78 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -26,6 +26,7 @@ import { createGatewayProfileGate } from './gatewayProfileGate.js'; import { composeCalendarHandlers } from './calendarHandlers.js'; import { createIncomeHandler } from './incomeHandler.js'; import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js'; +import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js'; import { createFrontStateHandler } from './frontStateHandler.js'; import { createReservedTurnHandler } from './reservedTurnHandler.js'; import { createReservedTurnStore } from './reservedTurnStore.js'; @@ -443,6 +444,11 @@ const createTurnDaemonRuntimeWithLease = async ( const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({ getWorld: () => worldRef, }); + const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({ + getWorld: () => worldRef, + startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, + commandEnv: monthlyCommandEnv, + }); const frontStateHandler = createFrontStateHandler({ getWorld: () => worldRef, map: snapshot.map ?? null, @@ -467,13 +473,14 @@ const createTurnDaemonRuntimeWithLease = async ( }); const calendarHandler = composeCalendarHandlers( monthlyEventHandler, + yearbookHandler.handler, options.calendarHandler ?? unification?.handler, + monthlyBoundaryPreHandler, nationTurnMonthlyHandler, hasEventAction('ProcessIncome') ? null : incomeHandler, frontStateHandler, neutralAuctionRegistrar.handler, - tournamentAutoStartHandler, - yearbookHandler.handler + tournamentAutoStartHandler ); const worldOptions: InMemoryTurnWorldOptions = { schedule, @@ -487,6 +494,7 @@ const createTurnDaemonRuntimeWithLease = async ( unitSet: snapshot.unitSet, getWorld: () => worldRef, commandProfile, + commandEnv: monthlyCommandEnv, })), calendarHandler: calendarHandler ?? undefined, }; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 4703e1d..5b42ec4 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -32,6 +32,7 @@ export interface TurnGeneral extends General { lastTurn?: GeneralLastTurn; penalty?: unknown; inheritancePoints?: Record; + refreshScoreTotal?: number; } export interface TurnDiplomacy { diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 63c7b45..edffb02 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -5,6 +5,7 @@ import { type TurnEngineDatabaseClient, type TurnEngineDiplomacyRow, type TurnEngineGeneralRow, + type TurnEngineGeneralAccessLogRow, type TurnEngineInheritancePointRow, type TurnEngineRankDataRow, type TurnEngineNationRow, @@ -170,7 +171,8 @@ const GENERAL_RANK_META_PREFIX_TYPES = new Set([ const mapGeneralRow = ( row: TurnEngineGeneralRow, rankRows: readonly TurnEngineRankDataRow[], - inheritanceRows: readonly TurnEngineInheritancePointRow[] + inheritanceRows: readonly TurnEngineInheritancePointRow[], + accessRow?: TurnEngineGeneralAccessLogRow ): TurnGeneral => { const legacySlots: GeneralItemSlots = { horse: normalizeCode(row.horseCode), @@ -243,6 +245,7 @@ const mapGeneralRow = ( turnTime: row.turnTime, recentWarTime: row.recentWarTime ?? null, inheritancePoints, + ...(accessRow ? { refreshScoreTotal: accessRow.refreshScoreTotal } : {}), }; }; @@ -340,19 +343,29 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) throw new Error('world_state row is required to start turn daemon.'); } - const [generalRows, rankRows, inheritanceRows, cityRows, nationRows, diplomacyRows, troopRows, eventRows] = - await Promise.all([ - prisma.general.findMany(), - prisma.rankData.findMany(), - prisma.inheritancePoint.findMany(), - prisma.city.findMany(), - prisma.nation.findMany(), - prisma.diplomacy.findMany(), - prisma.troop.findMany(), - prisma.event.findMany({ - orderBy: [{ priority: 'desc' }, { id: 'asc' }], - }), - ]); + const [ + generalRows, + rankRows, + inheritanceRows, + accessRows, + cityRows, + nationRows, + diplomacyRows, + troopRows, + eventRows, + ] = await Promise.all([ + prisma.general.findMany(), + prisma.rankData.findMany(), + prisma.inheritancePoint.findMany(), + prisma.generalAccessLog.findMany(), + prisma.city.findMany(), + prisma.nation.findMany(), + prisma.diplomacy.findMany(), + prisma.troop.findMany(), + prisma.event.findMany({ + orderBy: [{ priority: 'desc' }, { id: 'asc' }], + }), + ]); const ranksByGeneral = new Map(); for (const row of rankRows) { @@ -366,11 +379,13 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) bucket.push(row); inheritanceByUser.set(row.userId, bucket); } + const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row])); const generals = generalRows.map((row) => mapGeneralRow( row, ranksByGeneral.get(row.id) ?? [], - row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [] + row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [], + accessByGeneral.get(row.id) ) ); const cities = cityRows.map(mapCityRow); diff --git a/app/game-engine/src/turn/yearbookHandler.ts b/app/game-engine/src/turn/yearbookHandler.ts index 46017f3..74925e0 100644 --- a/app/game-engine/src/turn/yearbookHandler.ts +++ b/app/game-engine/src/turn/yearbookHandler.ts @@ -56,12 +56,9 @@ const buildMapSnapshot = (world: InMemoryTurnWorld, year: number, month: number) return [city.id, city.level, stateValue, city.nationId, region, supplyFlag]; }); - const nationList: MapNationCompact[] = world.listNations().map((nation) => [ - nation.id, - nation.name, - nation.color, - nation.capitalCityId ?? 0, - ]); + const nationList: MapNationCompact[] = world + .listNations() + .map((nation) => [nation.id, nation.name, nation.color, nation.capitalCityId ?? 0]); return { result: true, @@ -84,8 +81,7 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => { for (const city of cities) { const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 }; - const valueSum = - city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence; + const valueSum = city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence; const maxSum = city.populationMax + city.agricultureMax + @@ -152,40 +148,38 @@ export const createYearbookHandler = (options: { const ready = connector.connect(); const handler: TurnCalendarHandler = { - onMonthChanged: (context) => { + beforeMonthChanged: async (context) => { const world = options.getWorld(); if (!world) { return; } - void (async () => { - await ready; - const map = buildMapSnapshot(world, context.previousYear, context.previousMonth); - const nations = buildNationSnapshot(world); - const hash = buildHash(map, nations); + await ready; + const map = buildMapSnapshot(world, context.previousYear, context.previousMonth); + const nations = buildNationSnapshot(world); + const hash = buildHash(map, nations); - await connector.prisma.yearbookHistory.upsert({ - where: { - profileName_year_month: { - profileName: options.profileName, - year: context.previousYear, - month: context.previousMonth, - }, - }, - update: { - map, - nations, - hash, - }, - create: { + await connector.prisma.yearbookHistory.upsert({ + where: { + profileName_year_month: { profileName: options.profileName, year: context.previousYear, month: context.previousMonth, - map, - nations, - hash, }, - }); - })(); + }, + update: { + map, + nations, + hash, + }, + create: { + profileName: options.profileName, + year: context.previousYear, + month: context.previousMonth, + map, + nations, + hash, + }, + }); }, }; @@ -194,4 +188,4 @@ export const createYearbookHandler = (options: { }; return { handler, close }; -}; \ No newline at end of file +}; diff --git a/app/game-engine/test/monthlyBoundaryPreHandler.test.ts b/app/game-engine/test/monthlyBoundaryPreHandler.test.ts new file mode 100644 index 0000000..5a528ec --- /dev/null +++ b/app/game-engine/test/monthlyBoundaryPreHandler.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import type { City, Nation, TurnCommandEnv } from '@sammo-ts/logic'; + +import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyBoundaryPreHandler } from '../src/turn/monthlyBoundaryPreHandler.js'; +import { createNationTurnMonthlyHandler } from '../src/turn/nationTurnMonthlyHandler.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildGeneral = (id: number, makeLimit: number, refreshScoreTotal: number): TurnGeneral => ({ + id, + name: `장수${id}`, + nationId: 0, + cityId: id, + 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: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 2, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 0, makelimit: makeLimit }, + refreshScoreTotal, + turnTime: new Date('0200-12-01T00:00:00.000Z'), +}); + +const buildCity = (id: number, state: number, term: number): City => ({ + id, + name: `도시${id}`, + nationId: id <= 2 ? 1 : 0, + level: 1, + state, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + supplyState: 1, + frontState: 0, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + conflict: { 1: id * 10 }, + meta: { term }, +}); + +const nation: Nation = { + id: 1, + name: 'fixture-nation-1', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { + rate: 35, + rate_tmp: 10, + strategic_cmd_limit: 2, + surlimit: 1, + spy: { 1: 1, 2: 2 }, + }, +}; + +const commandEnv = { develCost: 18 } as TurnCommandEnv; + +describe('monthly pre-update boundary', () => { + it('matches the fixed legacy preUpdateMonthly state transition before MONTH actions', async () => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 12, + tickSeconds: 600, + lastTurnTime: new Date('0200-12-01T00:00:00.000Z'), + meta: { develcost: 18 }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { id: 'test', name: 'test', cities: [] }, + diplomacy: [], + events: [], + initialEvents: [], + generals: [buildGeneral(1, 2, 101), buildGeneral(2, 0, 1)], + cities: [ + buildCity(1, 31, 1), + buildCity(2, 32, 2), + buildCity(3, 33, 0), + buildCity(4, 34, 3), + buildCity(5, 41, 1), + buildCity(6, 42, 2), + buildCity(7, 43, 3), + ], + nations: [nation], + troops: [], + }; + let world: InMemoryTurnWorld | null = null; + const trace: unknown[] = []; + const boundaryHandler = createMonthlyBoundaryPreHandler({ + getWorld: () => world, + startYear: 190, + commandEnv, + }); + const nationHandler = createNationTurnMonthlyHandler({ getWorld: () => world }); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: composeCalendarHandlers(boundaryHandler, nationHandler, { + onMonthChanged: () => { + trace.push({ + state: world?.listCities().map((city) => city.state), + rate: world?.getNationById(1)?.meta.rate_tmp, + develCost: commandEnv.develCost, + }); + }, + }), + }); + + await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z')); + + expect(world.listGenerals().map((general) => [general.meta.makelimit, general.refreshScoreTotal])).toEqual([ + [1, 99], + [0, 0], + ]); + expect(world.getNationById(1)?.meta).toEqual( + expect.objectContaining({ + rate: 35, + rate_tmp: 35, + strategic_cmd_limit: 1, + surlimit: 0, + spy: { 2: 1 }, + }) + ); + expect(world.listCities().map((city) => city.state)).toEqual([0, 31, 0, 33, 0, 41, 42]); + expect(world.listCities().map((city) => city.meta.term)).toEqual([0, 1, 0, 2, 0, 1, 2]); + expect(world.listCities().map((city) => city.conflict)).toEqual([ + {}, + { 1: 20 }, + {}, + { 1: 40 }, + {}, + { 1: 60 }, + { 1: 70 }, + ]); + expect(world.getState().meta.develcost).toBe(40); + expect(commandEnv.develCost).toBe(40); + expect(trace).toEqual([{ state: [0, 31, 0, 33, 0, 41, 42], rate: 35, develCost: 40 }]); + }); +}); diff --git a/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts b/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts new file mode 100644 index 0000000..5e9210c --- /dev/null +++ b/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts @@ -0,0 +1,240 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { TurnCommandEnv } from '@sammo-ts/logic'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyBoundaryPreHandler } from '../src/turn/monthlyBoundaryPreHandler.js'; +import { createNationTurnMonthlyHandler } from '../src/turn/nationTurnMonthlyHandler.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; +import { createYearbookHandler } from '../src/turn/yearbookHandler.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalIds = [991_201, 991_202]; +const cityIds = [991_201, 991_202, 991_203, 991_204, 991_205, 991_206, 991_207]; +const nationId = 991_201; +const yearbookProfile = 'monthly-boundary-pre-persistence'; + +integration('monthly pre-update persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.general.deleteMany({ where: { id: { in: generalIds } } }); + await db.city.deleteMany({ where: { id: { in: cityIds } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } }); + await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); + }); + + afterAll(async () => { + await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.general.deleteMany({ where: { id: { in: generalIds } } }); + await db.city.deleteMany({ where: { id: { in: cityIds } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } }); + await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); + await closeDb?.(); + }); + + it('loads and commits access score, limits, nation metadata, world development cost, and city state', async () => { + await db.nation.create({ + data: { + id: nationId, + name: 'pre검증국', + color: '#777777', + level: 1, + typeCode: 'che_중립', + meta: { + rate: 35, + rate_tmp: 10, + strategic_cmd_limit: 2, + surlimit: 1, + spy: { 1: 1, 2: 2 }, + }, + }, + }); + await db.city.createMany({ + data: cityIds.map((id, index) => ({ + id, + name: `pre도시${index + 1}`, + level: 1, + nationId: index < 2 ? nationId : 0, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + region: 1, + conflict: { 1: (index + 1) * 10 }, + meta: { + state: [31, 32, 33, 34, 41, 42, 43][index], + term: [1, 2, 0, 3, 1, 2, 3][index], + }, + })), + }); + await db.general.createMany({ + data: generalIds.map((id, index) => ({ + id, + name: `pre장수${index + 1}`, + nationId: 0, + cityId: cityIds[index]!, + npcState: 2, + turnTime: new Date('0200-12-01T00:00:00.000Z'), + meta: { killturn: 0, makelimit: index === 0 ? 2 : 0 }, + })), + }); + await db.generalAccessLog.createMany({ + data: [ + { generalId: generalIds[0]!, refreshScoreTotal: 101 }, + { generalId: generalIds[1]!, refreshScoreTotal: 1 }, + ], + }); + const worldRow = await db.worldState.create({ + data: { + scenarioCode: 'monthly-boundary-pre-persistence', + currentYear: 200, + currentMonth: 12, + tickSeconds: 600, + config: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'che', unitSet: 'che' }, + }, + meta: { + develcost: 18, + scenarioMeta: { + title: 'pre persistence', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + }, + }, + }); + + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect( + loaded.snapshot.generals + .filter((general) => generalIds.includes(general.id)) + .map((general) => general.refreshScoreTotal) + ).toEqual([101, 1]); + + let world: InMemoryTurnWorld | null = null; + const commandEnv = { develCost: 18 } as TurnCommandEnv; + const boundary = createMonthlyBoundaryPreHandler({ + getWorld: () => world, + startYear: 190, + commandEnv, + }); + const nations = createNationTurnMonthlyHandler({ getWorld: () => world }); + const yearbook = createYearbookHandler({ + databaseUrl: databaseUrl!, + profileName: yearbookProfile, + getWorld: () => world, + }); + world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: composeCalendarHandlers(yearbook.handler, boundary, nations), + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z')); + await hooks.hooks.flushChanges?.({ + lastTurnTime: '0201-01-01T00:00:00.000Z', + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + + expect( + await db.generalAccessLog.findMany({ + where: { generalId: { in: generalIds } }, + orderBy: { generalId: 'asc' }, + select: { refreshScoreTotal: true }, + }) + ).toEqual([{ refreshScoreTotal: 99 }, { refreshScoreTotal: 0 }]); + expect( + ( + await db.general.findMany({ + where: { id: { in: generalIds } }, + orderBy: { id: 'asc' }, + select: { meta: true }, + }) + ).map((row) => (row.meta as Record).makelimit) + ).toEqual([1, 0]); + expect(await db.nation.findUniqueOrThrow({ where: { id: nationId } })).toMatchObject({ + meta: expect.objectContaining({ + rate: 35, + rate_tmp: 35, + strategic_cmd_limit: 1, + surlimit: 0, + spy: { 2: 1 }, + }), + }); + expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ + currentYear: 201, + currentMonth: 1, + meta: expect.objectContaining({ develcost: 40 }), + }); + const cityRows = await db.city.findMany({ + where: { id: { in: cityIds } }, + orderBy: { id: 'asc' }, + select: { conflict: true, meta: true }, + }); + expect(cityRows.map((row) => (row.meta as Record).state)).toEqual([ + 0, 31, 0, 33, 0, 41, 42, + ]); + expect(cityRows.map((row) => (row.meta as Record).term)).toEqual([0, 1, 0, 2, 0, 1, 2]); + expect(cityRows.map((row) => row.conflict)).toEqual([ + {}, + { 1: 20 }, + {}, + { 1: 40 }, + {}, + { 1: 60 }, + { 1: 70 }, + ]); + const yearbookRow = await db.yearbookHistory.findUniqueOrThrow({ + where: { + profileName_year_month: { + profileName: yearbookProfile, + year: 200, + month: 12, + }, + }, + }); + const yearbookStates = new Map( + (yearbookRow.map as { cityList: Array<[number, number, number]> }).cityList.map(([id, , state]) => [ + id, + state, + ]) + ); + expect(cityIds.map((id) => yearbookStates.get(id))).toEqual([31, 32, 33, 34, 41, 42, 43]); + } finally { + await hooks.close(); + await yearbook.close(); + } + }); +}); diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts index 183fe78..5608d86 100644 --- a/app/game-engine/test/nationTurnCompatibility.test.ts +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -164,10 +164,20 @@ describe('레거시 사령부 턴 실행 호환성', () => { ); }); - it('월 경계마다 전략·외교 제한을 1씩 감소시키고 0 아래로 내리지 않는다', () => { + it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => { const updates: Array<{ id: number; patch: Record }> = []; const nations = [ - { id: 1, meta: { strategic_cmd_limit: 2, surlimit: '1', keep: true } }, + { + id: 1, + meta: { + strategic_cmd_limit: 2, + surlimit: '1', + rate: 35, + rate_tmp: 10, + spy: '{"1":1,"2":2}', + keep: true, + }, + }, { id: 2, meta: { strategic_cmd_limit: 0, surlimit: 0 } }, ]; const handler = createNationTurnMonthlyHandler({ @@ -178,16 +188,32 @@ describe('레거시 사령부 턴 실행 호환성', () => { }) as never, }); - handler.onMonthChanged?.({} as never); + handler.beforeMonthChanged?.({} as never); expect(updates).toEqual([ { id: 1, - patch: { meta: { strategic_cmd_limit: 1, surlimit: 0, keep: true } }, + patch: { + meta: { + strategic_cmd_limit: 1, + surlimit: 0, + rate: 35, + rate_tmp: 35, + spy: { 2: 1 }, + keep: true, + }, + }, }, { id: 2, - patch: { meta: { strategic_cmd_limit: 0, surlimit: 0 } }, + patch: { + meta: { + strategic_cmd_limit: 0, + surlimit: 0, + rate_tmp: 20, + spy: {}, + }, + }, }, ]); }); diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 9efea38..98697d8 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -68,6 +68,12 @@ export interface TurnEngineInheritancePointRow { value: number; } +export interface TurnEngineGeneralAccessLogRow { + generalId: number; + userId: string | null; + refreshScoreTotal: number; +} + export interface TurnEngineCityRow { id: number; name: string; @@ -382,6 +388,9 @@ export interface TurnEngineDatabaseClient { inheritancePoint: { findMany(args?: unknown): Promise; }; + generalAccessLog: { + findMany(args?: unknown): Promise; + }; city: { findMany(args?: unknown): Promise; createMany(args: { data: TurnEngineCityCreateManyInput[] }): Promise;