feat: 프로필 플레이 감사 기본 조회 화면 구현

This commit is contained in:
2026-09-16 03:18:56 +00:00
parent 7d5ee1348d
commit 8f3852662e
9 changed files with 1091 additions and 2 deletions
@@ -22,6 +22,38 @@ import {
export const playAuditRouter = router({
nationSeries,
nations: auditProcedure.input(zAuditPage.omit({ nationId: true })).query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const identity = z.object({ id: z.number(), name: z.string(), color: z.string() });
if (input.at) {
const sample = await findAuditMonth(tx, world, input.at);
const rows = sample
? await tx.playAuditNation.findMany({
where: {
sampleId: sample.id,
nationId: input.cursor === undefined ? undefined : { gt: input.cursor },
},
orderBy: { nationId: 'asc' },
take: input.limit + 1,
select: { data: true },
})
: [];
return {
...world,
collected: Boolean(sample),
...pageResult(rows.map((row) => identity.parse(row.data)), input.limit, (row) => row.id),
};
}
const rows = await tx.nation.findMany({
where: { id: input.cursor === undefined ? undefined : { gt: input.cursor } },
orderBy: { id: 'asc' },
take: input.limit + 1,
select: { id: true, name: true, color: true },
});
return { ...world, collected: true, ...pageResult(rows, input.limit, (row) => row.id) };
})
),
capabilities: auditProcedure.query(({ ctx }) => ({
profileName: ctx.profile.name,
read: true,
@@ -2171,6 +2171,12 @@ integration('game API security over HTTP transport', () => {
return { status: response.status, body: (await response.json()) as unknown };
};
try {
await db.nation.createMany({
data: [
{ id: 99121, name: '현재감사국가1', color: '#ffffff' },
{ id: 99122, name: '현재감사국가2', color: '#ffffff' },
],
});
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
@@ -2284,6 +2290,24 @@ integration('game API security over HTTP transport', () => {
})),
});
await db.worldState.update({ where: { id: fixtureWorldId }, data: { currentMonth: 7 } });
expect((await get('nations', admin, { at: { year: 190, month: 1 }, limit: 1 })).body).toMatchObject({
result: { data: { collected: true, items: [{ id: ownerNationId, name: '국가1' }] } },
});
expect((await get('nations', admin, { at: { year: 190, month: 7 } })).body).toMatchObject({
result: { data: { collected: false, items: [] } },
});
expect((await get('nations', admin, { limit: 201 })).status).toBe(400);
expect((await get('nations')).status).toBe(401);
expect((await get('nations', await token(['admin']))).status).toBe(403);
expect((await get('nations', admin, { limit: 1 })).body).toMatchObject({
result: {
data: {
collected: true,
nextCursor: expect.any(Number),
items: [expect.objectContaining({ id: expect.any(Number), name: expect.any(String) })],
},
},
});
const series = await get('nationSeries', admin, {
nationId: ownerNationId,
from: { year: 190, month: 1 },
@@ -2355,6 +2379,7 @@ integration('game API security over HTTP transport', () => {
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
} finally {
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
await db.nation.deleteMany({ where: { id: { in: [99121, 99122] } } });
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
+285
View File
@@ -0,0 +1,285 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page } from '@playwright/test';
import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const world = {
year: 190,
month: 7,
startYear: 190,
serverId: 'audit-fixture',
tick: '100',
asOf: '2026-09-16T00:00:00.000Z',
};
const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 };
const population = { count: 2, gold: 200, rice: 400, dex, averageGold: 100, averageRice: 200, averageDex: dex };
const general = {
id: 1,
name: '감사장수',
userId: 'fixture',
nationId: 2,
cityId: 3,
troopId: 0,
npcState: 2,
gold: 1200,
rice: 2400,
stats: { leadership: 80, strength: 70, intelligence: 90 },
experience: 100,
dedication: 200,
officerLevel: 2,
injury: 0,
age: 30,
crew: 5000,
crewTypeId: 1,
train: 80,
atmos: 90,
dex,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
};
const install = async (page: Page, denied = false) => {
const requests: { operation: string; input: Record<string, unknown> }[] = [];
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_audit');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route(gameTrpcRoute, async (route) => {
const url = new URL(route.request().url());
const inputs = JSON.parse(url.searchParams.get('input') ?? route.request().postData() ?? '{}');
const results = decodeURIComponent(url.pathname.split('/trpc/')[1] ?? '')
.split(',')
.map((operation, index) => {
const input = inputs[index] ?? {};
requests.push({ operation, input });
const result = (data: unknown) => ({ result: { data } });
switch (operation) {
case 'auth.status':
return result({ ok: true });
case 'lobby.info':
return result({ myGeneral: null });
case 'playAudit.capabilities':
return denied
? {
error: {
message: '이 프로필의 플레이 감사 권한이 필요합니다.',
code: -32003,
data: { code: 'FORBIDDEN', httpStatus: 403 },
},
}
: result({ profileName: gameProfile, read: true, accounts: false });
case 'playAudit.coverage':
return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null });
case 'playAudit.nations':
return result({
...world,
collected: true,
items: [{ id: 2, name: '촉', color: '#ff0000' }],
nextCursor: null,
});
case 'playAudit.nationSeries':
return result({
...world,
nextCursor: null,
items: [
{
year: 190,
month: 1,
periodMonths: 6,
complete: true,
from: { year: 190, month: 1 },
to: { year: 190, month: 6 },
stockAsOf: { year: 190, month: 6 },
stock: {
id: 2,
name: '촉',
color: '#ff0000',
gold: 600,
rice: 1200,
tech: 100,
appliedRate: 20,
populations: { human: population, npc: population, troopNpc: population },
},
flows: { incomeGold: 21, incomeRice: null, paidGold: 10, paidRice: 0 },
months: [1, 2, 3, 4, 5, 6].map((month) => ({
year: 190,
month,
collected: true,
nationPresent: true,
settlementsComplete: true,
})),
},
],
});
case 'playAudit.generals':
return result({
...world,
collected: !input.at || (input.at as { month: number }).month !== 7,
sample: input.at ?? null,
nextCursor: input.cursor ? null : 1,
items:
input.at && (input.at as { month: number }).month === 7
? []
: [
{
...general,
id: input.cursor ? 2 : 1,
name: input.cursor ? '다음장수' : '감사장수',
},
],
});
case 'playAudit.cities':
return result({
...world,
collected: true,
sample: null,
nextCursor: null,
items: [
{
id: 3,
name: '성도',
nationId: 2,
level: 4,
state: 0,
population: 10000,
populationMax: 20000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
wall: 100,
wallMax: 200,
defence: 100,
defenceMax: 200,
supplyState: 1,
frontState: 0,
trust: 80,
},
],
});
default:
throw new Error(`Unexpected operation ${operation}`);
}
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
return requests;
};
const capture = async (page: Page, name: string) => {
await page.evaluate(() => document.fonts.ready);
const directory = resolve('/tmp/play-audit-browser', gameProfile.replace(':', '-'));
await mkdir(directory, { recursive: true });
const geometry = await page.evaluate(() => ({
viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
width: document.documentElement.scrollWidth,
nodes: [...document.querySelectorAll('.audit-page, .panel-card, select, input, button, table')].map((node) => {
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
return {
tag: node.tagName,
width: rect.width,
height: rect.height,
x: rect.x,
y: rect.y,
font: style.fontSize,
background: style.backgroundColor,
};
}),
}));
expect(geometry.width).toBeLessThanOrEqual(geometry.viewport.width);
await writeFile(resolve(directory, `${name}.json`), JSON.stringify(geometry, null, 2));
await writeFile(resolve(directory, `${name}.html`), await page.content());
await page.screenshot({ path: resolve(directory, `${name}.png`), fullPage: true });
};
test('profile audit without a general: chart controls, lazy reads, direct reload', async ({ page }) => {
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=nations&nation=2&fromYear=190&fromMonth=1&year=190&month=6'));
await expect(page.getByRole('cell', { name: '600', exact: true })).toBeVisible();
expect(requests.some((request) => ['playAudit.generals', 'playAudit.cities'].includes(request.operation))).toBe(
false
);
const count = requests.length;
await page.getByLabel('지표', { exact: true }).selectOption('incomeGold');
await expect(page.getByRole('cell', { name: '21', exact: true })).toBeVisible();
await page.getByLabel('지표', { exact: true }).selectOption('incomeRice');
await expect(page.getByRole('cell', { name: '자료 없음', exact: true })).toBeVisible();
expect(requests.length).toBe(count);
await page.getByText('월별 표본 수집됨', { exact: true }).click();
await expect(page.getByText('190년 1월: 수집됨')).toBeVisible();
await capture(page, 'desktop-series');
await page.reload();
await expect(page.getByRole('cell', { name: '600', exact: true })).toBeVisible();
expect(new URL(page.url()).pathname).toBe(gamePath('/play-audit'));
});
test('mobile city drilldown preserves month and includes foreign stationed generals', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=cities&nation=2&at=month&year=190&month=6'));
await page.getByText('내정 보기', { exact: true }).click();
await expect(page.getByText(/농업 100 \/ 200/)).toBeVisible();
await capture(page, 'mobile-cities');
await page.getByRole('button', { name: '모든 국가의 주둔 장수' }).click();
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
const input = requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input;
expect(input).toMatchObject({ cityId: 3, at: { year: 190, month: 6, kind: 'MONTH_END' } });
expect(input).not.toHaveProperty('nationId');
await page.getByText('상세 보기', { exact: true }).click();
await expect(page.getByText(/통솔 80/)).toBeVisible();
await capture(page, 'mobile-generals');
await page.getByRole('button', { name: '다음 50개 불러오기' }).click();
await expect(page.getByRole('rowheader', { name: /다음장수/ })).toBeVisible();
await page.goBack();
await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible();
await page.goto(gamePath('/play-audit?tab=generals&at=month&year=190&month=7'));
await expect(page.getByText('선택한 시점의 표본이 없습니다.')).toBeVisible();
});
test('denied capability does not request game audit data', async ({ page }) => {
const requests = await install(page, true);
await page.goto(gamePath('/play-audit'));
await expect(page.getByRole('alert')).toContainText('플레이 감사 권한');
expect(
requests.filter((request) => request.operation.startsWith('playAudit.')).map((request) => request.operation)
).toEqual(['playAudit.capabilities']);
await expect(page.getByLabel('조회 대상')).toHaveCount(0);
});
test('back navigation during a slow read keeps the newer city view', async ({ page }) => {
await install(page);
let release = () => {};
let markStarted = () => {};
const held = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
await page.route(gameTrpcRoute, async (route) => {
if (route.request().url().includes('playAudit.generals')) {
markStarted();
await held;
}
await route.fallback();
});
await page.goto(gamePath('/play-audit?tab=cities'));
await page.getByRole('button', { name: '모든 국가의 주둔 장수' }).click();
await started;
await expect(page.getByRole('button', { name: '조회', exact: true })).toBeDisabled();
await page.goBack();
await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible();
const response = page.waitForResponse((response) => response.url().includes('playAudit.generals'));
release();
await response;
await expect(page.getByRole('rowheader', { name: /성도/ })).toBeVisible();
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toHaveCount(0);
await page.getByRole('button', { name: '조회', exact: true }).focus();
await expect(page.getByRole('button', { name: '조회', exact: true })).toBeFocused();
});
@@ -19,6 +19,7 @@ const frontendEnv =
export default defineConfig({
testDir: '.',
testMatch: [
'playAudit.spec.ts',
'troop.spec.ts',
'typographyPolicy.spec.ts',
'board.spec.ts',
+2 -1
View File
@@ -45,7 +45,8 @@ body {
#app:has(.interface-settings-page),
#app:has(#tournament-container),
#app:has(#tournament-betting-container),
#app:has(#personnel-container) {
#app:has(#personnel-container),
#app:has(#play-audit-container) {
min-width: 320px;
}
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { trpc } from '../../utils/trpc';
type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
type Point = Series['items'][number];
const props = defineProps<{ data: Series }>();
const population = ref<'human' | 'npc' | 'troopNpc'>('human');
const metric = ref('gold');
const metrics = [
['gold', '국고 금'],
['rice', '국고 쌀'],
['tech', '기술력'],
['appliedRate', '적용 세율'],
['incomeGold', '실제 금 수입'],
['incomeRice', '실제 쌀 수입'],
['paidGold', '지급 금'],
['paidRice', '지급 쌀'],
['averageGold', '평균 금'],
['averageRice', '평균 쌀'],
['totalGold', '장수 보유 금 합계'],
['totalRice', '장수 보유 쌀 합계'],
['count', '장수 수'],
['dex1', '평균 보병 숙련'],
['dex2', '평균 궁병 숙련'],
['dex3', '평균 기병 숙련'],
['dex4', '평균 귀병 숙련'],
['dex5', '평균 차병 숙련'],
] as const;
const label = computed(() => metrics.find(([key]) => key === metric.value)?.[1] ?? '');
const value = (point: Point): number | null => {
const stock = point.stock;
switch (metric.value) {
case 'totalGold':
return stock?.populations[population.value].gold ?? null;
case 'totalRice':
return stock?.populations[population.value].rice ?? null;
case 'gold':
case 'rice':
case 'tech':
case 'appliedRate':
return stock?.[metric.value] ?? null;
case 'incomeGold':
case 'incomeRice':
case 'paidGold':
case 'paidRice':
return point.flows[metric.value];
case 'count':
case 'averageGold':
case 'averageRice':
return stock?.populations[population.value][metric.value] ?? null;
case 'dex1':
case 'dex2':
case 'dex3':
case 'dex4':
case 'dex5':
return stock?.populations[population.value].averageDex[metric.value] ?? null;
default:
return null;
}
};
const format = (number: number | null) =>
number === null ? '자료 없음' : number.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const maximum = computed(() => Math.max(1, ...props.data.items.map((row) => Math.abs(value(row) ?? 0))));
const date = (point: { year: number; month: number } | null) =>
point ? `${point.year}${point.month}` : '자료 없음';
</script>
<template>
<div class="series-controls">
<label
>지표
<select class="legacy-sort-select" v-model="metric" aria-label="지표">
<option v-for="[key, text] in metrics" :key="key" :value="key">{{ text }}</option>
</select></label
>
<label
>장수 집단
<select class="legacy-sort-select" v-model="population" aria-label="장수 집단">
<option value="human">유저</option>
<option value="npc">NPC</option>
<option value="troopNpc">부대장 NPC</option>
</select></label
>
</div>
<p>
수입·지급은 기간 합계, 국고·기술·장수 통계는 마지막 수집 월의 값입니다. 미수집 구간은 0으로 표시하지 않습니다.
</p>
<div class="table-scroll" tabindex="0" aria-label="국가 시계열 ">
<table>
<caption>
{{
label
}}
그래프와 수치
</caption>
<thead>
<tr>
<th scope="col">기간</th>
<th scope="col">{{ label }}</th>
<th scope="col">비교</th>
<th scope="col">마지막 표본</th>
<th scope="col">수집 상태</th>
</tr>
</thead>
<tbody>
<tr v-for="point in data.items" :key="`${point.year}-${point.month}`">
<th scope="row">{{ date(point.from) }} ~ {{ date(point.to) }}</th>
<td>{{ format(value(point)) }}</td>
<td class="bar-cell">
<span
v-if="value(point) !== null"
class="bar"
:style="{ width: `${(Math.abs(value(point) ?? 0) / maximum) * 100}%` }"
aria-hidden="true"
/>
</td>
<td>{{ date(point.stockAsOf) }}</td>
<td>
<details>
<summary>{{ point.complete ? '월별 표본 수집됨' : '일부 기간·자료 없음' }}</summary>
<ul>
<li v-for="month in point.months" :key="`${month.year}-${month.month}`">
{{ date(month) }}:
{{
!month.collected
? '미수집'
: !month.nationPresent
? '국가 없음'
: !month.settlementsComplete
? '정산 불완전'
: '수집됨'
}}
</li>
</ul>
</details>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.series-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
}
th,
td {
padding: 6px;
border: 1px solid gray;
text-align: left;
}
caption {
padding: 8px;
text-align: left;
}
.bar-cell {
min-width: 100px;
width: 25%;
}
.bar {
display: block;
height: 12px;
background: #7ac9e7;
}
summary {
cursor: pointer;
}
</style>
+6
View File
@@ -56,6 +56,12 @@ const accessPageByRouteName = {
} as const;
const routes = [
{
path: '/play-audit',
name: 'play-audit',
component: () => import('../views/PlayAuditView.vue'),
meta: { requiresAuth: true },
},
{
path: '/',
name: 'home',
@@ -0,0 +1,533 @@
<script setup lang="ts">
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 { usePageExit } from '../composables/usePageExit';
import { trpc } from '../utils/trpc';
type Coverage = Awaited<ReturnType<typeof trpc.playAudit.coverage.query>>;
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>>;
const route = useRoute();
const router = useRouter();
const { pageExitLabel, exitPage } = usePageExit();
const coverage = ref<Coverage | null>(null);
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 authorized = ref(false);
const profileName = ref('');
const loading = ref(false);
const error = ref('');
const tab = ref('nations');
const nationId = ref('');
const cityId = ref('');
const population = ref('');
const moment = ref('current');
const year = ref(0);
const month = ref(1);
const fromYear = ref(0);
const fromMonth = ref(1);
const resolution = ref<'month' | 'halfYear'>('halfYear');
let generation = 0;
const numeric = (value: unknown, fallback: number) =>
typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : fallback;
const at = computed(() =>
moment.value === 'current'
? undefined
: {
year: year.value,
month: month.value,
kind: moment.value === 'final' ? ('FINAL' as const) : ('MONTH_END' as const),
}
);
const format = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const nationName = (id: number) =>
nations.value?.items.find((item) => item.id === id)?.name ?? (id === 0 ? '무소속' : `국가 #${id}`);
const scopeLabel = computed(() =>
route.query.at !== 'month' && route.query.at !== 'final'
? '현재 상태'
: `${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
);
const readQuery = () => {
tab.value = ['nations', 'generals', 'cities'].includes(String(route.query.tab))
? String(route.query.tab)
: 'nations';
nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : '';
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
? String(route.query.population)
: '';
moment.value = ['month', 'final'].includes(String(route.query.at)) ? String(route.query.at) : 'current';
year.value = numeric(route.query.year, coverage.value?.year ?? 0);
month.value = numeric(route.query.month, coverage.value?.month ?? 1);
const defaultStart = Math.max((coverage.value?.startYear ?? year.value) * 12, year.value * 12 + month.value - 6);
fromYear.value = numeric(route.query.fromYear, Math.floor(defaultStart / 12));
fromMonth.value = numeric(route.query.fromMonth, (defaultStart % 12) + 1);
resolution.value = route.query.resolution === 'month' ? 'month' : 'halfYear';
};
const message = (cause: unknown) => (cause instanceof Error ? cause.message : '자료를 조회하지 못했습니다.');
const load = async (append = false) => {
if (append) readQuery();
const request = ++generation;
loading.value = true;
error.value = '';
if (!append) {
generals.value = null;
cities.value = null;
series.value = null;
}
const filter = { at: at.value, nationId: nationId.value === '' ? undefined : Number(nationId.value), limit: 50 };
try {
if (tab.value === 'generals') {
const response = await trpc.playAudit.generals.query({
...filter,
cityId: cityId.value === '' ? undefined : Number(cityId.value),
population:
population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc'
? population.value
: undefined,
cursor: append ? (generals.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
generals.value = {
...response,
items: append ? [...(generals.value?.items ?? []), ...response.items] : response.items,
};
} else if (tab.value === 'cities') {
const response = await trpc.playAudit.cities.query({
...filter,
cursor: append ? (cities.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
cities.value = {
...response,
items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items,
};
} else if (nationId.value !== '') {
const response = await trpc.playAudit.nationSeries.query({
nationId: Number(nationId.value),
from: { year: fromYear.value, month: fromMonth.value },
to: { year: year.value, month: month.value },
resolution: resolution.value,
limit: 50,
cursor: append ? (series.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
series.value = {
...response,
items: append ? [...(series.value?.items ?? []), ...response.items] : response.items,
};
}
} catch (cause) {
if (request === generation) error.value = message(cause);
} finally {
if (request === generation) loading.value = false;
}
};
const loadNations = async (append = false) => {
const request = generation;
const response = await trpc.playAudit.nations.query({
at: at.value,
limit: 50,
cursor: append ? (nations.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
nations.value = {
...response,
items: append ? [...(nations.value?.items ?? []), ...response.items] : response.items,
};
};
const apply = async () => {
const query = {
tab: tab.value,
nation: nationId.value || undefined,
city: cityId.value || undefined,
population: population.value || undefined,
at: moment.value,
year: String(year.value),
month: String(month.value),
fromYear: String(fromYear.value),
fromMonth: String(fromMonth.value),
resolution: resolution.value,
};
if (JSON.stringify(route.query) === JSON.stringify(query)) await refresh();
else {
const before = route.fullPath;
await router.push({ query });
if (before === route.fullPath) await refresh();
}
};
const refresh = async () => {
const dataRequest = load();
const request = generation;
const results = await Promise.allSettled([dataRequest, loadNations()]);
if (request !== generation) return;
for (const response of results) if (response.status === 'rejected') error.value = message(response.reason);
};
const showCityGenerals = async (id: number) => {
tab.value = 'generals';
cityId.value = String(id);
nationId.value = '';
population.value = '';
await apply();
};
const moreNations = async () => {
const request = generation;
loading.value = true;
try {
await loadNations(true);
} catch (cause) {
if (request === generation) error.value = message(cause);
} finally {
if (request === generation) loading.value = false;
}
};
watch(
() => route.fullPath,
() => {
if (authorized.value) {
readQuery();
void refresh();
}
}
);
onMounted(async () => {
loading.value = true;
try {
const capabilities = await trpc.playAudit.capabilities.query();
profileName.value = capabilities.profileName;
authorized.value = true;
coverage.value = await trpc.playAudit.coverage.query({ limit: 50 });
readQuery();
await refresh();
} catch (cause) {
error.value = message(cause);
} finally {
loading.value = false;
}
});
</script>
<template>
<main id="play-audit-container" class="audit-page">
<PanelCard title="플레이 감사" :subtitle="profileName || undefined">
<template #actions
><button class="legacy-button" @click="exitPage">{{ pageExitLabel }}</button></template
>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="loading" role="status">조회 </p>
<template v-if="authorized && coverage">
<p>
현재 {{ coverage.year }} {{ coverage.month }} · {{ scopeLabel }} · 현재 기수의 수집 자료를
조회합니다.
</p>
<p v-if="coverage.status === 'IDENTITY_MISSING'">
기수 식별자가 없어 과거 자료를 수집하지 못했습니다. 현재 상태만 확인할 있습니다.
</p>
<p v-else-if="coverage.status === 'NOT_COLLECTED'">
아직 수집된 월별 표본이 없습니다. 현재 상태는 조회할 있습니다.
</p>
<form class="filters" @submit.prevent="apply">
<label
>조회 대상<select class="legacy-sort-select" v-model="tab">
<option value="nations">국가 시계열</option>
<option value="generals">전체 장수</option>
<option value="cities">도시 상태</option>
</select></label
>
<label
>국가<select class="legacy-sort-select" v-model="nationId">
<option value="">{{ tab === 'nations' ? '국가 선택' : '모든 국가' }}</option>
<option value="0">무소속</option>
<option
v-for="nation in nations?.items.filter((item) => item.id !== 0)"
:key="nation.id"
:value="String(nation.id)"
>
{{ nation.name }} (#{{ nation.id }})
</option>
<option
v-if="
nationId &&
nationId !== '0' &&
!nations?.items.some((item) => String(item.id) === nationId)
"
:value="nationId"
>
국가 #{{ nationId }}
</option>
</select></label
>
<button
class="legacy-button"
v-if="nations?.nextCursor !== null && nations?.nextCursor !== undefined"
type="button"
:disabled="loading"
@click="moreNations"
>
국가 더 불러오기
</button>
<label
>국가 목록·상태 기준<select class="legacy-sort-select" v-model="moment">
<option value="current">현재</option>
<option value="month">월말</option>
<option value="final">최종 표본</option>
</select></label
>
<label
>{{ tab === 'nations' ? '종료 연도' : '표본 연도'
}}<input
v-model.number="year"
type="number"
:min="coverage.startYear"
:max="coverage.year"
required
/></label>
<label>월<input v-model.number="month" type="number" min="1" max="12" required /></label>
<template v-if="tab === 'nations'">
<label
>시작 연도<input
v-model.number="fromYear"
type="number"
:min="coverage.startYear"
:max="coverage.year"
required
/></label>
<label
>시작 월<input v-model.number="fromMonth" type="number" min="1" max="12" required
/></label>
<label
>간격<select class="legacy-sort-select" v-model="resolution">
<option value="halfYear">반기 (1~6월 / 7~12월)</option>
<option value="month">매월</option>
</select></label
>
</template>
<template v-if="tab === 'generals'">
<label>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></label>
<label
>장수 분류<select class="legacy-sort-select" v-model="population">
<option value="">전체</option>
<option value="human">유저</option>
<option value="npc">NPC</option>
<option value="troopNpc">부대장 NPC</option>
</select></label
>
</template>
<button class="legacy-button" type="submit" :disabled="loading">조회</button>
</form>
</template>
</PanelCard>
<PanelCard
v-if="authorized && coverage"
:title="tab === 'nations' ? '국가 시계열' : tab === 'generals' ? '전체 장수' : '도시 상태'"
>
<p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p>
<p v-if="tab === 'nations' && !nationId">
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다.
</p>
<AuditNationSeries v-if="series && tab === 'nations'" :data="series" />
<template v-if="generals && tab === 'generals'">
<p v-if="!generals.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!generals.items.length">조건에 맞는 장수가 없습니다.</p>
<div v-else 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="general in generals.items" :key="general.id">
<th scope="row">
{{ general.name }} (#{{ general.id }})<br />{{
general.npcState < 2 ? '유저' : general.npcState === 5 ? '부대장 NPC' : 'NPC'
}}
</th>
<td>
{{ nationName(general.nationId) }}<br />도시 #{{ general.cityId }} · 부대 #{{
general.troopId
}}
</td>
<td>{{ format(general.gold) }} / {{ format(general.rice) }}</td>
<td>
{{ format(general.crew) }} / {{ format(general.train) }} / {{ format(general.atmos)
}}<br />병종 #{{ general.crewTypeId }}
</td>
<td>
<details>
<summary>상세 보기</summary>
<p>
통솔 {{ general.stats.leadership }} · 무력 {{ general.stats.strength }} ·
지력 {{ general.stats.intelligence }}
</p>
<p>
경험 {{ format(general.experience) }} · 공헌
{{ format(general.dedication) }} · 관직 {{ general.officerLevel }}
</p>
<p>나이 {{ general.age }} · 부상 {{ general.injury }}</p>
<p>
숙련 (보 / 궁 / 기 / 귀 / 차):
{{ Object.values(general.dex).map(format).join(' / ') }}
</p>
<p>
성격 {{ general.role.personality ?? '없음' }} · 내정 특기
{{ general.role.specialDomestic ?? '없음' }} · 전투 특기
{{ general.role.specialWar ?? '없음' }}
</p>
<p>
장비 (말 / 무기 / 책 / 도구):
{{
Object.values(general.role.items)
.map((item) => item ?? '없음')
.join(' / ')
}}
</p>
</details>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-if="cities && tab === 'cities'">
<p v-if="!cities.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!cities.items.length">조건에 맞는 도시가 없습니다.</p>
<div v-else 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="city in cities.items" :key="city.id">
<th scope="row">{{ city.name }} (#{{ city.id }})</th>
<td>{{ nationName(city.nationId) }}</td>
<td>{{ format(city.population) }} / {{ format(city.populationMax) }}</td>
<td>
<details>
<summary>내정 보기</summary>
<p>
농업 {{ city.agriculture }} / {{ city.agricultureMax }} · 상업
{{ city.commerce }} / {{ city.commerceMax }}
</p>
<p>
치안 {{ city.security }} / {{ city.securityMax }} · 성벽 {{ city.wall }} /
{{ city.wallMax }} · 수비 {{ city.defence }} / {{ city.defenceMax }}
</p>
<p>
민심 {{ city.trust }} · 보급 {{ city.supplyState }} · 전방
{{ city.frontState }} · 상태 {{ city.state }} · 규모 {{ city.level }}
</p>
</details>
</td>
<td>
<button
class="legacy-button"
type="button"
:disabled="loading"
@click="showCityGenerals(city.id)"
>
모든 국가의 주둔 장수
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<button
class="legacy-button"
v-if="result?.nextCursor != null"
type="button"
:disabled="loading"
@click="load(true)"
>
다음 50개 불러오기
</button>
<button class="legacy-button" v-if="error && authorized" type="button" :disabled="loading" @click="refresh">
다시 조회
</button>
</PanelCard>
</main>
</template>
<style scoped>
.audit-page {
max-width: 1200px;
margin: 0 auto;
padding: 8px;
display: grid;
gap: 12px;
}
.audit-page > :deep(.panel-card) {
min-width: 0;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: end;
}
.filters label {
display: grid;
gap: 4px;
max-width: 100%;
}
.filters input {
width: 100px;
padding: 2px 4px;
border: 1px solid #91a39a;
background: #000;
color: #fff;
}
.filters select {
max-width: 100%;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
}
th,
td {
border: 1px solid gray;
padding: 6px;
text-align: left;
}
details {
min-width: 120px;
max-width: 300px;
}
summary {
cursor: pointer;
}
[role='alert'] {
color: #ffb9b9;
}
button,
select,
input {
font: inherit;
}
</style>
+27 -1
View File
@@ -1,11 +1,37 @@
# 플레이 감사 구현 기록과 수집 inventory
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API를 연결했다. 화면과 나머지 조회는 미구현이다.
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API,
국가 월/반기 시계열과 `/play-audit` 기본 조회 화면을 연결했다. 외교·정책·NPC trace·조사 도구는 미구현이다.
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
## 현재 구현
### 기본 조회 화면
프로필 game frontend의 `/play-audit`는 장수가 없는 감사 계정도 직접 접근한다.
`capabilities`가 허용된 뒤 coverage와 국가 목록을 읽고 선택한 조회만 요청한다.
권한 거부 시 다른 감사 자료를 미리 가져오지 않는다. URL에 탭·국가·도시·표본 월·기간을
보존하며 도시의 주둔 장수 연결은 당시 월을 유지하고 국가 필터를 해제한다.
장수·도시 목록은 50개씩 명시적으로 더 읽는다. 느린 이전 응답은 후속 조회를 덮지 않는다.
국가 목록은 현재 또는 한 월의 이름/ID/color만 반환한다. 현재 목록은 해당 세 필드만
SELECT하며 과거 목록은 한 표본의 국가 JSON을 51행까지 읽고 allowlist projection한다.
기본 50·최대 200과 ID cursor를 사용하고 기수 전체의 국가를 DISTINCT 스캔하지 않는다.
멸망국은 해당 월 기준 목록으로 선택한다. 국가 시계열의 기본 범위는 최근 6개월이며
지표·집단 전환은 이미 받은 집계에서 계산해 추가 요청을 하지 않는다.
PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라이브러리 없이
표의 막대와 수치를 함께 표시한다. stock 마지막 표본 월, 월별 수집 여부·국가 존재·정산
완전성을 펼쳐볼 수 있고 null은 `자료 없음`이다. 국가 보유 금쌀/기술/세율,
수입·지급, 집단 인원·보유 총량/평균·5병종 평균 숙련 지표를 제공한다.
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음,
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
월말/FINAL 장수·도시 projection을 보여주지만 국가 FINAL 별도 시계열, 지도,
로그/예약 명령/전투 상세, 검색·정렬, 관리자 패널 진입 버튼은 후속 구현으로 남는다.
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도