feat: 플레이 감사 최종 국가 표본 별도 조회 구현
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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' }] } },
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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<ReturnType<typeof trpc.playAudit.nations.query>>;
|
||||
type Generals = Awaited<ReturnType<typeof trpc.playAudit.generals.query>>;
|
||||
type Cities = Awaited<ReturnType<typeof trpc.playAudit.cities.query>>;
|
||||
type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
|
||||
type NationSnapshot = Awaited<ReturnType<typeof trpc.playAudit.nationSnapshot.query>>;
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { pageExitLabel, exitPage } = usePageExit();
|
||||
@@ -19,6 +21,7 @@ const nations = ref<Nations | null>(null);
|
||||
const generals = ref<Generals | null>(null);
|
||||
const cities = ref<Cities | null>(null);
|
||||
const series = ref<Series | null>(null);
|
||||
const nationSnapshot = ref<NationSnapshot | null>(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
|
||||
/></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
|
||||
>시작 연도<input
|
||||
v-model.number="fromYear"
|
||||
@@ -335,6 +351,7 @@ onMounted(async () => {
|
||||
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다.
|
||||
</p>
|
||||
<AuditNationSeries v-if="series && tab === 'nations'" :data="series" />
|
||||
<AuditNationSnapshot v-if="nationSnapshot && tab === 'nations'" :data="nationSnapshot" />
|
||||
<template v-if="generals && tab === 'generals'">
|
||||
<p v-if="!generals.collected">선택한 시점의 표본이 없습니다.</p>
|
||||
<p v-else-if="!generals.items.length">조건에 맞는 장수가 없습니다.</p>
|
||||
|
||||
@@ -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도
|
||||
|
||||
Reference in New Issue
Block a user