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 }}월 최종 표본
+
+
+
+
+
+
+
+
+
+ 장수 집단
+ 인원
+ 보유 금 / 쌀 합계
+ 평균 금 / 쌀
+ 평균 숙련 (보 / 궁 / 기 / 귀 / 차)
+
+
+
+ {{ label }}
+ {{ data.nation.populations[key].count }}
+
+ {{ format(data.nation.populations[key].gold) }} /
+ {{ format(data.nation.populations[key].rice) }}
+
+
+ {{ format(data.nation.populations[key].averageGold) }} /
+ {{ format(data.nation.populations[key].averageRice) }}
+
+ {{ Object.values(data.nation.populations[key].averageDex).map(format).join(' / ') }}
+
선택한 시점의 표본이 없습니다.
조건에 맞는 장수가 없습니다.
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도