feat: 플레이 감사 최종 국가 표본 별도 조회 구현

This commit is contained in:
2026-09-16 03:30:05 +00:00
parent b12a899526
commit e1a05e27e7
6 changed files with 254 additions and 5 deletions
+26 -2
View File
@@ -1,4 +1,4 @@
import { nationSeries } from './nationSeries.js'; import { nationSeries, zAuditNation } from './nationSeries.js';
import { z } from 'zod'; import { z } from 'zod';
import { canReadPlayAuditAccounts } from '@sammo-ts/common'; import { canReadPlayAuditAccounts } from '@sammo-ts/common';
import { router } from '../../trpc.js'; import { router } from '../../trpc.js';
@@ -22,6 +22,26 @@ import {
export const playAuditRouter = router({ export const playAuditRouter = router({
nationSeries, 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 }) => nations: auditProcedure.input(zAuditPage.omit({ nationId: true })).query(({ ctx, input }) =>
readAudit(ctx, async (tx) => { readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx); const world = await readAuditWorld(tx);
@@ -42,7 +62,11 @@ export const playAuditRouter = router({
return { return {
...world, ...world,
collected: Boolean(sample), 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({ const rows = await tx.nation.findMany({
@@ -1,4 +1,5 @@
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js'; import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import { asRecord } from '@sammo-ts/common';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { createServer, type Server as HttpServer } from 'node:http'; import { createServer, type Server as HttpServer } from 'node:http';
import os from 'node:os'; 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 } }); 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({ expect((await get('nations', admin, { at: { year: 190, month: 1 }, limit: 1 })).body).toMatchObject({
result: { data: { collected: true, items: [{ id: ownerNationId, name: '국가1' }] } }, result: { data: { collected: true, items: [{ id: ownerNationId, name: '국가1' }] } },
}); });
+33
View File
@@ -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': case 'playAudit.generals':
return result({ return result({
...world, ...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 page.getByRole('button', { name: '조회', exact: true }).focus();
await expect(page.getByRole('button', { name: '조회', exact: true })).toBeFocused(); 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');
});
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { trpc } from '../../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
defineProps<{ data: Snapshot }>();
const format = (value: number | null) =>
value === null ? '자료 없음' : value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const groups = [
['human', '유저'],
['npc', 'NPC'],
['troopNpc', '부대장 NPC'],
] as const;
</script>
<template>
<p v-if="!data.collected">선택한 시점의 최종 표본이 없습니다.</p>
<p v-else-if="!data.nation">최종 표본에 해당 국가가 없습니다.</p>
<template v-else>
<h3>{{ data.nation.name }} · {{ data.sample?.year }} {{ data.sample?.month }} 최종 표본</h3>
<p>
최종 표본은 월말 시계열과 별도로 표시합니다. 아래 정산은 표본을 수집할 때까지 해당 월에 관측한 값입니다.
</p>
<p v-if="!data.sample?.settlementsComplete">정산 수집이 불완전한 월입니다.</p>
<dl class="nation-values">
<div>
<dt>국고 / </dt>
<dd>{{ format(data.nation.gold) }} / {{ format(data.nation.rice) }}</dd>
</div>
<div>
<dt>기술력 / 적용 세율</dt>
<dd>{{ format(data.nation.tech) }} / {{ format(data.nation.appliedRate) }}%</dd>
</div>
<div>
<dt>실제 / 수입</dt>
<dd>{{ format(data.nation.incomeGold) }} / {{ format(data.nation.incomeRice) }}</dd>
</div>
<div>
<dt>지급 / </dt>
<dd>{{ format(data.nation.paidGold) }} / {{ format(data.nation.paidRice) }}</dd>
</div>
</dl>
<div class="table-scroll" tabindex="0" aria-label="최종 국가 장수 집계">
<table>
<thead>
<tr>
<th>장수 집단</th>
<th>인원</th>
<th>보유 / 합계</th>
<th>평균 / </th>
<th>평균 숙련 ( / / / / )</th>
</tr>
</thead>
<tbody>
<tr v-for="[key, label] in groups" :key="key">
<th scope="row">{{ label }}</th>
<td>{{ data.nation.populations[key].count }}</td>
<td>
{{ format(data.nation.populations[key].gold) }} /
{{ format(data.nation.populations[key].rice) }}
</td>
<td>
{{ format(data.nation.populations[key].averageGold) }} /
{{ format(data.nation.populations[key].averageRice) }}
</td>
<td>{{ Object.values(data.nation.populations[key].averageDex).map(format).join(' / ') }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</template>
<style scoped>
h3 {
font-size: var(--sammo-font-size-normal);
font-weight: bold;
}
.nation-values {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.nation-values dt {
font-weight: bold;
}
.nation-values dd {
margin: 0;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
}
th,
td {
border: 1px solid gray;
padding: 6px;
text-align: left;
}
</style>
+19 -2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue'; import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
import { usePageExit } from '../composables/usePageExit'; import { usePageExit } from '../composables/usePageExit';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -11,6 +12,7 @@ type Nations = Awaited<ReturnType<typeof trpc.playAudit.nations.query>>;
type Generals = Awaited<ReturnType<typeof trpc.playAudit.generals.query>>; type Generals = Awaited<ReturnType<typeof trpc.playAudit.generals.query>>;
type Cities = Awaited<ReturnType<typeof trpc.playAudit.cities.query>>; type Cities = Awaited<ReturnType<typeof trpc.playAudit.cities.query>>;
type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>; type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
type NationSnapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { pageExitLabel, exitPage } = usePageExit(); const { pageExitLabel, exitPage } = usePageExit();
@@ -19,6 +21,7 @@ const nations = ref<Nations | null>(null);
const generals = ref<Generals | null>(null); const generals = ref<Generals | null>(null);
const cities = ref<Cities | null>(null); const cities = ref<Cities | null>(null);
const series = ref<Series | null>(null); const series = ref<Series | null>(null);
const nationSnapshot = ref<NationSnapshot | null>(null);
const authorized = ref(false); const authorized = ref(false);
const profileName = ref(''); const profileName = ref('');
const loading = ref(false); const loading = ref(false);
@@ -54,7 +57,13 @@ const scopeLabel = computed(() =>
: `${route.query.year}${route.query.month}${route.query.at === 'final' ? '최종 표본' : '월말'}` : `${route.query.year}${route.query.month}${route.query.at === 'final' ? '최종 표본' : '월말'}`
); );
const result = computed(() => 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 = () => { const readQuery = () => {
tab.value = ['nations', 'generals', 'cities'].includes(String(route.query.tab)) tab.value = ['nations', 'generals', 'cities'].includes(String(route.query.tab))
@@ -83,6 +92,7 @@ const load = async (append = false) => {
generals.value = null; generals.value = null;
cities.value = null; cities.value = null;
series.value = null; series.value = null;
nationSnapshot.value = null;
} }
const filter = { at: at.value, nationId: nationId.value === '' ? undefined : Number(nationId.value), limit: 50 }; const filter = { at: at.value, nationId: nationId.value === '' ? undefined : Number(nationId.value), limit: 50 };
try { try {
@@ -111,6 +121,12 @@ const load = async (append = false) => {
...response, ...response,
items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items, 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 !== '') { } else if (nationId.value !== '') {
const response = await trpc.playAudit.nationSeries.query({ const response = await trpc.playAudit.nationSeries.query({
nationId: Number(nationId.value), nationId: Number(nationId.value),
@@ -292,7 +308,7 @@ onMounted(async () => {
required required
/></label> /></label>
<label>월<input v-model.number="month" type="number" min="1" max="12" required /></label> <label>월<input v-model.number="month" type="number" min="1" max="12" required /></label>
<template v-if="tab === 'nations'"> <template v-if="tab === 'nations' && moment !== 'final'">
<label <label
>시작 연도<input >시작 연도<input
v-model.number="fromYear" v-model.number="fromYear"
@@ -335,6 +351,7 @@ onMounted(async () => {
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다. 국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다.
</p> </p>
<AuditNationSeries v-if="series && tab === 'nations'" :data="series" /> <AuditNationSeries v-if="series && tab === 'nations'" :data="series" />
<AuditNationSnapshot v-if="nationSnapshot && tab === 'nations'" :data="nationSnapshot" />
<template v-if="generals && tab === 'generals'"> <template v-if="generals && tab === 'generals'">
<p v-if="!generals.collected">선택한 시점의 표본이 없습니다.</p> <p v-if="!generals.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!generals.items.length">조건에 맞는 장수가 없습니다.</p> <p v-else-if="!generals.items.length">조건에 맞는 장수가 없습니다.</p>
+7 -1
View File
@@ -28,7 +28,7 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음, 이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음,
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다. 넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
월말/FINAL 장수·도시 projection을 보여주지만 국가 FINAL 별도 시계열, 지도, 월말/FINAL 장수·도시 projection을 보여주지만 지도,
로그/예약 명령/전투 상세, 검색·정렬은 후속 구현으로 남는다. 로그/예약 명령/전투 상세, 검색·정렬은 후속 구현으로 남는다.
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다. 따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
@@ -40,6 +40,12 @@ game session transfer를 사용하고 Gateway가 감사 데이터를 대신 읽
재시도 가능한 오류를 표시한다. 기존 로비의 query fallback은 동작 변경 없이 유지하되 재시도 가능한 오류를 표시한다. 기존 로비의 query fallback은 동작 변경 없이 유지하되
새 감사 경로에는 적용하지 않는다. 서로 다른 origin의 관리자 진입은 지원하지 않는다. 새 감사 경로에는 적용하지 않는다. 서로 다른 origin의 관리자 진입은 지원하지 않는다.
`nationSnapshot`은 같은 권한/기수 범위에서 한 월말 또는 FINAL header와 그 국가의
복합 PK 행 하나만 읽는다. 최종 국가 화면은 이 API만 사용하며 월말 시계열을 동시에
요청하지 않는다. 국가 보유량·집단 통계와 해당 월 수집 시점까지 관측한 정산을 표시한다.
FINAL의 관측값을 월말/반기 합계에 추가하지 않는다. 표본 없음과 해당 국가 없음도 구분한다.
최종 수집 자체의 모든 게임 종료 경로 연결은 P1의 남은 lifecycle 검증을 따른다.
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로 `app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각 허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도 한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도