diff --git a/app/game-api/src/services/playAuditDiplomacy.ts b/app/game-api/src/services/playAuditDiplomacy.ts index f0c3db17..9292876b 100644 --- a/app/game-api/src/services/playAuditDiplomacy.ts +++ b/app/game-api/src/services/playAuditDiplomacy.ts @@ -1,8 +1,9 @@ -import { asRecord, GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common'; +import { GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common'; import { GamePrisma, hashAuditDiplomacyDocument, persistAuditDiplomacyEvents, + projectAuditDocumentState, readTurnRuntimeReady, type AuditDiplomacyEventDraft, } from '@sammo-ts/infra'; @@ -18,25 +19,7 @@ type DocumentAction = | 'LETTER_DESTROY_REQUESTED' | 'LETTER_DESTROYED'; -export const projectAuditDocumentState = (letter: Letter): Record => { - const aux = asRecord(letter.aux); - const src = asRecord(aux.src); - const dest = asRecord(aux.dest); - const reason = asRecord(aux.reason); - return { - state: letter.state, - srcSignerId: letter.srcSignerId, - destSignerId: letter.destSignerId, - srcNationName: typeof src.nationName === 'string' ? src.nationName : null, - destNationName: typeof dest.nationName === 'string' ? dest.nationName : null, - srcSignerName: typeof src.generalName === 'string' ? src.generalName : null, - destSignerName: typeof dest.generalName === 'string' ? dest.generalName : null, - stateOption: typeof aux.state_opt === 'string' ? aux.state_opt : null, - reason: typeof reason.reason === 'string' ? reason.reason : null, - reasonAction: typeof reason.action === 'string' ? reason.action : null, - reasonActorId: typeof reason.who === 'number' ? reason.who : null, - }; -}; +export { projectAuditDocumentState } from '@sammo-ts/infra'; interface AuditCoordinateRow { serverId: string | null; diff --git a/app/game-engine/src/playAudit/documentBaseline.ts b/app/game-engine/src/playAudit/documentBaseline.ts new file mode 100644 index 00000000..7e883a6a --- /dev/null +++ b/app/game-engine/src/playAudit/documentBaseline.ts @@ -0,0 +1,123 @@ +import { asRecord } from '@sammo-ts/common'; +import { + hashAuditDiplomacyDocument, + persistAuditDiplomacyEvents, + projectAuditDocumentState, + GamePrisma, +} from '@sammo-ts/infra'; +import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; + +export const hasAuditDocumentBaseline = (world: InMemoryTurnWorld): boolean => { + const state = world.getState(); + const { meta } = state; + const serverId = meta.serverId; + if (typeof serverId !== 'string' || !serverId.trim()) return true; + const marker = asRecord(meta.playAuditDocuments); + if (marker.serverId !== serverId) return false; + if ( + marker.schemaVersion !== 1 || + typeof marker.documentCount !== 'number' || + !Number.isSafeInteger(marker.documentCount) || + marker.documentCount < 0 || + typeof marker.year !== 'number' || + !Number.isSafeInteger(marker.year) || + marker.year < 0 || + typeof marker.month !== 'number' || + !Number.isInteger(marker.month) || + marker.month < 1 || + marker.month > 12 || + marker.year * 12 + marker.month > state.currentYear * 12 + state.currentMonth || + typeof marker.tick !== 'number' || + !Number.isSafeInteger(marker.tick) || + marker.tick < 0 || + typeof marker.clockRevision !== 'number' || + !Number.isSafeInteger(marker.clockRevision) || + marker.clockRevision < 0 || + typeof marker.observedAt !== 'string' || + !Number.isFinite(Date.parse(marker.observedAt)) + ) + throw new Error('Invalid play audit document boundary'); + return true; +}; + +/** CLOCK lock을 잡은 startup transaction 안에서만 호출한다. 과거 문서 사건을 복원하지 않는다. */ +export const persistAuditDocumentBaseline = async ( + db: GamePrisma.TransactionClient, + world: InMemoryTurnWorld, + observedAt: Date +): Promise => { + if (hasAuditDocumentBaseline(world)) return; + const state = world.getState(); + const serverId = state.meta.serverId; + if (typeof serverId !== 'string' || !serverId.trim()) return; + const observedAtIso = observedAt.toISOString(); + const [identity] = await db.$queryRaw>(GamePrisma.sql` + SELECT meta->>'serverId' AS "serverId" FROM world_state WHERE id = ${state.id} FOR UPDATE + `); + if (identity?.serverId !== serverId) throw new Error('Play audit document baseline season changed'); + const clock = world.getGameClockState(); + let cursor = 0; + let documentCount = 0; + while (true) { + // 문서 본문은 불변 참조의 해시에만 필요하다. 메모리에는 한 batch만 유지한다. + const letters = await db.diplomacyLetter.findMany({ + where: { id: { gt: cursor } }, + orderBy: { id: 'asc' }, + take: 200, + select: { + id: true, + srcNationId: true, + destNationId: true, + prevId: true, + textBrief: true, + textDetail: true, + srcSignerId: true, + destSignerId: true, + state: true, + aux: true, + date: true, + }, + }); + await persistAuditDiplomacyEvents( + db, + letters.map((letter) => ({ + schemaVersion: 1, + serverId, + srcNationId: letter.srcNationId, + destNationId: letter.destNationId, + category: 'DOCUMENT', + source: 'BASELINE', + eventType: 'LETTER_BASELINE', + documentId: letter.id, + documentHash: hashAuditDiplomacyDocument(letter), + previousDocumentId: letter.prevId, + year: state.currentYear, + month: state.currentMonth, + tick: BigInt(clock.tick), + clockRevision: BigInt(clock.revision), + executionId: 'document-baseline', + ordinal: letter.id, + requestId: null, + inputSequence: null, + actor: null, + before: null, + after: projectAuditDocumentState(letter), + })) + ); + documentCount += letters.length; + if (letters.length < 200) break; + cursor = letters[letters.length - 1]!.id; + } + world.updateWorldMeta({ + playAuditDocuments: { + schemaVersion: 1, + serverId, + year: state.currentYear, + month: state.currentMonth, + tick: clock.tick, + clockRevision: clock.revision, + observedAt: observedAtIso, + documentCount, + }, + }); +}; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 57ade6e1..e47dc0bd 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,4 +1,5 @@ import { persistAuditDiplomacyEvents } from '@sammo-ts/infra'; +import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js'; import { persistAuditPolicies } from '../playAudit/policyPersistence.js'; import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { persistAuditMonth } from '../playAudit/persistence.js'; @@ -76,6 +77,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; flushChanges(): Promise; + flushInitialAudit(observedAt: Date, force?: boolean): Promise; takeCommittedReadModelChanges(): RealtimeReadModelChanges | null; takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null; close(): Promise; @@ -2087,6 +2089,31 @@ export const createDatabaseTurnHooks = async ( committed.acknowledge(); enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite); }; + const flushInitialAudit = async (observedAt: Date, force = false): Promise => { + if (hasAuditDocumentBaseline(world)) { + if (force || world.hasPendingAuditRecords()) await flushChanges(); + return; + } + const checkpoint = world.captureState(); + let committed: Awaited>; + try { + committed = await prisma.$transaction(async (transaction) => { + // seed/RESET과 같은 schema lock을 먼저 잡아 문서 scan 중 초기화를 막는다. + await transaction.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(current_schema(), 0))::text AS lock_result`; + await options?.turnDaemonLease?.assertActive(transaction); + await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); + await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK); + await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world); + await persistAuditDocumentBaseline(transaction, world, observedAt); + return persistChanges(transaction); + }, transactionOptions); + } catch (error) { + world.restoreState(checkpoint); + throw error; + } + committed.acknowledge(); + enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite); + }; const hooks: TurnDaemonHooks = { flushChanges, commitCommand: async (requestId, result) => { @@ -2133,6 +2160,7 @@ export const createDatabaseTurnHooks = async ( return { hooks, flushChanges, + flushInitialAudit, takeCommittedReadModelChanges: () => { return takeCommittedReceipt()?.changes ?? null; }, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 08a9e616..26d21070 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -933,10 +933,8 @@ const createTurnDaemonRuntimeWithLease = async ( initializeAuditPolicies(world); const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs())); initializeAuditCollection(world, new Date(clock.nowMs())); - if (world.hasPendingAuditRecords() || diplomacyInitialized) { - await dbHooks.flushChanges(); - dbHooks.takeCommittedReadModelChangeReceipt(); - } + await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized); + dbHooks.takeCommittedReadModelChangeReceipt(); } catch (error) { await Promise.allSettled([ dbHooks.close(), diff --git a/app/game-engine/test/playAuditStartup.integration.test.ts b/app/game-engine/test/playAuditStartup.integration.test.ts index 8a0f68d0..abaf32a2 100644 --- a/app/game-engine/test/playAuditStartup.integration.test.ts +++ b/app/game-engine/test/playAuditStartup.integration.test.ts @@ -1,6 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common'; -import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +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'; @@ -66,6 +71,21 @@ integration('initial audit durability before runtime readiness', () => { 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(); @@ -92,6 +112,7 @@ integration('initial audit durability before runtime readiness', () => { 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) @@ -117,7 +138,7 @@ integration('initial audit durability before runtime readiness', () => { 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 }, + where: { serverId, category: 'RELATION' }, orderBy: { ordinal: 'asc' }, }); expect(diplomacy).toHaveLength( @@ -146,6 +167,26 @@ integration('initial audit durability before runtime readiness', () => { ).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( @@ -171,10 +212,20 @@ 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( + 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( @@ -208,4 +259,24 @@ integration('initial audit durability before runtime readiness', () => { expect(policies.every((policy) => policy.tick === 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); }); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index 902559a4..289809e8 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, baseline = false) => { +const install = async (page: Page, denied = false, baseline: boolean | 'document' = false) => { const requests: { operation: string; input: Record }[] = []; await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_audit'); @@ -101,10 +101,10 @@ const install = async (page: Page, denied = false, baseline = false) => { sequence: '1', srcNationId: 2, destNationId: 3, - category: 'RELATION', + category: baseline === 'document' ? 'DOCUMENT' : 'RELATION', source: 'BASELINE', - eventType: 'RELATION_BASELINE', - documentId: null, + eventType: baseline === 'document' ? 'LETTER_BASELINE' : 'RELATION_BASELINE', + documentId: baseline === 'document' ? 8 : null, previousDocumentId: null, year: 190, month: 1, @@ -150,25 +150,38 @@ const install = async (page: Page, denied = false, baseline = false) => { sequence: '1', srcNationId: 2, destNationId: 3, - category: 'RELATION', + category: baseline === 'document' ? 'DOCUMENT' : 'RELATION', source: 'BASELINE', - eventType: 'RELATION_BASELINE', - documentId: null, + eventType: baseline === 'document' ? 'LETTER_BASELINE' : 'RELATION_BASELINE', + documentId: baseline === 'document' ? 8 : null, previousDocumentId: null, year: 190, month: 1, actor: null, createdAt: world.asOf, before: null, - after: { state: 2, term: 0, dead: 0 }, + after: + baseline === 'document' + ? { state: 'ACTIVATED' } + : { state: 2, term: 0, dead: 0 }, tick: '0', clockRevision: '1', ordinal: 1, executionId: 'relation-baseline', requestId: null, inputSequence: null, - documentStatus: 'NOT_APPLICABLE', - document: null, + documentStatus: baseline === 'document' ? 'AVAILABLE' : 'NOT_APPLICABLE', + document: + baseline === 'document' + ? { + id: 8, + writtenAt: '2026-09-01T00:00:00.000Z', + brief: '보유 협정', + briefHtml: '

보유 협정

', + detail: '

도입 전 본문

', + detailHtml: '

도입 전 본문

', + } + : null, }, }); return result({ @@ -927,3 +940,14 @@ test('diplomacy baseline displays observed state without a fictional document or await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0); expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(1); }); + +test('existing diplomacy document is an initial observation with its preserved source', async ({ page }) => { + await install(page, false, 'document'); + 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.getByRole('region', { name: '당시 외교 문서' })).toContainText('도입 전 본문'); + await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible(); +}); diff --git a/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue index 582369cf..ccb662e9 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 = { + LETTER_BASELINE: '문서 최초 관측', RELATION_BASELINE: '관계 최초 관측', LETTER_PROPOSED: '문서 제안', LETTER_REPLACED: '문서 교체', diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index fbb127e5..d3e98407 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -223,6 +223,27 @@ WAL 실측, 이후 신생국·소멸국 및 기존 문서의 도입 기준은 실제 격리 PostgreSQL startup 검증은 실패 rollback, PREOPEN 저장, 재시작의 동일 행과 표식 및 readiness 경계를 확인한다. UI는 이를 '관계 최초 관측'으로 구분한다. +### 기존 외교 문서 도입 기준 + +`databaseHooks.flushInitialAudit`는 문서 도입 표식이 없는 기수에만 seed와 같은 schema lock → lease → CLOCK → +GENERAL_ACCESS 순서의 transaction을 열고 clock authority와 기수 identity를 확인한다. +`documentBaseline.ts`가 문서를 id cursor 200건씩 읽어 기존 불변 원문 참조/hash와 +관측 당시 상태·서명·이전 문서 번호를 저장한다. 원문 자체를 사건에 복제하지 않는다. +현재 유효 문서뿐 아니라 보유 중인 교체·종료 문서도 보존하며 과거 승낙 시점이나 +actor는 만들지 않는다. 기존 API가 쓰던 상태 projection을 infra로 옮겨 함께 사용한다. + +문서 기준 사건·world 표식과 대기 중인 초기 상태/정책/관계가 같은 fenced transaction에서 +확정되고 commit 뒤에만 ack한다. 실패는 memory checkpoint를 복원한다. 문서가 0건이어도 +표식을 저장하고 일반 재시작에서는 문서 SELECT를 수행하지 않는다. 본문 mutation API와 +같은 CLOCK lock 아래서 읽으며, 조회 준비가 되기 전에 종료한다. + +비용 재검토: 최초에 작은 identity SELECT 1회, 문서 SELECT `floor(L/200)+1`회와 기존 +writer의 batch INSERT/hash 확인 SELECT를 사용한다. 원문은 해시 계산에 필요한 한 batch만 +유지하며 매 턴 다시 읽지 않는다. 초기 실패 복원을 위한 world memory checkpoint 1개의 +비용과 대규모 도입 transaction 시간·WAL은 전체 COST gate에서 측정해야 한다. +실제 PG fixture는 201건/2 batch와 INITIAL 저장 실패의 전체 rollback, 원문 hash와 상태, +재시작의 동일 event/표식을 확인한다. 화면에서는 '문서 최초 관측'으로 구분한다. + ## NPC·국방 정책 버전 저장 기반 `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다. diff --git a/packages/infra/src/playAuditDiplomacy.ts b/packages/infra/src/playAuditDiplomacy.ts index 909e270d..d1793ccb 100644 --- a/packages/infra/src/playAuditDiplomacy.ts +++ b/packages/infra/src/playAuditDiplomacy.ts @@ -1,3 +1,4 @@ +import { asRecord } from '@sammo-ts/common'; import { createHash } from 'node:crypto'; import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; @@ -88,3 +89,28 @@ export const persistAuditDiplomacyEvents = async ( throw new Error('Play audit diplomacy replay payload conflict'); } }; + +export const projectAuditDocumentState = ( + letter: Pick< + GamePrisma.DiplomacyLetterGetPayload>, + 'state' | 'srcSignerId' | 'destSignerId' | 'aux' + > +): Record => { + const aux = asRecord(letter.aux); + const src = asRecord(aux.src); + const dest = asRecord(aux.dest); + const reason = asRecord(aux.reason); + return { + state: letter.state, + srcSignerId: letter.srcSignerId, + destSignerId: letter.destSignerId, + srcNationName: typeof src.nationName === 'string' ? src.nationName : null, + destNationName: typeof dest.nationName === 'string' ? dest.nationName : null, + srcSignerName: typeof src.generalName === 'string' ? src.generalName : null, + destSignerName: typeof dest.generalName === 'string' ? dest.generalName : null, + stateOption: typeof aux.state_opt === 'string' ? aux.state_opt : null, + reason: typeof reason.reason === 'string' ? reason.reason : null, + reasonAction: typeof reason.action === 'string' ? reason.action : null, + reasonActorId: typeof reason.who === 'number' ? reason.who : null, + }; +};