feat: 프로필 플레이 감사 기본 조회 화면 구현
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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도
|
||||
|
||||
Reference in New Issue
Block a user