플레이 감사를 3단 대시보드와 국가 추이 그래프로 개선한다
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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를 호출하지 않는다.
|
||||||
|
|||||||
Generated
+16
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user