diff --git a/app/game-api/src/messages/diplomaticResponse.ts b/app/game-api/src/messages/diplomaticResponse.ts index 93889b11..8ed21402 100644 --- a/app/game-api/src/messages/diplomaticResponse.ts +++ b/app/game-api/src/messages/diplomaticResponse.ts @@ -1,4 +1,6 @@ import { TRPCError } from '@trpc/server'; +import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra'; +import type { ApiInputExecutionContext } from '../inputEventBoundary.js'; import { asRecord, JosaUtil } from '@sammo-ts/common'; import { @@ -95,12 +97,25 @@ const persistEffects = async ( year: number, month: number, at: Date, - serverId: string | null + serverId: string | null, + audit?: { + before: GamePrisma.DiplomacyGetPayload>[]; + base: Omit; + } ): Promise => { + const changes: AuditDiplomacyEventDraft[] = []; + const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row])); + const project = (row: GamePrisma.DiplomacyGetPayload>) => ({ + state: row.stateCode, + term: row.term, + isDead: row.isDead, + isShowing: row.isShowing, + dead: typeof asRecord(row.meta).dead === 'number' ? asRecord(row.meta).dead : 0, + }); const logs: LogEntryDraft[] = []; for (const effect of effects) { if (effect.type === 'diplomacy:patch') { - await db.diplomacy.update({ + const updated = await db.diplomacy.update({ where: { srcNationId_destNationId: { srcNationId: effect.srcNationId, @@ -113,6 +128,24 @@ const persistEffects = async ( ...(effect.patch.meta !== undefined ? { meta: effect.patch.meta as InputJsonValue } : {}), }, }); + if (audit) { + const key = `${effect.srcNationId}:${effect.destNationId}`; + const previous = states.get(key); + if (!previous) throw new Error('Missing locked diplomacy audit state'); + const before = project(previous); + const after = project(updated); + if (JSON.stringify(before) !== JSON.stringify(after)) { + changes.push({ + ...audit.base, + srcNationId: effect.srcNationId, + destNationId: effect.destNationId, + ordinal: changes.length + 1, + before, + after, + }); + } + states.set(key, updated); + } } else if (effect.type === 'nation:patch' && effect.targetId !== undefined) { const patch = effect.patch; await db.nation.update({ @@ -126,6 +159,7 @@ const persistEffects = async ( } } await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId); + await persistAuditDiplomacyEvents(db, changes); }; const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise => { @@ -182,6 +216,7 @@ export const respondToDiplomaticMessage = async (options: { actor: GeneralRow; messageId: number; response: boolean; + auditInput?: ApiInputExecutionContext; }): Promise => { const { db, actor, messageId, response } = options; const message = await fetchMessageByIdForUpdate(db, messageId); @@ -197,7 +232,8 @@ export const respondToDiplomaticMessage = async (options: { } const serverIdValue = asRecord(world.meta).serverId; const serverId = typeof serverIdValue === 'string' && serverIdValue.trim() ? serverIdValue : null; - const now = (await loadCurrentGameTime(db)).now; + const gameTime = await loadCurrentGameTime(db); + const now = gameTime.now; const action = parseAction(message.payload.option?.action); if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) { throw new TRPCError({ code: 'BAD_REQUEST', message: '응답할 수 없는 메시지입니다.' }); @@ -421,7 +457,49 @@ export const respondToDiplomaticMessage = async (options: { ...(action === 'noAggression' ? { treatyYear: treatyYear!, treatyMonth: treatyMonth! } : {}), } ); - await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now, serverId); + const auditInput = options.auditInput; + if (auditInput && auditInput.actorUserId !== actor.userId) throw new Error('Diplomacy audit actor mismatch'); + await persistEffects( + db, + resolution.effects, + world.currentYear, + world.currentMonth, + now, + serverId, + auditInput && serverId + ? { + before: [actorDiplomacy, reverseDiplomacy], + base: { + schemaVersion: 1, + serverId, + category: 'RELATION', + source: 'API', + eventType: `MESSAGE_ACCEPTED_${action}`, + documentId: null, + documentHash: null, + previousDocumentId: null, + year: world.currentYear, + month: world.currentMonth, + tick: gameTime.tick === null ? null : BigInt(gameTime.tick), + clockRevision: gameTime.revision == null ? null : BigInt(gameTime.revision), + executionId: `api:${auditInput.requestId}`, + requestId: auditInput.requestId, + inputSequence: auditInput.sequence, + actor: { + userId: actor.userId, + generalId: actor.id, + name: actor.name, + nationId: actor.nationId, + officerLevel: actor.officerLevel, + npcState: actor.npcState, + permission: resolveNationPermission(actor, actorNation.meta, false), + messageId, + }, + }, + } + : undefined + ); + let affectedCityIds: number[] = []; if (resolution.refreshFront) { const worldConfig = asRecord(world.config); diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 9ec56e6e..c849c58a 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -371,7 +371,7 @@ export const messagesRouter = router({ eventType: 'messages.respond.diplomatic', payload: input, actorUserId: ctx.auth?.user.id, - execute: async (transaction) => { + execute: async (transaction, auditInput) => { const transactionContext = { ...ctx, db: transaction, changeJournal }; const transactionGeneral = ownsChangeJournal ? await getOwnedGeneral(transactionContext, input.generalId) @@ -379,6 +379,7 @@ export const messagesRouter = router({ const result = await respondToDiplomaticMessage({ db: transaction, actor: transactionGeneral, + auditInput, messageId: input.messageId, response: input.response, }); diff --git a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts index 1a8bbe3e..34ab36d7 100644 --- a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts +++ b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { JosaUtil } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; @@ -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, tombstoneMessages } from '../src/messages/store.js'; +import { fetchMessagesFromMailbox, insertMessage, tombstoneMessages } from '../src/messages/store.js'; import { appRouter } from '../src/router.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; @@ -25,6 +25,7 @@ const fixtureNationId = 841; const foreignNationId = fixtureNationId + 1; const fixtureGeneralId = 8_864_243; const foreignGeneralId = fixtureGeneralId + 1; +const fixtureCityId = 8_864_246; const fixtureWorldStateId = -8_864_241; const fixtureUserId = 'diplomacy-document-message-src-user'; const foreignUserId = 'diplomacy-document-message-dest-user'; @@ -82,7 +83,23 @@ integration('diplomacy document message persistence', () => { }; const cleanupRouteState = async (): Promise => { + await db.logEntry.deleteMany({ + where: { + OR: [ + { generalId: { in: [fixtureGeneralId, foreignGeneralId] } }, + { nationId: { in: [fixtureNationId, foreignNationId] } }, + ], + }, + }); await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: requestPrefix } }); + const proposalIds = await db.message.findMany({ + where: { mailbox: { in: [...fixtureMailboxes] } }, + select: { id: true }, + }); + await db.inputEvent.deleteMany({ + where: { requestId: { in: proposalIds.map(({ id }) => `messages.respond.diplomatic:${id}`) } }, + }); + await db.diplomacy.deleteMany({ where: { srcNationId: { in: [fixtureNationId, foreignNationId] } } }); await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } }); await db.diplomacyLetter.deleteMany({ where: { @@ -106,6 +123,7 @@ integration('diplomacy document message persistence', () => { await cleanupRouteState(); await db.general.deleteMany({ where: { id: { in: [fixtureGeneralId, foreignGeneralId] } } }); await db.nation.deleteMany({ where: { id: { in: [fixtureNationId, foreignNationId] } } }); + await db.city.deleteMany({ where: { id: fixtureCityId } }); await db.worldState.deleteMany({ where: { id: fixtureWorldStateId } }); }; @@ -306,6 +324,27 @@ integration('diplomacy document message persistence', () => { { id: foreignNationId, name: '상대국', color: '#654321' }, ], }); + await db.city.create({ + data: { + id: fixtureCityId, + name: '감사 도시', + level: 1, + nationId: foreignNationId, + population: 1000, + populationMax: 1000, + agriculture: 100, + agricultureMax: 100, + commerce: 100, + commerceMax: 100, + security: 100, + securityMax: 100, + defence: 100, + defenceMax: 100, + wall: 100, + wallMax: 100, + region: 1, + }, + }); await db.general.createMany({ data: [ { @@ -320,6 +359,7 @@ integration('diplomacy document message persistence', () => { }, { id: foreignGeneralId, + cityId: fixtureCityId, userId: foreignUserId, name: '상대수뇌', nationId: foreignNationId, @@ -616,6 +656,92 @@ integration('diplomacy document message persistence', () => { ); }); + it.each(['noAggression', 'cancelNA', 'stopWar'] as const)( + 'records both directions of %s with no duplicate on replay', + async (action) => { + const initialState = action === 'noAggression' ? 2 : action === 'cancelNA' ? 7 : 0; + await db.diplomacy.createMany({ + data: [ + { srcNationId: fixtureNationId, destNationId: foreignNationId, stateCode: initialState, term: 12 }, + { srcNationId: foreignNationId, destNationId: fixtureNationId, stateCode: initialState, term: 12 }, + ], + }); + const messageId = await insertMessage(db, { + mailbox: fixtureMailboxes[1], + msgType: 'diplomacy', + srcId: fixtureMailboxes[0], + destId: fixtureMailboxes[1], + time: logicalGameTime, + validUntil: new Date('9999-12-31T00:00:00Z'), + payload: { + text: '불가침 파기 제의', + option: { action, year: 209, month: 4 }, + src: { + generalId: fixtureGeneralId, + generalName: '원민수뇌', + nationId: fixtureNationId, + nationName: '원민국', + color: '#123456', + icon: '', + }, + dest: { + generalId: foreignGeneralId, + generalName: '상대수뇌', + nationId: foreignNationId, + nationName: '상대국', + color: '#654321', + icon: '', + }, + }, + }); + const context = buildContext('audit-relation', foreignAuth); + vi.spyOn(context.turnDaemon, 'requestCommand').mockResolvedValueOnce(null).mockResolvedValue({ + type: 'syncDiplomaticResponse', + ok: true, + generalId: foreignGeneralId, + messageId, + nations: 2, + diplomacy: 2, + cities: 0, + }); + const caller = appRouter.createCaller(context); + const input = { generalId: foreignGeneralId, messageId, response: true }; + await expect(caller.messages.respond(input)).rejects.toThrow( + '외교 상태를 게임 엔진에 동기화하지 못했습니다.' + ); + expect(await caller.messages.respond(input)).toEqual({ result: true, reason: 'success' }); + expect(await caller.messages.respond(input)).toEqual({ result: true, reason: 'success' }); + const events = await db.playAuditDiplomacyEvent.findMany({ + where: { serverId: requestPrefix }, + orderBy: { sequence: 'asc' }, + }); + expect(events).toHaveLength(2); + expect(new Set(events.map((event) => `${event.srcNationId}:${event.destNationId}`))).toEqual( + new Set([`${fixtureNationId}:${foreignNationId}`, `${foreignNationId}:${fixtureNationId}`]) + ); + for (const event of events) { + expect(event).toMatchObject({ + category: 'RELATION', + eventType: `MESSAGE_ACCEPTED_${action}`, + before: { state: initialState, term: 12 }, + actor: { generalId: foreignGeneralId, messageId }, + }); + const current = await db.diplomacy.findUniqueOrThrow({ + where: { + srcNationId_destNationId: { + srcNationId: event.srcNationId, + destNationId: event.destNationId, + }, + }, + }); + expect(event.after).toMatchObject({ state: current.stateCode, term: current.term }); + } + expect(await db.messageAction.findUniqueOrThrow({ where: { messageId } })).toMatchObject({ + status: 'RESOLVED', + }); + } + ); + 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; $$`); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index d4f21b36..37efb944 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -116,6 +116,23 @@ UPDATE 반환값에서 변경 전후 allowlist를 만들고, 원장 잠금 SELEC 일반 외교 알림은 WALL_TIME이고 제의 처리와 tombstone은 구분한다. 오래된 알림 테스트의 게임 tick 가정을 현행 envelope 계약에 맞췄다. +### 즉시 외교 응답의 관계 전이 + +`messages.respond`의 별도 `executeInputEvent` 경로에도 인증된 입력 context를 전달한다. +불가침 체결·불가침 파기·종전 수락에서 이미 잠근 양방향 관계 행을 before로 사용하고, +각 diplomacy UPDATE의 반환값을 after로 기록한다. state/term/dead/isDead/isShowing만 +투영하며 임의 meta를 복사하지 않는다. 같은 값의 재적용은 상태 전이에 포함하지 않는다. + +추가 상태/clock/입력 SELECT는 없고, 두 방향의 전이를 한 bulk INSERT와 ID/hash +확인 SELECT로 저장한다. 원장·관계·로그·알림과 같은 transaction이다. API commit 뒤 +엔진 메모리 동기화가 실패해도 재요청은 원장 결과를 재사용해 감사 이력을 중복 쓰지 +않는다. 엔진 동기화에서 같은 사건을 다시 수집하지 않는다. + +실제 PG에서 세 응답의 양방향 before/after, 처리 순서, RESOLVED 제의 상태와 동기화 +실패 후 재요청을 검증했다. 엔진 transport만 fixture 응답이므로 엔진 runtime 동기화 +완료의 증거는 아니다. 거절/실패/무변경은 관계 전이와 구분할 시도 원장 구현에 남겼다. +엔진 턴·월간 변화와 기준 수집, 외교 조회 화면은 아직 남았다. + ## NPC·국방 정책 버전 저장 기반 `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.