diff --git a/app/game-engine/src/turn/monthlyCoreEventAction.ts b/app/game-engine/src/turn/monthlyCoreEventAction.ts index b503bc8..a57208b 100644 --- a/app/game-engine/src/turn/monthlyCoreEventAction.ts +++ b/app/game-engine/src/turn/monthlyCoreEventAction.ts @@ -32,7 +32,7 @@ export const createProcessIncomeActionHandler = (incomeHandler: IncomeHandler): export const createNoticeToHistoryLogHandler = (options: { getWorld: () => InMemoryTurnWorld | null; }): MonthlyEventActionHandler => { - return (args) => { + return (args, environment) => { const text = args[0]; if (typeof text !== 'string') { throw new Error('NoticeToHistoryLog message must be a string.'); @@ -46,6 +46,8 @@ export const createNoticeToHistoryLogHandler = (options: { category: LogCategory.HISTORY, text, format: parseLogFormat(args[1]), + year: environment.year, + month: environment.month, }); }; }; diff --git a/app/game-engine/test/monthlyCatalogBoundaryPersistence.integration.test.ts b/app/game-engine/test/monthlyCatalogBoundaryPersistence.integration.test.ts new file mode 100644 index 0000000..66c6d18 --- /dev/null +++ b/app/game-engine/test/monthlyCatalogBoundaryPersistence.integration.test.ts @@ -0,0 +1,341 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createTurnDaemonRuntime } from '../src/turn/turnDaemon.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const profile = 'monthly-catalog-boundary'; +const eventIds = [991_001, 991_002] as const; + +const scenarioConfig = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'che', unitSet: 'che' }, +}; + +integration('monthly catalog boundary persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const clean = async () => { + await db.logEntry.deleteMany({}); + await db.yearbookHistory.deleteMany({}); + await db.generalTurn.deleteMany({}); + await db.nationTurn.deleteMany({}); + await db.rankData.deleteMany({}); + await db.generalAccessLog.deleteMany({}); + await db.diplomacy.deleteMany({}); + await db.event.deleteMany({}); + await db.general.deleteMany({}); + await db.nation.deleteMany({}); + await db.city.deleteMany({}); + await db.worldState.deleteMany({}); + await db.turnDaemonLease.deleteMany({ where: { profile } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await clean(); + }); + + afterAll(async () => { + await clean(); + await closeDb?.(); + }); + + it('matches the ref PRE_MONTH, month actions, and post-month projection in one daemon boundary', async () => { + await db.city.createMany({ + data: [ + { + id: 1, + name: '경계도시', + nationId: 1, + level: 1, + supplyState: 1, + frontState: 2, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + trust: 50, + trade: 100, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + region: 1, + conflict: {}, + meta: { state: 31, term: 1, officer_set: 1 }, + }, + { + id: 2, + name: '중립도시', + nationId: 0, + level: 8, + supplyState: 0, + frontState: 0, + population: 12_000, + populationMax: 22_000, + agriculture: 1_200, + agricultureMax: 2_200, + commerce: 1_200, + commerceMax: 2_200, + security: 1_200, + securityMax: 2_200, + trust: 55, + trade: 100, + defence: 1_200, + defenceMax: 2_200, + wall: 1_200, + wallMax: 2_200, + region: 1, + conflict: {}, + meta: { state: 0, term: 0, officer_set: 1 }, + }, + ], + }); + await db.nation.create({ + data: { + id: 1, + name: '경계국', + color: '#123456', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 20_000, + tech: 100, + level: 2, + typeCode: 'che_유가', + meta: { + power: 10, + gennum: 1, + bill: 100, + rate: 20, + rate_tmp: 10, + scout: 1, + chief_set: 1, + strategic_cmd_limit: 2, + surlimit: 1, + spy: {}, + }, + }, + }); + await db.general.createMany({ + data: [ + { + id: 1, + name: '경계군주', + nationId: 1, + cityId: 1, + officerLevel: 12, + npcState: 0, + leadership: 50, + strength: 50, + intel: 50, + age: 30, + bornYear: 160, + deadYear: 250, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 1100, + train: 80, + atmos: 70, + turnTime: new Date('2026-07-25T01:50:00.000Z'), + meta: { belong: 3, makelimit: 2, killturn: 0 }, + }, + { + id: 2, + name: '경계재야', + nationId: 0, + cityId: 2, + officerLevel: 0, + npcState: 0, + leadership: 40, + strength: 40, + intel: 40, + age: 25, + bornYear: 165, + deadYear: 250, + gold: 500, + rice: 500, + crew: 50, + crewTypeId: 1100, + train: 60, + atmos: 50, + turnTime: new Date('2026-07-25T01:50:00.000Z'), + meta: { belong: 4, makelimit: 0, killturn: 0 }, + }, + ], + }); + await db.generalTurn.createMany({ + data: [1, 2].flatMap((generalId) => + Array.from({ length: 30 }, (_, turnIdx) => ({ + generalId, + turnIdx, + actionCode: '휴식', + arg: {}, + })) + ), + }); + await db.event.createMany({ + data: [ + { + id: eventIds[0], + targetCode: 'PRE_MONTH', + priority: 1_000, + condition: true, + action: [['NoticeToHistoryLog', 'aggregate-pre', 6]], + meta: {}, + }, + { + id: eventIds[1], + targetCode: 'MONTH', + priority: 1_000, + condition: true, + action: [ + ['ProcessIncome', 'gold'], + ['NewYear'], + ['ResetOfficerLock'], + ['RandomizeCityTradeRate'], + ['ChangeCity', 'all', { pop: '+10', trust: 60 }], + ['NoticeToHistoryLog', 'aggregate-month', 6], + ['DeleteEvent'], + ], + meta: {}, + }, + ], + }); + await db.worldState.create({ + data: { + scenarioCode: profile, + currentYear: 190, + currentMonth: 12, + tickSeconds: 600, + config: scenarioConfig, + meta: { + hiddenSeed: 'monthly-boundary-core-catalog', + lastTurnTime: '2026-07-25T01:50:00.000Z', + isunited: 1, + refreshLimit: 3, + block_change_scout: false, + develcost: 18, + scenarioMeta: { + title: 'monthly catalog boundary', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + }, + }, + }); + + const runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + enableLeaseHeartbeat: false, + }); + try { + await runtime.world.advanceMonth(new Date('2026-07-25T02:00:00.000Z')); + await runtime.hooks?.flushChanges?.({ + lastTurnTime: runtime.world.getState().lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.worldState.findFirstOrThrow()).toMatchObject({ + currentYear: 191, + currentMonth: 1, + meta: expect.objectContaining({ develcost: 20 }), + }); + expect( + await db.general.findMany({ + orderBy: { id: 'asc' }, + select: { id: true, age: true, gold: true, meta: true }, + }) + ).toEqual([ + { id: 1, age: 31, gold: 1_400, meta: expect.objectContaining({ belong: 4, makelimit: 1 }) }, + { id: 2, age: 26, gold: 500, meta: expect.objectContaining({ belong: 4, makelimit: 0 }) }, + ]); + expect( + await db.city.findMany({ + orderBy: { id: 'asc' }, + select: { + id: true, + population: true, + trust: true, + trade: true, + frontState: true, + meta: true, + }, + }) + ).toEqual([ + { + id: 1, + population: 10_010, + trust: 60, + trade: null, + frontState: 0, + meta: expect.objectContaining({ state: 0, term: 0, officer_set: 0 }), + }, + { + id: 2, + population: 12_010, + trust: 60, + trade: 99, + frontState: 0, + meta: expect.objectContaining({ state: 0, term: 0, officer_set: 0 }), + }, + ]); + expect(await db.nation.findUniqueOrThrow({ where: { id: 1 } })).toMatchObject({ + gold: 9_753, + rice: 20_000, + meta: expect.objectContaining({ + power: 68, + gennum: 1, + prev_income_gold: 153, + rate_tmp: 20, + strategic_cmd_limit: 1, + surlimit: 0, + chief_set: 0, + }), + }); + expect( + await db.logEntry.findMany({ + orderBy: { id: 'asc' }, + select: { year: true, month: true, text: true }, + }) + ).toEqual([ + { year: 190, month: 12, text: '◆190년 12월:aggregate-pre' }, + { year: 191, month: 1, text: '●이번 수입은 금 153입니다.' }, + { year: 191, month: 1, text: '●봉급으로 금 400을 받았습니다.' }, + { + year: 191, + month: 1, + text: '●191년 1월:【지급】봄이 되어 봉록에 따라 자금이 지급됩니다.', + }, + { year: 191, month: 1, text: '●1월:191년이 되었습니다.' }, + { year: 191, month: 1, text: '◆191년 1월:aggregate-month' }, + ]); + expect(await db.event.findMany({ select: { id: true }, orderBy: { id: 'asc' } })).toEqual([ + { id: eventIds[0] }, + ]); + } finally { + await runtime.close(); + } + }); +}); diff --git a/app/game-engine/test/monthlyCoreEventAction.test.ts b/app/game-engine/test/monthlyCoreEventAction.test.ts index bb6c1f0..8a4eb0d 100644 --- a/app/game-engine/test/monthlyCoreEventAction.test.ts +++ b/app/game-engine/test/monthlyCoreEventAction.test.ts @@ -186,6 +186,8 @@ describe('core monthly event actions at the real month boundary', () => { category: LogCategory.HISTORY, text: '새해 알림', format: LogFormat.EVENT_YEAR_MONTH, + year: 191, + month: 1, }, { scope: LogScope.SYSTEM,