import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { createGamePostgresConnector, hashAuditDiplomacyDocument, type GamePrismaClient, type GamePrisma, } from '@sammo-ts/infra'; import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js'; import { createTurnDaemonRuntime, type TurnDaemonRuntime } from '../src/turn/turnDaemon.js'; const databaseUrl = process.env.PLAY_AUDIT_STARTUP_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const profile = 'hwe:903'; const serverId = 'audit-startup-fixture'; integration('initial audit durability before runtime readiness', () => { let db: GamePrismaClient; let closeDb: () => Promise; let runtime: TurnDaemonRuntime | undefined; const start = () => createTurnDaemonRuntime({ profile, databaseUrl: databaseUrl!, enableDatabaseFlush: true, enableLeaseHeartbeat: false, leaseOwnerId: 'audit-startup-fixture', }); const clock = () => db.worldState.findFirstOrThrow({ select: { clockPhase: true, clockTick: true, clockRevision: true, deadlineGeneration: true, clockWallAnchor: true, lastTurnTick: true, currentYear: true, currentMonth: true, }, }); beforeAll(async () => { if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('_audit_startup_fixture')) throw new Error('Initial audit test requires its dedicated fixture schema'); const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); await db.playAuditMonth.deleteMany(); await db.playAuditPolicy.deleteMany(); await db.playAuditDiplomacyEvent.deleteMany(); await seedScenarioToDatabase({ scenarioId: 903, databaseUrl: databaseUrl!, now: new Date(), installOptions: { openAt: new Date(Date.now() + 86_400_000), turnTermMinutes: 5, npcMode: 2, showImgLevel: 3, serverId, season: 1, }, }); await db.nation.createMany({ data: [ { id: 91990, name: '기준 발신국', color: '#ffffff' }, { id: 91991, name: '기준 수신국', color: '#000000' }, ], }); await db.diplomacy.create({ data: { srcNationId: 91990, destNationId: 91991, stateCode: 7, term: 12, meta: { dead: 34 } }, }); await db.diplomacyLetter.createMany({ data: Array.from({ length: 201 }, (_, index) => ({ id: 1000 + index, srcNationId: 91990, destNationId: 91991, prevId: index ? 999 + index : null, state: index === 200 ? ('ACTIVATED' as const) : ('REPLACED' as const), textBrief: `도입 전 문서 ${index}`, textDetail: `

보유 원문 ${index}

`, date: new Date('2026-09-01T00:00:00Z'), srcSignerId: 70001, destSignerId: 70002, aux: { src: { nationName: '옛 국명', generalName: '옛 서명자' }, debug: '비공개 임의 값' }, })), }); }, 60_000); afterAll(async () => { await runtime?.close(); await closeDb?.(); }); it('rolls initial policies, sample and collection marker back before readiness on persistence failure', async () => { const before = await clock(); await db.$executeRawUnsafe( "CREATE OR REPLACE FUNCTION audit_initial_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture initial audit failure'; END $$" ); await db.$executeRawUnsafe( "CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()" ); try { let error: unknown; try { runtime = await start(); } catch (cause) { error = cause; } expect(String(error)).toContain('fixture initial audit failure'); expect(await db.playAuditMonth.count()).toBe(0); expect(await db.playAuditPolicy.count()).toBe(0); expect(await db.playAuditDiplomacyEvent.count()).toBe(0); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toBeUndefined(); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toBeUndefined(); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined(); expect( (await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined) ).toBe(true); expect((await db.turnDaemonLease.findMany()).every((lease) => !lease.clockReady)).toBe(true); expect(await clock()).toEqual(before); } finally { await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month'); await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS audit_initial_fail()'); } }); it('persists PREOPEN baseline without starting the lifecycle and reuses it after restart', async () => { const beforeClock = await clock(); const beforeGenerals = await db.general.findMany({ orderBy: { id: 'asc' } }); const beforeInputs = await db.inputEvent.count(); expect(beforeClock.clockPhase).toBe('PREOPEN'); runtime = await start(); expect(await clock()).toEqual(beforeClock); expect(await db.general.findMany({ orderBy: { id: 'asc' } })).toEqual(beforeGenerals); expect(await db.inputEvent.count()).toBe(beforeInputs); expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true); const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } }); const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } }); const diplomacy = await db.playAuditDiplomacyEvent.findMany({ where: { serverId, category: 'RELATION' }, orderBy: { ordinal: 'asc' }, }); expect(diplomacy).toHaveLength( await db.diplomacy.count({ where: { srcNationId: { gt: 0 }, destNationId: { gt: 0 } } }) ); expect(diplomacy).toHaveLength(2); expect(diplomacy[0]).toMatchObject({ srcNationId: 91990, destNationId: 91991, before: null, after: { state: 7, term: 12, dead: 34 }, }); expect(diplomacy[1]).toMatchObject({ srcNationId: 91991, destNationId: 91990, after: { state: 2, term: 0, dead: 0 }, }); expect( diplomacy.every( (event) => event.source === 'BASELINE' && event.before === null && event.actor === null && event.requestId === null ) ).toBe(true); const diplomacyMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy; expect(diplomacyMarker).toMatchObject({ serverId, schemaVersion: 1, relationCount: diplomacy.length }); const documentEvents = await db.playAuditDiplomacyEvent.findMany({ where: { serverId, category: 'DOCUMENT' }, orderBy: { ordinal: 'asc' }, }); expect(documentEvents).toHaveLength(201); const currentLetter = await db.diplomacyLetter.findUniqueOrThrow({ where: { id: 1200 } }); expect(documentEvents[200]).toMatchObject({ source: 'BASELINE', eventType: 'LETTER_BASELINE', documentId: 1200, previousDocumentId: 1199, documentHash: hashAuditDiplomacyDocument(currentLetter), actor: null, before: null, after: { state: 'ACTIVATED', srcNationName: '옛 국명', srcSignerName: '옛 서명자' }, }); expect(documentEvents[0]).toMatchObject({ after: { state: 'REPLACED' } }); expect(JSON.stringify(documentEvents.map(({ after }) => after))).not.toContain('비공개 임의 값'); const documentMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments; expect(documentMarker).toMatchObject({ serverId, schemaVersion: 1, documentCount: 201 }); expect(policies).toHaveLength((await db.nation.count()) * 4); expect( policies.every( (policy) => policy.source === 'BASELINE' && policy.tick === beforeClock.clockTick && policy.inputSequence === null ) ).toBe(true); expect(await db.playAuditGeneral.count({ where: { sampleId: initial.id } })).toBe(beforeGenerals.length); const marker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection; expect(marker).toMatchObject({ serverId, schemaVersion: 1, year: beforeClock.currentYear, month: beforeClock.currentMonth, }); expect(runtime.world.hasPendingAuditRecords()).toBe(false); await runtime.close(); runtime = undefined; runtime = await start(); expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies); expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toEqual(marker); expect( await db.playAuditDiplomacyEvent.findMany({ where: { serverId, category: 'RELATION' }, orderBy: { ordinal: 'asc' }, }) ).toEqual(diplomacy); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toEqual(diplomacyMarker); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(documentMarker); expect( await db.playAuditDiplomacyEvent.findMany({ where: { serverId, category: 'DOCUMENT' }, orderBy: { ordinal: 'asc' }, }) ).toEqual(documentEvents); expect(await clock()).toEqual(beforeClock); expect(await db.inputEvent.count()).toBe(beforeInputs); await expect( db.playAuditMonth.create({ data: { ...initial, id: 'second-initial', month: initial.month === 12 ? 1 : initial.month + 1 }, }) ).rejects.toMatchObject({ code: 'P2002' }); }, 30_000); it('captures newly observed policies after durable clock recovery without replacing the initial sample', async () => { await runtime?.close(); runtime = undefined; const original = await db.worldState.findFirstOrThrow(); const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } }); await db.nation.create({ data: { id: 91992, name: '복구 관측국', color: '#ffffff' } }); await db.worldState.update({ where: { id: original.id }, data: { clockPhase: 'RUNNING', clockMode: 'realtime', clockTick: BigInt(GAME_TICKS_PER_TURN / 6), clockWallAnchor: new Date(Date.now() - 115 * 60_000), lastTurnTick: 0n, }, }); runtime = await start(); const recovered = await db.worldState.findFirstOrThrow(); expect(recovered.clockRevision).toBeGreaterThan(original.clockRevision); const policies = await db.playAuditPolicy.findMany({ where: { serverId, nationId: 91992 } }); expect(policies).toHaveLength(4); expect(policies.every((policy) => policy.tick === BigInt(runtime!.world.getGameClockState().tick))).toBe(true); expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]); }, 30_000); it('adopts an empty document collection without duplicating an existing initial sample', async () => { await runtime?.close(); runtime = undefined; const original = await db.worldState.findFirstOrThrow(); const meta = asRecord(original.meta); delete meta.playAuditDocuments; await db.diplomacyLetter.deleteMany(); await db.playAuditDiplomacyEvent.deleteMany({ where: { category: 'DOCUMENT' } }); await db.worldState.update({ where: { id: original.id }, data: { meta: meta as GamePrisma.InputJsonObject } }); const samples = await db.playAuditMonth.findMany({ orderBy: { id: 'asc' } }); runtime = await start(); const marker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments; expect(marker).toMatchObject({ serverId, schemaVersion: 1, documentCount: 0 }); expect(await db.playAuditDiplomacyEvent.count({ where: { category: 'DOCUMENT' } })).toBe(0); expect(await db.playAuditMonth.findMany({ orderBy: { id: 'asc' } })).toEqual(samples); await runtime.close(); runtime = undefined; runtime = await start(); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker); }, 30_000); });