플레이 감사를 3단 대시보드와 국가 추이 그래프로 개선한다

This commit is contained in:
2026-09-26 04:36:31 +00:00
parent 2b2edeaeff
commit e35679335c
12 changed files with 1040 additions and 321 deletions
@@ -1,3 +1,5 @@
import { GamePrisma } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
@@ -5,6 +7,7 @@ import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth }
const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() }); const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
const zPopulation = z.object({ const zPopulation = z.object({
count: z.number().int().nonnegative(), count: z.number().int().nonnegative(),
crew: z.number().nonnegative().nullable().default(null),
gold: z.number(), gold: z.number(),
rice: z.number(), rice: z.number(),
dex: zDex, dex: zDex,
@@ -92,6 +95,34 @@ export const summarizeNationPeriod = (start: number, width: number, months: Nati
}; };
}; };
/** 같은 transaction에서 commit된 당월 정산만 반환한다. 미관측을 0으로 만들지 않는다. */
export const projectCurrentSettlement = (meta: unknown, year: number, month: number, nationId: number) => {
const flows = asRecord(asRecord(meta).playAuditFlows);
const matches = flows.year === year && flows.month === month;
const entries = asRecord(flows.entries);
const resource = (key: 'gold' | 'rice') => {
const row = asRecord(entries[`${nationId}:${key}`]);
if (
!matches ||
row.nationId !== nationId ||
row.resource !== key ||
typeof row.income !== 'number' ||
!Number.isFinite(row.income) ||
typeof row.paid !== 'number' ||
!Number.isFinite(row.paid)
)
return null;
return { income: row.income, paid: row.paid };
};
return {
year,
month,
gold: resource('gold'),
rice: resource('rice'),
complete: matches && flows.complete === true,
};
};
const zCalendarMonth = zAuditMonth.omit({ kind: true }); const zCalendarMonth = zAuditMonth.omit({ kind: true });
export const nationSeries = auditProcedure export const nationSeries = auditProcedure
.input( .input(
@@ -151,6 +182,35 @@ export const nationSeries = auditProcedure
}) })
: []; : [];
const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)])); const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)]));
// 기존 집계는 같은 월의 장수 표본을 DB에서 합산한다. 원문은 전송하지 않는다.
const legacySamples = [...dataBySample]
.filter(([, nation]) => Object.values(nation.populations).some((group) => group.crew === null))
.map(([id]) => id);
if (legacySamples.length) {
const troops = await tx.$queryRaw<
{ sampleId: string; population: 'human' | 'npc' | 'troopNpc'; count: number; crew: number | null }[]
>(GamePrisma.sql`
SELECT sample_id AS "sampleId",
CASE WHEN npc_state = 5 THEN 'troopNpc' WHEN npc_state < 2 THEN 'human' ELSE 'npc' END AS population,
count(*)::int AS count,
CASE WHEN bool_and(jsonb_typeof(data->'crew') = 'number' AND data->'crew' IS NOT NULL)
THEN sum(CASE WHEN jsonb_typeof(data->'crew') = 'number' THEN (data->>'crew')::double precision END) ELSE NULL END AS crew
FROM play_audit_general
WHERE nation_id = ${input.nationId} AND sample_id IN (${GamePrisma.join(legacySamples)})
GROUP BY sample_id, population
`);
for (const sampleId of legacySamples) {
const nation = dataBySample.get(sampleId)!;
for (const key of ['human', 'npc', 'troopNpc'] as const) {
const group = nation.populations[key];
if (group.crew !== null) continue;
const row = troops.find((item) => item.sampleId === sampleId && item.population === key);
// 저장 인원수와 원본 표본 수가 같을 때만 확정한다.
if (group.count === 0 && !row) group.crew = 0;
else if (row?.count === group.count) group.crew = row.crew;
}
}
}
const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample])); const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample]));
const items: ReturnType<typeof summarizeNationPeriod>[] = []; const items: ReturnType<typeof summarizeNationPeriod>[] = [];
for (let period = start; period <= end; period += width) { for (let period = start; period <= end; period += width) {
@@ -166,6 +226,17 @@ export const nationSeries = auditProcedure
} }
items.push(summarizeNationPeriod(period, width, months)); items.push(summarizeNationPeriod(period, width, months));
} }
return { ...world, items, nextCursor: end < to ? dateOf(start + input.limit * width) : null }; const currentState =
world.serverId && end === current
? await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } })
: null;
return {
...world,
items,
currentSettlement: currentState
? projectCurrentSettlement(currentState.meta, world.year, world.month, input.nationId)
: null,
nextCursor: end < to ? dateOf(start + input.limit * width) : null,
};
}) })
); );
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
summarizeNationPeriod, summarizeNationPeriod,
projectCurrentSettlement,
zAuditNation,
type AuditNationData, type AuditNationData,
type NationMonthPoint, type NationMonthPoint,
} from '../src/router/playAudit/nationSeries.js'; } from '../src/router/playAudit/nationSeries.js';
const population = { const population = {
count: 0, count: 0,
crew: null,
gold: 0, gold: 0,
rice: 0, rice: 0,
dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 }, dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 },
@@ -89,3 +92,44 @@ describe('play audit half-year summary', () => {
}); });
}); });
}); });
describe('current committed settlement', () => {
it.each([
[1, 'gold'],
[7, 'rice'],
] as const)('exposes month %s settlement before month-end', (month, resource) => {
const meta = {
playAuditFlows: {
year: 200,
month,
complete: true,
entries: { [`2:${resource}`]: { nationId: 2, resource, income: 1234.5, paid: 234 } },
},
};
expect(projectCurrentSettlement(meta, 200, month, 2)[resource]).toEqual({ income: 1234.5, paid: 234 });
expect(projectCurrentSettlement(meta, 200, month + 1, 2)[resource]).toBeNull();
expect(projectCurrentSettlement(meta, 200, month, 3)[resource]).toBeNull();
});
it('keeps absent and malformed observations unknown, preserves actual zero', () => {
expect(projectCurrentSettlement({}, 200, 1, 2).gold).toBeNull();
const meta = {
playAuditFlows: {
year: 200,
month: 1,
complete: false,
entries: { '2:gold': { nationId: 2, resource: 'gold', income: 0, paid: 0 } },
},
};
expect(projectCurrentSettlement(meta, 200, 1, 2)).toMatchObject({
gold: { income: 0, paid: 0 },
complete: false,
});
meta.playAuditFlows.entries['2:gold'].income = NaN;
expect(projectCurrentSettlement(meta, 200, 1, 2).gold).toBeNull();
});
it('reads old snapshots without inventing troop counts', () => {
const old = JSON.parse(JSON.stringify(nation(1)));
delete old.populations.human.crew;
expect(zAuditNation.parse(old).populations.human.crew).toBeNull();
});
});
@@ -3119,6 +3119,39 @@ integration('game API security over HTTP transport', () => {
}, },
}, },
}); });
await db.playAuditNation.update({
where: { sampleId_nationId: { sampleId: `${seasonId}:190:6`, nationId: ownerNationId } },
data: {
data: {
...asRecord(finalNation.data),
populations: {
human: { ...population, count: 2 },
npc: { ...population, count: 1 },
troopNpc: population,
},
},
},
});
await db.playAuditGeneral.createMany({
data: [1, 2].map((id) => ({
sampleId: `${seasonId}:190:6`,
generalId: id,
nationId: ownerNationId,
cityId: 0,
npcState: 0,
data: { crew: id * 1200 },
})),
});
await db.playAuditGeneral.create({
data: {
sampleId: `${seasonId}:190:6`,
generalId: 3,
nationId: ownerNationId,
cityId: 0,
npcState: 2,
data: { crew: 'invalid' },
},
});
const series = await get('nationSeries', admin, { const series = await get('nationSeries', admin, {
nationId: ownerNationId, nationId: ownerNationId,
from: { year: 190, month: 1 }, from: { year: 190, month: 1 },
@@ -3134,7 +3167,10 @@ integration('game API security over HTTP transport', () => {
year: 190, year: 190,
month: 1, month: 1,
complete: true, complete: true,
stock: { gold: 600 }, stock: {
gold: 600,
populations: { human: { crew: 3600 }, npc: { crew: null }, troopNpc: { crew: 0 } },
},
flows: { incomeGold: 21, incomeRice: 0 }, flows: { incomeGold: 21, incomeRice: 0 },
}, },
], ],
@@ -3166,6 +3202,59 @@ integration('game API security over HTTP transport', () => {
result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } }, result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } },
}); });
// 정산 commit 직후, 아직 월말 표본이 없는 상태를 HTTP로 확인한다.
for (const [settlementMonth, resource] of [
[1, 'gold'],
[7, 'rice'],
] as const) {
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
currentYear: 191,
currentMonth: settlementMonth,
meta: {
serverId: seasonId,
scenarioMeta: { startYear: 190 },
playAuditFlows: {
year: 191,
month: settlementMonth,
complete: true,
entries: {
[`${ownerNationId}:${resource}`]: {
nationId: ownerNationId,
resource,
income: 8765.5,
paid: 4321,
},
},
},
},
},
});
const input = {
nationId: ownerNationId,
from: { year: 191, month: settlementMonth },
to: { year: 191, month: settlementMonth },
};
expect((await get('nationSeries', admin, input)).body).toMatchObject({
result: {
data: {
currentSettlement: {
year: 191,
month: settlementMonth,
[resource]: { income: 8765.5, paid: 4321 },
},
items: [{ stock: null, complete: false }],
},
},
});
expect((await get('nationSeries', undefined, input)).status).toBe(401);
expect((await get('nationSeries', await token(['admin']), input)).status).toBe(403);
expect(
(await get('nationSeries', await token(['admin.playAudit.read:other:default']), input)).status
).toBe(403);
}
// A synchronized opening may start before the scenario's gameplay year. // A synchronized opening may start before the scenario's gameplay year.
await db.worldState.update({ await db.worldState.update({
where: { id: fixtureWorldId }, where: { id: fixtureWorldId },
@@ -10,6 +10,7 @@ export type AuditPopulation = 'human' | 'npc' | 'troopNpc';
export interface AuditPopulationSummary { export interface AuditPopulationSummary {
count: number; count: number;
crew: number;
gold: number; gold: number;
rice: number; rice: number;
dex: AuditDex; dex: AuditDex;
@@ -44,6 +45,7 @@ export interface AuditNationSnapshot {
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 }); const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
const emptyPopulation = (): AuditPopulationSummary => ({ const emptyPopulation = (): AuditPopulationSummary => ({
count: 0, count: 0,
crew: 0,
gold: 0, gold: 0,
rice: 0, rice: 0,
dex: emptyDex(), dex: emptyDex(),
@@ -173,6 +175,7 @@ export const buildAuditSnapshot = (input: {
const population = nations.get(general.nationId)?.populations[projected.population]; const population = nations.get(general.nationId)?.populations[projected.population];
if (!population) continue; if (!population) continue;
population.count++; population.count++;
population.crew += projected.crew;
population.gold += projected.gold; population.gold += projected.gold;
population.rice += projected.rice; population.rice += projected.rice;
for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key]; for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key];
@@ -76,13 +76,15 @@ describe('play audit monthly projection', () => {
it('separates humans, NPCs and troop NPCs and retains empty nations and neutral generals', () => { it('separates humans, NPCs and troop NPCs and retains empty nations and neutral generals', () => {
const humans = [buildGeneral(1, 1), { ...buildGeneral(2, 1), npcState: 1, gold: 0 }]; const humans = [buildGeneral(1, 1), { ...buildGeneral(2, 1), npcState: 1, gold: 0 }];
humans[0]!.meta.dex1 = 10; humans[0]!.meta.dex1 = 10;
humans[0]!.crew = 3210;
humans[1]!.crew = 790;
const result = buildAuditSnapshot({ const result = buildAuditSnapshot({
nations: [buildNation(0, 0, {}), buildNation(1, 0, {}), buildNation(2, 0, {})], nations: [buildNation(0, 0, {}), buildNation(1, 0, {}), buildNation(2, 0, {})],
cities: [buildCity(1, 2)], cities: [buildCity(1, 2)],
generals: [ generals: [
...humans, ...humans,
{ ...buildGeneral(3, 1), npcState: 2 }, { ...buildGeneral(3, 1), npcState: 2, crew: 1200 },
{ ...buildGeneral(4, 1), npcState: 5 }, { ...buildGeneral(4, 1), npcState: 5, crew: 800 },
buildGeneral(5, 0), buildGeneral(5, 0),
], ],
settlements: [], settlements: [],
@@ -91,12 +93,13 @@ describe('play audit monthly projection', () => {
const nation = result.nations.find((row) => row.id === 1)!; const nation = result.nations.find((row) => row.id === 1)!;
expect(nation.populations.human).toMatchObject({ expect(nation.populations.human).toMatchObject({
count: 2, count: 2,
crew: 4000,
gold: 2000, gold: 2000,
averageGold: 1000, averageGold: 1000,
averageDex: { dex1: 5 }, averageDex: { dex1: 5 },
}); });
expect(nation.populations.npc.count).toBe(1); expect(nation.populations.npc).toMatchObject({ count: 1, crew: 1200 });
expect(nation.populations.troopNpc.count).toBe(1); expect(nation.populations.troopNpc).toMatchObject({ count: 1, crew: 800 });
expect(result.nations.find((row) => row.id === 2)!.populations.human.averageGold).toBeNull(); expect(result.nations.find((row) => row.id === 2)!.populations.human.averageGold).toBeNull();
expect(result.nations.find((row) => row.id === 0)!.populations.npc.count).toBe(1); expect(result.nations.find((row) => row.id === 0)!.populations.npc.count).toBe(1);
expect(result.cities[0]!.nationId).toBe(2); expect(result.cities[0]!.nationId).toBe(2);
+103 -5
View File
@@ -14,7 +14,16 @@ const world = {
collectionStart: { year: 190, month: 1, tick: '0', observedAt: '2026-09-16T00:00:00.000Z' }, collectionStart: { year: 190, month: 1, tick: '0', observedAt: '2026-09-16T00:00:00.000Z' },
}; };
const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 }; 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 population = {
crew: 5000,
count: 2,
gold: 200,
rice: 400,
dex,
averageGold: 100,
averageRice: 200,
averageDex: dex,
};
const general = { const general = {
id: 1, id: 1,
name: '감사장수', name: '감사장수',
@@ -76,7 +85,8 @@ const install = async (
page: Page, page: Page,
denied = false, denied = false,
baseline: boolean | 'document' | 'created' | 'removed' = false, baseline: boolean | 'document' | 'created' | 'removed' = false,
executionStatus?: 'PREPARING' | 'BLOCKED' executionStatus?: 'PREPARING' | 'BLOCKED',
dashboard = false
) => { ) => {
const requests: { operation: string; input: Record<string, unknown> }[] = []; const requests: { operation: string; input: Record<string, unknown> }[] = [];
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
@@ -442,6 +452,13 @@ const install = async (
case 'playAudit.nationSeries': case 'playAudit.nationSeries':
return result({ return result({
...world, ...world,
currentSettlement: {
year: 190,
month: 7,
complete: true,
gold: null,
rice: { income: 4321, paid: 1234 },
},
nextCursor: null, nextCursor: null,
items: [ items: [
{ {
@@ -471,7 +488,33 @@ const install = async (
settlementsComplete: true, settlementsComplete: true,
})), })),
}, },
], ].flatMap((point) =>
dashboard
? Array.from({ length: 6 }, (_, index) => ({
...point,
month: index + 1,
periodMonths: 1,
from: { year: 190, month: index + 1 },
to: { year: 190, month: index + 1 },
stockAsOf: { year: 190, month: index + 1 },
stock:
index === 2
? null
: {
...point.stock,
gold: 600 + index * 150,
rice: 1200 - index * 90,
populations: {
human: { ...population, crew: 5000 + index * 1000 },
npc: population,
troopNpc: population,
},
},
flows: { ...point.flows, incomeGold: index === 0 ? 2100 : 0 },
complete: index !== 2,
}))
: [point]
),
}); });
case 'playAudit.nationSnapshot': case 'playAudit.nationSnapshot':
return result({ return result({
@@ -952,7 +995,9 @@ test('policy filter drafts do not read until applied, including default dates',
await page.goto(gamePath('/play-audit?tab=policies&nation=2')); await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
await page.getByRole('link', { name: '국방 설정', exact: true }).click(); await page.getByRole('link', { name: '국방 설정', exact: true }).click();
await expect.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe('DEFENCE'); await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area)
.toBe('DEFENCE');
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length; const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByLabel('시작 월', { exact: true }).fill('3'); await page.getByLabel('시작 월', { exact: true }).fill('3');
await page.getByRole('button', { name: '조회', exact: true }).focus(); await page.getByRole('button', { name: '조회', exact: true }).focus();
@@ -1337,7 +1382,10 @@ for (const width of [1280, 390]) {
await menu.getByRole('link', { name: '국방 설정', exact: true }).click(); await menu.getByRole('link', { name: '국방 설정', exact: true }).click();
await expect(page).toHaveURL(/policyArea=DEFENCE/); await expect(page).toHaveURL(/policyArea=DEFENCE/);
await page.goBack(); await page.goBack();
await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute('aria-current', 'page'); await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute(
'aria-current',
'page'
);
await page.goBack(); await page.goBack();
await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page'); await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page');
await menu.getByRole('link', { name: '도시', exact: true }).hover(); await menu.getByRole('link', { name: '도시', exact: true }).hover();
@@ -1357,3 +1405,53 @@ for (const width of [1280, 390]) {
await page.screenshot({ path: `/tmp/play-audit-menu/${width}.png`, fullPage: true }); await page.screenshot({ path: `/tmp/play-audit-menu/${width}.png`, fullPage: true });
}); });
} }
for (const width of [1440, 390]) {
test(`audit dashboard charts, nation navigation and inspector at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 960 });
const requests = await install(page, false, false, undefined, true);
await page.goto(gamePath('/play-audit'));
await page.getByRole('button', { name: '촉 #2', exact: true }).click();
await expect(page).toHaveURL(/nation=2/);
await expect(page.getByRole('region', { name: '당월 정산', exact: true })).toContainText('4,321');
const charts = page.locator('.audit-chart canvas');
await expect(charts).toHaveCount(4);
const sizes = await charts.evaluateAll((nodes) =>
nodes.map((node) => ({
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
}))
);
for (const size of sizes) {
expect(size.width).toBeGreaterThan(width === 390 ? 260 : 600);
expect(size.height).toBeGreaterThanOrEqual(260);
}
await page.getByLabel('지표', { exact: true }).selectOption('crew');
await expect(page.getByRole('cell', { name: '15,000', exact: true })).toBeVisible();
const query = requests.findLast((request) => request.operation === 'playAudit.nationSeries')?.input;
expect(query).toMatchObject({ nationId: 2, resolution: 'month' });
await capture(page, `dashboard-${width}`);
await page.getByRole('button', { name: '최근 6개월', exact: true }).click();
await expect(page).toHaveURL(/fromMonth=2/);
await page
.getByRole('navigation', { name: '플레이 감사 메뉴' })
.getByRole('link', { name: '장수', exact: true })
.click();
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
const detail = page.getByRole('complementary', { name: '선택한 대상 상세' });
await expect(detail).toContainText('병력 5,000');
await expect(detail).toBeFocused();
if (width >= 1200) {
const left = await page.getByRole('region', { name: '감사 분석', exact: true }).boundingBox();
const right = await detail.boundingBox();
expect(right!.x).toBeGreaterThanOrEqual(left!.x + left!.width);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await capture(page, `inspector-${width}`);
await page.reload();
await expect(detail).toContainText('병력 5,000');
await expect(detail).toBeFocused();
if (width < 1200) await expect(detail).toBeInViewport();
});
}
+1
View File
@@ -46,6 +46,7 @@
"@trpc/client": "^11.8.1", "@trpc/client": "^11.8.1",
"@trpc/server": "^11.8.1", "@trpc/server": "^11.8.1",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"chart.js": "4.5.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
Chart,
CategoryScale,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
Legend,
} from 'chart.js';
Chart.register(CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, Legend);
const props = defineProps<{
title: string;
labels: string[];
lines: { label: string; values: (number | null)[]; color: string }[];
}>();
const canvas = ref<HTMLCanvasElement | null>(null);
let chart: Chart<'line'> | undefined;
const render = () => {
if (!canvas.value) return;
chart?.destroy();
chart = new Chart(canvas.value, {
type: 'line',
data: {
labels: props.labels,
datasets: props.lines.map((line) => ({
label: line.label,
data: line.values,
borderColor: line.color,
backgroundColor: line.color,
borderWidth: 2,
pointRadius: 3,
pointHitRadius: 12,
spanGaps: false,
})),
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: '#e5eee9' } } },
scales: {
x: { ticks: { color: '#c3d0c8', maxTicksLimit: 12 }, grid: { color: '#34473e' } },
y: { ticks: { color: '#c3d0c8' }, grid: { color: '#34473e' } },
},
},
});
};
onMounted(render);
watch(() => [props.labels, props.lines], render, { deep: true });
onBeforeUnmount(() => chart?.destroy());
</script>
<template>
<section class="audit-chart" :aria-label="title">
<h3>{{ title }}</h3>
<div class="chart-canvas">
<canvas
ref="canvas"
role="img"
:aria-label="`${title}. 아래 수치 표에서 정확한 값을 확인할 수 있습니다.`"
/>
</div>
<p v-if="!lines.some((line) => line.values.some((value) => value !== null))">이 기간에 수집된 값이 없습니다.</p>
</section>
</template>
<style scoped>
.audit-chart {
min-width: 0;
padding: 12px;
background: #14231c;
border: 1px solid #536b60;
border-radius: 6px;
}
h3 {
margin: 0 0 8px;
font-size: var(--sammo-font-size-emphasis);
}
.chart-canvas {
position: relative;
height: 300px;
min-width: 0;
}
@media (max-width: 600px) {
.chart-canvas {
height: 260px;
}
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import AuditLineChart from './AuditLineChart.vue';
import type { trpc } from '../../utils/trpc'; import type { trpc } from '../../utils/trpc';
type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>; type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
@@ -10,6 +11,7 @@ const metric = ref('gold');
const metrics = [ const metrics = [
['gold', '국고 금'], ['gold', '국고 금'],
['rice', '국고 쌀'], ['rice', '국고 쌀'],
['crew', '총 병사수'],
['tech', '기술력'], ['tech', '기술력'],
['appliedRate', '적용 세율'], ['appliedRate', '적용 세율'],
['incomeGold', '실제 금 수입'], ['incomeGold', '실제 금 수입'],
@@ -31,6 +33,8 @@ const label = computed(() => metrics.find(([key]) => key === metric.value)?.[1]
const value = (point: Point): number | null => { const value = (point: Point): number | null => {
const stock = point.stock; const stock = point.stock;
switch (metric.value) { switch (metric.value) {
case 'crew':
return totalCrew(point);
case 'totalGold': case 'totalGold':
return stock?.populations[population.value].gold ?? null; return stock?.populations[population.value].gold ?? null;
case 'totalRice': case 'totalRice':
@@ -59,6 +63,28 @@ const value = (point: Point): number | null => {
return null; return null;
} }
}; };
const totalCrew = (point: Point): number | null => {
if (!point.stock) return null;
const groups = Object.values(point.stock.populations);
return groups.some((group) => group.crew == null) ? null : groups.reduce((sum, group) => sum + group.crew!, 0);
};
const chartLabels = computed(() => props.data.items.map((point) => `${point.year}년 ${point.month}월`));
const resourceLines = computed(() => [
{ label: '국고 금', color: '#f5cc64', values: props.data.items.map((point) => point.stock?.gold ?? null) },
{ label: '국고 쌀', color: '#91d9a0', values: props.data.items.map((point) => point.stock?.rice ?? null) },
]);
const crewLines = computed(() => [{ label: '총 병사수', color: '#83cafa', values: props.data.items.map(totalCrew) }]);
const dexLines = computed(() =>
(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const).map((key, index) => ({
label: ['보병', '궁병', '기병', '귀병', '차병'][index]!,
color: ['#f5cc64', '#91d9a0', '#83cafa', '#e2a3f3', '#ff9c88'][index]!,
values: props.data.items.map((point) => point.stock?.populations[population.value].averageDex[key] ?? null),
}))
);
const incomeLines = computed(() => [
{ label: '실제 금 수입', color: '#f5cc64', values: props.data.items.map((point) => point.flows.incomeGold) },
{ label: '실제 쌀 수입', color: '#91d9a0', values: props.data.items.map((point) => point.flows.incomeRice) },
]);
const format = (number: number | null) => const format = (number: number | null) =>
number === null ? '자료 없음' : number.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); 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 maximum = computed(() => Math.max(1, ...props.data.items.map((row) => Math.abs(value(row) ?? 0))));
@@ -67,6 +93,42 @@ const date = (point: { year: number; month: number } | null) =>
</script> </script>
<template> <template>
<section v-if="data.currentSettlement" class="settlement-now" aria-label="당월 정산">
<h3>{{ data.currentSettlement.year }}년 {{ data.currentSettlement.month }}월 정산</h3>
<p>금은 1월, 쌀은 7월에 정산됩니다. 저장된 실제 수입·지급액은 월말 전에 확인할 수 있습니다.</p>
<div class="settlement-values">
<p v-for="resource in ['gold', 'rice'] as const" :key="resource">
<strong>{{ resource === 'gold' ? '금' : '쌀' }}</strong>
<template v-if="data.currentSettlement[resource]">
수입 {{ format(data.currentSettlement[resource]!.income) }} · 지급
{{ format(data.currentSettlement[resource]!.paid) }}
<span>{{ data.currentSettlement.complete ? '정산 관측됨' : '부분 관측' }}</span>
</template>
<span v-else>당월 관측된 정산 없음</span>
</p>
</div>
</section>
<div class="chart-grid">
<AuditLineChart title="국가 금·쌀 변화" :labels="chartLabels" :lines="resourceLines" />
<AuditLineChart title="총 병사수 변화" :labels="chartLabels" :lines="crewLines" />
<AuditLineChart title="실제 금·쌀 수입" :labels="chartLabels" :lines="incomeLines" />
</div>
<div class="population-buttons" aria-label="숙련도 장수 집단">
<button
v-for="[key, text] in [
['human', '유저'],
['npc', 'NPC'],
['troopNpc', '부대장 NPC'],
] as const"
:key="key"
class="legacy-button"
:aria-pressed="population === key"
@click="population = key"
>
{{ text }}
</button>
</div>
<AuditLineChart title="병종별 평균 숙련도 변화" :labels="chartLabels" :lines="dexLines" />
<div class="series-controls"> <div class="series-controls">
<label <label
>지표 >지표
@@ -142,6 +204,41 @@ const date = (point: { year: number; month: number } | null) =>
</template> </template>
<style scoped> <style scoped>
.chart-grid {
display: grid;
gap: 16px;
margin: 16px 0;
}
.population-buttons {
display: flex;
gap: 8px;
margin: 16px 0 8px;
flex-wrap: wrap;
}
.population-buttons [aria-pressed='true'] {
background: #254e3c;
border-color: #b6d6c2;
}
.settlement-now {
padding: 12px;
border: 1px solid #8aa986;
background: #203428;
}
.settlement-now h3 {
margin-top: 0;
}
.settlement-values {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
}
.settlement-values span {
margin-left: 8px;
}
.series-controls {
margin-top: 20px;
}
.series-controls { .series-controls {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+483 -303
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'; import { useRoute, useRouter, type LocationQueryRaw } 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';
@@ -70,7 +70,23 @@ const year = ref(0);
const month = ref(1); const month = ref(1);
const fromYear = ref(0); const fromYear = ref(0);
const fromMonth = ref(1); const fromMonth = ref(1);
const resolution = ref<'month' | 'halfYear'>('halfYear'); const resolution = ref<'month' | 'halfYear'>('month');
const inspector = ref<HTMLElement | null>(null);
const nationSearch = ref('');
const visibleNations = computed(
() => nations.value?.items.filter((item) => item.name.includes(nationSearch.value)) ?? []
);
const chooseNation = (id: string) =>
router.push({
query: {
...route.query,
nation: id || undefined,
general: undefined,
cityRecord: undefined,
decision: undefined,
},
});
let generation = 0; let generation = 0;
const numeric = (value: unknown, fallback: number) => const numeric = (value: unknown, fallback: number) =>
typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : fallback; typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : fallback;
@@ -96,6 +112,13 @@ const selectedCity = computed(() =>
? Number(route.query.cityRecord) ? Number(route.query.cityRecord)
: null : null
); );
const focusInspector = async () => {
if (!authorized.value || !coverage.value || (selectedGeneral.value === null && selectedCity.value === null)) return;
await nextTick();
inspector.value?.focus({ preventScroll: true });
if (window.innerWidth < 1200) inspector.value?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
watch([selectedGeneral, selectedCity], focusInspector);
const selectGeneral = (id: number) => const selectGeneral = (id: number) =>
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined, decision: undefined } }); router.push({ query: { ...route.query, general: String(id), cityRecord: undefined, decision: undefined } });
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined, decision: undefined } }); const closeGeneral = () => router.push({ query: { ...route.query, general: undefined, decision: undefined } });
@@ -216,11 +239,11 @@ const readQuery = () => {
month.value = numeric(route.query.month, coverage.value?.month ?? 1); month.value = numeric(route.query.month, coverage.value?.month ?? 1);
const defaultStart = Math.max( const defaultStart = Math.max(
(coverage.value?.startYear ?? year.value) * 12 + (coverage.value?.startMonth ?? 1) - 1, (coverage.value?.startYear ?? year.value) * 12 + (coverage.value?.startMonth ?? 1) - 1,
year.value * 12 + month.value - 6 year.value * 12 + month.value - (tab.value === 'nations' ? 12 : 6)
); );
fromYear.value = numeric(route.query.fromYear, Math.floor(defaultStart / 12)); fromYear.value = numeric(route.query.fromYear, Math.floor(defaultStart / 12));
fromMonth.value = numeric(route.query.fromMonth, (defaultStart % 12) + 1); fromMonth.value = numeric(route.query.fromMonth, (defaultStart % 12) + 1);
resolution.value = route.query.resolution === 'month' ? 'month' : 'halfYear'; resolution.value = route.query.resolution === 'halfYear' ? 'halfYear' : 'month';
}; };
const message = (cause: unknown) => (cause instanceof Error ? cause.message : '자료를 조회하지 못했습니다.'); const message = (cause: unknown) => (cause instanceof Error ? cause.message : '자료를 조회하지 못했습니다.');
const load = async (append = false) => { const load = async (append = false) => {
@@ -329,12 +352,26 @@ const apply = async () => {
if (before === route.fullPath) await refresh(); if (before === route.fullPath) await refresh();
} }
}; };
const recentPeriod = async (months: number) => {
if (!coverage.value) return;
year.value = coverage.value.year;
month.value = coverage.value.month;
const first = Math.max(
coverage.value.startYear * 12 + coverage.value.startMonth - 1,
year.value * 12 + month.value - months
);
fromYear.value = Math.floor(first / 12);
fromMonth.value = (first % 12) + 1;
moment.value = 'current';
await apply();
};
const refresh = async () => { const refresh = async () => {
const dataRequest = load(); const dataRequest = load();
const request = generation; const request = generation;
const results = await Promise.allSettled([dataRequest, loadNations()]); const results = await Promise.allSettled([dataRequest, loadNations()]);
if (request !== generation) return; if (request !== generation) return;
for (const response of results) if (response.status === 'rejected') error.value = message(response.reason); for (const response of results) if (response.status === 'rejected') error.value = message(response.reason);
await focusInspector();
}; };
const showCityGenerals = async (id: number) => { const showCityGenerals = async (id: number) => {
readQuery(); readQuery();
@@ -416,6 +453,10 @@ onMounted(async () => {
상태·정책 수집 시작: {{ coverage.collectionStart.year }}년 {{ coverage.collectionStart.month }}월 · 상태·정책 수집 시작: {{ coverage.collectionStart.year }}년 {{ coverage.collectionStart.month }}월 ·
{{ coverage.collectionStart.observedAt }} {{ coverage.collectionStart.observedAt }}
</p> </p>
</template>
</PanelCard>
<div v-if="authorized && coverage" class="audit-workspace">
<aside class="audit-sidebar" aria-label="감사 탐색">
<nav class="audit-navigation" aria-label="플레이 감사 메뉴"> <nav class="audit-navigation" aria-label="플레이 감사 메뉴">
<div class="menu-row" aria-label="감사 분류"> <div class="menu-row" aria-label="감사 분류">
<RouterLink <RouterLink
@@ -440,336 +481,475 @@ onMounted(async () => {
> >
</div> </div>
</nav> </nav>
<form class="filters" @submit.prevent="apply"> <section class="nation-browser" aria-label="국가 탐색">
<label <h2>국가</h2>
>국가<select class="legacy-sort-select" v-model="nationId"> <input v-model="nationSearch" aria-label="국가 이름 검색" placeholder="국가 이름 검색" />
<option value=""> <button class="legacy-button" :aria-pressed="!route.query.nation" @click="chooseNation('')">
{{ 전체 국가
tab === 'nations' || tab === 'policies' || tab === 'diplomacy' </button>
? '국가 선택'
: '모든 국가'
}}
</option>
<option v-if="tab !== 'diplomacy'" 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 <button
v-for="nation in visibleNations"
:key="nation.id"
class="legacy-button nation-choice"
:aria-pressed="String(route.query.nation) === String(nation.id)"
@click="chooseNation(String(nation.id))"
>
{{ nation.name }} <small>#{{ nation.id }}</small>
</button>
<p v-if="!visibleNations.length">조건에 맞는 국가가 없습니다.</p>
<button
v-if="nations?.nextCursor != null"
class="legacy-button" class="legacy-button"
v-if="nations?.nextCursor !== null && nations?.nextCursor !== undefined"
type="button"
:disabled="loading" :disabled="loading"
@click="moreNations" @click="moreNations"
> >
국가 더 불러오기 국가 더 불러오기
</button> </button>
<label </section>
>국가 목록·상태 기준<select class="legacy-sort-select" v-model="moment"> </aside>
<option value="current">현재</option> <section class="audit-content" aria-label="감사 분석">
<option value="month">월말</option> <PanelCard title="조회 조건">
<option value="final">최종 표본</option> <div v-if="tab === 'nations'" class="period-shortcuts" aria-label="빠른 조회 기간">
<option value="initial">수집 시작 기준</option> <button
</select></label v-for="months in [6, 12, 24]"
> :key="months"
<label class="legacy-button"
>{{ tab === 'nations' || tab === 'policies' || tab === 'diplomacy' ? '종료 연도' : '표본 연도' :disabled="loading"
}}<input @click="recentPeriod(months)"
v-model.number="year" >
type="number" 최근 {{ months }}개월
:min="coverage.startYear" </button>
:max="coverage.year" </div>
required <form class="filters" @submit.prevent="apply">
/></label> <p class="selected-nation">{{ nationId ? nationName(Number(nationId)) : '전체 국가' }}</p>
<label
>월<input
v-model.number="month"
type="number"
:min="year === coverage.startYear ? coverage.startMonth : 1"
:max="year === coverage.year ? coverage.month : 12"
required
/></label>
<template
v-if="
(tab === 'nations' && moment !== 'final' && moment !== 'initial') ||
tab === 'policies' ||
tab === 'diplomacy'
"
>
<label <label
>시작 연도<input >국가 목록·상태 기준<select class="legacy-sort-select" v-model="moment">
v-model.number="fromYear" <option value="current">현재</option>
<option value="month">월말</option>
<option value="final">최종 표본</option>
<option value="initial">수집 시작 기준</option>
</select></label
>
<label
>{{
tab === 'nations' || tab === 'policies' || tab === 'diplomacy'
? '종료 연도'
: '표본 연도'
}}<input
v-model.number="year"
type="number" type="number"
:min="coverage.startYear" :min="coverage.startYear"
:max="coverage.year" :max="coverage.year"
required required
/></label> /></label>
<label <label
>시작 월<input >월<input
v-model.number="fromMonth" v-model.number="month"
type="number" type="number"
:min="fromYear === coverage.startYear ? coverage.startMonth : 1" :min="year === coverage.startYear ? coverage.startMonth : 1"
:max="fromYear === coverage.year ? coverage.month : 12" :max="year === coverage.year ? coverage.month : 12"
required required
/></label> /></label>
<label v-if="tab === 'nations'" <template
>간격<select class="legacy-sort-select" v-model="resolution"> v-if="
<option value="halfYear">반기 (1~6월 / 7~12월)</option> (tab === 'nations' && moment !== 'final' && moment !== 'initial') ||
<option value="month">매월</option> tab === 'policies' ||
tab === 'diplomacy'
"
>
<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="fromYear === coverage.startYear ? coverage.startMonth : 1"
:max="fromYear === coverage.year ? coverage.month : 12"
required
/></label>
<label v-if="tab === 'nations'"
>간격<select class="legacy-sort-select" v-model="resolution">
<option value="halfYear">반기 (1~6월 / 7~12월)</option>
<option value="month">매월</option>
</select></label
>
</template>
<label v-if="tab === 'diplomacy'"
>상대 국가<select class="legacy-sort-select" v-model="otherNationId">
<option value="">국가 선택</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="
otherNationId &&
!nations?.items.some((item) => String(item.id) === otherNationId)
"
:value="otherNationId"
>
국가 #{{ otherNationId }}
</option>
</select></label </select></label
> >
</template> <template v-if="tab === 'generals'">
<label v-if="tab === 'diplomacy'" <label
>상대 국가<select class="legacy-sort-select" v-model="otherNationId"> >장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
<option value="">국가 선택</option> /></label>
<option <label
v-for="nation in nations?.items.filter((item) => item.id > 0)" >장수 번호 정렬<select
:key="nation.id" class="legacy-sort-select"
:value="String(nation.id)" v-model="generalOrder"
aria-label="장수 번호 정렬"
>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select></label
> >
{{ nation.name }} (#{{ nation.id }}) <label
</option> >도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시"
<option /></label>
v-if=" </template>
otherNationId && !nations?.items.some((item) => String(item.id) === otherNationId) <button class="legacy-button" type="submit" :disabled="loading">조회</button>
" </form>
:value="otherNationId" </PanelCard>
> <PanelCard
국가 #{{ otherNationId }} v-if="authorized && coverage"
</option> :title="
</select></label tab === 'nations'
> ? '국가 시계열'
<template v-if="tab === 'generals'"> : tab === 'generals'
<label ? '전체 장수'
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색" : tab === 'policies'
/></label> ? '정책 변경 이력'
<label : tab === 'diplomacy'
>장수 번호 정렬<select ? '외교 이력'
class="legacy-sort-select" : '도시 상태'
v-model="generalOrder" "
aria-label="장수 번호 정렬" >
> <p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p>
<option value="asc">오름차순</option> <p v-if="(tab === 'nations' || tab === 'policies' || tab === 'diplomacy') && !nationId">
<option value="desc">내림차순</option> 왼쪽 국가 목록에서 국가를 선택해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수
</select></label 있습니다.
> </p>
<label>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></label> <p v-if="tab === 'diplomacy' && !otherNationId">상대 왼쪽 국가 목록에서 국가를 선택해 주세요.</p>
</template> <AuditDiplomacyHistory
<button class="legacy-button" type="submit" :disabled="loading">조회</button> v-if="
</form> tab === 'diplomacy' &&
</template> route.query.tab === 'diplomacy' &&
</PanelCard> route.query.nation &&
<PanelCard route.query.otherNation
v-if="authorized && coverage" "
:title=" v-bind="appliedDiplomacy"
tab === 'nations' />
? '국가 시계열' <AuditPolicyHistory
: tab === 'generals' v-if="tab === 'policies' && route.query.tab === 'policies' && route.query.nation"
? '전체 장수' v-bind="appliedPolicy"
: tab === 'policies' />
? '정책 변경 이력' <AuditNationSeries v-if="series && tab === 'nations'" :data="series" />
: tab === 'diplomacy' <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>
> <div v-else class="table-scroll" tabindex="0" aria-label="장수 목록">
<p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p> <table>
<p v-if="(tab === 'nations' || tab === 'policies' || tab === 'diplomacy') && !nationId"> <thead>
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다. <tr>
</p> <th>장수</th>
<p v-if="tab === 'diplomacy' && !otherNationId">상대 국가를 선택하고 조회해 주세요.</p> <th>국가·위치</th>
<AuditDiplomacyHistory <th>금 / 쌀</th>
v-if=" <th>병력 / 훈련 / 사기</th>
tab === 'diplomacy' && <th>능력·숙련</th>
route.query.tab === 'diplomacy' && </tr>
route.query.nation && </thead>
route.query.otherNation <tbody>
" <tr
v-bind="appliedDiplomacy" v-for="general in generals.items"
/> :key="general.id"
<AuditPolicyHistory :class="{ 'selected-row': selectedGeneral === general.id }"
v-if="tab === 'policies' && route.query.tab === 'policies' && route.query.nation"
v-bind="appliedPolicy"
/>
<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>
<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">
<button class="legacy-button" @click="selectGeneral(general.id)">
{{ general.name }} (#{{ general.id }})</button
><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">
<button class="legacy-button" @click="selectCity(city.id)">
{{ city.name }} (#{{ city.id }})
</button>
</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)"
> >
모든 국가의 주둔 장수 <th scope="row">
</button> <button
</td> class="legacy-button"
</tr> :aria-pressed="selectedGeneral === general.id"
</tbody> @click="selectGeneral(general.id)"
</table> >
</div> {{ general.name }} (#{{ general.id }})</button
</template> ><br />{{
<button general.npcState < 2
class="legacy-button" ? '유저'
v-if="result?.nextCursor != null" : general.npcState === 5
type="button" ? '부대장 NPC'
:disabled="loading" : 'NPC'
@click="load(true)" }}
> </th>
다음 50개 불러오기 <td>
</button> {{ nationName(general.nationId) }}<br />도시 #{{ general.cityId }} · 부대
<button class="legacy-button" v-if="error && authorized" type="button" :disabled="loading" @click="refresh"> #{{ general.troopId }}
다시 조회 </td>
</button> <td>{{ format(general.gold) }} / {{ format(general.rice) }}</td>
</PanelCard> <td>
<AuditGeneralDetail {{ format(general.crew) }} / {{ format(general.train) }} /
v-if="authorized && selectedGeneral !== null" {{ format(general.atmos) }}<br />병종 #{{ general.crewTypeId }}
:general-id="selectedGeneral" </td>
:at="selectedAt" <td>
@close="closeGeneral" <details>
/> <summary>상세 보기</summary>
<AuditCityDetail <p>
v-if="authorized && selectedCity !== null" 통솔 {{ general.stats.leadership }} · 무력
:city-id="selectedCity" {{ general.stats.strength }} · 지력 {{ general.stats.intelligence }}
:at="selectedAt" </p>
@close="closeCity" <p>
@generals="showCityGenerals" 경험 {{ 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">
<button class="legacy-button" @click="selectCity(city.id)">
{{ city.name }} (#{{ city.id }})
</button>
</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>
</section>
<aside ref="inspector" class="audit-inspector" tabindex="-1" aria-label="선택한 대상 상세">
<PanelCard v-if="selectedGeneral === null && selectedCity === null" title="대상 상세">
<p>장수나 도시 이름을 누르면 이곳에 상세 정보가 표시됩니다.</p>
<p v-if="nationId">선택 국가: {{ nationName(Number(nationId)) }}</p>
<RouterLink class="legacy-button" :to="menuTarget('generals')">선택 국가 장수 보기</RouterLink>
</PanelCard>
<AuditGeneralDetail
v-if="authorized && selectedGeneral !== null"
:general-id="selectedGeneral"
:at="selectedAt"
@close="closeGeneral"
/>
<AuditCityDetail
v-if="authorized && selectedCity !== null"
:city-id="selectedCity"
:at="selectedAt"
@close="closeCity"
@generals="showCityGenerals"
/>
</aside>
</div>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.audit-workspace {
display: grid;
grid-template-columns: 190px minmax(0, 1fr) 340px;
gap: 16px;
align-items: start;
}
.audit-sidebar,
.audit-content,
.audit-inspector {
min-width: 0;
}
.audit-content {
display: grid;
gap: 12px;
}
.audit-sidebar,
.audit-inspector {
position: sticky;
top: 12px;
max-height: calc(100vh - 24px);
overflow: auto;
}
.audit-sidebar {
padding: 10px;
border: 1px solid #536b60;
border-radius: 6px;
background: #14231c;
}
.audit-inspector:focus-visible {
outline: 2px solid #d1e6a1;
outline-offset: 2px;
}
.nation-browser {
display: grid;
gap: 6px;
}
.nation-browser h2 {
margin: 8px 0;
font-size: var(--sammo-font-size-emphasis);
}
.nation-browser input {
min-width: 0;
width: 100%;
box-sizing: border-box;
padding: 6px;
background: #000;
color: #fff;
border: 1px solid #91a39a;
}
.nation-choice {
text-align: left;
overflow-wrap: anywhere;
}
.nation-browser [aria-pressed='true'],
.selected-row {
background: #254e3c;
border-color: #b6d6c2;
}
.period-shortcuts {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.selected-nation {
flex-basis: 100%;
margin: 0;
font-weight: bold;
}
@media (min-width: 1200px) {
.audit-sidebar .menu-row {
flex-direction: column;
}
}
@media (max-width: 1199px) {
.audit-workspace {
grid-template-columns: 180px minmax(0, 1fr);
}
.audit-inspector {
grid-column: 2;
position: static;
max-height: none;
}
}
@media (max-width: 700px) {
.audit-workspace {
grid-template-columns: minmax(0, 1fr);
}
.audit-sidebar,
.audit-inspector {
position: static;
max-height: none;
grid-column: 1;
}
.nation-browser {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.nation-browser h2,
.nation-browser input {
grid-column: 1 / -1;
}
}
.audit-page { .audit-page {
max-width: 1200px; max-width: 1920px;
margin: 0 auto; margin: 0 auto;
padding: 8px; padding: 8px;
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.audit-page > :deep(.panel-card) { .audit-page :deep(.panel-card) {
min-width: 0; min-width: 0;
} }
.audit-navigation { .audit-navigation {
+30 -7
View File
@@ -36,7 +36,6 @@ assert하며 SQL/params 원문은 저장하지 않는다. 관측60,702단계마
저장·hash 조회를 확인한 범위다. WAL·retained heap, 실제 scenario 계측 전후와 전체 저장·hash 조회를 확인한 범위다. WAL·retained heap, 실제 scenario 계측 전후와 전체
COST gate는 여전히 남는다. COST gate는 여전히 남는다.
### 정책·외교 사건의 요청 처리 상태 ### 정책·외교 사건의 요청 처리 상태
`playAudit.requestState`는 현재 기수의 정책 버전 또는 외교 사건 ID만 받는다. `playAudit.requestState`는 현재 기수의 정책 버전 또는 외교 사건 ID만 받는다.
@@ -172,15 +171,19 @@ migration head를 가리킨다. 실제 PG의55→56/빈56/no-op·기존 값/null
국가 목록은 현재 또는 한 월의 이름/ID/color만 반환한다. 현재 목록은 해당 세 필드만 국가 목록은 현재 또는 한 월의 이름/ID/color만 반환한다. 현재 목록은 해당 세 필드만
SELECT하며 과거 목록은 한 표본의 국가 JSON을 51행까지 읽고 allowlist projection한다. SELECT하며 과거 목록은 한 표본의 국가 JSON을 51행까지 읽고 allowlist projection한다.
기본 50·최대 200과 ID cursor를 사용하고 기수 전체의 국가를 DISTINCT 스캔하지 않는다. 기본 50·최대 200과 ID cursor를 사용하고 기수 전체의 국가를 DISTINCT 스캔하지 않는다.
멸망국은 해당 월 기준 목록으로 선택한다. 국가 시계열의 기본 범위는 최근 6개월이며 멸망국은 해당 월 기준 목록으로 선택한다. 국가 시계열 API의 기본 범위는 최근 6개월, 화면의 기본 범위는 최근 12개월·월별이며
지표·집단 전환은 이미 받은 집계에서 계산해 추가 요청을 하지 않는다. 지표·집단 전환은 이미 받은 집계에서 계산해 추가 요청을 하지 않는다.
PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라이브러리 없이 PanelCard, legacy-button, legacy-sort-select를 재사용한다. Chart.js로 국고 금·쌀,
표의 막대와 수치를 함께 표시한다. stock 마지막 표본 월, 월별 수집 여부·국가 존재·정산 총 병사수, 실제 수입, 집단별 5병종 평균 숙련도의 큰 그래프를 표시하고 수치 표를 함께 제공한다.
결측값은 선을 끊고 표시하며 최근 6/12/24개월 바로가기와 월/반기 조회를 제공한다. stock 마지막 표본 월, 월별 수집 여부·국가 존재·정산
완전성을 펼쳐볼 수 있고 null은 `자료 없음`이다. 국가 보유 금쌀/기술/세율, 완전성을 펼쳐볼 수 있고 null은 `자료 없음`이다. 국가 보유 금쌀/기술/세율,
수입·지급, 집단 인원·보유 총량/평균·5병종 평균 숙련 지표를 제공한다. 수입·지급, 집단 인원·보유 총량/평균·5병종 평균 숙련 지표를 제공한다.
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음, 이 화면은 Core 신규 UX다. 최대 폭 1920px, 1200px 이상에서 탐색/분석/대상 상세의
3열 구조다. 701~1199px에서는 상세가 분석 아래에, 700px 이하는 한 열에 표시된다.
국가는 검색 가능한 목록에서 즉시 선택하며 장수·도시 클릭은 상세 영역에 focus를 옮긴다.
390px 모바일에서 문서 가로 넘침 없음,
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다. 넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
월말/FINAL 장수·도시 projection을 보여주지만 지도, 월말/FINAL 장수·도시 projection을 보여주지만 지도,
전투 통계, 자원·능력별 정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다. 전투 통계, 자원·능력별 정렬은 후속 구현으로 남는다. 로그와 현재 예약 조회는 아래 구현을 따른다.
@@ -619,7 +622,8 @@ no-general 허용, 무인증·일반 admin·다른 profile·제재 거부, 200
`nationSeries`는 국가 1개의 월별 집계만 조회한다. 월/반기 해상도, 기간, 페이지 크기 `nationSeries`는 국가 1개의 월별 집계만 조회한다. 월/반기 해상도, 기간, 페이지 크기
50 기본/200 최대를 받고 긴 기수는 다음 기간 cursor로 이어 읽는다. 한 번에 읽는 50 기본/200 최대를 받고 긴 기수는 다음 기간 cursor로 이어 읽는다. 한 번에 읽는
월 header는 최대 1200개(200반기), 국가 집계도 그 범위의 해당 국가만 읽으며 월 header는 최대 1200개(200반기), 국가 집계도 그 범위의 해당 국가만 읽으며
장수·도시 원본이나 전체 trace를 읽지 않는다. 기본 기간은 최근 6개월이고 반기는 병사수 집계가 없는 기존 표본만 해당 국가의 장수 원본을 DB에서 집계한다. 도시 원본이나
전체 trace는 읽지 않는다. API 기본 기간은 최근 6개월이고 반기는
1~~6월/7~~12월 경계로 묶는다. 보유·기술·집단 평균은 마지막 수집 표본과 그 시점을 1~~6월/7~~12월 경계로 묶는다. 보유·기술·집단 평균은 마지막 수집 표본과 그 시점을
반환하고 수입/급여만 기간 합산한다. 누락·국가 없음·불완전 정산의 흐름은 null, 반환하고 수입/급여만 기간 합산한다. 누락·국가 없음·불완전 정산의 흐름은 null,
관측한 정산 없음은 0이다. 기간 일부 요청은 from/to와 complete=false로 표시한다. 관측한 정산 없음은 0이다. 기간 일부 요청은 from/to와 complete=false로 표시한다.
@@ -640,7 +644,7 @@ SQL/bytes는 아직 실측하지 않았으며 아래는 현재 소스에서 확
| 월별 내구성 | `turn/inMemoryWorld.ts`의 capture/restore, peek/acknowledge와 pending yearbook; `turn/databaseHooks.ts`의 `persistChanges` | 별도 audit pending을 같은 transaction과 savepoint에 포함. 기존 연감의 장기보존 테이블에 상세 감사를 넣지 않음 | 실패·중복·재시작, bounded 삭제 | | 월별 내구성 | `turn/inMemoryWorld.ts`의 capture/restore, peek/acknowledge와 pending yearbook; `turn/databaseHooks.ts`의 `persistChanges` | 별도 audit pending을 같은 transaction과 savepoint에 포함. 기존 연감의 장기보존 테이블에 상세 감사를 넣지 않음 | 실패·중복·재시작, bounded 삭제 |
| 기수 identity | `scenario/scenarioSeeder.ts`의 `install.serverId`, `GameHistory` 충돌 검사 | profile명으로 대체하지 않음. 외부 install 입력을 만드는 지점과 RESET 전체 경로를 추가 추적한 뒤 수집 활성화 | 신규 identity 생성, 재시도, 기존 설치에 identity 누락 시 처리 | | 기수 identity | `scenario/scenarioSeeder.ts`의 `install.serverId`, `GameHistory` 충돌 검사 | profile명으로 대체하지 않음. 외부 install 입력을 만드는 지점과 RESET 전체 경로를 추가 추적한 뒤 수집 활성화 | 신규 identity 생성, 재시도, 기존 설치에 identity 누락 시 처리 |
| 외교 | game-api `router/diplomacy/index.ts`, engine 월간 외교 처리 | 불변 문서는 참조, 갱신되는 내용만 당시 버전 저장. 현재 상태 월복사만으로 사건을 대신하지 않음 | 모든 API/engine mutation별 inventory | | 외교 | game-api `router/diplomacy/index.ts`, engine 월간 외교 처리 | 불변 문서는 참조, 갱신되는 내용만 당시 버전 저장. 현재 상태 월복사만으로 사건을 대신하지 않음 | 모든 API/engine mutation별 inventory |
| NPC 정책 | `turn/worldCommandHandler.ts` → `turn/npcPolicyMutation.ts` | CAS 성공하고 실제 값이 달라진 경우에만 불변 버전. 무변경/거부는 적용 버전에서 제외 | NPC 결정의 버전 참조, 거부/무변경 시도 원장 | | NPC 정책 | `turn/worldCommandHandler.ts` → `turn/npcPolicyMutation.ts` | CAS 성공하고 실제 값이 달라진 경우에만 불변 버전. 무변경/거부는 적용 버전에서 제외 | NPC 결정의 버전 참조, 거부/무변경 시도 원장 |
| 권한 | Gateway `adminCapabilities.ts`, `adminAuth.ts`; game-api `trpc.ts` 인증·제재 middleware | scoped 감사 권한과 공통 계정 추가 권한 분리. `getMyGeneral` 요구 없이 서버에서 검사 | catalog/token/flush/HTTP matrix 전체 연결 | | 권한 | Gateway `adminCapabilities.ts`, `adminAuth.ts`; game-api `trpc.ts` 인증·제재 middleware | scoped 감사 권한과 공통 계정 추가 권한 분리. `getMyGeneral` 요구 없이 서버에서 검사 | catalog/token/flush/HTTP matrix 전체 연결 |
월간 실행은 이전 월 snapshot → 달 변경 → 새달 `onMonthChanged` 순서다. 월간 실행은 이전 월 snapshot → 달 변경 → 새달 `onMonthChanged` 순서다.
@@ -662,3 +666,22 @@ SQL/bytes는 아직 실측하지 않았으며 아래는 현재 소스에서 확
월 저장 검증: `playAuditCollection.test.ts`, `playAuditPersistence.integration.test.ts`와 월 저장 검증: `playAuditCollection.test.ts`, `playAuditPersistence.integration.test.ts`와
확장한 `monthlyBoundaryPrePersistence.integration.test.ts`. 정확한 명령·결과는 확장한 `monthlyBoundaryPrePersistence.integration.test.ts`. 정확한 명령·결과는
상위 보고서 `2026-09-16-플레이-감사-월별-저장.md`에 기록한다. 상위 보고서 `2026-09-16-플레이-감사-월별-저장.md`에 기록한다.
## 2026-09-26 국가 그래프와 당월 정산
`nationSeries.currentSettlement`는 현재 월을 포함한 페이지에서만 같은 RepeatableRead
transaction의 `world_state.meta.playAuditFlows`를 읽는다. 국가·자원·연월과 유한 숫자를
확인하여 commit된 수입/지급을 반환한다. 1월 금, 7월 쌀 정산이 월말 표본을 기다리지 않고
노출되며, 당월 관측 없음·부분 관측·관측됨을 구별한다. 월말/반기 흐름 합계와 완전성은
기존 계약을 유지한다. gameplay 정산, RNG, flush 순서는 변경하지 않는다.
새 국가 표본은 집단별 `crew` 합계를 저장한다. 기존 JSON의 crew 누락은 null로 읽고,
시계열 조회 때 그 국가와 해당 표본 ID에 한정하여 PostgreSQL이 장수 표본을 한 번 집계한다.
저장된 집단 인원수와 원본 행 수가 일치할 때만 합계를 사용한다. 원본 누락은 null,
관측된 빈 집단은 0이며 live 장수로 과거를 채우지 않는다. 최대 1200개 표본에 한정된
추가 GROUP BY 조회이며, 새 집계가 있는 표본은 원본 재집계에서 제외한다.
차트는 [Chart.js line](https://www.chartjs.org/docs/latest/charts/line.html)과
[responsive](https://www.chartjs.org/docs/latest/configuration/responsive.html) 계약을 사용한다.
필요한 line 구성요소만 등록하고 resize·unmount를 처리하며 null 구간을 연결하지 않는다.
차트와 수치 표는 같은 응답을 사용하고 집단/지표 전환은 추가 API를 호출하지 않는다.
+16
View File
@@ -363,6 +363,9 @@ importers:
'@vueuse/core': '@vueuse/core':
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.4.0(vue@3.5.41(typescript@6.0.3)) version: 14.4.0(vue@3.5.41(typescript@6.0.3))
chart.js:
specifier: 4.5.1
version: 4.5.1
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.4.0 version: 4.4.0
@@ -1480,6 +1483,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@lukeed/ms@2.0.2': '@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3288,6 +3294,10 @@ packages:
character-entities-legacy@3.0.0: character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@@ -5826,6 +5836,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
'@kurkle/color@0.3.4': {}
'@lukeed/ms@2.0.2': {} '@lukeed/ms@2.0.2': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
@@ -7362,6 +7374,10 @@ snapshots:
character-entities-legacy@3.0.0: {} character-entities-legacy@3.0.0: {}
chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3