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년 4월:【종전】병국정국종전합니다.'; 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: 3, 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.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } }); 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.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } }); 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 a shared war countdown and the exact transition history 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: { serverId: scenarioCode }, }, }); const state: TurnWorldState = { id: worldRow.id, currentYear: 193, currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), meta: { serverId: scenarioCode }, }; 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 { const checkpoint = world.captureState(); await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); const queued = world.peekDirtyState().pendingAuditDiplomacy; expect(queued.length).toBeGreaterThan(0); world.restoreState(checkpoint); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued); await db.$executeRawUnsafe(`CREATE FUNCTION reject_monthly_audit_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'monthly audit fixture failure'; END; $$`); await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`); try { await expect(hooks.flushChanges()).rejects.toThrow('monthly audit fixture failure'); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued); expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0); expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ currentMonth: 1, }); expect( await db.diplomacy.findUniqueOrThrow({ where: { srcNationId_destNationId: { srcNationId: nationIds[0]!, destNationId: nationIds[1]!, }, }, }) ).toMatchObject({ stateCode: 1, term: 1 }); } finally { await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event'); await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()'); } await hooks.hooks.flushChanges?.({ lastTurnTime: '0193-02-01T00:00:00.000Z', processedGenerals: 0, processedTurns: 0, durationMs: 0, partial: false, }); await world.advanceMonth(new Date('0193-03-01T00:00:00.000Z')); await hooks.hooks.flushChanges?.({ lastTurnTime: '0193-03-01T00:00:00.000Z', processedGenerals: 0, processedTurns: 0, durationMs: 0, partial: false, }); await world.advanceMonth(new Date('0193-04-01T00:00:00.000Z')); await hooks.hooks.flushChanges?.({ lastTurnTime: '0193-04-01T00:00:00.000Z', processedGenerals: 0, processedTurns: 0, durationMs: 0, partial: false, }); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); const events = await db.playAuditDiplomacyEvent.findMany({ where: { serverId: scenarioCode }, orderBy: { sequence: 'asc' }, }); expect( events.every( (event) => event.source === 'ENGINE' && event.actor === null && event.requestId === null && event.inputSequence === null ) ).toBe(true); const startEvents = events.filter((event) => event.month === 2); expect(startEvents.map((event) => event.ordinal)).toEqual(startEvents.map((_, index) => index + 1)); expect( startEvents.find((event) => event.srcNationId === nationIds[0] && event.destNationId === nationIds[1]) ).toMatchObject({ before: { state: 1, term: 1, dead: 777 }, after: { state: 0, term: 6, dead: 0 }, }); expect( events.some((event) => event.srcNationId === nationIds[0] && event.destNationId === nationIds[3]) ).toBe(false); await hooks.flushChanges(); expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(events.length); 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: 4, dead: 0 }, { fromNationId: nationIds[0], toNationId: nationIds[2], state: 0, term: 3, dead: 50 }, { fromNationId: nationIds[1], toNationId: nationIds[0], state: 0, term: 4, dead: 0 }, { fromNationId: nationIds[1], toNationId: nationIds[3], state: 2, term: 0, dead: 0 }, { fromNationId: nationIds[2], toNationId: nationIds[0], state: 0, term: 3, 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: 4, text: stopLog }, ]); expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ currentYear: 193, currentMonth: 4, }); } finally { await hooks.close(); } }); });