From 3d60e6122d94b7fc0e16aada8135a1a896853c25 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 05:02:32 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=B2=84=EC=A0=84=EA=B3=BC=20=EC=A0=84=ED=9B=84=20?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/playAudit/index.ts | 3 + app/game-api/src/router/playAudit/policies.ts | 159 +++++++++++ .../test/playAuditPolicyProjection.test.ts | 68 +++++ .../securityTransport.integration.test.ts | 104 +++++++ app/game-frontend/e2e/playAudit.spec.ts | 172 ++++++++++++ .../playAudit/AuditPolicyHistory.vue | 256 ++++++++++++++++++ app/game-frontend/src/views/PlayAuditView.vue | 69 ++++- docs/design/play-audit-implementation.md | 25 +- 8 files changed, 845 insertions(+), 11 deletions(-) create mode 100644 app/game-api/src/router/playAudit/policies.ts create mode 100644 app/game-api/test/playAuditPolicyProjection.test.ts create mode 100644 app/game-frontend/src/components/playAudit/AuditPolicyHistory.vue diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index eeeda460..4a48c64d 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,6 +1,7 @@ import { nationSeries, zAuditNation } from './nationSeries.js'; import { cityDetail, generalDetail, generalTurns } from './details.js'; import { generalLogs } from './logs.js'; +import { policyHistory, policyVersion } from './policies.js'; import { z } from 'zod'; import { canReadPlayAuditAccounts } from '@sammo-ts/common'; import { router } from '../../trpc.js'; @@ -23,6 +24,8 @@ import { } from './projection.js'; export const playAuditRouter = router({ + policyHistory, + policyVersion, generalLogs, cityDetail, generalDetail, diff --git a/app/game-api/src/router/playAudit/policies.ts b/app/game-api/src/router/playAudit/policies.ts new file mode 100644 index 00000000..9c68cf1d --- /dev/null +++ b/app/game-api/src/router/playAudit/policies.ts @@ -0,0 +1,159 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { asRecord } from '@sammo-ts/common'; +import type { GamePrisma } from '@sammo-ts/infra'; +import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; + +const zArea = z.enum(['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE']); +const zMonth = zAuditMonth.omit({ kind: true }); +const zActor = z + .object({ + generalId: z.number().int(), + name: z.string(), + nationId: z.number().int(), + officerLevel: z.number().int(), + npcState: z.number().int(), + }) + .nullable(); +const summarySelect = { + id: true, + schemaVersion: true, + nationId: true, + area: true, + revision: true, + previousId: true, + source: true, + year: true, + month: true, + actor: true, + createdAt: true, +} satisfies GamePrisma.PlayAuditPolicySelect; +type Summary = GamePrisma.PlayAuditPolicyGetPayload<{ select: typeof summarySelect }>; + +/** 향후 자국 권한 API의 공개 정보 경계. 실제 자국 인가는 호출부에서 별도로 수행한다. */ +export const projectPolicySummary = (row: Summary) => { + if (row.schemaVersion !== 1) + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '지원하지 않는 정책 기록 버전입니다.' }); + return { + id: row.id, + nationId: row.nationId, + area: zArea.parse(row.area), + revision: row.revision, + previousId: row.previousId, + source: z.enum(['BASELINE', 'CHANGE', 'OBSERVED_GAP']).parse(row.source), + year: row.year, + month: row.month, + actor: zActor.parse(row.actor), + createdAt: row.createdAt, + }; +}; + +/** 계정·요청·내부 진단 값을 제외한 정책 전후 projection이다. */ +export const projectPolicyConfiguration = ( + row: Summary & { before: GamePrisma.JsonValue; after: GamePrisma.JsonValue } +) => { + const before = row.before === null ? null : asRecord(row.before); + const after = asRecord(row.after); + return { + ...projectPolicySummary(row), + fields: [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])].sort().map((key) => ({ + key, + beforeJson: before === null ? null : JSON.stringify(before[key] ?? null), + afterJson: JSON.stringify(after[key] ?? null), + changed: before !== null && JSON.stringify(before[key] ?? null) !== JSON.stringify(after[key] ?? null), + })), + }; +}; + +export const policyHistory = auditProcedure + .input( + z + .object({ + nationId: z.number().int().nonnegative(), + area: zArea, + from: zMonth, + to: zMonth, + cursor: z.number().int().positive().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 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.playAuditPolicy.findMany({ + where: { + serverId: world.serverId, + nationId: input.nationId, + area: input.area, + revision: input.cursor === undefined ? undefined : { lt: 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 } }, + ], + }, + ], + }, + orderBy: { revision: 'desc' }, + take: input.limit + 1, + select: summarySelect, + }) + : []; + return { + ...world, + coverage: world.serverId ? ('RECORDED_VERSIONS_ONLY' as const) : ('IDENTITY_MISSING' as const), + items: rows.slice(0, input.limit).map(projectPolicySummary), + nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.revision : null, + }; + }) + ); + +export const policyVersion = auditProcedure + .input(z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }).strict()) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const row = world.serverId + ? await tx.playAuditPolicy.findFirst({ + where: { id: input.id, serverId: world.serverId }, + select: { + ...summarySelect, + tick: true, + ordinal: true, + before: true, + after: true, + requestId: true, + inputSequence: true, + }, + }) + : null; + if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 정책 버전을 찾을 수 없습니다.' }); + return { + ...world, + version: { + ...projectPolicyConfiguration(row), + tick: row.tick?.toString() ?? null, + ordinal: row.ordinal, + requestId: row.requestId, + inputSequence: row.inputSequence?.toString() ?? null, + }, + }; + }) + ); diff --git a/app/game-api/test/playAuditPolicyProjection.test.ts b/app/game-api/test/playAuditPolicyProjection.test.ts new file mode 100644 index 00000000..2e907dc1 --- /dev/null +++ b/app/game-api/test/playAuditPolicyProjection.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { projectPolicyConfiguration } from '../src/router/playAudit/policies.js'; + +const row = { + id: 'policy', + schemaVersion: 1, + nationId: 2, + area: 'DEFENCE', + revision: 2, + previousId: 'baseline', + source: 'CHANGE', + year: 190, + month: 2, + tick: 99, + ordinal: 18, + actor: { + userId: 'private-account', + generalId: 3, + name: '당시군주', + nationId: 2, + officerLevel: 12, + npcState: 0, + permission: 4, + debug: 'internal', + }, + createdAt: new Date('2026-09-16T00:00:00Z'), + before: { scout: 0 }, + after: { scout: 1 }, + requestId: 'internal-request', + inputSequence: 123n, +}; + +describe('policy public configuration boundary', () => { + it('retains historical office and values while excluding account and admin diagnostics', () => { + const projection = projectPolicyConfiguration(row); + expect(projection).toMatchObject({ + nationId: 2, + revision: 2, + actor: { generalId: 3, officerLevel: 12 }, + fields: [{ key: 'scout', beforeJson: '0', afterJson: '1', changed: true }], + }); + const serialized = JSON.stringify(projection); + for (const excluded of [ + 'private-account', + 'permission', + 'debug', + 'internal-request', + 'inputSequence', + 'tick', + 'ordinal', + ]) + expect(serialized).not.toContain(excluded); + }); + it('refuses an unsupported record schema rather than guessing its meaning', () => { + expect(() => projectPolicyConfiguration({ ...row, schemaVersion: 2 })).toThrow('지원하지 않는 정책 기록 버전'); + }); + it('distinguishes unobserved baseline from a recorded inherited setting', () => { + expect( + projectPolicyConfiguration({ + ...row, + source: 'BASELINE', + actor: null, + before: null, + after: { scout: null }, + }).fields + ).toEqual([{ key: 'scout', beforeJson: null, afterJson: 'null', changed: false }]); + }); +}); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 61b73f12..69b61bb0 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { projectCurrentGeneral } from '../src/router/playAudit/projection.js'; import { asRecord } from '@sammo-ts/common'; import fs from 'node:fs/promises'; @@ -2154,6 +2155,8 @@ integration('game API security over HTTP transport', () => { const auditUserId = `audit-http-${process.pid}`; const seasonId = `audit-season-${process.pid}`; const sampleId = `${seasonId}:190:1`; + const policyId = (revision: number) => + createHash('sha256').update(`${seasonId}:policy:${revision}`).digest('hex'); const originalWorld = await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldId } }); const token = async (roles: string[], sanctions: GameSessionTokenPayload['sanctions'] = {}) => { const payload = buildPayload(`audit-${roles.join('-')}`, sanctions, auditUserId); @@ -2238,6 +2241,100 @@ integration('game API security over HTTP transport', () => { }); const admin = await token([`admin.playAudit.read:${profileName}`]); const beforeInputs = await db.inputEvent.count(); + const policyInput = { + nationId: 99128, + area: 'DEFENCE', + from: { year: 190, month: 1 }, + to: { year: 190, month: 2 }, + }; + await db.playAuditPolicy.createMany({ + data: [1, 2, 3].map((revision) => ({ + id: policyId(revision), + serverId: seasonId, + nationId: 99128, + area: 'DEFENCE', + revision, + previousId: revision > 1 ? policyId(revision - 1) : null, + source: revision === 1 ? 'BASELINE' : 'CHANGE', + year: 190, + month: revision === 3 ? 2 : 1, + ordinal: revision, + tick: 12, + requestId: revision > 1 ? 'audit-policy-request' : null, + inputSequence: revision > 1 ? 9007199254740993n : null, + actor: + revision > 1 + ? { + userId: 'hidden-account', + generalId, + name: '당시군주', + nationId: 99128, + officerLevel: 12, + npcState: 0, + permission: 3, + extra: 'hidden-extra', + } + : GamePrisma.DbNull, + before: revision === 1 ? GamePrisma.DbNull : { scout: revision - 1 }, + after: { scout: revision }, + hash: 'fixture', + })), + }); + const policyPage = await get('policyHistory', admin, { ...policyInput, limit: 1 }); + expect(policyPage.status).toBe(200); + expect(policyPage.body).toMatchObject({ + result: { + data: { + nextCursor: 3, + items: [{ revision: 3, source: 'CHANGE', actor: { name: '당시군주', officerLevel: 12 } }], + }, + }, + }); + for (const excluded of ['hidden-account', 'hidden-extra', 'before', 'after', 'inputSequence', 'requestId']) + expect(JSON.stringify(policyPage.body)).not.toContain(excluded); + expect((await get('policyHistory', admin, { ...policyInput, cursor: 3 })).body).toMatchObject({ + result: { data: { nextCursor: null, items: [{ revision: 2 }, { revision: 1, actor: null }] } }, + }); + expect( + (await get('policyHistory', admin, { ...policyInput, to: { year: 190, month: 1 } })).body + ).toMatchObject({ result: { data: { items: [{ revision: 2 }, { revision: 1 }] } } }); + const policyDetail = await get('policyVersion', admin, { id: policyId(3) }); + expect(policyDetail.status).toBe(200); + expect(policyDetail.body).toMatchObject({ + result: { + data: { + version: { + previousId: policyId(2), + inputSequence: '9007199254740993', + fields: [{ key: 'scout', beforeJson: '2', afterJson: '3', changed: true }], + }, + }, + }, + }); + expect(JSON.stringify(policyDetail.body)).not.toContain('hidden-account'); + expect((await get('policyVersion', admin, { id: policyId(1) })).body).toMatchObject({ + result: { data: { version: { fields: [{ beforeJson: null, changed: false }] } } }, + }); + expect((await get('policyVersion', admin, { id: policyId(99) })).status).toBe(404); + for (const patch of [ + { limit: 201 }, + { cursor: 0 }, + { area: 'ANY' }, + { from: { year: 189, month: 12 } }, + { to: { year: 191, month: 1 } }, + { from: { year: 190, month: 2 }, to: { year: 190, month: 1 } }, + ]) + expect((await get('policyHistory', admin, { ...policyInput, ...patch })).status).toBe(400); + expect((await get('policyVersion', admin, { id: '../bad' })).status).toBe(400); + for (const [operation, input] of [ + ['policyHistory', policyInput], + ['policyVersion', { id: policyId(3) }], + ] 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 logGeneralId = 99129; // No live general: death must not hide retained records. const ownLogs = await Promise.all( ['HISTORY', 'ACTION', 'BATTLE_BRIEF', 'BATTLE_DETAIL'].map((category) => @@ -2398,6 +2495,8 @@ integration('game API security over HTTP transport', () => { serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } }, }); expect((await get('capabilities', blocked)).status).toBe(403); + expect((await get('policyHistory', blocked, policyInput)).status).toBe(403); + expect((await get('policyVersion', blocked, { id: policyId(3) })).status).toBe(403); const population = { count: 0, gold: 0, @@ -2647,6 +2746,10 @@ integration('game API security over HTTP transport', () => { expect((await get('generals', admin, { at: { year: 190, month: 1 } })).body).toMatchObject({ result: { data: { collected: false, items: [] } }, }); + expect((await get('policyHistory', admin, policyInput)).body).toMatchObject({ + result: { data: { items: [] } }, + }); + expect((await get('policyVersion', admin, { id: policyId(3) })).status).toBe(404); expect(await db.inputEvent.count()).toBe(beforeInputs); await redis!.client.publish( `${redisPrefix}:flush`, @@ -2658,6 +2761,7 @@ 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.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/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index 51087cc3..0ac4ec2c 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -88,6 +88,72 @@ const install = async (page: Page, denied = false) => { ], nextCursor: null, }); + case 'playAudit.policyHistory': + return result({ + ...world, + coverage: 'RECORDED_VERSIONS_ONLY', + items: [ + { + id: String(input.cursor ? 'a' : 'b').repeat(64), + nationId: 2, + area: input.area, + revision: input.cursor ? 1 : 2, + source: input.cursor ? 'BASELINE' : 'CHANGE', + year: 190, + month: 6, + previousId: input.cursor ? null : 'a'.repeat(64), + actor: input.cursor + ? null + : { + generalId: 1, + name: '당시군주', + nationId: 2, + officerLevel: 12, + npcState: 0, + }, + createdAt: world.asOf, + }, + ], + nextCursor: input.cursor ? null : 2, + }); + case 'playAudit.policyVersion': { + const baseline = input.id === 'a'.repeat(64); + return result({ + ...world, + version: { + id: input.id, + nationId: 2, + area: 'DEFENCE', + revision: baseline ? 1 : 2, + source: baseline ? 'BASELINE' : 'CHANGE', + year: 190, + month: 6, + previousId: baseline ? null : 'a'.repeat(64), + actor: baseline + ? null + : { generalId: 1, name: '당시군주', nationId: 2, officerLevel: 12, npcState: 0 }, + createdAt: world.asOf, + tick: '100', + ordinal: baseline ? 1 : 2, + requestId: baseline ? null : 'policy-request-fixture', + inputSequence: baseline ? null : '9007199254740993', + fields: [ + { + key: 'scout', + beforeJson: baseline ? null : '0', + afterJson: '1', + changed: !baseline, + }, + { + key: 'priority', + beforeJson: baseline ? null : 'null', + afterJson: '[""]', + changed: !baseline, + }, + ], + }, + }); + } case 'playAudit.coverage': return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null }); case 'playAudit.nations': @@ -510,3 +576,109 @@ test('initial calendar before the scenario year bounds default periods and month .poll(() => requests.find((r) => r.operation === 'playAudit.nationSeries')?.input) .toMatchObject({ from: { year: 189, month: 10 }, to: { year: 189, month: 10 } }); }); + +test('policy history reads summaries and selected versions only, preserving deep links and pagination', async ({ + page, +}) => { + const requests = await install(page); + const path = '/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6'; + await page.goto(gamePath(path)); + await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible(); + expect( + requests.some(({ operation }) => + ['playAudit.policyVersion', 'playAudit.nationSeries', 'playAudit.generals'].includes(operation) + ) + ).toBe(false); + const listReads = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length; + const nationReads = requests.filter(({ operation }) => operation === 'playAudit.nations').length; + await page.getByRole('button', { name: '버전 2', exact: true }).click(); + await expect(page.getByText('임관 권유 설정 (변경)', { exact: true })).toBeVisible(); + await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible(); + expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(listReads); + expect(requests.filter(({ operation }) => operation === 'playAudit.nations')).toHaveLength(nationReads); + await page.getByText('요청 연결', { exact: true }).click(); + await expect(page.getByText('입력 순번 9007199254740993', { exact: true })).toBeVisible(); + expect(await page.evaluate(() => Object.hasOwn(window, 'auditInjected'))).toBe(false); + await capture(page, 'desktop-policy-detail'); + await page.reload(); + await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible(); + await page.getByRole('button', { name: '이전 정책 버전', exact: true }).click(); + await expect(page.getByText(/국가 #2 · 버전 1/)).toBeVisible(); + await expect(page.getByRole('cell', { name: '관측하지 않음', exact: true })).toHaveCount(2); + await page.goBack(); + await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await capture(page, 'mobile-policy-detail'); + await page.getByRole('button', { name: '정책 상세 닫기' }).click(); + await expect(page.getByRole('heading', { name: /선택 정책 버전/ })).toHaveCount(0); + await page.getByRole('button', { name: '다음 정책 50개' }).click(); + await expect(page.getByRole('button', { name: '버전 1', exact: true })).toBeVisible(); + expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input).toMatchObject({ + area: 'DEFENCE', + cursor: 2, + nationId: 2, + }); + await page.getByLabel('정책 영역').selectOption('NPC_GENERAL_PRIORITY'); + const before = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length; + await page.getByRole('button', { name: '조회', exact: true }).click(); + await expect + .poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length) + .toBeGreaterThan(before); + expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe( + 'NPC_GENERAL_PRIORITY' + ); +}); + +test('policy detail failure retries independently without reloading its history', async ({ page }) => { + const requests = await install(page); + let fail = true; + await page.route(gameTrpcRoute, async (route) => { + if (fail && route.request().url().includes('playAudit.policyVersion')) { + fail = false; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + error: { + message: '정책 버전 일시 오류', + code: -32603, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 }, + }, + }, + ]), + }); + return; + } + await route.fallback(); + }); + await page.goto( + gamePath('/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6') + ); + await page.getByRole('button', { name: '버전 2', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('정책 버전 일시 오류'); + await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible(); + const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length; + await page.getByRole('button', { name: '버전 다시 조회', exact: true }).click(); + await expect(page.getByText(/국가 #2 · 버전 2/)).toBeVisible(); + expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count); +}); + +test('policy filter drafts do not read until applied, including default dates', async ({ page }) => { + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=policies&nation=2')); + await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible(); + const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length; + await page.getByLabel('시작 월', { exact: true }).fill('3'); + await page.getByLabel('정책 영역').selectOption('DEFENCE'); + await page.getByRole('button', { name: '조회', exact: true }).focus(); + expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory')).toHaveLength(count); + await page.getByRole('button', { name: '조회', exact: true }).click(); + await expect + .poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length) + .toBe(count + 1); + expect(requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input).toMatchObject({ + area: 'DEFENCE', + from: { year: 190, month: 3 }, + }); +}); diff --git a/app/game-frontend/src/components/playAudit/AuditPolicyHistory.vue b/app/game-frontend/src/components/playAudit/AuditPolicyHistory.vue new file mode 100644 index 00000000..daf0c4ee --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditPolicyHistory.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/app/game-frontend/src/views/PlayAuditView.vue b/app/game-frontend/src/views/PlayAuditView.vue index 9b26a240..3d0d36b2 100644 --- a/app/game-frontend/src/views/PlayAuditView.vue +++ b/app/game-frontend/src/views/PlayAuditView.vue @@ -5,6 +5,7 @@ import PanelCard from '../components/ui/PanelCard.vue'; import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue'; import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue'; import AuditGeneralDetail from '../components/playAudit/AuditGeneralDetail.vue'; +import AuditPolicyHistory from '../components/playAudit/AuditPolicyHistory.vue'; import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue'; import { usePageExit } from '../composables/usePageExit'; import { trpc } from '../utils/trpc'; @@ -29,6 +30,28 @@ const profileName = ref(''); const loading = ref(false); const error = ref(''); const tab = ref('nations'); +const policyArea = ref<'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY' | 'DEFENCE'>('NPC_VALUES'); +const appliedPolicy = computed(() => { + const to = { + year: numeric(route.query.year, coverage.value?.year ?? 0), + month: numeric(route.query.month, coverage.value?.month ?? 1), + }; + const start = Math.max( + (coverage.value?.startYear ?? to.year) * 12 + (coverage.value?.startMonth ?? 1) - 1, + to.year * 12 + to.month - 6 + ); + return { + nationId: numeric(route.query.nation, 0), + area: (['NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE'].includes(String(route.query.policyArea)) + ? route.query.policyArea + : 'NPC_VALUES') as typeof policyArea.value, + from: { + year: numeric(route.query.fromYear, Math.floor(start / 12)), + month: numeric(route.query.fromMonth, (start % 12) + 1), + }, + to, + }; +}); const nationId = ref(''); const cityId = ref(''); const population = ref(''); @@ -91,9 +114,10 @@ const result = computed(() => : series.value ); const readQuery = () => { - tab.value = ['nations', 'generals', 'cities'].includes(String(route.query.tab)) + tab.value = ['nations', 'generals', 'cities', 'policies'].includes(String(route.query.tab)) ? String(route.query.tab) : 'nations'; + policyArea.value = appliedPolicy.value.area; nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : ''; cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : ''; population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population)) @@ -149,6 +173,8 @@ const load = async (append = false) => { ...response, items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items, }; + } else if (tab.value === 'policies') { + // 정책 목록/상세는 해당 component가 필요한 요청만 실행한다. } else if (nationId.value !== '' && moment.value === 'final') { const response = await trpc.playAudit.nationSnapshot.query({ nationId: Number(nationId.value), @@ -201,6 +227,7 @@ const apply = async () => { fromYear: String(fromYear.value), fromMonth: String(fromMonth.value), resolution: resolution.value, + policyArea: tab.value === 'policies' ? policyArea.value : undefined, }; if (JSON.stringify(route.query) === JSON.stringify(query)) await refresh(); else { @@ -236,7 +263,10 @@ const moreNations = async () => { } }; watch( - () => JSON.stringify(Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord')), + () => + JSON.stringify( + Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy') + ), () => { if (authorized.value) { readQuery(); @@ -286,11 +316,14 @@ onMounted(async () => { + -