From 4247e05922bc94b250327e12a6e4b50f0bce3279 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 22:08:08 +0000 Subject: [PATCH] feat(engine): add monthly diplomacy history --- .../src/turn/monthlyNationStatsHandler.ts | 68 +++++- .../test/monthlyDiplomacyHandler.test.ts | 100 +++++++++ ...lyDiplomacyPersistence.integration.test.ts | 203 ++++++++++++++++++ 3 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 app/game-engine/test/monthlyDiplomacyHandler.test.ts create mode 100644 app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts diff --git a/app/game-engine/src/turn/monthlyNationStatsHandler.ts b/app/game-engine/src/turn/monthlyNationStatsHandler.ts index 574b90a..a63a0e2 100644 --- a/app/game-engine/src/turn/monthlyNationStatsHandler.ts +++ b/app/game-engine/src/turn/monthlyNationStatsHandler.ts @@ -1,4 +1,5 @@ -import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; @@ -163,7 +164,72 @@ export const createMonthlyDiplomacyHandler = (options: { const cachedGeneralCounts = new Map( world.listNations().map((nation) => [nation.id, Math.max(1, Math.floor(readNumber(nation.meta.gennum, 1)))]) ); + const before = world.listDiplomacy(); + const declarationStarts = before + .filter( + (entry) => + entry.state === DIPLOMACY_STATE.DECLARATION && + entry.term <= 1 && + entry.fromNationId < entry.toNationId + ) + .sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId); world.advanceDiplomacyMonth(cachedGeneralCounts); + const afterByKey = new Map( + world.listDiplomacy().map((entry) => [`${entry.fromNationId}:${entry.toNationId}`, entry] as const) + ); + + for (const entry of declarationStarts) { + const nation1 = world.getNationById(entry.fromNationId); + const nation2 = world.getNationById(entry.toNationId); + if (!nation1 || !nation2) { + throw new Error( + `Monthly diplomacy declaration references a missing nation (${entry.fromNationId}, ${entry.toNationId}).` + ); + } + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `【개전】${nation1.name}${JosaUtil.pick(nation1.name, '와')} ${nation2.name}${JosaUtil.pick(nation2.name, '이')} 전쟁을 시작합니다.`, + format: LogFormat.YEAR_MONTH, + }); + } + + const stopWarSeen = new Set(); + const endingCandidates = before + .filter((entry) => entry.state === DIPLOMACY_STATE.WAR) + .sort((left, right) => right.fromNationId - left.fromNationId || right.toNationId - left.toNationId); + for (const entry of endingCandidates) { + const low = Math.min(entry.fromNationId, entry.toNationId); + const high = Math.max(entry.fromNationId, entry.toNationId); + const pairKey = `${low}:${high}`; + if (!stopWarSeen.has(pairKey)) { + stopWarSeen.add(pairKey); + continue; + } + const after = afterByKey.get(`${entry.fromNationId}:${entry.toNationId}`); + const opposite = afterByKey.get(`${entry.toNationId}:${entry.fromNationId}`); + if ( + after?.state !== DIPLOMACY_STATE.TRADE || + after.term !== 0 || + opposite?.state !== DIPLOMACY_STATE.TRADE || + opposite.term !== 0 + ) { + continue; + } + const nation1 = world.getNationById(entry.fromNationId); + const nation2 = world.getNationById(entry.toNationId); + if (!nation1 || !nation2) { + throw new Error( + `Monthly diplomacy truce references a missing nation (${entry.fromNationId}, ${entry.toNationId}).` + ); + } + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `【종전】${nation1.name}${JosaUtil.pick(nation1.name, '와')} ${nation2.name}${JosaUtil.pick(nation2.name, '이')} 종전합니다.`, + format: LogFormat.YEAR_MONTH, + }); + } }, }); diff --git a/app/game-engine/test/monthlyDiplomacyHandler.test.ts b/app/game-engine/test/monthlyDiplomacyHandler.test.ts new file mode 100644 index 0000000..4f6ef91 --- /dev/null +++ b/app/game-engine/test/monthlyDiplomacyHandler.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { LogCategory, LogFormat, LogScope, type Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyDiplomacyHandler } from '../src/turn/monthlyNationStatsHandler.js'; +import type { TurnDiplomacy, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildNation = (id: number, name: string, generalCount: number): Nation => ({ + id, + name, + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { gennum: generalCount }, +}); + +const diplomacy: TurnDiplomacy[] = [ + { fromNationId: 1, toNationId: 2, state: 1, term: 1, dead: 777, meta: {} }, + { fromNationId: 2, toNationId: 1, state: 1, term: 1, dead: 888, meta: {} }, + { fromNationId: 1, toNationId: 3, state: 0, term: 5, dead: 250, meta: {} }, + { fromNationId: 3, toNationId: 1, state: 0, term: 5, dead: 50, meta: {} }, + { fromNationId: 3, toNationId: 4, state: 0, term: 1, dead: 0, meta: {} }, + { fromNationId: 4, toNationId: 3, state: 0, term: 1, dead: 0, meta: {} }, + { fromNationId: 2, toNationId: 4, state: 7, term: 1, dead: 999, meta: {} }, +]; + +describe('monthly diplomacy post-update', () => { + it('matches legacy casualty terms, state transitions, and global log order', async () => { + const state: TurnWorldState = { + id: 1, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: {}, + }; + 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: [], + cities: [], + nations: [ + buildNation(1, '갑국', 2), + buildNation(2, '을국', 1), + buildNation(3, '병국', 1), + buildNation(4, '정국', 1), + ], + troops: [], + }; + let world: InMemoryTurnWorld | null = null; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + autoAdvanceDiplomacyMonth: false, + calendarHandler: createMonthlyDiplomacyHandler({ getWorld: () => world }), + }); + + await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); + + const observedKeys = new Set(diplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`)); + expect( + world.listDiplomacy().filter((entry) => observedKeys.has(`${entry.fromNationId}:${entry.toNationId}`)) + ).toEqual([ + { fromNationId: 1, toNationId: 2, state: 0, term: 6, dead: 0, meta: {} }, + { fromNationId: 2, toNationId: 1, state: 0, term: 6, dead: 0, meta: {} }, + { fromNationId: 1, toNationId: 3, state: 0, term: 5, dead: 50, meta: {} }, + { fromNationId: 3, toNationId: 1, state: 0, term: 4, dead: 50, meta: {} }, + { fromNationId: 3, toNationId: 4, state: 2, term: 0, dead: 0, meta: {} }, + { fromNationId: 4, toNationId: 3, state: 2, term: 0, dead: 0, meta: {} }, + { fromNationId: 2, toNationId: 4, state: 2, term: 0, dead: 0, meta: {} }, + ]); + expect(world.consumeDirtyState().logs).toEqual([ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【개전】갑국을국전쟁을 시작합니다.', + format: LogFormat.YEAR_MONTH, + }, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【종전】병국정국종전합니다.', + format: LogFormat.YEAR_MONTH, + }, + ]); + }); +}); diff --git a/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts new file mode 100644 index 0000000..e3ecd0b --- /dev/null +++ b/app/game-engine/test/monthlyDiplomacyPersistence.integration.test.ts @@ -0,0 +1,203 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { Nation } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyDiplomacyHandler } from '../src/turn/monthlyNationStatsHandler.js'; +import type { TurnDiplomacy, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const nationIds = [992_101, 992_102, 992_103, 992_104]; +const scenarioCode = 'monthly-diplomacy-persistence'; +const startLog = + '●193년 2월:【개전】갑국을국전쟁을 시작합니다.'; +const stopLog = '●193년 2월:【종전】병국정국종전합니다.'; + +const buildNation = (id: number, name: string, generalCount: number): Nation => ({ + id, + name, + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { gennum: generalCount }, +}); + +const diplomacy: TurnDiplomacy[] = [ + { fromNationId: nationIds[0]!, toNationId: nationIds[1]!, state: 1, term: 1, dead: 777, meta: {} }, + { fromNationId: nationIds[1]!, toNationId: nationIds[0]!, state: 1, term: 1, dead: 888, meta: {} }, + { fromNationId: nationIds[0]!, toNationId: nationIds[2]!, state: 0, term: 5, dead: 250, meta: {} }, + { fromNationId: nationIds[2]!, toNationId: nationIds[0]!, state: 0, term: 5, dead: 50, meta: {} }, + { fromNationId: nationIds[2]!, toNationId: nationIds[3]!, state: 0, term: 1, dead: 0, meta: {} }, + { fromNationId: nationIds[3]!, toNationId: nationIds[2]!, state: 0, term: 1, dead: 0, meta: {} }, + { fromNationId: nationIds[1]!, toNationId: nationIds[3]!, state: 7, term: 1, dead: 999, meta: {} }, +]; + +integration('monthly diplomacy 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.diplomacy.deleteMany({ + where: { + OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }], + }, + }); + await db.logEntry.deleteMany({ where: { text: { in: [startLog, stopLog] } } }); + await db.nation.deleteMany({ where: { id: { in: nationIds } } }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + }); + + afterAll(async () => { + await db.diplomacy.deleteMany({ + where: { + OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }], + }, + }); + await db.logEntry.deleteMany({ where: { text: { in: [startLog, stopLog] } } }); + await db.nation.deleteMany({ where: { id: { in: nationIds } } }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + await closeDb?.(); + }); + + it('commits diplomacy transitions and exact global history text together', async () => { + const nations = [ + buildNation(nationIds[0]!, '갑국', 2), + buildNation(nationIds[1]!, '을국', 1), + buildNation(nationIds[2]!, '병국', 1), + buildNation(nationIds[3]!, '정국', 1), + ]; + await db.nation.createMany({ + data: nations.map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + gold: 0, + rice: 0, + tech: 0, + level: 1, + typeCode: nation.typeCode, + meta: nation.meta, + })), + }); + await db.diplomacy.createMany({ + data: diplomacy.map((entry) => ({ + srcNationId: entry.fromNationId, + destNationId: entry.toNationId, + stateCode: entry.state, + term: entry.term, + meta: { dead: entry.dead }, + })), + }); + 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: 'test', unitSet: 'default' }, + }, + meta: {}, + }, + }); + const state: TurnWorldState = { + id: worldRow.id, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: {}, + }; + 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: [], + cities: [], + nations, + troops: [], + }; + let world: InMemoryTurnWorld | null = null; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + autoAdvanceDiplomacyMonth: false, + calendarHandler: createMonthlyDiplomacyHandler({ getWorld: () => world }), + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); + await hooks.hooks.flushChanges?.({ + lastTurnTime: '0193-02-01T00:00:00.000Z', + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + + const rows = await db.diplomacy.findMany({ + where: { + OR: [{ srcNationId: { in: nationIds } }, { destNationId: { in: nationIds } }], + }, + orderBy: [{ srcNationId: 'asc' }, { destNationId: 'asc' }], + }); + expect( + rows + .filter((row) => + diplomacy.some( + (entry) => entry.fromNationId === row.srcNationId && entry.toNationId === row.destNationId + ) + ) + .map((row) => ({ + fromNationId: row.srcNationId, + toNationId: row.destNationId, + state: row.stateCode, + term: row.term, + dead: (row.meta as { dead?: number }).dead ?? 0, + })) + ).toEqual([ + { fromNationId: nationIds[0], toNationId: nationIds[1], state: 0, term: 6, dead: 0 }, + { fromNationId: nationIds[0], toNationId: nationIds[2], state: 0, term: 5, dead: 50 }, + { fromNationId: nationIds[1], toNationId: nationIds[0], state: 0, term: 6, dead: 0 }, + { fromNationId: nationIds[1], toNationId: nationIds[3], state: 2, term: 0, dead: 0 }, + { fromNationId: nationIds[2], toNationId: nationIds[0], state: 0, term: 4, dead: 50 }, + { fromNationId: nationIds[2], toNationId: nationIds[3], state: 2, term: 0, dead: 0 }, + { fromNationId: nationIds[3], toNationId: nationIds[2], state: 2, term: 0, dead: 0 }, + ]); + expect( + await db.logEntry.findMany({ + where: { text: { in: [startLog, stopLog] } }, + orderBy: { id: 'asc' }, + select: { scope: true, category: true, year: true, month: true, text: true }, + }) + ).toEqual([ + { scope: 'SYSTEM', category: 'HISTORY', year: 193, month: 2, text: startLog }, + { scope: 'SYSTEM', category: 'HISTORY', year: 193, month: 2, text: stopLog }, + ]); + } finally { + await hooks.close(); + } + }); +});