diff --git a/app/game-api/src/router/playAudit/diplomacy.ts b/app/game-api/src/router/playAudit/diplomacy.ts new file mode 100644 index 00000000..9e4a2904 --- /dev/null +++ b/app/game-api/src/router/playAudit/diplomacy.ts @@ -0,0 +1,213 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { hashAuditDiplomacyDocument, type GamePrisma } from '@sammo-ts/infra'; +import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; + +const zActor = z + .object({ + generalId: z.number().int(), + name: z.string(), + nationId: z.number().int(), + officerLevel: z.number().int(), + npcState: z.number().int(), + actionKey: z.string().optional(), + kind: z.enum(['nation', 'general']).optional(), + actionOrdinal: z.number().int().positive().optional(), + messageId: z.number().int().positive().optional(), + }) + .nullable(); +const zState = z + .object({ + state: z.union([z.number().int(), z.enum(['PROPOSED', 'ACTIVATED', 'REPLACED', 'CANCELLED'])]), + term: z.number().int().optional(), + dead: z.number().optional(), + isDead: z.boolean().optional(), + isShowing: z.boolean().optional(), + srcSignerId: z.number().int().nullable().optional(), + destSignerId: z.number().int().nullable().optional(), + srcNationName: z.string().nullable().optional(), + destNationName: z.string().nullable().optional(), + srcSignerName: z.string().nullable().optional(), + destSignerName: z.string().nullable().optional(), + stateOption: z.string().nullable().optional(), + reason: z.string().nullable().optional(), + reasonAction: z.string().nullable().optional(), + reasonActorId: z.number().int().nullable().optional(), + }) + .nullable(); +const summarySelect = { + id: true, + sequence: true, + schemaVersion: true, + srcNationId: true, + destNationId: true, + category: true, + source: true, + eventType: true, + documentId: true, + previousDocumentId: true, + year: true, + month: true, + actor: true, + createdAt: true, +} satisfies GamePrisma.PlayAuditDiplomacyEventSelect; +type Summary = GamePrisma.PlayAuditDiplomacyEventGetPayload<{ select: typeof summarySelect }>; +export const projectDiplomacySummary = (row: Summary) => { + if (row.schemaVersion !== 1) + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '지원하지 않는 외교 기록 버전입니다.' }); + return { + id: row.id, + sequence: row.sequence.toString(), + srcNationId: row.srcNationId, + destNationId: row.destNationId, + category: z.enum(['DOCUMENT', 'RELATION']).parse(row.category), + source: z.enum(['API', 'ENGINE', 'BASELINE']).parse(row.source), + eventType: row.eventType, + documentId: row.documentId, + previousDocumentId: row.previousDocumentId, + year: row.year, + month: row.month, + actor: zActor.parse(row.actor), + createdAt: row.createdAt, + }; +}; +const zSequence = z + .string() + .regex(/^[1-9][0-9]{0,18}$/) + .refine((value) => /^[1-9][0-9]{0,18}$/.test(value) && BigInt(value) <= 9223372036854775807n); +const zId = z.string().regex(/^[a-f0-9]{64}$/); + +export const diplomacyHistory = auditProcedure + .input( + z + .object({ + nationId: z.number().int().positive(), + otherNationId: z.number().int().positive(), + from: zAuditMonth.omit({ kind: true }), + to: zAuditMonth.omit({ kind: true }), + category: z.enum(['DOCUMENT', 'RELATION']).optional(), + cursor: zSequence.optional(), + limit: z.number().int().min(1).max(200).default(50), + }) + .strict() + .refine((input) => input.nationId !== input.otherNationId, '서로 다른 국가를 선택해 주세요.') + ) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const from = monthOrdinal(input.from.year, input.from.month); + const to = monthOrdinal(input.to.year, input.to.month); + if ( + from > to || + from < monthOrdinal(world.startYear, world.startMonth) || + to > monthOrdinal(world.year, world.month) + ) + throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 외교 조회 기간을 선택해 주세요.' }); + const rows = world.serverId + ? await tx.playAuditDiplomacyEvent.findMany({ + where: { + serverId: world.serverId, + nationA: Math.min(input.nationId, input.otherNationId), + nationB: Math.max(input.nationId, input.otherNationId), + category: input.category, + sequence: input.cursor === undefined ? undefined : { lt: BigInt(input.cursor) }, + AND: [ + { + OR: [ + { year: { gt: input.from.year } }, + { year: input.from.year, month: { gte: input.from.month } }, + ], + }, + { + OR: [ + { year: { lt: input.to.year } }, + { year: input.to.year, month: { lte: input.to.month } }, + ], + }, + ], + }, + select: summarySelect, + orderBy: { sequence: 'desc' }, + take: input.limit + 1, + }) + : []; + return { + ...world, + coverage: world.serverId ? ('RECORDED_EVENTS_ONLY' as const) : ('IDENTITY_MISSING' as const), + items: rows.slice(0, input.limit).map(projectDiplomacySummary), + nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.sequence.toString() : null, + }; + }) + ); + +export const diplomacyEvent = auditProcedure.input(z.object({ id: zId }).strict()).query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const row = world.serverId + ? await tx.playAuditDiplomacyEvent.findFirst({ + where: { id: input.id, serverId: world.serverId }, + select: { + ...summarySelect, + before: true, + after: true, + tick: true, + clockRevision: true, + executionId: true, + ordinal: true, + requestId: true, + inputSequence: true, + documentHash: true, + }, + }) + : null; + if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 외교 기록을 찾을 수 없습니다.' }); + const document = + row.documentId === null + ? null + : await tx.diplomacyLetter.findUnique({ + where: { id: row.documentId }, + select: { + id: true, + srcNationId: true, + destNationId: true, + prevId: true, + textBrief: true, + textDetail: true, + srcSignerId: true, + date: true, + }, + }); + const documentStatus = + row.documentId === null + ? ('NOT_APPLICABLE' as const) + : !document + ? ('MISSING_REFERENCE' as const) + : hashAuditDiplomacyDocument(document) !== row.documentHash + ? ('HASH_MISMATCH' as const) + : ('AVAILABLE' as const); + return { + ...world, + event: { + ...projectDiplomacySummary(row), + before: zState.parse(row.before), + after: zState.parse(row.after), + tick: row.tick?.toString() ?? null, + clockRevision: row.clockRevision?.toString() ?? null, + executionId: row.executionId, + ordinal: row.ordinal, + requestId: row.requestId, + inputSequence: row.inputSequence?.toString() ?? null, + documentStatus, + document: + documentStatus === 'AVAILABLE' && document + ? { + id: document.id, + brief: document.textBrief, + detail: document.textDetail, + writtenAt: document.date, + } + : null, + }, + }; + }) +); diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index 87d9dc7b..f23fa20f 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,3 +1,4 @@ +import { diplomacyHistory, diplomacyEvent } from './diplomacy.js'; import { nationSeries, zAuditNation } from './nationSeries.js'; import { cityDetail, generalDetail, generalTurns } from './details.js'; import { generalLogs } from './logs.js'; @@ -24,6 +25,8 @@ import { } from './projection.js'; export const playAuditRouter = router({ + diplomacyHistory, + diplomacyEvent, policyHistory, policyVersion, generalLogs, diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 85238365..82d20fe9 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -12,6 +12,7 @@ import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts import { createGamePostgresConnector, GamePrisma, + hashAuditDiplomacyDocument, createRedisConnector, enqueueWebPushOutboxEvents, resolveRedisConfigFromEnv, @@ -2241,6 +2242,135 @@ integration('game API security over HTTP transport', () => { }); const admin = await token([`admin.playAudit.read:${profileName}`]); const beforeInputs = await db.inputEvent.count(); + const diplomacyInput = { + nationId: 99121, + otherNationId: 99122, + from: { year: 190, month: 1 }, + to: { year: 190, month: 2 }, + }; + const letter = await db.diplomacyLetter.create({ + data: { + srcNationId: 99121, + destNationId: 99122, + srcSignerId: generalId, + state: 'CANCELLED', + textBrief: '당시 외교 문서', + textDetail: '
역사 본문
', + }, + }); + await db.playAuditDiplomacyEvent.createMany({ + data: [1, 2, 3].map((ordinal) => ({ + id: policyId(100 + ordinal), + sequence: 9007199254741000n + BigInt(ordinal), + schemaVersion: 1, + serverId: seasonId, + nationA: 99121, + nationB: 99122, + srcNationId: 99121, + destNationId: 99122, + category: ordinal === 2 ? 'RELATION' : 'DOCUMENT', + source: 'API', + eventType: 'LETTER_ACCEPTED', + documentId: ordinal === 2 ? null : letter.id, + documentHash: ordinal === 3 ? 'wrong-hash' : hashAuditDiplomacyDocument(letter), + year: 190, + month: ordinal === 3 ? 2 : 1, + executionId: 'http-fixture', + ordinal, + requestId: 'hidden-request-in-list', + inputSequence: 9007199254740993n, + actor: { + userId: 'hidden-account', + generalId, + name: '당시군주', + nationId: 99121, + officerLevel: 12, + npcState: 0, + debug: 'hidden-debug', + }, + before: { state: 'PROPOSED', debug: 'hidden-before' }, + after: { state: 'ACTIVATED', debug: 'hidden-after' }, + hash: 'fixture', + })), + }); + const diplomacyPage = await get('diplomacyHistory', admin, { ...diplomacyInput, limit: 1 }); + expect(diplomacyPage.status).toBe(200); + expect(diplomacyPage.body).toMatchObject({ + result: { + data: { + nextCursor: '9007199254741003', + coverage: 'RECORDED_EVENTS_ONLY', + items: [{ id: policyId(103), sequence: '9007199254741003' }], + }, + }, + }); + for (const hidden of [ + 'hidden-account', + 'hidden-debug', + 'hidden-request-in-list', + 'before', + 'after', + '역사 본문', + ]) + expect(JSON.stringify(diplomacyPage.body)).not.toContain(hidden); + expect( + ( + await get('diplomacyHistory', admin, { + ...diplomacyInput, + nationId: 99122, + otherNationId: 99121, + cursor: '9007199254741003', + }) + ).body + ).toMatchObject({ + result: { data: { nextCursor: null, items: [{ id: policyId(102) }, { id: policyId(101) }] } }, + }); + expect( + (await get('diplomacyHistory', admin, { ...diplomacyInput, category: 'RELATION' })).body + ).toMatchObject({ result: { data: { items: [{ id: policyId(102) }] } } }); + const eventDetail = await get('diplomacyEvent', admin, { id: policyId(101) }); + expect(eventDetail.status).toBe(200); + expect(eventDetail.body).toMatchObject({ + result: { + data: { + event: { + before: { state: 'PROPOSED' }, + after: { state: 'ACTIVATED' }, + inputSequence: '9007199254740993', + documentStatus: 'AVAILABLE', + document: { detail: '역사 본문
' }, + }, + }, + }, + }); + for (const hidden of ['hidden-account', 'hidden-debug', 'hidden-before', 'hidden-after']) + expect(JSON.stringify(eventDetail.body)).not.toContain(hidden); + expect((await get('diplomacyEvent', admin, { id: policyId(103) })).body).toMatchObject({ + result: { data: { event: { documentStatus: 'HASH_MISMATCH', document: null } } }, + }); + await db.diplomacyLetter.delete({ where: { id: letter.id } }); + expect((await get('diplomacyEvent', admin, { id: policyId(101) })).body).toMatchObject({ + result: { data: { event: { documentStatus: 'MISSING_REFERENCE', document: null } } }, + }); + for (const patch of [ + { cursor: 'x' }, + { cursor: '9223372036854775808' }, + { limit: 201 }, + { otherNationId: 99121 }, + { from: { year: 189, month: 12 } }, + { to: { year: 191, month: 1 } }, + ]) + expect((await get('diplomacyHistory', admin, { ...diplomacyInput, ...patch })).status).toBe(400); + expect((await get('diplomacyEvent', admin, { id: '../bad' })).status).toBe(400); + for (const [operation, input] of [ + ['diplomacyHistory', diplomacyInput], + ['diplomacyEvent', { id: policyId(101) }], + ] as const) { + expect((await get(operation, undefined, input)).status).toBe(401); + for (const roles of [['admin'], ['admin.playAudit.read:other:default']]) + expect((await get(operation, await token(roles), input)).status).toBe(403); + } + const policyInput = { nationId: 99128, area: 'DEFENCE', @@ -2496,6 +2626,8 @@ integration('game API security over HTTP transport', () => { }); expect((await get('capabilities', blocked)).status).toBe(403); expect((await get('policyHistory', blocked, policyInput)).status).toBe(403); + expect((await get('diplomacyHistory', blocked, diplomacyInput)).status).toBe(403); + expect((await get('diplomacyEvent', blocked, { id: policyId(101) })).status).toBe(403); expect((await get('policyVersion', blocked, { id: policyId(3) })).status).toBe(403); const population = { count: 0, @@ -2819,6 +2951,10 @@ integration('game API security over HTTP transport', () => { result: { data: { items: [] } }, }); expect((await get('policyVersion', admin, { id: policyId(3) })).status).toBe(404); + expect((await get('diplomacyHistory', admin, diplomacyInput)).body).toMatchObject({ + result: { data: { items: [] } }, + }); + expect((await get('diplomacyEvent', admin, { id: policyId(101) })).status).toBe(404); expect(await db.inputEvent.count()).toBe(beforeInputs); await redis!.client.publish( `${redisPrefix}:flush`, @@ -2831,6 +2967,8 @@ integration('game API security over HTTP transport', () => { await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401); } finally { await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } }); + await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } }); + await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } }); await db.logEntry.deleteMany({ where: { text: { startsWith: `${seasonId}:` } } }); await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } }); await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } }); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index b63585bc..774de654 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -166,6 +166,25 @@ sequence를 예약 턴의 실행 ID로 오인하지 않는다. 메모리 Map을 실제 DB 재로드와 당시 NPC 정책/결정 trace 연결은 후속 gate다. 즉시 명령 executor와 특수 상태 변경을 포함한 최종 mutation inventory 및 초기 기준/조회 화면도 남았다. +### 외교 이력 조회 API + +프로필 game-api의 `diplomacyHistory/diplomacyEvent`는 공통 감사 권한과 read-only +transaction을 재사용한다. 목록은 서로 다른 국가 두 개와 현재 기수 안의 기간을 +필수로 받아 sequence 역순 기본50/최대200개만 반환한다. 국가쌍의 입력 순서는 무관하지만 +각 사건의 방향은 유지한다. sequence/cursor/tick은 정밀도를 잃지 않는 문자열이다. + +목록에는 본문·전후 값·요청 원장 정보를 싣지 않는다. 상세는 해당 기수 event 1개와 +필요한 문서 1개만 읽고 원문 hash를 검증한다. AVAILABLE/NOT_APPLICABLE/ +MISSING_REFERENCE/HASH_MISMATCH를 구분하고 원문 부재·불일치에서는 본문을 반환하지 +않는다. 기록의 before/after를 보여 주며 현재 문서 상태로 덮어쓰지 않는다. actor와 +상태는 allowlist로 투영하여 계정 ID·debug 등 내부 값을 제외한다. + +coverage는 RECORDED_EVENTS_ONLY이며 최초 수집 전의 상태 완전성을 주장하지 않는다. +국가쌍/sequence index를 사용 가능한 형태지만 실제 큰 기수의 기간별 scan/EXPLAIN 비용 +검증은 후속 gate다. 실제 HTTP+PG/Redis에서 권한·sanction·잘못된 범위/cursor, +본문 지연 조회/hash 상태·기수 전환의 접근 차단과 input_event 무증가를 검증했다. +외교 화면과 초기 기준 수집은 아직 남아 있다. + ## NPC·국방 정책 버전 저장 기반 `PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.