From 22cf566c6b217280eee21c47162891a4965970d7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 08:14:48 +0000 Subject: [PATCH] =?UTF-8?q?NPC=20=EA=B2=B0=EC=A0=95=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=9B=94=EB=B3=84=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=EB=A5=BC=20=EC=97=B0=EA=B2=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EA=B0=90=EC=82=AC=20=ED=99=94=EB=A9=B4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/router/playAudit/decisions.ts | 196 ++++++++++++++ app/game-api/src/router/playAudit/index.ts | 3 + .../securityTransport.integration.test.ts | 129 +++++++++ app/game-frontend/e2e/playAudit.spec.ts | 139 ++++++++++ .../playAudit/AuditGeneralDecisions.vue | 244 ++++++++++++++++++ .../playAudit/AuditGeneralDetail.vue | 22 +- .../playAudit/AuditNationSeries.vue | 2 +- .../playAudit/AuditNationSnapshot.vue | 2 +- app/game-frontend/src/views/PlayAuditView.vue | 13 +- app/gateway-api/test/releaseManifest.test.ts | 2 +- docs/design/play-audit-implementation.md | 23 +- docs/play-audit-operations.md | 8 +- packages/infra/prisma/game.prisma | 2 +- .../migration.sql | 2 + release-manifest.json | 2 +- 15 files changed, 772 insertions(+), 17 deletions(-) create mode 100644 app/game-api/src/router/playAudit/decisions.ts create mode 100644 app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue create mode 100644 packages/infra/prisma/migrations/20260916080000_index_play_audit_decision_month/migration.sql diff --git a/app/game-api/src/router/playAudit/decisions.ts b/app/game-api/src/router/playAudit/decisions.ts new file mode 100644 index 00000000..5b47bb9f --- /dev/null +++ b/app/game-api/src/router/playAudit/decisions.ts @@ -0,0 +1,196 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import type { GamePrisma } from '@sammo-ts/infra'; +import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; + +const zId = z.string().regex(/^[a-f0-9]{64}$/); +const zTick = z + .string() + .regex(/^\d{1,16}$/) + .refine((value) => BigInt(value) <= BigInt(Number.MAX_SAFE_INTEGER)); +const zSummary = z.object({ + schemaVersion: z.literal(1), + coverage: z.literal('PROCEDURES'), + clockRevision: z.number().int(), + codeVersion: z.string().nullable(), + policyRefs: z.object({ + NPC_VALUES: zId.optional(), + NPC_NATION_PRIORITY: zId.optional(), + NPC_GENERAL_PRIORITY: zId.optional(), + DEFENCE: zId.optional(), + }), + requestedAction: z.string(), + selectedAction: z.string().nullable(), + selectedReason: z.string().nullable(), + executedAction: z.string(), + completed: z.boolean().nullable(), + usedFallback: z.boolean(), + blockedReason: z.string().nullable(), +}); +const zValue = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.object({ entityId: z.number() }), + z.object({ unprojected: z.literal(true) }), +]); +const zStep = z.intersection( + z.object({ + sequence: z.number().int().nonnegative(), + phase: z.enum(['general', 'nation']), + generalId: z.number().int(), + nationId: z.number().int(), + cityId: z.number().int(), + npcState: z.number().int(), + year: z.number().int(), + month: z.number().int(), + tick: z.number().nullable(), + }), + z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('DECISION_START'), reservedAction: z.string() }), + z.object({ kind: z.literal('DECISION_END'), action: z.string().nullable(), reason: z.string().nullable() }), + z.object({ kind: z.literal('DECISION_ERROR') }), + z.object({ kind: z.literal('PROCEDURE_START'), procedure: z.string() }), + z.object({ + kind: z.literal('PROCEDURE_END'), + procedure: z.string(), + action: z.string().nullable(), + reason: z.string().nullable(), + }), + z.object({ + kind: z.literal('PROCEDURE_SKIP'), + procedure: z.string(), + reason: z.enum(['POLICY', 'AUTOMATION', 'NO_HANDLER']), + }), + z.object({ + kind: z.literal('CANDIDATE'), + action: z.string(), + result: z.enum(['INVALID_ARGS', 'allow', 'deny', 'unknown']), + constraint: z.string().nullable(), + }), + z.object({ + kind: z.literal('RNG'), + method: z.string(), + parameters: z.array(z.number()).nullable(), + result: z.union([zValue, z.array(zValue)]), + }), + ]) +); +const select = { + id: true, + executionId: true, + phase: true, + generalId: true, + nationId: true, + cityId: true, + npcState: true, + year: true, + month: true, + tick: true, + stepCount: true, + summary: true, + createdAt: true, +} satisfies GamePrisma.PlayAuditDecisionSelect; +const project = (row: GamePrisma.PlayAuditDecisionGetPayload<{ select: typeof select }>) => ({ + ...row, + phase: z.enum(['general', 'nation']).parse(row.phase), + tick: row.tick.toString(), + summary: zSummary.parse(row.summary), +}); +export const decisionHistory = auditProcedure + .input( + z + .object({ + generalId: z.number().int().positive().max(2147483647), + month: zAuditMonth.omit({ kind: true }).optional(), + phase: z.enum(['general', 'nation']).optional(), + cursor: z.object({ tick: zTick, id: zId }).strict().optional(), + limit: z.number().int().min(1).max(200).default(50), + }) + .strict() + ) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const month = input.month ?? { year: world.year, month: world.month }; + const ordinal = monthOrdinal(month.year, month.month); + if ( + ordinal < monthOrdinal(world.startYear, world.startMonth) || + ordinal > monthOrdinal(world.year, world.month) + ) + throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 결정 조회 월을 선택해 주세요.' }); + const rows = world.serverId + ? await tx.playAuditDecision.findMany({ + where: { + serverId: world.serverId, + generalId: input.generalId, + year: month.year, + month: month.month, + phase: input.phase, + ...(input.cursor + ? { + OR: [ + { tick: { lt: BigInt(input.cursor.tick) } }, + { tick: BigInt(input.cursor.tick), id: { lt: input.cursor.id } }, + ], + } + : {}), + }, + orderBy: [{ tick: 'desc' }, { id: 'desc' }], + take: input.limit + 1, + select, + }) + : []; + const last = rows[input.limit - 1]; + return { + ...world, + month, + coverage: world.serverId ? ('PROCEDURES_ONLY' as const) : ('IDENTITY_MISSING' as const), + items: rows.slice(0, input.limit).map(project), + nextCursor: rows.length > input.limit && last ? { tick: last.tick.toString(), id: last.id } : null, + }; + }) + ); +export const decisionDetail = auditProcedure + .input( + z + .object({ + id: zId, + generalId: z.number().int().positive().max(2147483647), + cursor: z.number().int().nonnegative().max(2147483647).optional(), + limit: z.number().int().min(1).max(4).default(1), + }) + .strict() + ) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const row = world.serverId + ? await tx.playAuditDecision.findFirst({ + where: { id: input.id, generalId: input.generalId, serverId: world.serverId }, + select, + }) + : null; + if (!row) + throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수에서 해당 결정 기록을 찾을 수 없습니다.' }); + const chunks = await tx.playAuditDecisionChunk.findMany({ + where: { decisionId: row.id, ordinal: input.cursor === undefined ? undefined : { gt: input.cursor } }, + orderBy: { ordinal: 'asc' }, + take: input.limit, + select: { ordinal: true, steps: true }, + }); + return { + ...world, + decision: project(row), + chunks: chunks.slice(0, input.limit).map((chunk) => ({ + ordinal: chunk.ordinal, + steps: z.array(zStep).max(128).parse(chunk.steps), + })), + nextCursor: + chunks.length && chunks.at(-1)!.ordinal + 1 < Math.ceil(row.stepCount / 128) + ? chunks.at(-1)!.ordinal + : null, + }; + }) + ); diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index ec8b45af..e8220708 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,3 +1,4 @@ +import { decisionHistory, decisionDetail } from './decisions.js'; import { diplomacyHistory, diplomacyEvent } from './diplomacy.js'; import { nationSeries, zAuditNation } from './nationSeries.js'; import { cityDetail, generalDetail, generalTurns } from './details.js'; @@ -25,6 +26,8 @@ import { } from './projection.js'; export const playAuditRouter = router({ + decisionHistory, + decisionDetail, diplomacyHistory, diplomacyEvent, policyHistory, diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 88e595c8..44cbc4c8 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2243,6 +2243,129 @@ integration('game API security over HTTP transport', () => { }); const admin = await token([`admin.playAudit.read:${profileName}`]); const beforeInputs = await db.inputEvent.count(); + const decisionIds = [policyId(501), policyId(502)].sort().reverse(); + const decisionGeneral = 99129; // live general 없이 보존 이력을 읽는다. + const decisionSummary = { + schemaVersion: 1, + coverage: 'PROCEDURES', + clockRevision: 1, + codeVersion: null, + policyRefs: { DEFENCE: policyId(1), secret: 'decision-secret' }, + requestedAction: '휴식', + selectedAction: 'che_징병', + selectedReason: '징병', + executedAction: '휴식', + completed: false, + usedFallback: true, + blockedReason: '자원 부족', + seed: 'decision-secret', + }; + await db.playAuditDecision.createMany({ + data: decisionIds.map((id, index) => ({ + id, + serverId: seasonId, + executionId: id, + phase: index ? 'nation' : 'general', + generalId: decisionGeneral, + nationId: ownerNationId, + cityId: 1, + npcState: index ? 1 : 2, + year: 190, + month: 1, + tick: 4_320_000_000n, + stepCount: 129, + summary: decisionSummary, + hash: id, + })), + }); + const step = { + phase: 'general', + generalId: decisionGeneral, + nationId: ownerNationId, + cityId: 1, + npcState: 2, + year: 190, + month: 1, + tick: 4_320_000_000, + kind: 'PROCEDURE_START', + procedure: '상세에서만표시', + secret: 'decision-secret', + }; + await db.playAuditDecisionChunk.createMany({ + data: [ + { + decisionId: decisionIds[0]!, + ordinal: 0, + steps: Array.from({ length: 128 }, (_, sequence) => ({ ...step, sequence })), + }, + { decisionId: decisionIds[0]!, ordinal: 1, steps: [{ ...step, sequence: 128 }] }, + ], + }); + const decisionInput = { generalId: decisionGeneral, month: { year: 190, month: 1 }, limit: 1 }; + expect((await get('decisionHistory', undefined, decisionInput)).status).toBe(401); + expect((await get('decisionHistory', await token(['admin']), decisionInput)).status).toBe(403); + const decisionList = await get('decisionHistory', admin, decisionInput); + expect(decisionList.body).toMatchObject({ + result: { + data: { + coverage: 'PROCEDURES_ONLY', + items: [{ id: decisionIds[0], tick: '4320000000' }], + nextCursor: { tick: '4320000000', id: decisionIds[0] }, + }, + }, + }); + expect(JSON.stringify(decisionList.body)).not.toContain('상세에서만표시'); + expect(JSON.stringify(decisionList.body)).not.toContain('decision-secret'); + expect( + ( + await get('decisionHistory', admin, { + ...decisionInput, + cursor: { tick: '4320000000', id: decisionIds[0] }, + }) + ).body + ).toMatchObject({ result: { data: { items: [{ id: decisionIds[1] }], nextCursor: null } } }); + expect((await get('decisionHistory', admin, { ...decisionInput, phase: 'nation' })).body).toMatchObject({ + result: { data: { items: [{ id: decisionIds[1] }] } }, + }); + const decisionDetailInput = { generalId: decisionGeneral, id: decisionIds[0] }; + const decisionPage = await get('decisionDetail', admin, decisionDetailInput); + expect(decisionPage.status).toBe(200); + expect(decisionPage.body).toMatchObject({ + result: { + data: { + chunks: [ + { + ordinal: 0, + steps: expect.arrayContaining( + [{ ...step, secret: undefined, sequence: 0 }].map( + ({ secret: _secret, ...value }) => value + ) + ), + }, + ], + nextCursor: 0, + }, + }, + }); + expect(JSON.stringify(decisionPage.body)).not.toContain('decision-secret'); + expect((await get('decisionDetail', admin, { ...decisionDetailInput, cursor: 0 })).body).toMatchObject({ + result: { data: { chunks: [{ ordinal: 1, steps: [{ sequence: 128 }] }], nextCursor: null } }, + }); + expect((await get('decisionDetail', admin, { ...decisionDetailInput, generalId })).status).toBe(404); + expect((await get('decisionDetail', admin, { ...decisionDetailInput, limit: 5 })).status).toBe(400); + expect((await get('decisionHistory', admin, { ...decisionInput, limit: 201 })).status).toBe(400); + expect( + ( + await get('decisionHistory', admin, { + ...decisionInput, + cursor: { tick: '9007199254740992', id: decisionIds[0] }, + }) + ).status + ).toBe(400); + expect( + (await get('decisionHistory', admin, { ...decisionInput, month: { year: 9999, month: 1 } })).status + ).toBe(400); + const diplomacyInput = { nationId: 99121, otherNationId: 99122, @@ -3002,6 +3125,10 @@ integration('game API security over HTTP transport', () => { result: { data: { items: [] } }, }); expect((await get('diplomacyEvent', admin, { id: policyId(101) })).status).toBe(404); + expect((await get('decisionHistory', admin, decisionInput)).body).toMatchObject({ + result: { data: { items: [] } }, + }); + expect((await get('decisionDetail', admin, decisionDetailInput)).status).toBe(404); expect(await db.inputEvent.count()).toBe(beforeInputs); await redis!.client.publish( `${redisPrefix}:flush`, @@ -3013,6 +3140,8 @@ integration('game API security over HTTP transport', () => { ); await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401); } finally { + await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: seasonId } } }); + await db.playAuditDecision.deleteMany({ where: { serverId: seasonId } }); await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } }); await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } }); await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } }); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index ae2663b1..6aca2e1f 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -43,6 +43,34 @@ const general = { items: { horse: null, weapon: null, book: null, item: null }, }, }; +const decision = { + id: 'd'.repeat(64), + executionId: 'e'.repeat(64), + phase: 'general', + generalId: 1, + nationId: 2, + cityId: 3, + npcState: 2, + year: 190, + month: 6, + tick: '100', + stepCount: 129, + createdAt: '2026-09-16T00:00:00.000Z', + summary: { + schemaVersion: 1, + coverage: 'PROCEDURES', + clockRevision: 1, + codeVersion: null, + policyRefs: {}, + requestedAction: '휴식', + selectedAction: 'che_징병', + selectedReason: '징병 선택', + executedAction: '휴식', + completed: false, + usedFallback: true, + blockedReason: '자원 부족', + }, +}; const install = async (page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false) => { const requests: { operation: string; input: Record }[] = []; await page.addInitScript((profile) => { @@ -73,6 +101,47 @@ const install = async (page: Page, denied = false, baseline: boolean | 'document }, } : result({ profileName: gameProfile, read: true, accounts: false }); + case 'playAudit.decisionHistory': + return result({ + ...world, + month: input.month ?? { year: 190, month: 7 }, + coverage: 'PROCEDURES_ONLY', + items: [ + { + ...decision, + id: input.cursor ? 'c'.repeat(64) : decision.id, + phase: input.cursor ? 'nation' : 'general', + }, + ], + nextCursor: input.cursor ? null : { tick: '100', id: decision.id }, + }); + case 'playAudit.decisionDetail': + return result({ + ...world, + decision, + chunks: [ + { + ordinal: input.cursor === undefined ? 0 : 1, + steps: [ + { + phase: 'general', + generalId: 1, + nationId: 2, + cityId: 3, + npcState: 2, + year: 190, + month: 6, + tick: 100, + sequence: input.cursor === undefined ? 0 : 128, + ...(input.cursor === undefined + ? { kind: 'PROCEDURE_START', procedure: '징병판정' } + : { kind: 'DECISION_END', action: 'che_징병', reason: '징병 선택' }), + }, + ], + }, + ], + nextCursor: input.cursor === undefined ? 0 : null, + }); case 'playAudit.generalLogs': return result({ ...world, @@ -1015,3 +1084,73 @@ test('general search is explicit and persists across pagination and reload', asy await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible(); await capture(page, 'mobile-general-search'); }); + +test('NPC decisions are explicit, paginated, independently addressable and escaped', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=6&general=1')); + await expect(page.getByRole('heading', { name: '선택 장수 상세' })).toBeVisible(); + expect(requests.some((r) => r.operation.startsWith('playAudit.decision'))).toBe(false); + await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click(); + await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible(); + expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory').at(-1)?.input).toMatchObject({ + generalId: 1, + month: { year: 190, month: 6 }, + }); + expect(requests.some((r) => r.operation === 'playAudit.decisionDetail')).toBe(false); + const counts = { + list: requests.filter((r) => r.operation === 'playAudit.generals').length, + history: requests.filter((r) => r.operation === 'playAudit.decisionHistory').length, + }; + await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click(); + await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정'); + await expect(page.getByRole('list', { name: '판단 절차' }).locator('b')).toHaveCount(0); + await page.getByRole('button', { name: '판단 절차 더 불러오기', exact: true }).click(); + await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('최종 선택'); + expect(requests.filter((r) => r.operation === 'playAudit.decisionDetail').at(-1)?.input).toMatchObject({ + id: decision.id, + generalId: 1, + cursor: 0, + limit: 1, + }); + expect(requests.filter((r) => r.operation === 'playAudit.generals')).toHaveLength(counts.list); + expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(counts.history); + await capture(page, 'mobile-npc-decision'); + await page.reload(); + await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정'); + await page.getByRole('button', { name: '결정 목록 더 불러오기', exact: true }).click(); + await expect(page.getByRole('button', { name: '수뇌 판단 · tick 100', exact: true })).toBeVisible(); +}); + +test('NPC decision detail retry preserves history and other general information', async ({ page }) => { + const requests = await install(page); + let fail = true; + await page.route(gameTrpcRoute, async (route) => { + if (fail && route.request().url().includes('playAudit.decisionDetail')) { + fail = false; + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify([ + { + error: { + message: '결정 상세 재시도', + code: -32603, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 }, + }, + }, + ]), + }); + } else await route.fallback(); + }); + await page.goto(gamePath('/play-audit?tab=generals&general=1')); + await page.getByRole('button', { name: 'NPC 결정 기록 조회', exact: true }).click(); + await page.getByRole('button', { name: '개인 판단 · tick 100', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('결정 상세 재시도'); + await expect(page.getByRole('button', { name: '개인 판단 · tick 100', exact: true })).toBeVisible(); + const count = requests.filter((r) => r.operation === 'playAudit.decisionHistory').length; + await page.getByRole('button', { name: '결정 상세 다시 조회', exact: true }).click(); + await expect(page.getByRole('list', { name: '판단 절차' })).toContainText('징병판정'); + expect(requests.filter((r) => r.operation === 'playAudit.decisionHistory')).toHaveLength(count); + await capture(page, 'desktop-npc-decision'); +}); diff --git a/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue b/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue new file mode 100644 index 00000000..d8656b96 --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditGeneralDecisions.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue b/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue index cda266b5..d4ba700c 100644 --- a/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue +++ b/app/game-frontend/src/components/playAudit/AuditGeneralDetail.vue @@ -1,5 +1,7 @@