diff --git a/app/game-api/src/router/playAudit/nationSeries.ts b/app/game-api/src/router/playAudit/nationSeries.ts index 80386335..6702ca4d 100644 --- a/app/game-api/src/router/playAudit/nationSeries.ts +++ b/app/game-api/src/router/playAudit/nationSeries.ts @@ -1,3 +1,5 @@ +import { GamePrisma } from '@sammo-ts/infra'; +import { asRecord } from '@sammo-ts/common'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; 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 zPopulation = z.object({ count: z.number().int().nonnegative(), + crew: z.number().nonnegative().nullable().default(null), gold: z.number(), rice: z.number(), 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 }); export const nationSeries = auditProcedure .input( @@ -151,6 +182,35 @@ export const nationSeries = auditProcedure }) : []; 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 items: ReturnType[] = []; for (let period = start; period <= end; period += width) { @@ -166,6 +226,17 @@ export const nationSeries = auditProcedure } 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, + }; }) ); diff --git a/app/game-api/test/playAuditNationSeries.test.ts b/app/game-api/test/playAuditNationSeries.test.ts index 3bcde1ac..2a9cb86b 100644 --- a/app/game-api/test/playAuditNationSeries.test.ts +++ b/app/game-api/test/playAuditNationSeries.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; import { summarizeNationPeriod, + projectCurrentSettlement, + zAuditNation, type AuditNationData, type NationMonthPoint, } from '../src/router/playAudit/nationSeries.js'; const population = { count: 0, + crew: null, gold: 0, rice: 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(); + }); +}); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index de589379..77b2f970 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -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, { nationId: ownerNationId, from: { year: 190, month: 1 }, @@ -3134,7 +3167,10 @@ integration('game API security over HTTP transport', () => { year: 190, month: 1, complete: true, - stock: { gold: 600 }, + stock: { + gold: 600, + populations: { human: { crew: 3600 }, npc: { crew: null }, troopNpc: { crew: 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 } }] } }, }); + // 정산 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. await db.worldState.update({ where: { id: fixtureWorldId }, diff --git a/app/game-engine/src/playAudit/snapshot.ts b/app/game-engine/src/playAudit/snapshot.ts index 1ce192be..779d7b70 100644 --- a/app/game-engine/src/playAudit/snapshot.ts +++ b/app/game-engine/src/playAudit/snapshot.ts @@ -10,6 +10,7 @@ export type AuditPopulation = 'human' | 'npc' | 'troopNpc'; export interface AuditPopulationSummary { count: number; + crew: number; gold: number; rice: number; dex: AuditDex; @@ -44,6 +45,7 @@ export interface AuditNationSnapshot { const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 }); const emptyPopulation = (): AuditPopulationSummary => ({ count: 0, + crew: 0, gold: 0, rice: 0, dex: emptyDex(), @@ -173,6 +175,7 @@ export const buildAuditSnapshot = (input: { const population = nations.get(general.nationId)?.populations[projected.population]; if (!population) continue; population.count++; + population.crew += projected.crew; population.gold += projected.gold; population.rice += projected.rice; for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key]; diff --git a/app/game-engine/test/playAuditSnapshot.test.ts b/app/game-engine/test/playAuditSnapshot.test.ts index 800d7db7..a5d18070 100644 --- a/app/game-engine/test/playAuditSnapshot.test.ts +++ b/app/game-engine/test/playAuditSnapshot.test.ts @@ -76,13 +76,15 @@ describe('play audit monthly projection', () => { 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 }]; humans[0]!.meta.dex1 = 10; + humans[0]!.crew = 3210; + humans[1]!.crew = 790; const result = buildAuditSnapshot({ nations: [buildNation(0, 0, {}), buildNation(1, 0, {}), buildNation(2, 0, {})], cities: [buildCity(1, 2)], generals: [ ...humans, - { ...buildGeneral(3, 1), npcState: 2 }, - { ...buildGeneral(4, 1), npcState: 5 }, + { ...buildGeneral(3, 1), npcState: 2, crew: 1200 }, + { ...buildGeneral(4, 1), npcState: 5, crew: 800 }, buildGeneral(5, 0), ], settlements: [], @@ -91,12 +93,13 @@ describe('play audit monthly projection', () => { const nation = result.nations.find((row) => row.id === 1)!; expect(nation.populations.human).toMatchObject({ count: 2, + crew: 4000, gold: 2000, averageGold: 1000, averageDex: { dex1: 5 }, }); - expect(nation.populations.npc.count).toBe(1); - expect(nation.populations.troopNpc.count).toBe(1); + expect(nation.populations.npc).toMatchObject({ count: 1, crew: 1200 }); + 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 === 0)!.populations.npc.count).toBe(1); expect(result.cities[0]!.nationId).toBe(2); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index a15f06d9..730d90dd 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -14,7 +14,16 @@ const world = { 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 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 = { id: 1, name: '감사장수', @@ -76,7 +85,8 @@ const install = async ( page: Page, denied = false, baseline: boolean | 'document' | 'created' | 'removed' = false, - executionStatus?: 'PREPARING' | 'BLOCKED' + executionStatus?: 'PREPARING' | 'BLOCKED', + dashboard = false ) => { const requests: { operation: string; input: Record }[] = []; await page.addInitScript((profile) => { @@ -442,6 +452,13 @@ const install = async ( case 'playAudit.nationSeries': return result({ ...world, + currentSettlement: { + year: 190, + month: 7, + complete: true, + gold: null, + rice: { income: 4321, paid: 1234 }, + }, nextCursor: null, items: [ { @@ -471,7 +488,33 @@ const install = async ( 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': 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 expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible(); 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; await page.getByLabel('시작 월', { exact: true }).fill('3'); 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 expect(page).toHaveURL(/policyArea=DEFENCE/); 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 expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page'); 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 }); }); } + +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(); + }); +} diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 4136e571..82e0bf21 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -46,6 +46,7 @@ "@trpc/client": "^11.8.1", "@trpc/server": "^11.8.1", "@vueuse/core": "^14.1.0", + "chart.js": "4.5.1", "date-fns": "^4.1.0", "es-toolkit": "^1.43.0", "mitt": "^3.0.1", diff --git a/app/game-frontend/src/components/playAudit/AuditLineChart.vue b/app/game-frontend/src/components/playAudit/AuditLineChart.vue new file mode 100644 index 00000000..7c3cc457 --- /dev/null +++ b/app/game-frontend/src/components/playAudit/AuditLineChart.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/app/game-frontend/src/components/playAudit/AuditNationSeries.vue b/app/game-frontend/src/components/playAudit/AuditNationSeries.vue index 10b52837..873725e9 100644 --- a/app/game-frontend/src/components/playAudit/AuditNationSeries.vue +++ b/app/game-frontend/src/components/playAudit/AuditNationSeries.vue @@ -1,5 +1,6 @@