From 652e7234578146398266f7a987b0cf9d09c48b29 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 22:20:55 +0000 Subject: [PATCH] feat(engine): migrate monthly wander disband --- app/game-engine/src/turn/inMemoryWorld.ts | 131 ++--- .../src/turn/monthlyWanderHandler.ts | 166 +++++++ app/game-engine/src/turn/turnDaemon.ts | 7 + .../test/monthlyWanderHandler.test.ts | 330 +++++++++++++ ...nthlyWanderPersistence.integration.test.ts | 457 ++++++++++++++++++ 5 files changed, 1029 insertions(+), 62 deletions(-) create mode 100644 app/game-engine/src/turn/monthlyWanderHandler.ts create mode 100644 app/game-engine/test/monthlyWanderHandler.test.ts create mode 100644 app/game-engine/test/monthlyWanderPersistence.integration.test.ts diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 5bd495e..72dda91 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1025,6 +1025,74 @@ export class InMemoryTurnWorld { return changes; } + collapseNation(nationId: number): boolean { + const nation = this.nations.get(nationId); + if (!nation) { + return false; + } + const generalIds = Array.from(this.generals.values()) + .filter((general) => general.nationId === nationId) + .map((general) => general.id); + + // Legacy deleteNation() calls DeleteConflict() before removing the + // nation. Without this, a later conquest can award a city to a + // nation ID that no longer exists. + for (const city of this.cities.values()) { + const rawConflict = city.meta.conflict; + if (rawConflict === null || rawConflict === undefined) { + continue; + } + let conflict: Record; + try { + const parsed = typeof rawConflict === 'string' ? (JSON.parse(rawConflict) as unknown) : rawConflict; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + continue; + } + conflict = { ...(parsed as Record) }; + } catch { + continue; + } + const key = String(nationId); + if (!Object.prototype.hasOwnProperty.call(conflict, key)) { + continue; + } + delete conflict[key]; + this.cities.set(city.id, { + ...city, + meta: { + ...city.meta, + conflict: JSON.stringify(conflict), + }, + }); + this.dirtyCityIds.add(city.id); + } + + this.deletedNationSnapshots.push({ + nation: { ...nation }, + generalIds, + removedAt: new Date(this.state.lastTurnTime.getTime()), + }); + for (const general of this.generals.values()) { + if (general.nationId !== nationId) { + continue; + } + const updated = applyGeneralPatch(general, { + nationId: 0, + officerLevel: 0, + troopId: 0, + }); + this.generals.set(general.id, normalizeGeneralTurnTime(updated, this.state.lastTurnTime)); + this.dirtyGeneralIds.add(general.id); + } + for (const troop of Array.from(this.troops.values())) { + if (troop.nationId === nationId) { + this.removeTroop(troop.id); + } + } + this.removeNation(nationId); + return true; + } + private removeCollapsedNations(): void { const collapsedNationIds: number[] = []; for (const nation of this.nations.values()) { @@ -1043,68 +1111,7 @@ export class InMemoryTurnWorld { } for (const nationId of collapsedNationIds) { - // Legacy deleteNation() calls DeleteConflict() before removing the - // nation. Without this, a later conquest can award a city to a - // nation ID that no longer exists. - for (const city of this.cities.values()) { - const rawConflict = city.meta.conflict; - if (rawConflict === null || rawConflict === undefined) { - continue; - } - let conflict: Record; - try { - const parsed = typeof rawConflict === 'string' ? (JSON.parse(rawConflict) as unknown) : rawConflict; - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - continue; - } - conflict = { ...(parsed as Record) }; - } catch { - continue; - } - const key = String(nationId); - if (!Object.prototype.hasOwnProperty.call(conflict, key)) { - continue; - } - delete conflict[key]; - this.cities.set(city.id, { - ...city, - meta: { - ...city.meta, - conflict: JSON.stringify(conflict), - }, - }); - this.dirtyCityIds.add(city.id); - } - - const nation = this.nations.get(nationId); - if (nation) { - const generalIds = Array.from(this.generals.values()) - .filter((general) => general.nationId === nationId) - .map((general) => general.id); - this.deletedNationSnapshots.push({ - nation: { ...nation }, - generalIds, - removedAt: new Date(this.state.lastTurnTime.getTime()), - }); - } - for (const general of this.generals.values()) { - if (general.nationId !== nationId) { - continue; - } - const updated = applyGeneralPatch(general, { - nationId: 0, - officerLevel: 0, - troopId: 0, - }); - this.generals.set(general.id, normalizeGeneralTurnTime(updated, this.state.lastTurnTime)); - this.dirtyGeneralIds.add(general.id); - } - for (const troop of Array.from(this.troops.values())) { - if (troop.nationId === nationId) { - this.removeTroop(troop.id); - } - } - this.removeNation(nationId); + this.collapseNation(nationId); } } diff --git a/app/game-engine/src/turn/monthlyWanderHandler.ts b/app/game-engine/src/turn/monthlyWanderHandler.ts new file mode 100644 index 0000000..30e761b --- /dev/null +++ b/app/game-engine/src/turn/monthlyWanderHandler.ts @@ -0,0 +1,166 @@ +import { JosaUtil } from '@sammo-ts/common'; +import { LogCategory, LogFormat, LogScope, type TurnCommandEnv } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; + +const readNumber = (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; +}; + +const formatHourMinute = (date: Date): string => + `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`; + +export const createMonthlyWanderHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + startYear: number; + commandEnv: TurnCommandEnv; +}): TurnCalendarHandler => ({ + onMonthChanged: (context) => { + const world = options.getWorld(); + if (!world || context.currentYear < options.startYear + 2) { + return; + } + const baseGold = options.commandEnv.baseGold > 0 ? options.commandEnv.baseGold : 1_000; + const baseRice = options.commandEnv.baseRice > 0 ? options.commandEnv.baseRice : 1_000; + const wanderers = world + .listGenerals() + .filter((general) => { + const nation = world.getNationById(general.nationId); + return nation?.level === 0 && general.officerLevel === 12; + }) + .sort((left, right) => left.id - right.id); + + for (const wanderer of wanderers) { + const nation = world.getNationById(wanderer.nationId); + if (!nation || nation.level !== 0 || wanderer.officerLevel !== 12) { + continue; + } + const nationGenerals = world + .listGenerals() + .filter((general) => general.nationId === nation.id) + .sort((left, right) => left.id - right.id); + const nationCities = world.listCities().filter((city) => city.nationId === nation.id); + const nationGeneralIds = nationGenerals.map((general) => general.id); + const nationNameJosaYi = JosaUtil.pick(nation.name, '이'); + const nationNameJosaUl = JosaUtil.pick(nation.name, '을'); + const nationNameJosaUn = JosaUtil.pick(nation.name, '은'); + const wandererNameJosaYi = JosaUtil.pick(wanderer.name, '이'); + + // Preserve the legacy two-UPDATE bug: gold is capped first, then the + // rice UPDATE tests the already-capped gold column. + for (const general of nationGenerals) { + const gold = Math.min(general.gold, baseGold); + const rice = gold > baseRice ? Math.min(general.rice, baseRice) : general.rice; + const belong = readNumber(general.meta.belong); + const maxBelong = readNumber(general.meta.max_belong); + world.updateGeneral(general.id, { + gold, + rice, + meta: { + ...general.meta, + belong: 0, + officer_city: 0, + officerCity: 0, + ...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}), + }, + }); + } + const updatedWanderer = world.getGeneralById(wanderer.id); + if (!updatedWanderer) { + continue; + } + world.updateGeneral(wanderer.id, { + gold: Math.min(updatedWanderer.gold, baseGold), + rice: Math.min(updatedWanderer.rice, baseRice), + lastTurn: { command: '해산', arg: {} }, + meta: { + ...updatedWanderer.meta, + makelimit: 12, + }, + }); + for (const city of nationCities) { + world.updateCity(city.id, { nationId: 0, frontState: 0 }); + } + + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `【멸망】${nation.name}${nationNameJosaUn} 멸망했습니다.`, + format: LogFormat.YEAR_MONTH, + }); + for (const general of nationGenerals.filter((general) => general.id !== wanderer.id)) { + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: general.id, + text: `${nation.name}${nationNameJosaYi} 멸망`, + format: LogFormat.YEAR_MONTH, + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: general.id, + text: `${nation.name}${nationNameJosaYi} 멸망했습니다.`, + format: LogFormat.PLAIN, + }); + } + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: wanderer.id, + text: `${nation.name}${nationNameJosaUl} 해산`, + format: LogFormat.YEAR_MONTH, + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: wanderer.id, + text: `${nation.name}${nationNameJosaYi} 멸망`, + format: LogFormat.YEAR_MONTH, + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: wanderer.id, + text: '초반 제한후 방랑군은 자동 해산됩니다.', + format: LogFormat.PLAIN, + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: wanderer.id, + text: `세력을 해산했습니다. <1>${formatHourMinute(wanderer.turnTime)}`, + format: LogFormat.MONTH, + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: wanderer.id, + text: `${nation.name}${nationNameJosaYi} 멸망했습니다.`, + format: LogFormat.PLAIN, + }); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + text: `${wanderer.name}${wandererNameJosaYi} 세력을 해산했습니다.`, + format: LogFormat.MONTH, + }); + + if (!world.collapseNation(nation.id)) { + throw new Error(`Monthly wander disband could not remove nation ${nation.id}.`); + } + if (nationGeneralIds.some((generalId) => world.getGeneralById(generalId)?.nationId !== 0)) { + throw new Error(`Monthly wander disband did not detach every general of nation ${nation.id}.`); + } + } + }, +}); diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index bfdb822..e1527aa 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -27,6 +27,7 @@ import { composeCalendarHandlers } from './calendarHandlers.js'; import { createIncomeHandler } from './incomeHandler.js'; import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js'; import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js'; +import { createMonthlyWanderHandler } from './monthlyWanderHandler.js'; import { createMonthlyDiplomacyHandler, createMonthlyNationCountHandler, @@ -467,6 +468,11 @@ const createTurnDaemonRuntimeWithLease = async ( const monthlyWarSettingHandler = createMonthlyWarSettingHandler({ getWorld: () => worldRef, }); + const monthlyWanderHandler = createMonthlyWanderHandler({ + getWorld: () => worldRef, + startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, + commandEnv: monthlyCommandEnv, + }); const frontStateHandler = createFrontStateHandler({ getWorld: () => worldRef, map: snapshot.map ?? null, @@ -497,6 +503,7 @@ const createTurnDaemonRuntimeWithLease = async ( monthlyNationStatsHandler, monthlyDiplomacyHandler, monthlyWarSettingHandler, + monthlyWanderHandler, monthlyNationCountHandler, options.calendarHandler ?? unification?.handler, hasEventAction('ProcessIncome') ? null : incomeHandler, diff --git a/app/game-engine/test/monthlyWanderHandler.test.ts b/app/game-engine/test/monthlyWanderHandler.test.ts new file mode 100644 index 0000000..fd8cd48 --- /dev/null +++ b/app/game-engine/test/monthlyWanderHandler.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from 'vitest'; +import { LogCategory, LogFormat, LogScope, type City, type Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyWanderHandler } from '../src/turn/monthlyWanderHandler.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildGeneral = (options: { + id: number; + name: string; + nationId: number; + cityId: number; + officerLevel: number; + npcState: number; + gold: number; + rice: number; + belong: number; + turnTime: Date; +}): TurnGeneral => ({ + id: options.id, + name: options.name, + nationId: options.nationId, + cityId: options.cityId, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: options.officerLevel, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: options.gold, + rice: options.rice, + crew: 100, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 20, + npcState: options.npcState, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 0, makelimit: 0, belong: options.belong }, + turnTime: options.turnTime, +}); + +const buildNation = (id: number, name: string, level: number, generalCount: number): Nation => ({ + id, + name, + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level, + typeCode: 'che_중립', + meta: { gennum: generalCount }, +}); + +const buildCity = (id: number, name: string, nationId: number): City => ({ + id, + name, + nationId, + level: 4, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + supplyState: 1, + frontState: 1, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + conflict: {}, + meta: {}, +}); + +describe('monthly wandering nation cleanup', () => { + it('matches legacy automatic disband resources, archive inputs, and logs after the opening limit', async () => { + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { baseGold: 1_000, baseRice: 1_000 }, + environment: { mapName: 'test', unitSet: 'default' }, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 195, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0195-01-01T00:00:00.000Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + scenarioMeta: { + title: 'test', + startYear: 193, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { id: 'test', name: 'test', cities: [] }, + diplomacy: [ + { fromNationId: 1, toNationId: 4, state: 2, term: 0, dead: 0, meta: {} }, + { fromNationId: 4, toNationId: 1, state: 2, term: 0, dead: 0, meta: {} }, + ], + events: [], + initialEvents: [], + generals: [ + buildGeneral({ + id: 1, + name: '방랑주', + nationId: 4, + cityId: 1, + officerLevel: 12, + npcState: 2, + gold: 2_000, + rice: 3_000, + belong: 7, + turnTime: new Date('0195-02-01T12:28:00.000Z'), + }), + buildGeneral({ + id: 2, + name: '방랑객', + nationId: 4, + cityId: 1, + officerLevel: 1, + npcState: 0, + gold: 1_500, + rice: 4_000, + belong: 5, + turnTime: new Date('0195-02-01T12:53:00.000Z'), + }), + buildGeneral({ + id: 3, + name: '존속장', + nationId: 1, + cityId: 2, + officerLevel: 12, + npcState: 2, + gold: 500, + rice: 500, + belong: 1, + turnTime: new Date('0195-02-01T13:21:00.000Z'), + }), + ], + cities: [buildCity(1, '방랑성', 4), buildCity(2, '존속성', 1)], + nations: [buildNation(1, '존속국', 1, 1), buildNation(4, '방랑국', 0, 2)], + troops: [], + }; + let world: InMemoryTurnWorld | null = null; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createMonthlyWanderHandler({ + getWorld: () => world, + startYear: 193, + commandEnv: buildCommandEnv(scenarioConfig), + }), + }); + + await world.advanceMonth(new Date('0195-02-01T00:00:00.000Z')); + + expect(world.getNationById(4)).toBeNull(); + expect(world.getGeneralById(1)).toMatchObject({ + nationId: 0, + officerLevel: 0, + gold: 1_000, + rice: 1_000, + lastTurn: { command: '해산', arg: {} }, + meta: { belong: 0, makelimit: 12 }, + }); + expect(world.getGeneralById(2)).toMatchObject({ + nationId: 0, + officerLevel: 0, + gold: 1_000, + rice: 4_000, + meta: { belong: 0, max_belong: 5 }, + }); + expect(world.getGeneralById(3)).toMatchObject({ + nationId: 1, + officerLevel: 12, + gold: 500, + rice: 500, + }); + expect(world.getCityById(1)).toMatchObject({ nationId: 0, frontState: 0 }); + expect(world.listDiplomacy()).toEqual([]); + + const changes = world.consumeDirtyState(); + expect(changes.deletedNations).toEqual([4]); + expect(changes.deletedNationSnapshots).toEqual([ + expect.objectContaining({ + nation: expect.objectContaining({ id: 4, name: '방랑국' }), + generalIds: [1, 2], + }), + ]); + expect(changes.logs).toEqual([ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【멸망】방랑국멸망했습니다.', + format: LogFormat.YEAR_MONTH, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: 2, + text: '방랑국멸망', + format: LogFormat.YEAR_MONTH, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 2, + text: '방랑국멸망했습니다.', + format: LogFormat.PLAIN, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: 1, + text: '방랑국을 해산', + format: LogFormat.YEAR_MONTH, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: 1, + text: '방랑국멸망', + format: LogFormat.YEAR_MONTH, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 1, + text: '초반 제한후 방랑군은 자동 해산됩니다.', + format: LogFormat.PLAIN, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 1, + text: '세력을 해산했습니다. <1>12:28', + format: LogFormat.MONTH, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 1, + text: '방랑국멸망했습니다.', + format: LogFormat.PLAIN, + }, + { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + text: '방랑주가 세력을 해산했습니다.', + format: LogFormat.MONTH, + }, + ]); + }); + + it('does not disband before startYear + 2', async () => { + const scenarioConfig: 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' }, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 194, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0194-01-01T00:00:00.000Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + diplomacy: [], + events: [], + initialEvents: [], + generals: [ + buildGeneral({ + id: 1, + name: '방랑주', + nationId: 4, + cityId: 1, + officerLevel: 12, + npcState: 2, + gold: 2_000, + rice: 3_000, + belong: 7, + turnTime: new Date('0194-02-01T12:28:00.000Z'), + }), + ], + cities: [buildCity(1, '방랑성', 4)], + nations: [buildNation(4, '방랑국', 0, 1)], + troops: [], + }; + let world: InMemoryTurnWorld | null = null; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createMonthlyWanderHandler({ + getWorld: () => world, + startYear: 193, + commandEnv: buildCommandEnv(scenarioConfig), + }), + }); + + await world.advanceMonth(new Date('0194-02-01T00:00:00.000Z')); + + expect(world.getNationById(4)).not.toBeNull(); + expect(world.getGeneralById(1)).toMatchObject({ nationId: 4, gold: 2_000, rice: 3_000 }); + expect(world.peekDirtyState().logs).toEqual([]); + }); +}); diff --git a/app/game-engine/test/monthlyWanderPersistence.integration.test.ts b/app/game-engine/test/monthlyWanderPersistence.integration.test.ts new file mode 100644 index 0000000..80d0896 --- /dev/null +++ b/app/game-engine/test/monthlyWanderPersistence.integration.test.ts @@ -0,0 +1,457 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyWanderHandler } from '../src/turn/monthlyWanderHandler.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const nationIds = [993_101, 993_104]; +const generalIds = [993_101, 993_102, 993_103]; +const cityIds = [993_101, 993_102]; +const scenarioCode = 'monthly-wander-persistence'; +const serverId = 'monthly-wander-test'; + +const buildGeneral = (options: { + id: number; + name: string; + nationId: number; + cityId: number; + officerLevel: number; + npcState: number; + gold: number; + rice: number; + belong: number; + turnTime: Date; +}): TurnGeneral => ({ + id: options.id, + name: options.name, + nationId: options.nationId, + cityId: options.cityId, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: options.officerLevel, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: options.gold, + rice: options.rice, + crew: 100, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 20, + npcState: options.npcState, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 0, makelimit: 0, belong: options.belong }, + turnTime: options.turnTime, +}); + +const buildNation = (id: number, name: string, level: number, generalCount: number): Nation => ({ + id, + name, + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level, + typeCode: 'che_중립', + meta: { gennum: generalCount }, +}); + +const buildCity = (id: number, name: string, nationId: number): City => ({ + id, + name, + nationId, + level: 4, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + supplyState: 1, + frontState: 1, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + conflict: {}, + meta: {}, +}); + +integration('monthly wandering nation persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const cleanup = async (): Promise => { + await db.diplomacy.deleteMany({ + where: { + OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }], + }, + }); + await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.logEntry.deleteMany({ + where: { + OR: [ + { generalId: { in: generalIds } }, + { text: { contains: '방랑국' } }, + { text: { contains: '방랑주' } }, + ], + }, + }); + await db.general.deleteMany({ where: { id: { in: generalIds } } }); + await db.city.deleteMany({ where: { id: { in: cityIds } } }); + await db.nation.deleteMany({ where: { id: { in: nationIds } } }); + await db.oldNation.deleteMany({ where: { serverId, nation: nationIds[1] } }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await closeDb?.(); + }); + + it('commits detachments, legacy resource bug, logs, and old-nation archive atomically', async () => { + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { baseGold: 1_000, baseRice: 1_000 }, + environment: { mapName: 'test', unitSet: 'default' }, + }; + const nations = [buildNation(nationIds[0]!, '존속국', 1, 1), buildNation(nationIds[1]!, '방랑국', 0, 2)]; + const generals = [ + buildGeneral({ + id: generalIds[0]!, + name: '방랑주', + nationId: nationIds[1]!, + cityId: cityIds[0]!, + officerLevel: 12, + npcState: 2, + gold: 2_000, + rice: 3_000, + belong: 7, + turnTime: new Date('0195-02-01T12:28:00.000Z'), + }), + buildGeneral({ + id: generalIds[1]!, + name: '방랑객', + nationId: nationIds[1]!, + cityId: cityIds[0]!, + officerLevel: 1, + npcState: 0, + gold: 1_500, + rice: 4_000, + belong: 5, + turnTime: new Date('0195-02-01T12:53:00.000Z'), + }), + buildGeneral({ + id: generalIds[2]!, + name: '존속장', + nationId: nationIds[0]!, + cityId: cityIds[1]!, + officerLevel: 12, + npcState: 2, + gold: 500, + rice: 500, + belong: 1, + turnTime: new Date('0195-02-01T13:21:00.000Z'), + }), + ]; + const cities = [ + buildCity(cityIds[0]!, '방랑성', nationIds[1]!), + buildCity(cityIds[1]!, '존속성', nationIds[0]!), + ]; + await db.nation.createMany({ + data: nations.map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + gold: 0, + rice: 0, + tech: 0, + level: nation.level, + typeCode: nation.typeCode, + meta: nation.meta, + })), + }); + await db.city.createMany({ + data: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + supplyState: city.supplyState, + frontState: city.frontState, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + })), + }); + await db.general.createMany({ + data: generals.map((general) => ({ + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + officerLevel: general.officerLevel, + npcState: general.npcState, + gold: general.gold, + rice: general.rice, + crew: general.crew, + turnTime: general.turnTime, + meta: general.meta, + })), + }); + await db.diplomacy.createMany({ + data: [ + { + srcNationId: nationIds[0]!, + destNationId: nationIds[1]!, + stateCode: 2, + term: 0, + meta: { dead: 0 }, + }, + { + srcNationId: nationIds[1]!, + destNationId: nationIds[0]!, + stateCode: 2, + term: 0, + meta: { dead: 0 }, + }, + ], + }); + const worldRow = await db.worldState.create({ + data: { + scenarioCode, + currentYear: 195, + currentMonth: 1, + tickSeconds: 600, + config: scenarioConfig as unknown as GamePrisma.InputJsonValue, + meta: { serverId }, + }, + }); + const state: TurnWorldState = { + id: worldRow.id, + currentYear: 195, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0195-01-01T00:00:00.000Z'), + meta: { serverId }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + scenarioMeta: { + title: 'test', + startYear: 193, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { id: 'test', name: 'test', cities: [] }, + diplomacy: [ + { fromNationId: nationIds[0]!, toNationId: nationIds[1]!, state: 2, term: 0, dead: 0, meta: {} }, + { fromNationId: nationIds[1]!, toNationId: nationIds[0]!, state: 2, term: 0, dead: 0, meta: {} }, + ], + events: [], + initialEvents: [], + generals, + cities, + nations, + troops: [], + }; + let world: InMemoryTurnWorld | null = null; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createMonthlyWanderHandler({ + getWorld: () => world, + startYear: 193, + commandEnv: buildCommandEnv(scenarioConfig), + }), + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await world.advanceMonth(new Date('0195-02-01T00:00:00.000Z')); + await hooks.hooks.flushChanges?.({ + lastTurnTime: '0195-02-01T00:00:00.000Z', + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + + expect(await db.nation.findUnique({ where: { id: nationIds[1]! } })).toBeNull(); + expect( + await db.general.findMany({ + where: { id: { in: generalIds.slice(0, 2) } }, + orderBy: { id: 'asc' }, + select: { + nationId: true, + officerLevel: true, + gold: true, + rice: true, + lastTurn: true, + meta: true, + }, + }) + ).toEqual([ + expect.objectContaining({ + nationId: 0, + officerLevel: 0, + gold: 1_000, + rice: 1_000, + lastTurn: { command: '해산', arg: {} }, + meta: expect.objectContaining({ belong: 0, makelimit: 12 }), + }), + expect.objectContaining({ + nationId: 0, + officerLevel: 0, + gold: 1_000, + rice: 4_000, + meta: expect.objectContaining({ belong: 0, max_belong: 5 }), + }), + ]); + expect(await db.city.findUniqueOrThrow({ where: { id: cityIds[0]! } })).toMatchObject({ + nationId: 0, + frontState: 0, + }); + expect( + await db.diplomacy.count({ + where: { + OR: [{ srcNationId: nationIds[1]! }, { destNationId: nationIds[1]! }], + }, + }) + ).toBe(0); + const archive = await db.oldNation.findUniqueOrThrow({ + where: { serverId_nation: { serverId, nation: nationIds[1]! } }, + }); + expect(archive.data).toMatchObject({ + nation: nationIds[1], + name: '방랑국', + generals: generalIds.slice(0, 2), + }); + const logs = await db.logEntry.findMany({ + where: { + OR: [ + { generalId: { in: generalIds.slice(0, 2) } }, + { text: { contains: '방랑국' } }, + { text: { contains: '방랑주' } }, + ], + }, + orderBy: { id: 'asc' }, + select: { scope: true, category: true, generalId: true, year: true, month: true, text: true }, + }); + expect(logs).toEqual([ + { + scope: 'SYSTEM', + category: 'HISTORY', + generalId: null, + year: 195, + month: 2, + text: '●195년 2월:【멸망】방랑국멸망했습니다.', + }, + { + scope: 'GENERAL', + category: 'HISTORY', + generalId: generalIds[1], + year: 195, + month: 2, + text: '●195년 2월:방랑국멸망', + }, + { + scope: 'GENERAL', + category: 'ACTION', + generalId: generalIds[1], + year: 195, + month: 2, + text: '방랑국멸망했습니다.', + }, + { + scope: 'GENERAL', + category: 'HISTORY', + generalId: generalIds[0], + year: 195, + month: 2, + text: '●195년 2월:방랑국을 해산', + }, + { + scope: 'GENERAL', + category: 'HISTORY', + generalId: generalIds[0], + year: 195, + month: 2, + text: '●195년 2월:방랑국멸망', + }, + { + scope: 'GENERAL', + category: 'ACTION', + generalId: generalIds[0], + year: 195, + month: 2, + text: '●초반 제한후 방랑군은 자동 해산됩니다.', + }, + { + scope: 'GENERAL', + category: 'ACTION', + generalId: generalIds[0], + year: 195, + month: 2, + text: '●2월:세력을 해산했습니다. <1>12:28', + }, + { + scope: 'GENERAL', + category: 'ACTION', + generalId: generalIds[0], + year: 195, + month: 2, + text: '방랑국멸망했습니다.', + }, + { + scope: 'SYSTEM', + category: 'ACTION', + generalId: null, + year: 195, + month: 2, + text: '●2월:방랑주가 세력을 해산했습니다.', + }, + ]); + } finally { + await hooks.close(); + } + }); +});