From 3aa01637555443c9e03353aed18e370e30f44cba Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 21:57:10 +0000 Subject: [PATCH] feat(engine): migrate monthly nation statistics --- app/game-engine/src/turn/databaseHooks.ts | 5 +- app/game-engine/src/turn/inMemoryWorld.ts | 25 +- .../src/turn/monthlyNationStatsHandler.ts | 222 ++++++++++++++++ app/game-engine/src/turn/turnDaemon.ts | 25 +- app/game-engine/src/turn/worldLoader.ts | 35 +-- .../test/monthlyNationStatsHandler.test.ts | 241 ++++++++++++++++++ ...NationStatsPersistence.integration.test.ts | 239 +++++++++++++++++ 7 files changed, 765 insertions(+), 27 deletions(-) create mode 100644 app/game-engine/src/turn/monthlyNationStatsHandler.ts create mode 100644 app/game-engine/test/monthlyNationStatsHandler.test.ts create mode 100644 app/game-engine/test/monthlyNationStatsPersistence.integration.test.ts diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 4f982be..996b78e 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -516,7 +516,10 @@ const buildNationUpdate = ( rice: nation.rice, level: nation.level, typeCode: nation.typeCode, - meta: asJson(nation.meta), + meta: asJson({ + ...nation.meta, + power: nation.power, + }), }); const buildTroopUpdate = ( diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index a9bd277..5bd495e 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -91,6 +91,7 @@ export interface InMemoryTurnWorldOptions { schedule: TurnSchedule; generalTurnHandler?: GeneralTurnHandler; calendarHandler?: TurnCalendarHandler; + autoAdvanceDiplomacyMonth?: boolean; } export interface TurnWorldChanges { @@ -257,6 +258,7 @@ export class InMemoryTurnWorld { private schedule: TurnSchedule; private readonly generalTurnHandler: GeneralTurnHandler; private readonly calendarHandler?: TurnCalendarHandler; + private readonly autoAdvanceDiplomacyMonth: boolean; private readonly generals = new Map(); private readonly cities = new Map(); private readonly nations = new Map(); @@ -303,6 +305,7 @@ export class InMemoryTurnWorld { execute: () => ({}), } satisfies GeneralTurnHandler); this.calendarHandler = options.calendarHandler; + this.autoAdvanceDiplomacyMonth = options.autoAdvanceDiplomacyMonth ?? true; const worldKillturn = resolveWorldKillturn(this.state.meta); for (const general of snapshot.generals) { @@ -894,7 +897,9 @@ export class InMemoryTurnWorld { meta, }; - this.advanceDiplomacyMonth(); + if (this.autoAdvanceDiplomacyMonth) { + this.advanceDiplomacyMonth(); + } await this.calendarHandler?.onMonthChanged?.(context); if (nextYear !== previousYear) { await this.calendarHandler?.onYearChanged?.(context); @@ -1122,20 +1127,22 @@ export class InMemoryTurnWorld { } } - private advanceDiplomacyMonth(): void { + advanceDiplomacyMonth(generalCounts?: Map): void { if (this.diplomacy.size === 0) { return; } - const generalCounts = new Map(); - for (const general of this.generals.values()) { - const nationId = general.nationId; - if (nationId <= 0) { - continue; + const resolvedGeneralCounts = generalCounts ?? new Map(); + if (!generalCounts) { + for (const general of this.generals.values()) { + const nationId = general.nationId; + if (nationId <= 0) { + continue; + } + resolvedGeneralCounts.set(nationId, (resolvedGeneralCounts.get(nationId) ?? 0) + 1); } - generalCounts.set(nationId, (generalCounts.get(nationId) ?? 0) + 1); } - const updated = processDiplomacyMonth(this.listDiplomacy(), generalCounts); + const updated = processDiplomacyMonth(this.listDiplomacy(), resolvedGeneralCounts); for (const entry of updated) { const key = buildDiplomacyKey(entry.fromNationId, entry.toNationId); const prev = this.diplomacy.get(key); diff --git a/app/game-engine/src/turn/monthlyNationStatsHandler.ts b/app/game-engine/src/turn/monthlyNationStatsHandler.ts new file mode 100644 index 0000000..574b90a --- /dev/null +++ b/app/game-engine/src/turn/monthlyNationStatsHandler.ts @@ -0,0 +1,222 @@ +import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; + +const MAX_AVAILABLE_WAR_SETTING_COUNT = 10; +const MONTHLY_AVAILABLE_WAR_SETTING_INCREMENT = 2; + +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 resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => { + const state = world.getState(); + const value = state.meta.hiddenSeed ?? state.meta.seed ?? state.id; + return typeof value === 'string' || typeof value === 'number' ? value : String(value); +}; + +const calculateNationPower = ( + world: InMemoryTurnWorld, + nationId: number +): { + power: number; + totalCrew: number; +} => { + const nation = world.getNationById(nationId); + if (!nation) { + return { power: 0, totalCrew: 0 }; + } + const generals = world.listGenerals().filter((general) => general.nationId === nationId); + const suppliedCities = world.listCities().filter((city) => city.nationId === nationId && city.supplyState === 1); + const generalResources = generals.reduce((sum, general) => sum + general.gold + general.rice, 0); + const resourcePower = Math.round((nation.gold + nation.rice + generalResources) / 100); + const techPower = readNumber(asRecord(nation.meta).tech); + + let cityPower = 0; + if (nation.level !== 0 && suppliedCities.length > 0) { + const population = suppliedCities.reduce((sum, city) => sum + city.population, 0); + const current = suppliedCities.reduce( + (sum, city) => + sum + city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence, + 0 + ); + const maximum = suppliedCities.reduce( + (sum, city) => + sum + + city.populationMax + + city.agricultureMax + + city.commerceMax + + city.securityMax + + city.wallMax + + city.defenceMax, + 0 + ); + cityPower = maximum > 0 ? Math.round((population * current) / maximum / 100) : 0; + } + + let generalPower = 0; + let dexterityPower = 0; + let experiencePower = 0; + let totalCrew = 0; + for (const general of generals) { + const meta = asRecord(general.meta); + const killCrew = readNumber(meta.rank_killcrew_person); + const deathCrew = readNumber(meta.rank_deathcrew_person); + const ratio = (killCrew + 1000) / (deathCrew + 1000); + const npcMultiplier = general.npcState < 2 ? 1.2 : 1; + const leadership = general.stats.leadership; + const leaderCore = leadership >= 40 ? leadership : 0; + generalPower += + ratio * npcMultiplier * leaderCore * 2 + + (Math.sqrt(general.stats.intelligence * general.stats.strength) * 2 + leadership / 2) / 2; + dexterityPower += + readNumber(meta.dex1) + + readNumber(meta.dex2) + + readNumber(meta.dex3) + + readNumber(meta.dex4) + + readNumber(meta.dex5); + experiencePower += general.experience + general.dedication; + totalCrew += general.crew; + } + + const power = Math.round( + (resourcePower + + techPower + + cityPower + + generalPower + + Math.round(dexterityPower / 1000) + + Math.round(experiencePower / 100)) / + 10 + ); + return { power, totalCrew }; +}; + +const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): void => { + const citiesByNation = new Map(); + for (const city of world.listCities().sort((left, right) => left.id - right.id)) { + const names = citiesByNation.get(city.nationId) ?? []; + names.push(city.name); + citiesByNation.set(city.nationId, names); + } + + for (const nation of world.listNations().sort((left, right) => left.id - right.id)) { + const calculated = calculateNationPower(world, nation.id); + const power = Math.round(calculated.power * rng.nextRange(0.95, 1.05)); + const meta = asRecord(nation.meta); + const previousMax = asRecord(meta.max_power); + const previousCities = Array.isArray(previousMax.maxCities) + ? previousMax.maxCities.filter((name): name is string => typeof name === 'string') + : []; + const currentCities = citiesByNation.get(nation.id) ?? []; + const maxPower = { + ...previousMax, + maxPower: Math.max(readNumber(previousMax.maxPower), power), + maxCrew: Math.max(readNumber(previousMax.maxCrew), Math.trunc(calculated.totalCrew)), + maxCities: currentCities.length > previousCities.length ? currentCities : previousCities, + }; + world.updateNation(nation.id, { + power, + meta: { + ...nation.meta, + power, + max_power: maxPower, + }, + }); + } +}; + +export const createMonthlyNationStatsHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): TurnCalendarHandler => ({ + onMonthChanged: (context) => { + const world = options.getWorld(); + if (!world) { + return; + } + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize(resolveHiddenSeed(world), 'monthly', context.previousYear, context.previousMonth) + ) + ); + updateNationPower(world, rng); + }, +}); + +export const createMonthlyDiplomacyHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): TurnCalendarHandler => ({ + onMonthChanged: () => { + const world = options.getWorld(); + if (!world) { + return; + } + const cachedGeneralCounts = new Map( + world.listNations().map((nation) => [nation.id, Math.max(1, Math.floor(readNumber(nation.meta.gennum, 1)))]) + ); + world.advanceDiplomacyMonth(cachedGeneralCounts); + }, +}); + +export const createMonthlyWarSettingHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): TurnCalendarHandler => ({ + onMonthChanged: () => { + const world = options.getWorld(); + if (!world) { + return; + } + for (const nation of world.listNations()) { + const availableCount = Math.min( + MAX_AVAILABLE_WAR_SETTING_COUNT, + Math.max(0, Math.floor(readNumber(nation.meta.available_war_setting_cnt))) + + MONTHLY_AVAILABLE_WAR_SETTING_INCREMENT + ); + world.updateNation(nation.id, { + meta: { + ...nation.meta, + available_war_setting_cnt: availableCount, + }, + }); + } + }, +}); + +export const createMonthlyNationCountHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): TurnCalendarHandler => ({ + onMonthChanged: () => { + const world = options.getWorld(); + if (!world) { + return; + } + const counts = new Map(); + for (const general of world.listGenerals()) { + if (general.nationId <= 0 || general.npcState === 5) { + continue; + } + counts.set(general.nationId, (counts.get(general.nationId) ?? 0) + 1); + } + for (const nation of world.listNations()) { + const count = counts.get(nation.id); + if (count === undefined) { + continue; + } + world.updateNation(nation.id, { + meta: { + ...nation.meta, + gennum: count, + }, + }); + } + }, +}); diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 5133c78..bfdb822 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -27,6 +27,12 @@ import { composeCalendarHandlers } from './calendarHandlers.js'; import { createIncomeHandler } from './incomeHandler.js'; import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js'; import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js'; +import { + createMonthlyDiplomacyHandler, + createMonthlyNationCountHandler, + createMonthlyNationStatsHandler, + createMonthlyWarSettingHandler, +} from './monthlyNationStatsHandler.js'; import { createFrontStateHandler } from './frontStateHandler.js'; import { createReservedTurnHandler } from './reservedTurnHandler.js'; import { createReservedTurnStore } from './reservedTurnStore.js'; @@ -449,6 +455,18 @@ const createTurnDaemonRuntimeWithLease = async ( startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, commandEnv: monthlyCommandEnv, }); + const monthlyNationStatsHandler = createMonthlyNationStatsHandler({ + getWorld: () => worldRef, + }); + const monthlyDiplomacyHandler = createMonthlyDiplomacyHandler({ + getWorld: () => worldRef, + }); + const monthlyNationCountHandler = createMonthlyNationCountHandler({ + getWorld: () => worldRef, + }); + const monthlyWarSettingHandler = createMonthlyWarSettingHandler({ + getWorld: () => worldRef, + }); const frontStateHandler = createFrontStateHandler({ getWorld: () => worldRef, map: snapshot.map ?? null, @@ -474,9 +492,13 @@ const createTurnDaemonRuntimeWithLease = async ( const calendarHandler = composeCalendarHandlers( monthlyEventHandler, yearbookHandler.handler, - options.calendarHandler ?? unification?.handler, monthlyBoundaryPreHandler, nationTurnMonthlyHandler, + monthlyNationStatsHandler, + monthlyDiplomacyHandler, + monthlyWarSettingHandler, + monthlyNationCountHandler, + options.calendarHandler ?? unification?.handler, hasEventAction('ProcessIncome') ? null : incomeHandler, frontStateHandler, neutralAuctionRegistrar.handler, @@ -497,6 +519,7 @@ const createTurnDaemonRuntimeWithLease = async ( commandEnv: monthlyCommandEnv, })), calendarHandler: calendarHandler ?? undefined, + autoAdvanceDiplomacyMonth: false, }; const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions); worldRef = world; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index edffb02..90fc556 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -282,22 +282,25 @@ const mapCityRow = (row: TurnEngineCityRow): City => { }; }; -const mapNationRow = (row: TurnEngineNationRow): Nation => ({ - id: row.id, - name: row.name, - color: row.color, - capitalCityId: row.capitalCityId, - chiefGeneralId: row.chiefGeneralId, - gold: row.gold, - rice: row.rice, - power: 0, - level: row.level, - typeCode: row.typeCode, - meta: { - ...asTriggerRecord(row.meta), - tech: row.tech, - }, -}); +const mapNationRow = (row: TurnEngineNationRow): Nation => { + const meta = asTriggerRecord(row.meta); + return { + id: row.id, + name: row.name, + color: row.color, + capitalCityId: row.capitalCityId, + chiefGeneralId: row.chiefGeneralId, + gold: row.gold, + rice: row.rice, + power: readMetaNumber(meta, 'power') ?? 0, + level: row.level, + typeCode: row.typeCode, + meta: { + ...meta, + tech: row.tech, + }, + }; +}; const mapDiplomacyRow = (row: TurnEngineDiplomacyRow): TurnDiplomacy => { const { meta, dead } = readDiplomacyMeta(asRecord(row.meta)); diff --git a/app/game-engine/test/monthlyNationStatsHandler.test.ts b/app/game-engine/test/monthlyNationStatsHandler.test.ts new file mode 100644 index 0000000..c09ff68 --- /dev/null +++ b/app/game-engine/test/monthlyNationStatsHandler.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { + createMonthlyDiplomacyHandler, + createMonthlyNationCountHandler, + createMonthlyNationStatsHandler, + createMonthlyWarSettingHandler, +} from '../src/turn/monthlyNationStatsHandler.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildGeneral = (options: { + id: number; + nationId: number; + npcState: number; + gold: number; + rice: number; + crew: number; + leadership: number; + strength: number; + intelligence: number; + experience: number; + dedication: number; + dexterity: number[]; + killCrew: number; + deathCrew: number; +}): TurnGeneral => ({ + id: options.id, + name: `장수${options.id}`, + nationId: options.nationId, + cityId: options.id, + troopId: 0, + stats: { + leadership: options.leadership, + strength: options.strength, + intelligence: options.intelligence, + }, + experience: options.experience, + dedication: options.dedication, + officerLevel: 1, + 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: options.crew, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 20, + npcState: options.npcState, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 0, + dex1: options.dexterity[0] ?? 0, + dex2: options.dexterity[1] ?? 0, + dex3: options.dexterity[2] ?? 0, + dex4: options.dexterity[3] ?? 0, + dex5: options.dexterity[4] ?? 0, + rank_killcrew_person: options.killCrew, + rank_deathcrew_person: options.deathCrew, + }, + turnTime: new Date('0193-01-01T00:00:00.000Z'), +}); + +const buildCity = (id: number, nationId: number, scale: number): City => ({ + id, + name: nationId === 1 ? '갑성' : '을성', + nationId, + level: 4, + state: 0, + population: 1_000 * scale, + populationMax: 2_000 * scale, + agriculture: 100 * scale, + agricultureMax: 200 * scale, + commerce: 100 * scale, + commerceMax: 200 * scale, + security: 100 * scale, + securityMax: 200 * scale, + supplyState: 1, + frontState: 0, + defence: 100 * scale, + defenceMax: 200 * scale, + wall: 100 * scale, + wallMax: 200 * scale, + conflict: {}, + meta: {}, +}); + +const buildNation = ( + id: number, + patch: Pick & { meta: Nation['meta'] } +): Nation => ({ + id, + name: id === 1 ? '갑국' : '을국', + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + typeCode: 'che_중립', + ...patch, +}); + +describe('monthly nation statistics boundary', () => { + it('matches the fixed legacy power, maxima, war-setting count, and final general cache', async () => { + const state: TurnWorldState = { + id: 1, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: { hiddenSeed: 'monthly-post-nation-stats-fixture' }, + }; + 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({ + id: 1, + nationId: 1, + npcState: 0, + gold: 1_000, + rice: 2_000, + crew: 100, + leadership: 50, + strength: 40, + intelligence: 30, + experience: 100, + dedication: 200, + dexterity: [1_000, 0, 0, 0, 0], + killCrew: 100, + deathCrew: 0, + }), + buildGeneral({ + id: 2, + nationId: 2, + npcState: 2, + gold: 3_000, + rice: 4_000, + crew: 200, + leadership: 60, + strength: 50, + intelligence: 40, + experience: 300, + dedication: 400, + dexterity: [2_000, 1_000, 0, 0, 0], + killCrew: 0, + deathCrew: 100, + }), + ], + cities: [buildCity(1, 1, 1), buildCity(2, 2, 2)], + nations: [ + buildNation(1, { + gold: 10_000, + rice: 20_000, + power: 7, + level: 2, + meta: { + tech: 100, + gennum: 9, + available_war_setting_cnt: 1, + max_power: { + maxPower: 999, + maxCrew: 50, + maxCities: ['옛도시', '옛도시2'], + }, + }, + }), + buildNation(2, { + gold: 2_000, + rice: 3_000, + power: 8, + level: 1, + meta: { tech: 50, gennum: 8 }, + }), + ], + troops: [], + }; + let world: InMemoryTurnWorld | null = null; + const trace: number[] = []; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + autoAdvanceDiplomacyMonth: false, + calendarHandler: composeCalendarHandlers( + { + onMonthChanged: () => { + trace.push(world?.getNationById(1)?.power ?? -1); + }, + }, + createMonthlyNationStatsHandler({ getWorld: () => world }), + createMonthlyDiplomacyHandler({ getWorld: () => world }), + createMonthlyWarSettingHandler({ getWorld: () => world }), + createMonthlyNationCountHandler({ getWorld: () => world }) + ), + }); + + await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); + + expect(trace).toEqual([7]); + expect(world.getNationById(1)).toMatchObject({ + power: 64, + meta: { + power: 64, + gennum: 1, + available_war_setting_cnt: 3, + max_power: { + maxPower: 999, + maxCrew: 100, + maxCities: ['옛도시', '옛도시2'], + }, + }, + }); + expect(world.getNationById(2)).toMatchObject({ + power: 37, + meta: { + power: 37, + gennum: 1, + available_war_setting_cnt: 2, + max_power: { + maxPower: 37, + maxCrew: 200, + maxCities: ['을성'], + }, + }, + }); + }); +}); diff --git a/app/game-engine/test/monthlyNationStatsPersistence.integration.test.ts b/app/game-engine/test/monthlyNationStatsPersistence.integration.test.ts new file mode 100644 index 0000000..c3414f5 --- /dev/null +++ b/app/game-engine/test/monthlyNationStatsPersistence.integration.test.ts @@ -0,0 +1,239 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +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 { + createMonthlyDiplomacyHandler, + createMonthlyNationCountHandler, + createMonthlyNationStatsHandler, + createMonthlyWarSettingHandler, +} from '../src/turn/monthlyNationStatsHandler.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalIds = [991_301, 991_302]; +const cityIds = [991_301, 991_302]; +const nationIds = [991_301, 991_302]; +const scenarioCode = 'monthly-nation-stats-persistence'; + +integration('monthly nation statistics 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.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: { in: nationIds } } }); + // The turn loader intentionally expects one world per isolated game schema. + await db.worldState.deleteMany(); + }); + + afterAll(async () => { + 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: { in: nationIds } } }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + await closeDb?.(); + }); + + it('loads previous power and commits power, maxima, war-setting count, and actual general count', async () => { + await db.nation.createMany({ + data: [ + { + id: nationIds[0]!, + name: '갑국', + color: '#777777', + gold: 10_000, + rice: 20_000, + tech: 100, + level: 2, + typeCode: 'che_중립', + meta: { + power: 7, + gennum: 9, + available_war_setting_cnt: 1, + max_power: { + maxPower: 999, + maxCrew: 50, + maxCities: ['옛도시', '옛도시2'], + }, + }, + }, + { + id: nationIds[1]!, + name: '을국', + color: '#777777', + gold: 2_000, + rice: 3_000, + tech: 50, + level: 1, + typeCode: 'che_중립', + meta: { power: 8, gennum: 8 }, + }, + ], + }); + await db.city.createMany({ + data: cityIds.map((id, index) => { + const scale = index + 1; + return { + id, + name: index === 0 ? '갑성' : '을성', + level: 4, + nationId: nationIds[index]!, + supplyState: 1, + population: 1_000 * scale, + populationMax: 2_000 * scale, + agriculture: 100 * scale, + agricultureMax: 200 * scale, + commerce: 100 * scale, + commerceMax: 200 * scale, + security: 100 * scale, + securityMax: 200 * scale, + defence: 100 * scale, + defenceMax: 200 * scale, + wall: 100 * scale, + wallMax: 200 * scale, + region: 1, + }; + }), + }); + await db.general.createMany({ + data: [ + { + id: generalIds[0]!, + name: '갑장', + nationId: nationIds[0]!, + cityId: cityIds[0]!, + npcState: 0, + leadership: 50, + strength: 40, + intel: 30, + experience: 100, + dedication: 200, + gold: 1_000, + rice: 2_000, + crew: 100, + turnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: { killturn: 0, dex1: 1_000, dex2: 0, dex3: 0, dex4: 0, dex5: 0 }, + }, + { + id: generalIds[1]!, + name: '을장', + nationId: nationIds[1]!, + cityId: cityIds[1]!, + npcState: 2, + leadership: 60, + strength: 50, + intel: 40, + experience: 300, + dedication: 400, + gold: 3_000, + rice: 4_000, + crew: 200, + turnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: { killturn: 0, dex1: 2_000, dex2: 1_000, dex3: 0, dex4: 0, dex5: 0 }, + }, + ], + }); + await db.rankData.createMany({ + data: [ + { generalId: generalIds[0]!, nationId: nationIds[0]!, type: 'killcrew_person', value: 100 }, + { generalId: generalIds[0]!, nationId: nationIds[0]!, type: 'deathcrew_person', value: 0 }, + { generalId: generalIds[1]!, nationId: nationIds[1]!, type: 'killcrew_person', value: 0 }, + { generalId: generalIds[1]!, nationId: nationIds[1]!, type: 'deathcrew_person', value: 100 }, + ], + }); + const worldRow = await db.worldState.create({ + data: { + scenarioCode, + currentYear: 193, + currentMonth: 1, + 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: { hiddenSeed: 'monthly-post-nation-stats-fixture' }, + }, + }); + + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect( + loaded.snapshot.nations + .filter((nation) => nationIds.includes(nation.id)) + .sort((left, right) => left.id - right.id) + .map((nation) => nation.power) + ).toEqual([7, 8]); + + let world: InMemoryTurnWorld | null = null; + world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + autoAdvanceDiplomacyMonth: false, + calendarHandler: composeCalendarHandlers( + createMonthlyNationStatsHandler({ getWorld: () => world }), + createMonthlyDiplomacyHandler({ getWorld: () => world }), + createMonthlyWarSettingHandler({ getWorld: () => world }), + createMonthlyNationCountHandler({ getWorld: () => world }) + ), + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); + const updatedPowers = nationIds.map((id) => world?.getNationById(id)?.power); + expect(updatedPowers.every((power) => typeof power === 'number' && power > 0)).toBe(true); + await hooks.hooks.flushChanges?.({ + lastTurnTime: '0193-02-01T00:00:00.000Z', + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + + const rows = await db.nation.findMany({ + where: { id: { in: nationIds } }, + orderBy: { id: 'asc' }, + select: { meta: true }, + }); + expect(rows.map((row) => row.meta)).toEqual([ + expect.objectContaining({ + power: updatedPowers[0], + gennum: 1, + available_war_setting_cnt: 3, + max_power: { + maxPower: 999, + maxCrew: 100, + maxCities: ['옛도시', '옛도시2'], + }, + }), + expect.objectContaining({ + power: updatedPowers[1], + gennum: 1, + available_war_setting_cnt: 2, + max_power: { + maxPower: updatedPowers[1], + maxCrew: 200, + maxCities: ['을성'], + }, + }), + ]); + expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ + currentYear: 193, + currentMonth: 2, + }); + } finally { + await hooks.close(); + } + }); +});