diff --git a/app/game-engine/src/playAudit/diplomacy.ts b/app/game-engine/src/playAudit/diplomacy.ts index 2c6d554e..33bc3449 100644 --- a/app/game-engine/src/playAudit/diplomacy.ts +++ b/app/game-engine/src/playAudit/diplomacy.ts @@ -1,3 +1,4 @@ +import { asRecord } from '@sammo-ts/common'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; import type { TurnDiplomacy } from '../turn/types.js'; @@ -99,3 +100,81 @@ export const recordTurnAuditDiplomacy = ( after: nextState, }); }; + +/** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */ +export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => { + const state = world.getState(); + const serverId = state.meta.serverId; + if (typeof serverId !== 'string' || !serverId.trim()) return false; + const previous = asRecord(state.meta.playAuditDiplomacy); + if (previous.serverId === serverId) { + if ( + previous.schemaVersion !== 1 || + typeof previous.year !== 'number' || + previous.year < 0 || + !Number.isInteger(previous.year) || + !Number.isInteger(previous.month) || + typeof previous.month !== 'number' || + previous.month < 1 || + previous.month > 12 || + typeof previous.tick !== 'number' || + !Number.isSafeInteger(previous.tick) || + previous.tick < 0 || + typeof previous.clockRevision !== 'number' || + !Number.isSafeInteger(previous.clockRevision) || + previous.clockRevision < 0 || + typeof previous.relationCount !== 'number' || + !Number.isInteger(previous.relationCount) || + previous.relationCount < 0 || + previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth || + typeof previous.observedAt !== 'string' || + !Number.isFinite(Date.parse(previous.observedAt)) + ) + throw new Error('Invalid play audit diplomacy boundary'); + return false; + } + const clock = world.getGameClockState(); + const observedAtIso = observedAt.toISOString(); + const relations = world + .listDiplomacy() + .filter((entry) => entry.fromNationId > 0 && entry.toNationId > 0) + .sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId); + for (const [index, entry] of relations.entries()) { + world.queueAuditDiplomacy({ + schemaVersion: 1, + serverId, + srcNationId: entry.fromNationId, + destNationId: entry.toNationId, + category: 'RELATION', + source: 'BASELINE', + eventType: 'RELATION_BASELINE', + documentId: null, + documentHash: null, + previousDocumentId: null, + year: state.currentYear, + month: state.currentMonth, + tick: BigInt(clock.tick), + clockRevision: BigInt(clock.revision), + executionId: 'relation-baseline', + ordinal: index + 1, + requestId: null, + inputSequence: null, + actor: null, + before: null, + after: { state: entry.state, term: entry.term, dead: entry.dead }, + }); + } + world.updateWorldMeta({ + playAuditDiplomacy: { + schemaVersion: 1, + serverId, + year: state.currentYear, + month: state.currentMonth, + tick: clock.tick, + clockRevision: clock.revision, + observedAt: observedAtIso, + relationCount: relations.length, + }, + }); + return true; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 74f2681b..08a9e616 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,3 +1,4 @@ +import { initializeAuditDiplomacy } from '../playAudit/diplomacy.js'; import { initializeAuditPolicies } from '../playAudit/policy.js'; import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js'; import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/collection.js'; @@ -806,6 +807,7 @@ const createTurnDaemonRuntimeWithLease = async ( worldRef = world; if (!databaseFlushEnabled) { initializeAuditPolicies(world); + initializeAuditDiplomacy(world, new Date(clock.nowMs())); initializeAuditCollection(world, new Date(clock.nowMs())); } @@ -929,8 +931,9 @@ const createTurnDaemonRuntimeWithLease = async ( // 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다. // 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다. initializeAuditPolicies(world); + const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs())); initializeAuditCollection(world, new Date(clock.nowMs())); - if (world.hasPendingAuditRecords()) { + if (world.hasPendingAuditRecords() || diplomacyInitialized) { await dbHooks.flushChanges(); dbHooks.takeCommittedReadModelChangeReceipt(); } diff --git a/app/game-engine/test/playAuditCollection.test.ts b/app/game-engine/test/playAuditCollection.test.ts index f43f0a36..906492e9 100644 --- a/app/game-engine/test/playAuditCollection.test.ts +++ b/app/game-engine/test/playAuditCollection.test.ts @@ -1,3 +1,4 @@ +import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js'; import { describe, expect, it } from 'vitest'; import type { City, Nation } from '@sammo-ts/logic'; import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js'; @@ -128,6 +129,37 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => { return world; }; describe('play audit collection durability state', () => { + it('marks an empty diplomacy baseline without inventing relations and validates before queuing', () => { + const world = buildWorld(); + world.removeNation(1); + world.removeNation(2); + expect(() => initializeAuditDiplomacy(world, new Date('invalid'))).toThrow(RangeError); + expect(world.getState().meta.playAuditDiplomacy).toBeUndefined(); + expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); + expect(initializeAuditDiplomacy(world)).toBe(true); + expect(world.getState().meta.playAuditDiplomacy).toMatchObject({ relationCount: 0 }); + expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); + expect(initializeAuditDiplomacy(world)).toBe(false); + }); + + it('captures a single diplomacy baseline and restores its marker and queue together', () => { + const world = buildWorld(); + const checkpoint = world.captureState(); + expect(initializeAuditDiplomacy(world, new Date('2026-09-16T00:00:00Z'))).toBe(true); + const events = world.peekDirtyState().pendingAuditDiplomacy; + expect(events).toHaveLength(2); + expect(events.map((event) => [event.srcNationId, event.destNationId])).toEqual([ + [1, 2], + [2, 1], + ]); + expect(events[0]).toMatchObject({ source: 'BASELINE', before: null, after: { state: 2, term: 0, dead: 0 } }); + expect(initializeAuditDiplomacy(world)).toBe(false); + expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(events); + world.restoreState(checkpoint); + expect(world.getState().meta.playAuditDiplomacy).toBeUndefined(); + expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); + }); + it('preserves consecutive diplomacy transitions in one turn and restores them with the checkpoint', () => { const world = buildWorld({ execute: ({ general }) => ({ diff --git a/app/game-engine/test/playAuditStartup.integration.test.ts b/app/game-engine/test/playAuditStartup.integration.test.ts index 23791ccc..8a0f68d0 100644 --- a/app/game-engine/test/playAuditStartup.integration.test.ts +++ b/app/game-engine/test/playAuditStartup.integration.test.ts @@ -43,6 +43,7 @@ integration('initial audit durability before runtime readiness', () => { closeDb = () => connector.disconnect(); await db.playAuditMonth.deleteMany(); await db.playAuditPolicy.deleteMany(); + await db.playAuditDiplomacyEvent.deleteMany(); await seedScenarioToDatabase({ scenarioId: 903, databaseUrl: databaseUrl!, @@ -56,6 +57,15 @@ integration('initial audit durability before runtime readiness', () => { 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 } }, + }); }, 60_000); afterAll(async () => { await runtime?.close(); @@ -80,6 +90,8 @@ integration('initial audit durability before runtime readiness', () => { 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).playAuditCollection).toBeUndefined(); expect( (await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined) @@ -104,6 +116,37 @@ integration('initial audit durability before runtime readiness', () => { 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 }, + 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 }); + expect(policies).toHaveLength((await db.nation.count()) * 4); expect( policies.every( @@ -128,6 +171,10 @@ integration('initial audit durability before runtime readiness', () => { 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 }, orderBy: { ordinal: 'asc' } })).toEqual( + diplomacy + ); + expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toEqual(diplomacyMarker); expect(await clock()).toEqual(beforeClock); expect(await db.inputEvent.count()).toBe(beforeInputs); await expect( diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index 71b25424..902559a4 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -43,7 +43,7 @@ const general = { items: { horse: null, weapon: null, book: null, item: null }, }, }; -const install = async (page: Page, denied = false) => { +const install = async (page: Page, denied = false, baseline = false) => { const requests: { operation: string; input: Record }[] = []; await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_audit'); @@ -90,6 +90,29 @@ const install = async (page: Page, denied = false) => { nextCursor: null, }); case 'playAudit.diplomacyHistory': + if (baseline) + return result({ + ...world, + coverage: 'RECORDED_EVENTS_ONLY', + nextCursor: null, + items: [ + { + id: 'c'.repeat(64), + sequence: '1', + srcNationId: 2, + destNationId: 3, + category: 'RELATION', + source: 'BASELINE', + eventType: 'RELATION_BASELINE', + documentId: null, + previousDocumentId: null, + year: 190, + month: 1, + actor: null, + createdAt: world.asOf, + }, + ], + }); return result({ ...world, coverage: 'RECORDED_EVENTS_ONLY', @@ -119,6 +142,35 @@ const install = async (page: Page, denied = false) => { ], }); case 'playAudit.diplomacyEvent': + if (baseline) + return result({ + ...world, + event: { + id: input.id, + sequence: '1', + srcNationId: 2, + destNationId: 3, + category: 'RELATION', + source: 'BASELINE', + eventType: 'RELATION_BASELINE', + documentId: null, + previousDocumentId: null, + year: 190, + month: 1, + actor: null, + createdAt: world.asOf, + before: null, + after: { state: 2, term: 0, dead: 0 }, + tick: '0', + clockRevision: '1', + ordinal: 1, + executionId: 'relation-baseline', + requestId: null, + inputSequence: null, + documentStatus: 'NOT_APPLICABLE', + document: null, + }, + }); return result({ ...world, event: { @@ -863,3 +915,15 @@ test('diplomacy detail failure retries independently', async ({ page }) => { await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible(); expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count); }); + +test('diplomacy baseline displays observed state without a fictional document or actor', async ({ page }) => { + const requests = await install(page, false, true); + await page.goto( + gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6') + ); + await page.getByRole('button', { name: '관계 최초 관측', exact: true }).click(); + await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('교역'); + await expect(page.getByLabel('외교 전후 값', { exact: true })).toContainText('미관측 / 없음'); + await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0); + expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(1); +}); diff --git a/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue index 311ca690..582369cf 100644 --- a/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue +++ b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue @@ -22,6 +22,7 @@ let generation = 0; let detailGeneration = 0; const selected = computed(() => (typeof route.query.event === 'string' ? route.query.event : null)); const labels: Record = { + RELATION_BASELINE: '관계 최초 관측', LETTER_PROPOSED: '문서 제안', LETTER_REPLACED: '문서 교체', LETTER_ACCEPTED: '문서 승인', diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 3091287e..fbb127e5 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -204,6 +204,25 @@ Chromium의 CHE/HWE에서 desktop1280×720/mobile390×844, DPR1로 검증했다. 원문 script 비실행을 검사한다. 외교 최초 기준과 최종 mutation inventory/전체 비용 검증은 남아 있으며 화면 추가만으로 R4 전체 완료를 판단하지 않는다. +### 외교 관계 최초 관측 + +`initializeAuditDiplomacy`는 복구된 clock과 이미 로드한 관계에서 실제 국가쌍의 +방향별 state/term/dead를 한 번만 기록한다. 재야(0)는 제외하며 당시의 관측 값만 +보존한다. 이전 상태·원인·actor는 null이고 과거 체결 시점을 추정하지 않는다. +`playAuditDiplomacy` 기수 표식과 사건을 readiness 전 기존 fenced flush에서 함께 +저장한다. 관계가 없는 경우에도 표식은 flush하고, 정상 재시작은 다시 쓰지 않는다. +checkpoint rollback은 표식과 pending 사건을 함께 복원한다. + +추가 DB 전체 SELECT는 없다. 초기 관계 목록의 짧은 필드만 기존 200행 batch writer로 +저장하며 최초 저장량은 방향별 관계 수에 비례한다. 정상 재시작은 메모리 표식 검사만 +수행한다. 기본 교역 관계도 최초에는 보존해 국가쌍 조회의 시작 값을 제공하지만, +월간 기본 matrix 생성은 계속 전이로 기록하지 않는다. 대규모 국가 수의 payload와 +WAL 실측, 이후 신생국·소멸국 및 기존 문서의 도입 기준은 별도 검증이 남아 있다. + +단위 검증은 재야 제외·빈 관계·잘못된 관측 시각·재호출·checkpoint 복원을 다룬다. +실제 격리 PostgreSQL startup 검증은 실패 rollback, PREOPEN 저장, 재시작의 동일 행과 +표식 및 readiness 경계를 확인한다. UI는 이를 '관계 최초 관측'으로 구분한다. + ## NPC·국방 정책 버전 저장 기반 `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.