From b9d9b63b14723c61b0b9388bcedd1bdf54c1d353 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 05:46:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=99=B8=EA=B5=90=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=9E=85=EB=A0=A5=20=EC=9B=90?= =?UTF-8?q?=EC=9E=A5=EA=B3=BC=20=ED=95=A8=EA=BB=98=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/context.ts | 3 + app/game-api/src/inputEventBoundary.ts | 20 ++- app/game-api/src/router/diplomacy/index.ts | 72 ++++---- .../src/services/playAuditDiplomacy.ts | 156 ++++++++++++++++++ app/game-api/src/trpc.ts | 3 +- ...lomacyDocumentMessages.integration.test.ts | 107 ++++++++++-- app/game-engine/src/playAudit/retention.ts | 12 ++ .../playAuditRetention.integration.test.ts | 33 ++++ .../architecture/game-clock-participants.json | 9 + docs/design/play-audit-implementation.md | 28 ++++ packages/infra/prisma/game.prisma | 35 ++++ .../migration.sql | 51 ++++++ packages/infra/src/db.ts | 1 + packages/infra/src/index.ts | 1 + packages/infra/src/playAuditDiplomacy.ts | 90 ++++++++++ 15 files changed, 576 insertions(+), 45 deletions(-) create mode 100644 app/game-api/src/services/playAuditDiplomacy.ts create mode 100644 packages/infra/prisma/migrations/20260916050000_add_play_audit_diplomacy/migration.sql create mode 100644 packages/infra/src/playAuditDiplomacy.ts diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index a39893cf..9d640ed6 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import type { ApiInputExecutionContext } from './inputEventBoundary.js'; import type { ChangeJournal } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra'; @@ -99,6 +100,8 @@ export type InputJsonValue = GamePrisma.InputJsonValue; export type DatabaseClient = InfraDatabaseClient; export interface GameApiContext { + /** 현재 API 업무 transaction이 잠근 입력 원장의 인증된 식별자다. */ + auditInput?: ApiInputExecutionContext; requestId?: string; generalAccessTracking?: boolean; /** Validated server-issued proof for one realtime refresh burst. */ diff --git a/app/game-api/src/inputEventBoundary.ts b/app/game-api/src/inputEventBoundary.ts index b0b29e9e..15a46dd8 100644 --- a/app/game-api/src/inputEventBoundary.ts +++ b/app/game-api/src/inputEventBoundary.ts @@ -18,6 +18,7 @@ export interface ApiInputPayloadIdentity { } interface LockedInputEvent { + sequence: bigint; target: 'API' | 'ENGINE'; eventType: string; payload: GamePrisma.JsonValue; @@ -27,6 +28,14 @@ interface LockedInputEvent { attempts: number; } +export interface ApiInputExecutionContext { + requestId: string; + sequence: bigint; + actorUserId: string | null; + eventType: string; + attempt: number; +} + type InputEventOutcome = { kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown }; @@ -115,6 +124,7 @@ const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise( GamePrisma.sql` SELECT + sequence, target, event_type AS "eventType", payload, @@ -235,7 +245,7 @@ export const executeInputEvent = async (options: { payload: unknown; actorUserId?: string | null; acquireClockFence?: boolean; - execute(db: DatabaseClient): Promise; + execute(db: DatabaseClient, context?: ApiInputExecutionContext): Promise; }): Promise => { const { db, requestId, eventType, payload, execute } = options; const actorUserId = options.actorUserId ?? null; @@ -275,7 +285,13 @@ export const executeInputEvent = async (options: { await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`); businessStarted = true; try { - const value = await execute(transaction); + const value = await execute(transaction, { + requestId, + sequence: row.sequence, + actorUserId, + eventType, + attempt: row.attempts + 1, + }); const durableResult = canonicalJsonValue(value); await transaction.$executeRaw(GamePrisma.sql` UPDATE input_event diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index 9bcf1e2d..803c2a47 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -15,6 +15,7 @@ import { import type { GameApiContext, GeneralRow, NationRow } from '../../context.js'; import { insertMessage } from '../../messages/store.js'; import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js'; +import { createDiplomacyDocumentAudit, projectAuditDocumentState } from '../../services/playAuditDiplomacy.js'; import { readDatabaseWallTime } from '../../services/wallClock.js'; import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; @@ -216,6 +217,7 @@ export const diplomacyRouter = router({ assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); + const audit = createDiplomacyDocumentAudit(ctx, me, permission); if (permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } @@ -262,20 +264,21 @@ export const diplomacyRouter = router({ } if (prevLetter.state === 'PROPOSED') { + const before = projectAuditDocumentState(prevLetter); const aux = asRecord(prevLetter.aux); aux.reason = { who: me.id, action: 'new_letter', reason: 'new_letter', }; - await ctx.db.diplomacyLetter.update({ + const updated = await ctx.db.diplomacyLetter.update({ where: { id: prevId }, data: { state: 'REPLACED', aux: aux as GamePrisma.InputJsonValue }, }); + audit.record(updated, before, 'LETTER_REPLACED'); } - destNationId = - prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId; + destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId; } const nations = await ctx.db.nation.findMany({ @@ -320,6 +323,7 @@ export const diplomacyRouter = router({ }, }); + audit.record(created, null, 'LETTER_PROPOSED'); const letterIdText = String(created.id); const josaYi = JosaUtil.pick(letterIdText, '이'); const text = prevId @@ -333,6 +337,7 @@ export const diplomacyRouter = router({ time: created.date, }); + await audit.flush(); return { id: created.id }; }), respondLetter: accessAuthedInputProcedure( @@ -347,6 +352,7 @@ export const diplomacyRouter = router({ assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); + const audit = createDiplomacyDocumentAudit(ctx, me, permission); if (permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } @@ -362,14 +368,11 @@ export const diplomacyRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' }); } - const { srcNation, destNation } = await loadLetterNations( - ctx, - letter.srcNationId, - letter.destNationId - ); + const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId); const messageSrc = buildActorTarget(me, destNation); const messageDest = buildNationTarget(srcNation); const messageTime = await readDatabaseWallTime(ctx.db); + const before = projectAuditDocumentState(letter); const aux = asRecord(letter.aux); let messageText: string; if (input.agree) { @@ -379,7 +382,7 @@ export const diplomacyRouter = router({ dest.generalIcon = messageSrc.icon; aux.dest = dest; - await ctx.db.diplomacyLetter.update({ + const updated = await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'ACTIVATED', @@ -388,16 +391,20 @@ export const diplomacyRouter = router({ }, }); + audit.record(updated, before, 'LETTER_ACCEPTED'); let prevId = letter.prevId; while (prevId) { const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } }); if (!prevLetter) { break; } - await ctx.db.diplomacyLetter.update({ + const updated = await ctx.db.diplomacyLetter.update({ where: { id: prevId }, data: { state: 'REPLACED' }, }); + if (prevLetter.state !== 'REPLACED') { + audit.record(updated, projectAuditDocumentState(prevLetter), 'LETTER_REPLACED'); + } prevId = prevLetter.prevId; } messageText = `외교 서신( #${letter.id})이 승인되었습니다.`; @@ -407,10 +414,11 @@ export const diplomacyRouter = router({ action: 'disagree', reason: input.reason ?? '', }; - await ctx.db.diplomacyLetter.update({ + const updated = await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); + audit.record(updated, before, 'LETTER_REJECTED'); messageText = `외교 서신(#${letter.id})이 거부되었습니다.`; if (input.reason && input.reason !== '0') { messageText += ` 이유 : ${input.reason}`; @@ -426,14 +434,16 @@ export const diplomacyRouter = router({ includeNational: true, }); + await audit.flush(); return { ok: true }; }), - rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })) - .mutation(async ({ ctx, input }) => { + rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })).mutation( + async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); + const audit = createDiplomacyDocumentAudit(ctx, me, permission); if (permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } @@ -449,14 +459,11 @@ export const diplomacyRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' }); } - const { srcNation, destNation } = await loadLetterNations( - ctx, - letter.srcNationId, - letter.destNationId - ); + const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId); const messageSrc = buildActorTarget(me, srcNation); const messageDest = buildNationTarget(destNation); const messageTime = await readDatabaseWallTime(ctx.db); + const before = projectAuditDocumentState(letter); const aux = asRecord(letter.aux); aux.reason = { who: me.id, @@ -464,11 +471,12 @@ export const diplomacyRouter = router({ reason: '회수', }; - await ctx.db.diplomacyLetter.update({ + const updated = await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); + audit.record(updated, before, 'LETTER_WITHDRAWN'); await sendDocumentNotice({ ctx, src: messageSrc, @@ -477,14 +485,17 @@ export const diplomacyRouter = router({ time: messageTime, }); + await audit.flush(); return { ok: true }; - }), - destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })) - .mutation(async ({ ctx, input }) => { + } + ), + destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })).mutation( + async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); + const audit = createDiplomacyDocumentAudit(ctx, me, permission); if (permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } @@ -500,6 +511,7 @@ export const diplomacyRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' }); } + const before = projectAuditDocumentState(letter); const aux = asRecord(letter.aux); const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null; const myStateOpt = letter.srcNationId === me.nationId ? 'try_destroy_src' : 'try_destroy_dest'; @@ -508,11 +520,7 @@ export const diplomacyRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' }); } - const { srcNation, destNation } = await loadLetterNations( - ctx, - letter.srcNationId, - letter.destNationId - ); + const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId); const actorNation = letter.srcNationId === me.nationId ? srcNation : destNation; const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation; const messageSrc = buildActorTarget(me, actorNation); @@ -522,18 +530,20 @@ export const diplomacyRouter = router({ let messageText: string; if (stateOpt && stateOpt !== myStateOpt) { - await ctx.db.diplomacyLetter.update({ + const updated = await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); + audit.record(updated, before, 'LETTER_DESTROYED'); resultState = 'CANCELLED'; messageText = `외교 서신(#${letter.id})을 파기했습니다.`; } else { aux.state_opt = myStateOpt; - await ctx.db.diplomacyLetter.update({ + const updated = await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { aux: aux as GamePrisma.InputJsonValue }, }); + audit.record(updated, before, 'LETTER_DESTROY_REQUESTED'); resultState = 'ACTIVATED'; messageText = `외교 서신(#${letter.id})을 파기 요청합니다.`; } @@ -545,6 +555,8 @@ export const diplomacyRouter = router({ text: messageText, time: messageTime, }); + await audit.flush(); return { state: resultState }; - }), + } + ), }); diff --git a/app/game-api/src/services/playAuditDiplomacy.ts b/app/game-api/src/services/playAuditDiplomacy.ts new file mode 100644 index 00000000..f0c3db17 --- /dev/null +++ b/app/game-api/src/services/playAuditDiplomacy.ts @@ -0,0 +1,156 @@ +import { asRecord, GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common'; +import { + GamePrisma, + hashAuditDiplomacyDocument, + persistAuditDiplomacyEvents, + readTurnRuntimeReady, + type AuditDiplomacyEventDraft, +} from '@sammo-ts/infra'; +import type { GameApiContext, GeneralRow } from '../context.js'; + +type Letter = GamePrisma.DiplomacyLetterGetPayload>; +type DocumentAction = + | 'LETTER_PROPOSED' + | 'LETTER_REPLACED' + | 'LETTER_ACCEPTED' + | 'LETTER_REJECTED' + | 'LETTER_WITHDRAWN' + | '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, + }; +}; + +interface AuditCoordinateRow { + serverId: string | null; + year: number; + month: number; + wallNow: Date; + clockBaseTime: Date | null; + clockTick: bigint | null; + clockMode: string; + clockWallAnchor: Date | null; + clockPhase: string; + clockRevision: bigint; + clockRecoveryStartTick: bigint | null; + clockRecoveryEndTick: bigint | null; + clockRecoveryStartWallAt: Date | null; + tickSeconds: number; +} + +/** API 입력 transaction의 기존 clock fence 안에서 작은 시계/기수 투영만 읽는다. */ +const readCoordinate = async (ctx: GameApiContext) => { + const [row] = await ctx.db.$queryRaw(GamePrisma.sql` + SELECT meta->>'serverId' AS "serverId", current_year AS year, current_month AS month, + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow", + clock_base_time AS "clockBaseTime", clock_tick AS "clockTick", clock_mode AS "clockMode", + clock_wall_anchor AS "clockWallAnchor", clock_phase AS "clockPhase", clock_revision AS "clockRevision", + clock_recovery_start_tick AS "clockRecoveryStartTick", clock_recovery_end_tick AS "clockRecoveryEndTick", + clock_recovery_start_wall_at AS "clockRecoveryStartWallAt", tick_seconds AS "tickSeconds" + FROM world_state ORDER BY id LIMIT 1 + `); + if (!row?.serverId?.trim()) return null; + let tick: bigint | null = null; + if (row.clockBaseTime && row.clockTick !== null && row.clockWallAnchor) { + const clockTick = Number(row.clockTick); + const revision = Number(row.clockRevision); + if (!Number.isSafeInteger(clockTick) || !Number.isSafeInteger(revision)) + throw new Error('Play audit diplomacy clock outside safe integer range'); + const mode = row.clockMode === 'manual' ? 'manual' : 'realtime'; + const phase = row.clockPhase ? parseGameClockPhase(row.clockPhase) : inferClockPhase(mode); + const clock = new GameClock({ + baseTime: row.clockBaseTime, + tick: clockTick, + mode, + wallAnchor: row.clockWallAnchor, + recovery: readTurnRecovery(row), + turnSeconds: row.tickSeconds, + phase, + revision, + }); + const ready = + phase !== 'RUNNING' || mode !== 'realtime' || (await readTurnRuntimeReady(ctx.db, row.clockRevision)); + tick = BigInt(ready ? clock.nowTick(row.wallNow) : clock.tick); + } + return { + serverId: row.serverId, + year: row.year, + month: row.month, + tick, + clockRevision: row.clockRevision, + wallAt: row.wallNow, + }; +}; + +export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => { + const changes: { + letter: Letter; + before: Record | null; + after: Record; + eventType: DocumentAction; + }[] = []; + return { + record: (letter: Letter, before: Record | null, eventType: DocumentAction) => { + if (!ctx.auditInput) return; + changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType }); + }, + flush: async (): Promise => { + // 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다. + const input = ctx.auditInput; + if (!input || !changes.length) return; + if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id) + throw new Error('Play audit diplomacy actor mismatch'); + const coordinate = await readCoordinate(ctx); + if (!coordinate) return; + const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({ + schemaVersion: 1, + serverId: coordinate.serverId, + srcNationId: change.letter.srcNationId, + destNationId: change.letter.destNationId, + category: 'DOCUMENT', + source: 'API', + eventType: change.eventType, + documentId: change.letter.id, + documentHash: hashAuditDiplomacyDocument(change.letter), + previousDocumentId: change.letter.prevId, + year: coordinate.year, + month: coordinate.month, + tick: coordinate.tick, + clockRevision: coordinate.clockRevision, + executionId: `api:${input.requestId}`, + ordinal: index + 1, + requestId: input.requestId, + inputSequence: input.sequence, + actor: { + userId: actor.userId, + generalId: actor.id, + name: actor.name, + nationId: actor.nationId, + officerLevel: actor.officerLevel, + npcState: actor.npcState, + permission, + }, + before: change.before, + after: change.after, + })); + await persistAuditDiplomacyEvents(ctx.db, events); + }, + }; +}; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 154837c0..1745bfbd 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -81,11 +81,12 @@ const createInputEventMiddleware = (acquireClockFence: boolean) => payload, actorUserId: ctx.auth?.user.id, acquireClockFence, - execute: async (transaction) => { + execute: async (transaction, auditInput) => { const result = await next({ ctx: { ...ctx, db: transaction, + auditInput, changeJournal, turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId), }, diff --git a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts index 91319879..1a8bbe3e 100644 --- a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts +++ b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { JosaUtil, MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; +import { JosaUtil } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; import { @@ -15,7 +15,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import type { GameApiContext } from '../src/context.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; -import { fetchMessagesFromMailbox, invalidateMessages } from '../src/messages/store.js'; +import { fetchMessagesFromMailbox, tombstoneMessages } from '../src/messages/store.js'; import { appRouter } from '../src/router.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; @@ -82,6 +82,7 @@ integration('diplomacy document message persistence', () => { }; const cleanupRouteState = async (): Promise => { + await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: requestPrefix } }); await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } }); await db.diplomacyLetter.deleteMany({ where: { @@ -231,8 +232,9 @@ integration('diplomacy document message persistence', () => { type, src: srcMailbox, dest: destMailbox, - time: logicalGameTime, - timeTick: logicalGameTick, + time: expect.any(Date), + timeTick: null, + occurredGameTick: null, }); expect(payload).toMatchObject({ src: options.src, @@ -295,7 +297,7 @@ integration('diplomacy document message persistence', () => { clockMode: 'manual', clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'), config: {}, - meta: {}, + meta: { serverId: requestPrefix }, }, }); await db.nation.createMany({ @@ -382,7 +384,7 @@ integration('diplomacy document message persistence', () => { await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId); }); - it('keeps permanent messages readable without a clock and does not resurrect them after invalidation', async () => { + it('keeps permanent messages readable without a clock and preserves tombstoned content after clock recovery', async () => { const created = await appRouter .createCaller(buildContext('legacy-clock-fallback', fixtureAuth)) .diplomacy.sendLetter({ @@ -395,7 +397,7 @@ integration('diplomacy document message persistence', () => { where: { mailbox: receiverMailbox, type: 'diplomacy' }, orderBy: { id: 'desc' }, }); - expect(receiver.validUntilTick).toBe(BigInt(MAX_SAFE_GAME_TICK)); + expect(receiver.validUntilTick).toBeNull(); await db.worldState.update({ where: { id: fixtureWorldStateId }, @@ -416,10 +418,10 @@ integration('diplomacy document message persistence', () => { }) ); - await invalidateMessages(db, [receiver.id]); + await tombstoneMessages(db, [receiver.id]); await expect( - db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { validUntilTick: true } }) - ).resolves.toEqual({ validUntilTick: 0n }); + db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { tombstonedAtWall: true } }) + ).resolves.toEqual({ tombstonedAtWall: expect.any(Date) }); await expect( fetchMessagesFromMailbox({ db, @@ -428,7 +430,7 @@ integration('diplomacy document message persistence', () => { limit: 15, fromSeq: 0, }) - ).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id })); + ).resolves.toContainEqual(expect.objectContaining({ id: receiver.id, text: '삭제된 메시지입니다.' })); } finally { await db.worldState.update({ where: { id: fixtureWorldStateId }, @@ -448,7 +450,7 @@ integration('diplomacy document message persistence', () => { limit: 15, fromSeq: 0, }) - ).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id })); + ).resolves.toContainEqual(expect.objectContaining({ id: receiver.id, text: '삭제된 메시지입니다.' })); }); it('stores diplomacy and national copies for both approval and rejection responses', async () => { @@ -561,6 +563,86 @@ integration('diplomacy document message persistence', () => { await expectInputEvent(completeRequestId, 'destroyLetter', foreignUserId); }); + it('records ordered document transitions once, bound to durable input and immutable content', async () => { + const input = { destNationId: foreignNationId, brief: '감사 문서', detail: '불변 본문' }; + const sender = appRouter.createCaller(buildContext('audit-send', fixtureAuth)); + const created = await sender.diplomacy.sendLetter(input); + await expect(sender.diplomacy.sendLetter(input)).resolves.toEqual(created); + await appRouter.createCaller(buildContext('audit-accept', foreignAuth)).diplomacy.respondLetter({ + letterId: created.id, + agree: true, + }); + await appRouter + .createCaller(buildContext('audit-destroy-src', fixtureAuth)) + .diplomacy.destroyLetter({ letterId: created.id }); + await appRouter + .createCaller(buildContext('audit-destroy-dest', foreignAuth)) + .diplomacy.destroyLetter({ letterId: created.id }); + const events = await db.playAuditDiplomacyEvent.findMany({ + where: { serverId: requestPrefix }, + orderBy: { sequence: 'asc' }, + }); + expect(events.map((event) => event.eventType)).toEqual([ + 'LETTER_PROPOSED', + 'LETTER_ACCEPTED', + 'LETTER_DESTROY_REQUESTED', + 'LETTER_DESTROYED', + ]); + expect(events[0]).toMatchObject({ + year: 208, + month: 4, + tick: logicalGameTick, + documentId: created.id, + before: null, + actor: { userId: fixtureUserId, generalId: fixtureGeneralId }, + }); + expect(events[1]).toMatchObject({ + before: { state: 'PROPOSED', destSignerId: null }, + after: { state: 'ACTIVATED', destSignerId: foreignGeneralId }, + }); + expect(events[2]).toMatchObject({ before: { stateOption: null }, after: { stateOption: 'try_destroy_src' } }); + expect(events[3]).toMatchObject({ before: { state: 'ACTIVATED' }, after: { state: 'CANCELLED' } }); + expect(new Set(events.map((event) => event.documentHash)).size).toBe(1); + for (const event of events) { + const journal = await db.inputEvent.findUniqueOrThrow({ where: { requestId: event.requestId! } }); + expect(event.inputSequence).toBe(journal.sequence); + expect(JSON.stringify(event.after)).not.toContain('불변 본문'); + } + await expect( + db.diplomacyLetter.update({ where: { id: created.id }, data: { textDetail: '변조' } }) + ).rejects.toThrow('Diplomacy document content is immutable'); + expect((await db.diplomacyLetter.findUniqueOrThrow({ where: { id: created.id } })).textDetail).toBe( + '불변 본문' + ); + }); + + it('rolls back the document and notices if the audit insert fails', async () => { + await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`); + await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event + FOR EACH ROW EXECUTE FUNCTION reject_audit_document_fixture()`); + try { + await expect( + appRouter.createCaller(buildContext('audit-failure', fixtureAuth)).diplomacy.sendLetter({ + destNationId: foreignNationId, + brief: '감사 실패', + detail: '롤백', + }) + ).rejects.toThrow('injected audit insert failure'); + expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0); + expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0); + expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0); + expect( + await db.inputEvent.findUniqueOrThrow({ + where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` }, + }) + ).toMatchObject({ status: 'FAILED', attempts: 1 }); + } finally { + await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event'); + await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()'); + } + }); + it('rolls letter and message writes back together while retaining the failed API input event', async () => { const failure = new Error('injected diplomacy message transaction rollback'); const requestId = 'send-rollback'; @@ -576,6 +658,7 @@ integration('diplomacy document message persistence', () => { await expect(db.diplomacyLetter.findFirst({ where: { textBrief: 'rollback 외교문서' } })).resolves.toBeNull(); await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0); + await expect(db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).resolves.toBe(0); await expect( db.readModelRevision.count({ where: { diff --git a/app/game-engine/src/playAudit/retention.ts b/app/game-engine/src/playAudit/retention.ts index 9fe81868..8ead1fd6 100644 --- a/app/game-engine/src/playAudit/retention.ts +++ b/app/game-engine/src/playAudit/retention.ts @@ -20,6 +20,18 @@ export const prunePreviousAuditBatch = async ( if (!lock?.locked) return { status: 'busy', deleted: 0 }; const world = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } }); if (asRecord(world?.meta).serverId !== expectedServerId) return { status: 'identityChanged', deleted: 0 }; + const diplomacyEvents = await tx.playAuditDiplomacyEvent.findMany({ + where: { serverId: { not: expectedServerId } }, + orderBy: { sequence: 'asc' }, + take: AUDIT_RETENTION_BATCH_SIZE, + select: { id: true }, + }); + if (diplomacyEvents.length) { + const deleted = await tx.playAuditDiplomacyEvent.deleteMany({ + where: { id: { in: diplomacyEvents.map((row) => row.id) } }, + }); + return { status: 'progress', deleted: deleted.count }; + } const policies = await tx.playAuditPolicy.findMany({ where: { serverId: { not: expectedServerId } }, orderBy: { id: 'asc' }, diff --git a/app/game-engine/test/playAuditRetention.integration.test.ts b/app/game-engine/test/playAuditRetention.integration.test.ts index 11aa281f..09b0e55e 100644 --- a/app/game-engine/test/playAuditRetention.integration.test.ts +++ b/app/game-engine/test/playAuditRetention.integration.test.ts @@ -215,4 +215,37 @@ integration('bounded previous-season audit retention', () => { expect(await prunePreviousAuditBatch(db, 'policy-active')).toEqual({ status: 'complete', deleted: 0 }); expect(await db.playAuditPolicy.findUnique({ where: { id: 'active-policy' } })).not.toBeNull(); }); + it('prunes diplomacy history in bounded batches without touching the active season', async () => { + await db.playAuditMonth.deleteMany(); + await db.playAuditPolicy.deleteMany(); + await db.playAuditDiplomacyEvent.deleteMany(); + await db.worldState.updateMany({ data: { meta: { serverId: 'diplomacy-active' } } }); + const row = (id: string, serverId: string, ordinal: number) => ({ + id, + serverId, + ordinal, + executionId: 'fixture', + nationA: 1, + nationB: 2, + srcNationId: 1, + destNationId: 2, + category: 'DOCUMENT', + source: 'API', + eventType: 'LETTER_PROPOSED', + year: 190, + month: 1, + hash: id, + }); + await db.playAuditDiplomacyEvent.createMany({ + data: [ + ...Array.from({ length: 201 }, (_, index) => row(`old-diplomacy-${index}`, 'old-diplomacy', index + 1)), + row('active-diplomacy', 'diplomacy-active', 1), + ], + }); + expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'progress', deleted: 200 }); + expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'progress', deleted: 1 }); + expect(await prunePreviousAuditBatch(db, 'diplomacy-active')).toEqual({ status: 'complete', deleted: 0 }); + expect(await db.playAuditDiplomacyEvent.findUnique({ where: { id: 'active-diplomacy' } })).not.toBeNull(); + await db.playAuditDiplomacyEvent.deleteMany(); + }); }); diff --git a/docs/architecture/game-clock-participants.json b/docs/architecture/game-clock-participants.json index 06d88987..0ffc860c 100644 --- a/docs/architecture/game-clock-participants.json +++ b/docs/architecture/game-clock-participants.json @@ -4,6 +4,7 @@ "tickPerTurn": 36000000, "policies": ["SHIFT", "KEEP", "REBUILD", "FORBID"], "coveredFields": [ + "play_audit_diplomacy_event.clock_revision", "input_event.accepted_game_tick", "input_event.accepted_clock_revision", "input_event.accepted_deadline_generation", @@ -84,6 +85,14 @@ "board, authentication, account, audit, and operator timestamps" ], "participants": [ + { + "key": "play-audit-diplomacy-occurrence", + "policy": "KEEP", + "authorityFields": ["play_audit_diplomacy_event.tick", "play_audit_diplomacy_event.clock_revision"], + "projectionFields": [], + "owner": "game-api/play-audit-diplomacy", + "migration": "Historical occurrence coordinates remain in their original revision; never shift them with future deadlines." + }, { "key": "world-clock", "policy": "REBUILD", diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 479499ed..d4f21b36 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -88,6 +88,34 @@ R1을 완료했다고 판단하지 않는다. world metadata로 계산하며 추가 DB 조회·쓰기나 시나리오/AI 규칙 변경은 없다. PREOPEN은 wall-clock 대기 상태이며, 검증하는 것은 공식 개방 때의 논리 게임 달력이다. +## 외교 문서 상태 이벤트 저장 기반 + +`diplomacy.sendLetter/respondLetter/rollbackLetter/destroyLetter`의 기존 입력 원장 +transaction에 제안·교체·승인·거절·회수·파기 요청·파기를 연결했다. 기존 SELECT와 +UPDATE 반환값에서 변경 전후 allowlist를 만들고, 원장 잠금 SELECT의 sequence를 +재사용한다. 감사 실패는 문서/알림과 함께 rollback하며 실패한 입력 원장은 남는다. +성공 재요청은 기존 결과를 반환해 이벤트를 중복 저장하지 않는다. + +- migration55의 `play_audit_diplomacy_event`는 기수, 방향 있는 국가쌍, 실행/로컬 순번, + DB sequence, 처리 당시 달력/tick/revision, actor와 작은 상태 전후 값을 저장한다. + DB sequence와 입력 접수 sequence는 다른 개념이며 숫자 간격은 허용한다. +- 본문은 기존 `diplomacy_letter`를 ID/hash로 참조한다. 작성 본문·작성자·작성 시각· + 국가쌍·prevId의 UPDATE를 DB trigger로 거부하고, 기존 새 문서 작성 경로를 유지한다. + 상태·서명·aux 갱신은 허용한다. 기존 문서의 과거 상태를 소급 생성하지 않는다. +- 문서 쓰기 요청당 작은 world/clock SELECT 1회, RUNNING realtime이면 readiness + SELECT 1회, 이벤트 200개당 bulk INSERT와 ID/hash 확인 SELECT 각 1회가 추가된다. + 원문 SELECT와 본문 복사는 추가하지 않는다. 기존 알림 wall time 조회와 결합해 + 줄일 여지는 남으며, SQL/WAL 비용 gate의 실측 완료를 뜻하지 않는다. +- reset 뒤 이전 이벤트는 기존 retention 경로에서 ID 최대200개씩 정리한다. + 과거 tick/revision은 시계 이동 대상이 아닌 KEEP 이력으로 등록한다. +- 기존 `rollbackLetter`는 회수다. 현재 복구 mutation은 없어 복구 이력을 꾸며내지 않는다. + +실제 PostgreSQL에서 문서 8개 시나리오, unit 7건, retention 4건, 신규55개/ +증분54→55/재실행 migration을 검증했다. 외교 상태의 API 즉시 응답·엔진 월간/턴 +변경, 도입 당시 기준, 감사 조회 API/UI는 후속 구현이며 **R4 전체 완료가 아니다**. +일반 외교 알림은 WALL_TIME이고 제의 처리와 tombstone은 구분한다. 오래된 알림 +테스트의 게임 tick 가정을 현행 envelope 계약에 맞췄다. + ## NPC·국방 정책 버전 저장 기반 `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다. diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 3d1280bc..d6fe82af 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -1166,3 +1166,38 @@ model PlayAuditPolicy { @@index([serverId, nationId, year, month, ordinal]) @@map("play_audit_policy") } + +model PlayAuditDiplomacyEvent { + id String @id + sequence BigInt @unique @default(autoincrement()) + schemaVersion Int @default(1) @map("schema_version") + serverId String @map("server_id") + nationA Int @map("nation_a") + nationB Int @map("nation_b") + srcNationId Int @map("src_nation_id") + destNationId Int @map("dest_nation_id") + category String + source String + eventType String @map("event_type") + documentId Int? @map("document_id") + documentHash String? @map("document_hash") + previousDocumentId Int? @map("previous_document_id") + year Int + month Int + tick BigInt? + clockRevision BigInt? @map("clock_revision") + executionId String @map("execution_id") + ordinal Int + requestId String? @map("request_id") + inputSequence BigInt? @map("input_sequence") + actor Json? + before Json? + after Json? + hash String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + @@unique([serverId, executionId, ordinal]) + @@index([serverId, nationA, nationB, sequence], map: "audit_diplomacy_pair_sequence_idx") + @@index([serverId, documentId, sequence]) + @@map("play_audit_diplomacy_event") +} diff --git a/packages/infra/prisma/migrations/20260916050000_add_play_audit_diplomacy/migration.sql b/packages/infra/prisma/migrations/20260916050000_add_play_audit_diplomacy/migration.sql new file mode 100644 index 00000000..eb6c6ac0 --- /dev/null +++ b/packages/infra/prisma/migrations/20260916050000_add_play_audit_diplomacy/migration.sql @@ -0,0 +1,51 @@ +CREATE TABLE "play_audit_diplomacy_event" ( + "id" TEXT PRIMARY KEY, + "sequence" BIGSERIAL NOT NULL UNIQUE, + "schema_version" INTEGER NOT NULL DEFAULT 1 CHECK ("schema_version" > 0), + "server_id" TEXT NOT NULL, + "nation_a" INTEGER NOT NULL, + "nation_b" INTEGER NOT NULL, + "src_nation_id" INTEGER NOT NULL, + "dest_nation_id" INTEGER NOT NULL, + "category" TEXT NOT NULL CHECK ("category" IN ('DOCUMENT', 'RELATION')), + "source" TEXT NOT NULL CHECK ("source" IN ('API', 'ENGINE', 'BASELINE')), + "event_type" TEXT NOT NULL, + "document_id" INTEGER, + "document_hash" TEXT, + "previous_document_id" INTEGER, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL CHECK ("month" BETWEEN 1 AND 12), + "tick" BIGINT, + "clock_revision" BIGINT, + "execution_id" TEXT NOT NULL, + "ordinal" INTEGER NOT NULL CHECK ("ordinal" > 0), + "request_id" TEXT, + "input_sequence" BIGINT, + "actor" JSONB, + "before" JSONB, + "after" JSONB, + "hash" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ("nation_a" <= "nation_b") +); +CREATE UNIQUE INDEX "play_audit_diplomacy_event_server_id_execution_id_ordinal_key" + ON "play_audit_diplomacy_event" ("server_id", "execution_id", "ordinal"); +CREATE INDEX "audit_diplomacy_pair_sequence_idx" + ON "play_audit_diplomacy_event" ("server_id", "nation_a", "nation_b", "sequence"); +CREATE INDEX "play_audit_diplomacy_event_server_id_document_id_sequence_idx" + ON "play_audit_diplomacy_event" ("server_id", "document_id", "sequence"); + +-- 수정은 새 문서(prev_id)로 만든다. 원문을 한 번만 참조할 수 있도록 기존 작성 내용을 고정한다. +CREATE FUNCTION preserve_diplomacy_document_content() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF ROW(NEW.id, NEW.src_nation_id, NEW.dest_nation_id, NEW.prev_id, NEW.text_brief, NEW.text_detail, NEW.src_signer, NEW.date) + IS DISTINCT FROM + ROW(OLD.id, OLD.src_nation_id, OLD.dest_nation_id, OLD.prev_id, OLD.text_brief, OLD.text_detail, OLD.src_signer, OLD.date) + THEN + RAISE EXCEPTION 'Diplomacy document content is immutable; create a replacement document'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER preserve_diplomacy_document_content BEFORE UPDATE ON "diplomacy_letter" + FOR EACH ROW EXECUTE FUNCTION preserve_diplomacy_document_content(); diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 5ad49282..6bd20b7e 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -23,6 +23,7 @@ export interface DatabaseClient { playAuditNation: GamePrisma.PlayAuditNationDelegate; playAuditCity: GamePrisma.PlayAuditCityDelegate; playAuditGeneral: GamePrisma.PlayAuditGeneralDelegate; + playAuditDiplomacyEvent: GamePrisma.PlayAuditDiplomacyEventDelegate; rankData: GamePrisma.RankDataDelegate; hallOfFame: GamePrisma.HallOfFameDelegate; gameHistory: GamePrisma.GameHistoryDelegate; diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index 6100c22a..5c5cd916 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -11,5 +11,6 @@ export * from './readModelOutboxDispatcher.js'; export * from './readModelCoverageActivation.js'; export * from './gameSchemaAdvisoryLock.js'; export * from './inputEventClock.js'; +export * from './playAuditDiplomacy.js'; export * from './messageEnvelope.js'; export * from './webPushOutbox.js'; diff --git a/packages/infra/src/playAuditDiplomacy.ts b/packages/infra/src/playAuditDiplomacy.ts new file mode 100644 index 00000000..909e270d --- /dev/null +++ b/packages/infra/src/playAuditDiplomacy.ts @@ -0,0 +1,90 @@ +import { createHash } from 'node:crypto'; +import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; + +export interface AuditDiplomacyEventDraft { + schemaVersion: 1; + serverId: string; + srcNationId: number; + destNationId: number; + category: 'DOCUMENT' | 'RELATION'; + source: 'API' | 'ENGINE' | 'BASELINE'; + eventType: string; + documentId: number | null; + documentHash: string | null; + previousDocumentId: number | null; + year: number; + month: number; + tick: bigint | null; + clockRevision: bigint | null; + executionId: string; + ordinal: number; + requestId: string | null; + inputSequence: bigint | null; + actor: Record | null; + before: Record | null; + after: Record | null; +} + +export const hashAuditDiplomacy = (value: unknown): string => + createHash('sha256') + .update( + JSON.stringify(value, (_key, item: unknown) => { + if (typeof item === 'bigint') return item.toString(); + if (item && typeof item === 'object' && !Array.isArray(item)) + return Object.fromEntries( + Object.entries(item).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + ); + return item; + }) + ) + .digest('hex'); + +/** 본문은 불변 문서 행을 참조한다. event별로 HTML을 복제하지 않는다. */ +export const hashAuditDiplomacyDocument = (letter: { + id: number; + srcNationId: number; + destNationId: number; + prevId: number | null; + textBrief: string; + textDetail: string; + srcSignerId: number; + date: Date; +}): string => + hashAuditDiplomacy({ + id: letter.id, + srcNationId: letter.srcNationId, + destNationId: letter.destNationId, + prevId: letter.prevId, + textBrief: letter.textBrief, + textDetail: letter.textDetail, + srcSignerId: letter.srcSignerId, + date: letter.date.toISOString(), + }); + +export const persistAuditDiplomacyEvents = async ( + db: Pick, + events: readonly AuditDiplomacyEventDraft[] +): Promise => { + const json = (value: Record | null) => + value === null ? GamePrisma.DbNull : (JSON.parse(JSON.stringify(value)) as GamePrisma.InputJsonValue); + for (let offset = 0; offset < events.length; offset += 200) { + const batch = events.slice(offset, offset + 200).map((event) => ({ + ...event, + id: hashAuditDiplomacy([event.serverId, event.executionId, event.ordinal]), + nationA: Math.min(event.srcNationId, event.destNationId), + nationB: Math.max(event.srcNationId, event.destNationId), + actor: json(event.actor), + before: json(event.before), + after: json(event.after), + hash: hashAuditDiplomacy(event), + })); + await db.playAuditDiplomacyEvent.createMany({ data: batch, skipDuplicates: true }); + const saved = await db.playAuditDiplomacyEvent.findMany({ + where: { id: { in: batch.map((event) => event.id) } }, + select: { id: true, hash: true }, + }); + const hashes = new Map(saved.map((row) => [row.id, row.hash])); + if (batch.some((event) => hashes.get(event.id) !== event.hash)) + throw new Error('Play audit diplomacy replay payload conflict'); + } +};