From 5daa7b48ff6b20e62cc5d750937bdecbe9570fe6 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 22:47:30 +0000 Subject: [PATCH] fix(engine): preserve core monthly event arguments --- app/game-engine/src/turn/incomeHandler.ts | 134 +++----- .../src/turn/monthlyCoreEventAction.ts | 97 ++++++ app/game-engine/src/turn/turnDaemon.ts | 71 +--- .../test/monthlyCoreEventAction.test.ts | 259 ++++++++++++++ ...lyCoreEventPersistence.integration.test.ts | 323 ++++++++++++++++++ 5 files changed, 746 insertions(+), 138 deletions(-) create mode 100644 app/game-engine/src/turn/monthlyCoreEventAction.ts create mode 100644 app/game-engine/test/monthlyCoreEventAction.test.ts create mode 100644 app/game-engine/test/monthlyCoreEventPersistence.integration.test.ts diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index 9431b86..171efe8 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -141,29 +141,11 @@ const processIncomeForNation = ( let income = 0; if (type === 'gold') { - income = getGoldIncome( - incomeContext, - nationCities, - officerCounts, - nation.capitalCityId ?? 0, - nation.level - ); + income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); } else { income = - getRiceIncome( - incomeContext, - nationCities, - officerCounts, - nation.capitalCityId ?? 0, - nation.level - ) + - getWallIncome( - incomeContext, - nationCities, - officerCounts, - nation.capitalCityId ?? 0, - nation.level - ); + getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + + getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); } const incomeValue = roundResource(income); @@ -185,7 +167,8 @@ const processIncomeForNation = ( } const incomeText = incomeValue.toLocaleString(); - const incomeLog = type === 'gold' ? `이번 수입은 금 ${incomeText}입니다.` : `이번 수입은 쌀 ${incomeText}입니다.`; + const incomeLog = + type === 'gold' ? `이번 수입은 금 ${incomeText}입니다.` : `이번 수입은 쌀 ${incomeText}입니다.`; for (const general of nationGenerals) { const pay = Math.round(getBill(general.dedication) * ratio); if (type === 'gold') { @@ -208,72 +191,67 @@ const processIncomeForNation = ( } }; +export interface IncomeHandler extends TurnCalendarHandler { + runResource(resource: 'gold' | 'rice'): void; +} + export const createIncomeHandler = (options: { getWorld: () => InMemoryTurnWorld | null; scenarioConfig: ScenarioConfig; nationTraits: Map; -}): TurnCalendarHandler => { +}): IncomeHandler => { const constValues = asRecord(options.scenarioConfig.const); const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], 0); const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], 0); - const handler: TurnCalendarHandler = { + const runResource = (type: 'gold' | 'rice'): void => { + const world = options.getWorld(); + if (!world) { + return; + } + const nations = world.listNations(); + const generals = world.listGenerals(); + const cities = world.listCities(); + + const byNation: Map = new Map(); + for (const general of generals) { + const bucket = byNation.get(general.nationId) ?? []; + bucket.push(general); + byNation.set(general.nationId, bucket); + } + + for (const nation of nations) { + const nationGenerals = byNation.get(nation.id) ?? []; + const officerCounts = buildOfficerCountMap(nationGenerals); + processIncomeForNation( + world, + nation, + nationGenerals, + cities, + officerCounts, + options.nationTraits, + type, + type === 'gold' ? baseGold : baseRice + ); + } + + const logger = new ActionLogger(); + if (type === 'gold') { + logger.pushGlobalHistoryLog('【지급】봄이 되어 봉록에 따라 자금이 지급됩니다.'); + } else { + logger.pushGlobalHistoryLog('【지급】가을이 되어 봉록에 따라 군량이 지급됩니다.'); + } + pushLogs(world, logger.flush()); + }; + + const handler: IncomeHandler = { + runResource, onMonthChanged: (context: TurnCalendarContext) => { - const world = options.getWorld(); - if (!world) { - return; + if (context.currentMonth === 1) { + runResource('gold'); + } else if (context.currentMonth === 7) { + runResource('rice'); } - const month = context.currentMonth; - if (month !== 1 && month !== 7) { - return; - } - - const nations = world.listNations(); - const generals = world.listGenerals(); - const cities = world.listCities(); - - const byNation: Map = new Map(); - for (const general of generals) { - const bucket = byNation.get(general.nationId) ?? []; - bucket.push(general); - byNation.set(general.nationId, bucket); - } - - for (const nation of nations) { - const nationGenerals = byNation.get(nation.id) ?? []; - const officerCounts = buildOfficerCountMap(nationGenerals); - if (month === 1) { - processIncomeForNation( - world, - nation, - nationGenerals, - cities, - officerCounts, - options.nationTraits, - 'gold', - baseGold - ); - } else if (month === 7) { - processIncomeForNation( - world, - nation, - nationGenerals, - cities, - officerCounts, - options.nationTraits, - 'rice', - baseRice - ); - } - } - - const logger = new ActionLogger(); - if (month === 1) { - logger.pushGlobalHistoryLog('【지급】봄이 되어 봉록에 따라 자금이 지급됩니다.'); - } else if (month === 7) { - logger.pushGlobalHistoryLog('【지급】가을이 되어 봉록에 따라 군량이 지급됩니다.'); - } - pushLogs(world, logger.flush()); }, }; diff --git a/app/game-engine/src/turn/monthlyCoreEventAction.ts b/app/game-engine/src/turn/monthlyCoreEventAction.ts new file mode 100644 index 0000000..b503bc8 --- /dev/null +++ b/app/game-engine/src/turn/monthlyCoreEventAction.ts @@ -0,0 +1,97 @@ +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; + +import type { IncomeHandler } from './incomeHandler.js'; +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; + +const parseLogFormat = (value: unknown): LogFormat => { + if (value === undefined) { + return LogFormat.YEAR_MONTH; + } + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < LogFormat.RAWTEXT || + value > LogFormat.NOTICE_YEAR_MONTH + ) { + throw new Error('NoticeToHistoryLog format must be an integer from 0 through 8.'); + } + return value; +}; + +export const createProcessIncomeActionHandler = (incomeHandler: IncomeHandler): MonthlyEventActionHandler => { + return (args) => { + const resource = args[0]; + if (resource !== 'gold' && resource !== 'rice') { + throw new Error('ProcessIncome resource must be gold or rice.'); + } + incomeHandler.runResource(resource); + }; +}; + +export const createNoticeToHistoryLogHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return (args) => { + const text = args[0]; + if (typeof text !== 'string') { + throw new Error('NoticeToHistoryLog message must be a string.'); + } + const world = options.getWorld(); + if (!world) { + return; + } + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text, + format: parseLogFormat(args[1]), + }); + }; +}; + +export const createNewYearHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return (_args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + for (const general of world.listGenerals()) { + const belong = general.meta.belong; + world.updateGeneral(general.id, { + age: general.age + 1, + meta: { + ...general.meta, + ...(general.nationId !== 0 + ? { belong: typeof belong === 'number' && Number.isFinite(belong) ? belong + 1 : 1 } + : {}), + }, + }); + } + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + text: `${environment.year}년이 되었습니다.`, + format: LogFormat.MONTH, + }); + }; +}; + +export const createResetOfficerLockHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return () => { + const world = options.getWorld(); + if (!world) { + return; + } + for (const nation of world.listNations()) { + world.updateNation(nation.id, { meta: { ...nation.meta, chief_set: 0 } }); + } + for (const city of world.listCities()) { + world.updateCity(city.id, { meta: { ...city.meta, officer_set: 0 } }); + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index aa3a3b2..6517527 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,10 +1,4 @@ -import { - LogCategory, - LogScope, - loadActionModuleBundle, - type TurnCommandProfile, - type TurnSchedule, -} from '@sammo-ts/logic'; +import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic'; import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common'; import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic'; @@ -73,6 +67,12 @@ import { createFinishNationBettingHandler, createOpenNationBettingHandler } from import { createScoutBlockHandler } from './monthlyScoutBlockAction.js'; import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler } from './monthlySpecialityBetrayAction.js'; import { createLostUniqueItemHandler, createMergeInheritPointRankHandler } from './monthlyUniqueInheritAction.js'; +import { + createNewYearHandler, + createNoticeToHistoryLogHandler, + createProcessIncomeActionHandler, + createResetOfficerLockHandler, +} from './monthlyCoreEventAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; @@ -390,59 +390,10 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, }) ); - eventActions.set('ProcessIncome', async (_args, environment) => { - await incomeHandler.onMonthChanged?.({ - previousYear: environment.month === 1 ? environment.year - 1 : environment.year, - previousMonth: environment.month === 1 ? 12 : environment.month - 1, - currentYear: environment.year, - currentMonth: environment.month, - turnTime: environment.turnTime, - }); - }); - eventActions.set('NoticeToHistoryLog', (args) => { - const text = args[0]; - if (typeof text !== 'string' || !worldRef) { - return; - } - worldRef.pushLog({ - scope: LogScope.SYSTEM, - category: LogCategory.HISTORY, - text, - }); - }); - eventActions.set('NewYear', (_args, environment) => { - if (!worldRef) { - return; - } - for (const general of worldRef.listGenerals()) { - const belong = general.meta.belong; - worldRef.updateGeneral(general.id, { - age: general.age + 1, - meta: { - ...general.meta, - ...(general.nationId !== 0 - ? { belong: typeof belong === 'number' && Number.isFinite(belong) ? belong + 1 : 1 } - : {}), - }, - }); - } - worldRef.pushLog({ - scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - text: `${environment.year}년이 되었습니다.`, - }); - }); - eventActions.set('ResetOfficerLock', () => { - if (!worldRef) { - return; - } - for (const nation of worldRef.listNations()) { - worldRef.updateNation(nation.id, { meta: { ...nation.meta, chief_set: 0 } }); - } - for (const city of worldRef.listCities()) { - worldRef.updateCity(city.id, { meta: { ...city.meta, officer_set: 0 } }); - } - }); + eventActions.set('ProcessIncome', createProcessIncomeActionHandler(incomeHandler)); + eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: () => worldRef })); + eventActions.set('NewYear', createNewYearHandler({ getWorld: () => worldRef })); + eventActions.set('ResetOfficerLock', createResetOfficerLockHandler({ getWorld: () => worldRef })); const monthlyEventHandler = createMonthlyEventHandler({ getWorld: () => worldRef, startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, diff --git a/app/game-engine/test/monthlyCoreEventAction.test.ts b/app/game-engine/test/monthlyCoreEventAction.test.ts new file mode 100644 index 0000000..bb6c1f0 --- /dev/null +++ b/app/game-engine/test/monthlyCoreEventAction.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type Nation } from '@sammo-ts/logic'; + +import { createIncomeHandler } from '../src/turn/incomeHandler.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { + createNewYearHandler, + createNoticeToHistoryLogHandler, + createProcessIncomeActionHandler, + createResetOfficerLockHandler, +} from '../src/turn/monthlyCoreEventAction.js'; +import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const map: MapDefinition = { + id: 'core-event-test', + name: 'core-event-test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const buildNation = (): Nation => ({ + id: 1, + name: '갑국', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 20_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { rate: 20, bill: 0, chief_set: 1 }, +}); + +const buildCity = (): City => ({ + id: 1, + name: '갑성', + nationId: 1, + level: 4, + state: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + conflict: {}, + meta: { trust: 80, trade: 100, officer_set: 1 }, +}); + +const buildGeneral = (id: number, nationId: number, belong: number): TurnGeneral => ({ + id, + name: `장수${id}`, + nationId, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 100, + officerLevel: 5, + 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: 100, + crewTypeId: 1100, + train: 50, + atmos: 50, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, belong }, + turnTime: new Date('0190-06-01T00:00:00.000Z'), +}); + +const buildWorld = ( + events: TurnWorldSnapshot['events'], + registerActions: (world: () => InMemoryTurnWorld | null) => Map, + options: { + currentMonth: number; + generals?: TurnGeneral[]; + nations?: Nation[]; + cities?: City[]; + } +): InMemoryTurnWorld => { + const state: TurnWorldState = { + id: 1, + currentYear: 190, + currentMonth: options.currentMonth, + tickSeconds: 600, + lastTurnTime: new Date(`0190-${String(options.currentMonth).padStart(2, '0')}-01T00:00:00.000Z`), + meta: { hiddenSeed: 'monthly-core-actions' }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { baseGold: 0, baseRice: 0 }, + environment: { mapName: map.id, unitSet: 'default' }, + }, + scenarioMeta: { + title: 'core event test', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map, + diplomacy: [], + events, + initialEvents: [], + generals: options.generals ?? [], + cities: options.cities ?? [], + nations: options.nations ?? [], + troops: [], + }; + let world: InMemoryTurnWorld | null = null; + const actions = registerActions(() => world); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createMonthlyEventHandler({ + getWorld: () => world, + startYear: 190, + actions, + }), + }); + return world; +}; + +describe('core monthly event actions at the real month boundary', () => { + it('preserves notice format, NewYear month log, age/belong, and officer lock reset', async () => { + const world = buildWorld( + [ + { + id: 1, + targetCode: 'month', + priority: 1, + condition: true, + action: [ + ['NoticeToHistoryLog', '새해 알림', LogFormat.EVENT_YEAR_MONTH], + ['NewYear'], + ['ResetOfficerLock'], + ], + meta: {}, + }, + ], + (getWorld) => + new Map([ + ['NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld })], + ['NewYear', createNewYearHandler({ getWorld })], + ['ResetOfficerLock', createResetOfficerLockHandler({ getWorld })], + ]), + { + currentMonth: 12, + generals: [buildGeneral(1, 1, 3), buildGeneral(2, 0, 4)], + nations: [buildNation()], + cities: [buildCity()], + } + ); + + await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z')); + + expect(world.getGeneralById(1)).toMatchObject({ age: 31, meta: { belong: 4 } }); + expect(world.getGeneralById(2)).toMatchObject({ age: 31, meta: { belong: 4 } }); + expect(world.getNationById(1)?.meta.chief_set).toBe(0); + expect(world.getCityById(1)?.meta.officer_set).toBe(0); + expect(world.peekDirtyState().logs).toEqual([ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '새해 알림', + format: LogFormat.EVENT_YEAR_MONTH, + }, + { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + text: '191년이 되었습니다.', + format: LogFormat.MONTH, + }, + ]); + }); + + it('uses the ProcessIncome resource argument instead of inferring it from the month', async () => { + const world = buildWorld( + [ + { + id: 2, + targetCode: 'month', + priority: 1, + condition: true, + action: [['ProcessIncome', 'gold']], + meta: {}, + }, + ], + (getWorld) => { + const incomeHandler = createIncomeHandler({ + getWorld, + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: { baseGold: 0, baseRice: 0 }, + environment: { mapName: map.id, unitSet: 'default' }, + }, + nationTraits: new Map(), + }); + return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]); + }, + { + currentMonth: 6, + generals: [buildGeneral(1, 1, 3)], + nations: [buildNation()], + cities: [buildCity()], + } + ); + + await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); + + expect(world.getNationById(1)).toMatchObject({ + gold: 10_210, + rice: 20_000, + meta: { + prev_income_gold: 210, + }, + }); + expect(world.getGeneralById(1)).toMatchObject({ gold: 1_000, rice: 1_000 }); + expect(world.getNationById(1)?.meta.prev_income_rice).toBeUndefined(); + expect(world.peekDirtyState().logs).toContainEqual( + expect.objectContaining({ + category: LogCategory.HISTORY, + text: '【지급】봄이 되어 봉록에 따라 자금이 지급됩니다.', + }) + ); + }); +}); diff --git a/app/game-engine/test/monthlyCoreEventPersistence.integration.test.ts b/app/game-engine/test/monthlyCoreEventPersistence.integration.test.ts new file mode 100644 index 0000000..1044582 --- /dev/null +++ b/app/game-engine/test/monthlyCoreEventPersistence.integration.test.ts @@ -0,0 +1,323 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { City, MapDefinition, Nation } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { createIncomeHandler } from '../src/turn/incomeHandler.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { + createNewYearHandler, + createNoticeToHistoryLogHandler, + createProcessIncomeActionHandler, + createResetOfficerLockHandler, +} from '../src/turn/monthlyCoreEventAction.js'; +import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.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 generalId = 992_401; +const cityId = 992_401; +const nationId = 992_401; +const fixtureYear = 2190; + +const map: MapDefinition = { + id: 'monthly-core-event-persistence', + name: 'monthly-core-event-persistence', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +integration('core monthly event action 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.logEntry.deleteMany({ where: { year: fixtureYear } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.city.deleteMany({ where: { id: cityId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany(); + }); + + afterAll(async () => { + await db.logEntry.deleteMany({ where: { year: fixtureYear } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.city.deleteMany({ where: { id: cityId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-core-event-persistence' } }); + await closeDb?.(); + }); + + it('commits explicit income, formatted logs, NewYear state, and reset locks in one flush', async () => { + const nation: Nation = { + id: nationId, + name: '갑국', + color: '#777777', + capitalCityId: cityId, + chiefGeneralId: generalId, + gold: 10_000, + rice: 20_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { rate: 20, bill: 0, chief_set: 1 }, + }; + const city: City = { + id: cityId, + name: '갑성', + nationId, + level: 4, + state: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + conflict: {}, + meta: { trust: 80, trade: 100, officer_set: 1 }, + }; + const general: TurnGeneral = { + id: generalId, + name: '갑장', + nationId, + cityId, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 100, + officerLevel: 5, + 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: 100, + crewTypeId: 1100, + train: 50, + atmos: 50, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, belong: 3 }, + turnTime: new Date(`${fixtureYear}-06-01T00:00:00.000Z`), + }; + await db.nation.create({ + data: { + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: cityId, + gold: nation.gold, + rice: nation.rice, + tech: 0, + level: nation.level, + typeCode: nation.typeCode, + meta: nation.meta, + }, + }); + await db.city.create({ + data: { + id: city.id, + name: city.name, + level: city.level, + nationId, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: 80, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + meta: city.meta, + }, + }); + await db.general.create({ + data: { + id: general.id, + name: general.name, + nationId, + cityId, + officerLevel: general.officerLevel, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + experience: general.experience, + dedication: general.dedication, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + atmos: general.atmos, + age: general.age, + turnTime: general.turnTime, + meta: general.meta, + }, + }); + const worldRow = await db.worldState.create({ + data: { + scenarioCode: 'monthly-core-event-persistence', + currentYear: fixtureYear, + currentMonth: 6, + tickSeconds: 600, + config: {}, + meta: { hiddenSeed: 'monthly-core-event-persistence' }, + }, + }); + const events: TurnWorldSnapshot['events'] = [ + { + id: 1, + targetCode: 'month', + priority: 1, + condition: true, + action: [ + ['NoticeToHistoryLog', '경계 알림', 6], + ['NewYear'], + ['ResetOfficerLock'], + ['ProcessIncome', 'gold'], + ], + meta: {}, + }, + ]; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: { baseGold: 0, baseRice: 0 }, + environment: { mapName: map.id, unitSet: 'default' }, + }, + scenarioMeta: { + title: 'monthly core event persistence', + startYear: fixtureYear, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map, + diplomacy: [], + events, + initialEvents: [], + generals: [general], + cities: [city], + nations: [nation], + troops: [], + }; + const state: TurnWorldState = { + id: worldRow.id, + currentYear: fixtureYear, + currentMonth: 6, + tickSeconds: 600, + lastTurnTime: general.turnTime, + meta: { hiddenSeed: 'monthly-core-event-persistence' }, + }; + let world: InMemoryTurnWorld | null = null; + const incomeHandler = createIncomeHandler({ + getWorld: () => world, + scenarioConfig: snapshot.scenarioConfig, + nationTraits: new Map(), + }); + const actions = new Map([ + ['NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: () => world })], + ['NewYear', createNewYearHandler({ getWorld: () => world })], + ['ResetOfficerLock', createResetOfficerLockHandler({ getWorld: () => world })], + ['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)], + ]); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: createMonthlyEventHandler({ + getWorld: () => world, + startYear: fixtureYear, + actions, + }), + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await world.advanceMonth(new Date(`${fixtureYear}-07-01T00:00:00.000Z`)); + await hooks.hooks.flushChanges?.({ + lastTurnTime: `${fixtureYear}-07-01T00:00:00.000Z`, + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + + expect( + await db.general.findUnique({ where: { id: generalId }, select: { age: true, meta: true } }) + ).toEqual({ + age: 31, + meta: expect.objectContaining({ belong: 4 }), + }); + expect( + await db.nation.findUnique({ where: { id: nationId }, select: { gold: true, rice: true, meta: true } }) + ).toEqual({ + gold: 10_210, + rice: 20_000, + meta: expect.objectContaining({ chief_set: 0, prev_income_gold: 210 }), + }); + expect(await db.city.findUnique({ where: { id: cityId }, select: { meta: true } })).toEqual({ + meta: expect.objectContaining({ officer_set: 0 }), + }); + expect( + await db.logEntry.findMany({ + where: { year: fixtureYear }, + orderBy: { id: 'asc' }, + select: { category: true, text: true }, + }) + ).toEqual( + expect.arrayContaining([ + { + category: 'HISTORY', + text: `◆${fixtureYear}년 7월:경계 알림`, + }, + { + category: 'ACTION', + text: `●7월:${fixtureYear}년이 되었습니다.`, + }, + { + category: 'HISTORY', + text: `●${fixtureYear}년 7월:【지급】봄이 되어 봉록에 따라 자금이 지급됩니다.`, + }, + ]) + ); + } finally { + await hooks.close(); + } + }); +});