From e1a05e27e7fedd17919eae420b0a6c980f38a356 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 03:30:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EC=B5=9C=EC=A2=85=20=EA=B5=AD=EA=B0=80=20=ED=91=9C?= =?UTF-8?q?=EB=B3=B8=20=EB=B3=84=EB=8F=84=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/playAudit/index.ts | 28 ++++- .../securityTransport.integration.test.ts | 67 ++++++++++++ app/game-frontend/e2e/playAudit.spec.ts | 33 ++++++ .../playAudit/AuditNationSnapshot.vue | 102 ++++++++++++++++++ app/game-frontend/src/views/PlayAuditView.vue | 21 +++- docs/design/play-audit-implementation.md | 8 +- 6 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 app/game-frontend/src/components/playAudit/AuditNationSnapshot.vue diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index 75a044f9..1fd92d86 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,4 +1,4 @@ -import { nationSeries } from './nationSeries.js'; +import { nationSeries, zAuditNation } from './nationSeries.js'; import { z } from 'zod'; import { canReadPlayAuditAccounts } from '@sammo-ts/common'; import { router } from '../../trpc.js'; @@ -22,6 +22,26 @@ import { export const playAuditRouter = router({ nationSeries, + nationSnapshot: auditProcedure + .input(z.object({ nationId: z.number().int().nonnegative(), at: zAuditMonth }).strict()) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const sample = await findAuditMonth(tx, world, input.at); + const row = sample + ? await tx.playAuditNation.findUnique({ + where: { sampleId_nationId: { sampleId: sample.id, nationId: input.nationId } }, + select: { data: true }, + }) + : null; + return { + ...world, + sample, + collected: Boolean(sample), + nation: row ? zAuditNation.parse(row.data) : null, + }; + }) + ), nations: auditProcedure.input(zAuditPage.omit({ nationId: true })).query(({ ctx, input }) => readAudit(ctx, async (tx) => { const world = await readAuditWorld(tx); @@ -42,7 +62,11 @@ export const playAuditRouter = router({ return { ...world, collected: Boolean(sample), - ...pageResult(rows.map((row) => identity.parse(row.data)), input.limit, (row) => row.id), + ...pageResult( + rows.map((row) => identity.parse(row.data)), + input.limit, + (row) => row.id + ), }; } const rows = await tx.nation.findMany({ diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 4db4abb6..67bde580 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -1,4 +1,5 @@ import { projectCurrentGeneral } from '../src/router/playAudit/projection.js'; +import { asRecord } from '@sammo-ts/common'; import fs from 'node:fs/promises'; import { createServer, type Server as HttpServer } from 'node:http'; import os from 'node:os'; @@ -2290,6 +2291,72 @@ integration('game API security over HTTP transport', () => { })), }); await db.worldState.update({ where: { id: fixtureWorldId }, data: { currentMonth: 7 } }); + const finalNation = await db.playAuditNation.findUniqueOrThrow({ + where: { sampleId_nationId: { sampleId: `${seasonId}:190:6`, nationId: ownerNationId } }, + }); + await db.playAuditMonth.create({ + data: { + id: `${seasonId}:final`, + serverId: seasonId, + year: 190, + month: 6, + kind: 'FINAL', + settlementsComplete: true, + hash: 'http-final-fixture', + nations: { + create: { + nationId: ownerNationId, + data: { + ...asRecord(finalNation.data), + gold: 999, + incomeGold: 999, + hiddenSecret: 'must-not-expose', + }, + }, + }, + }, + }); + expect( + ( + await get('nationSnapshot', admin, { + nationId: ownerNationId, + at: { year: 190, month: 6, kind: 'FINAL' }, + }) + ).body + ).toMatchObject({ + result: { + data: { collected: true, sample: { kind: 'FINAL' }, nation: { gold: 999, incomeGold: 999 } }, + }, + }); + expect( + JSON.stringify( + ( + await get('nationSnapshot', admin, { + nationId: ownerNationId, + at: { year: 190, month: 6, kind: 'FINAL' }, + }) + ).body + ) + ).not.toContain('must-not-expose'); + expect( + ( + await get('nationSnapshot', admin, { + nationId: ownerNationId, + at: { year: 190, month: 7, kind: 'FINAL' }, + }) + ).body + ).toMatchObject({ result: { data: { collected: false, nation: null } } }); + expect( + (await get('nationSnapshot', admin, { nationId: 0, at: { year: 190, month: 6, kind: 'FINAL' } })).body + ).toMatchObject({ result: { data: { collected: true, nation: null } } }); + expect( + ( + await get('nationSnapshot', await token(['admin']), { + nationId: ownerNationId, + at: { year: 190, month: 6, kind: 'FINAL' }, + }) + ).status + ).toBe(403); expect((await get('nations', admin, { at: { year: 190, month: 1 }, limit: 1 })).body).toMatchObject({ result: { data: { collected: true, items: [{ id: ownerNationId, name: '국가1' }] } }, }); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index d9243a8b..0659dcd2 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -114,6 +114,26 @@ const install = async (page: Page, denied = false) => { }, ], }); + case 'playAudit.nationSnapshot': + return result({ + ...world, + collected: true, + sample: { year: 190, month: 6, kind: 'FINAL', settlementsComplete: true }, + nation: { + id: 2, + name: '촉', + color: '#ff0000', + gold: 999, + rice: 222, + tech: 100, + appliedRate: 20, + incomeGold: 99, + incomeRice: 0, + paidGold: 0, + paidRice: 0, + populations: { human: population, npc: population, troopNpc: population }, + }, + }); case 'playAudit.generals': return result({ ...world, @@ -283,3 +303,16 @@ test('back navigation during a slow read keeps the newer city view', async ({ pa await page.getByRole('button', { name: '조회', exact: true }).focus(); await expect(page.getByRole('button', { name: '조회', exact: true })).toBeFocused(); }); + +test('final nation snapshot is separate from the monthly series', async ({ page }) => { + const requests = await install(page); + await page.goto(gamePath('/play-audit?tab=nations&nation=2&at=final&year=190&month=6')); + await expect(page.getByRole('heading', { name: '촉 · 190년 6월 최종 표본' })).toBeVisible(); + await expect(page.getByText('999 / 222', { exact: true })).toBeVisible(); + expect(requests.some((request) => request.operation === 'playAudit.nationSeries')).toBe(false); + expect(requests.find((request) => request.operation === 'playAudit.nationSnapshot')?.input).toEqual({ + nationId: 2, + at: { year: 190, month: 6, kind: 'FINAL' }, + }); + await capture(page, 'final-nation'); +}); diff --git a/app/game-frontend/src/components/playAudit/AuditNationSnapshot.vue b/app/game-frontend/src/components/playAudit/AuditNationSnapshot.vue new file mode 100644 index 00000000..e6ec9772 --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditNationSnapshot.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/app/game-frontend/src/views/PlayAuditView.vue b/app/game-frontend/src/views/PlayAuditView.vue index ea8d6513..36abeb3f 100644 --- a/app/game-frontend/src/views/PlayAuditView.vue +++ b/app/game-frontend/src/views/PlayAuditView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import PanelCard from '../components/ui/PanelCard.vue'; import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue'; +import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue'; import { usePageExit } from '../composables/usePageExit'; import { trpc } from '../utils/trpc'; @@ -11,6 +12,7 @@ type Nations = Awaited>; type Generals = Awaited>; type Cities = Awaited>; type Series = Awaited>; +type NationSnapshot = Awaited>; const route = useRoute(); const router = useRouter(); const { pageExitLabel, exitPage } = usePageExit(); @@ -19,6 +21,7 @@ const nations = ref(null); const generals = ref(null); const cities = ref(null); const series = ref(null); +const nationSnapshot = ref(null); const authorized = ref(false); const profileName = ref(''); const loading = ref(false); @@ -54,7 +57,13 @@ const scopeLabel = computed(() => : `${route.query.year}년 ${route.query.month}월 ${route.query.at === 'final' ? '최종 표본' : '월말'}` ); const result = computed(() => - tab.value === 'generals' ? generals.value : tab.value === 'cities' ? cities.value : series.value + tab.value === 'generals' + ? generals.value + : tab.value === 'cities' + ? cities.value + : nationSnapshot.value + ? { ...nationSnapshot.value, nextCursor: null } + : series.value ); const readQuery = () => { tab.value = ['nations', 'generals', 'cities'].includes(String(route.query.tab)) @@ -83,6 +92,7 @@ const load = async (append = false) => { generals.value = null; cities.value = null; series.value = null; + nationSnapshot.value = null; } const filter = { at: at.value, nationId: nationId.value === '' ? undefined : Number(nationId.value), limit: 50 }; try { @@ -111,6 +121,12 @@ const load = async (append = false) => { ...response, items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items, }; + } else if (nationId.value !== '' && moment.value === 'final') { + const response = await trpc.playAudit.nationSnapshot.query({ + nationId: Number(nationId.value), + at: { year: year.value, month: month.value, kind: 'FINAL' }, + }); + if (request === generation) nationSnapshot.value = response; } else if (nationId.value !== '') { const response = await trpc.playAudit.nationSeries.query({ nationId: Number(nationId.value), @@ -292,7 +308,7 @@ onMounted(async () => { required /> -