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 @@
+
+
+
+ 선택한 시점의 최종 표본이 없습니다.
+ 최종 표본에 해당 국가가 없습니다.
+
+ {{ data.nation.name }} · {{ data.sample?.year }}년 {{ data.sample?.month }}월 최종 표본
+
+ 최종 표본은 월말 시계열과 별도로 표시합니다. 아래 정산은 이 표본을 수집할 때까지 해당 월에 관측한 값입니다.
+
+ 정산 수집이 불완전한 월입니다.
+
+
+
국고 금 / 쌀
+ {{ format(data.nation.gold) }} / {{ format(data.nation.rice) }}
+
+
+
기술력 / 적용 세율
+ {{ format(data.nation.tech) }} / {{ format(data.nation.appliedRate) }}%
+
+
+
실제 금 / 쌀 수입
+ {{ format(data.nation.incomeGold) }} / {{ format(data.nation.incomeRice) }}
+
+
+
지급 금 / 쌀
+ {{ format(data.nation.paidGold) }} / {{ format(data.nation.paidRice) }}
+
+
+
+
+
+
+
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
/>
월
-
+
시작 연도 {
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다.
+
선택한 시점의 표본이 없습니다.
조건에 맞는 장수가 없습니다.
diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md
index 10a3bf4a..f1d2e883 100644
--- a/docs/design/play-audit-implementation.md
+++ b/docs/design/play-audit-implementation.md
@@ -28,7 +28,7 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음,
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
-월말/FINAL 장수·도시 projection을 보여주지만 국가 FINAL 별도 시계열, 지도,
+월말/FINAL 장수·도시 projection을 보여주지만 지도,
로그/예약 명령/전투 상세, 검색·정렬은 후속 구현으로 남는다.
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
@@ -40,6 +40,12 @@ game session transfer를 사용하고 Gateway가 감사 데이터를 대신 읽
재시도 가능한 오류를 표시한다. 기존 로비의 query fallback은 동작 변경 없이 유지하되
새 감사 경로에는 적용하지 않는다. 서로 다른 origin의 관리자 진입은 지원하지 않는다.
+`nationSnapshot`은 같은 권한/기수 범위에서 한 월말 또는 FINAL header와 그 국가의
+복합 PK 행 하나만 읽는다. 최종 국가 화면은 이 API만 사용하며 월말 시계열을 동시에
+요청하지 않는다. 국가 보유량·집단 통계와 해당 월 수집 시점까지 관측한 정산을 표시한다.
+FINAL의 관측값을 월말/반기 합계에 추가하지 않는다. 표본 없음과 해당 국가 없음도 구분한다.
+최종 수집 자체의 모든 게임 종료 경로 연결은 P1의 남은 lifecycle 검증을 따른다.
+
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도