From 7d5ee1348d8163841bd0f00096224f133575e409 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 16 Sep 2026 02:59:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EA=B5=AD=EA=B0=80=20=EC=9B=94=EB=B3=84=20=EB=B0=98?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=9C=EA=B3=84=EC=97=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/playAudit/index.ts | 2 + .../src/router/playAudit/nationSeries.ts | 171 ++++++++++++++++++ .../test/playAuditNationSeries.test.ts | 91 ++++++++++ .../securityTransport.integration.test.ts | 88 +++++++++ docs/design/play-audit-implementation.md | 10 +- 5 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 app/game-api/src/router/playAudit/nationSeries.ts create mode 100644 app/game-api/test/playAuditNationSeries.test.ts diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index ec7de5ce..deab726c 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,3 +1,4 @@ +import { nationSeries } from './nationSeries.js'; import { z } from 'zod'; import { canReadPlayAuditAccounts } from '@sammo-ts/common'; import { router } from '../../trpc.js'; @@ -20,6 +21,7 @@ import { } from './projection.js'; export const playAuditRouter = router({ + nationSeries, capabilities: auditProcedure.query(({ ctx }) => ({ profileName: ctx.profile.name, read: true, diff --git a/app/game-api/src/router/playAudit/nationSeries.ts b/app/game-api/src/router/playAudit/nationSeries.ts new file mode 100644 index 00000000..6ecdd4bf --- /dev/null +++ b/app/game-api/src/router/playAudit/nationSeries.ts @@ -0,0 +1,171 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js'; + +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(), + gold: z.number(), + rice: z.number(), + dex: zDex, + averageGold: z.number().nullable(), + averageRice: z.number().nullable(), + averageDex: z.object({ + dex1: z.number().nullable(), + dex2: z.number().nullable(), + dex3: z.number().nullable(), + dex4: z.number().nullable(), + dex5: z.number().nullable(), + }), +}); +export const zAuditNation = z.object({ + id: z.number(), + name: z.string(), + color: z.string(), + gold: z.number(), + rice: z.number(), + tech: z.number(), + appliedRate: z.number(), + incomeGold: z.number().nullable(), + incomeRice: z.number().nullable(), + paidGold: z.number().nullable(), + paidRice: z.number().nullable(), + populations: z.object({ human: zPopulation, npc: zPopulation, troopNpc: zPopulation }), +}); +export type AuditNationData = z.infer; +export interface NationMonthPoint { + ordinal: number; + collected: boolean; + settlementsComplete: boolean; + data: AuditNationData | null; +} +const dateOf = (ordinal: number): { year: number; month: number } => ({ + year: Math.floor(ordinal / 12), + month: (ordinal % 12) + 1, +}); +const flowKeys = ['incomeGold', 'incomeRice', 'paidGold', 'paidRice'] as const; + +export const summarizeNationPeriod = (start: number, width: number, months: NationMonthPoint[]) => { + let latest: NationMonthPoint | undefined; + for (const point of months) if (point.data !== null) latest = point; + const complete = months.length === width && months.every((point) => point.collected && point.data !== null); + const flows: Record<(typeof flowKeys)[number], number | null> = { + incomeGold: null, + incomeRice: null, + paidGold: null, + paidRice: null, + }; + for (const key of flowKeys) { + if ( + months.length > 0 && + months.every((point) => point.collected && point.settlementsComplete && point.data?.[key] != null) + ) { + flows[key] = months.reduce((sum, point) => sum + point.data![key]!, 0); + } + } + return { + ...dateOf(start), + periodMonths: width, + complete, + from: months.length ? dateOf(months[0]!.ordinal) : null, + to: months.length ? dateOf(months[months.length - 1]!.ordinal) : null, + stockAsOf: latest ? dateOf(latest.ordinal) : null, + stock: latest?.data + ? { + id: latest.data.id, + name: latest.data.name, + color: latest.data.color, + gold: latest.data.gold, + rice: latest.data.rice, + tech: latest.data.tech, + appliedRate: latest.data.appliedRate, + populations: latest.data.populations, + } + : null, + flows, + months: months.map((point) => ({ + ...dateOf(point.ordinal), + collected: point.collected, + nationPresent: point.data !== null, + settlementsComplete: point.settlementsComplete, + })), + }; +}; + +const zCalendarMonth = zAuditMonth.omit({ kind: true }); +export const nationSeries = auditProcedure + .input( + z + .object({ + nationId: z.number().int().nonnegative(), + from: zCalendarMonth.optional(), + to: zCalendarMonth.optional(), + resolution: z.enum(['month', 'halfYear']).default('halfYear'), + cursor: zCalendarMonth.optional(), + limit: z.number().int().min(1).max(200).default(50), + }) + .strict() + ) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const current = monthOrdinal(world.year, world.month); + const from = input.from + ? monthOrdinal(input.from.year, input.from.month) + : Math.max(world.startYear * 12, current - 5); + const to = input.to ? monthOrdinal(input.to.year, input.to.month) : current; + if (from < world.startYear * 12 || to > current || from > to) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안에서 시작·종료 월을 선택해 주세요.' }); + } + const width = input.resolution === 'month' ? 1 : 6; + const first = Math.floor(from / width) * width; + const start = input.cursor ? monthOrdinal(input.cursor.year, input.cursor.month) : first; + if (start < first || start > to || start % width !== 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '조회 기간에 맞는 다음 페이지를 선택해 주세요.' }); + } + const end = Math.min(to, start + input.limit * width - 1); + const low = dateOf(Math.max(from, start)); + const high = dateOf(end); + const samples = world.serverId + ? await tx.playAuditMonth.findMany({ + where: { + serverId: world.serverId, + kind: 'MONTH_END', + AND: [ + { OR: [{ year: { gt: low.year } }, { year: low.year, month: { gte: low.month } }] }, + { OR: [{ year: { lt: high.year } }, { year: high.year, month: { lte: high.month } }] }, + ], + }, + select: { id: true, year: true, month: true, settlementsComplete: true }, + orderBy: [{ year: 'asc' }, { month: 'asc' }], + take: input.limit * width, + }) + : []; + const nations = samples.length + ? await tx.playAuditNation.findMany({ + where: { + sampleId: { in: samples.map((sample) => sample.id) }, + nationId: input.nationId, + }, + select: { sampleId: true, data: true }, + }) + : []; + const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)])); + const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample])); + const items: ReturnType[] = []; + for (let period = start; period <= end; period += width) { + const months: NationMonthPoint[] = []; + for (let ordinal = Math.max(from, period); ordinal <= Math.min(end, period + width - 1); ordinal++) { + const sample = sampleByMonth.get(ordinal); + months.push({ + ordinal, + collected: Boolean(sample), + settlementsComplete: sample?.settlementsComplete ?? false, + data: sample ? (dataBySample.get(sample.id) ?? null) : null, + }); + } + items.push(summarizeNationPeriod(period, width, months)); + } + return { ...world, items, 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 new file mode 100644 index 00000000..3bcde1ac --- /dev/null +++ b/app/game-api/test/playAuditNationSeries.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { + summarizeNationPeriod, + type AuditNationData, + type NationMonthPoint, +} from '../src/router/playAudit/nationSeries.js'; + +const population = { + count: 0, + gold: 0, + rice: 0, + dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 }, + averageGold: null, + averageRice: null, + averageDex: { dex1: null, dex2: null, dex3: null, dex4: null, dex5: null }, +}; +const nation = (month: number): AuditNationData => ({ + id: 1, + name: `국가${month}`, + color: '#ffffff', + gold: month * 100, + rice: month * 200, + tech: month * 10, + appliedRate: 20, + incomeGold: month, + incomeRice: 0, + paidGold: month / 2, + paidRice: 0, + populations: { human: population, npc: population, troopNpc: population }, +}); +const months: NationMonthPoint[] = Array.from({ length: 6 }, (_, index) => ({ + ordinal: 2400 + index, + collected: true, + settlementsComplete: true, + data: nation(index + 1), +})); + +describe('play audit half-year summary', () => { + it('sums flows but takes stock, names and population denominators from the last month', () => { + const summary = summarizeNationPeriod(2400, 6, months); + expect(summary).toMatchObject({ + year: 200, + month: 1, + complete: true, + stockAsOf: { year: 200, month: 6 }, + stock: { + name: '국가6', + gold: 600, + rice: 1200, + tech: 60, + populations: { human: { count: 0, averageGold: null } }, + }, + flows: { incomeGold: 21, incomeRice: 0, paidGold: 10.5, paidRice: 0 }, + }); + }); + it('reports partial range independently from complete collection of the requested months', () => { + expect(summarizeNationPeriod(2400, 6, months.slice(0, 2))).toMatchObject({ + complete: false, + from: { year: 200, month: 1 }, + to: { year: 200, month: 2 }, + flows: { incomeGold: 3 }, + }); + }); + it('does not call a missing month or absent nation zero income', () => { + const missing = months.map((point, index) => + index === 1 ? { ...point, collected: false, data: null } : point + ); + expect(summarizeNationPeriod(2400, 6, missing)).toMatchObject({ complete: false, flows: { incomeGold: null } }); + expect( + summarizeNationPeriod( + 2400, + 6, + months.map((point) => ({ ...point, data: null })) + ) + ).toMatchObject({ + complete: false, + stock: null, + stockAsOf: null, + flows: { incomeRice: null }, + }); + }); + it('keeps incomplete settlement coverage unknown while preserving stock coverage', () => { + const partial = months.map((point, index) => ({ ...point, settlementsComplete: index !== 0 })); + expect(summarizeNationPeriod(2400, 6, partial)).toMatchObject({ complete: true, flows: { incomeGold: null } }); + expect(summarizeNationPeriod(2400, 6, [])).toMatchObject({ + complete: false, + stock: null, + flows: { incomeGold: null }, + }); + }); +}); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 50bd8c78..7c436b57 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2243,6 +2243,94 @@ integration('game API security over HTTP transport', () => { serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } }, }); expect((await get('capabilities', blocked)).status).toBe(403); + const population = { + count: 0, + gold: 0, + rice: 0, + dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 }, + averageGold: null, + averageRice: null, + averageDex: { dex1: null, dex2: null, dex3: null, dex4: null, dex5: null }, + }; + await db.playAuditMonth.createMany({ + data: [2, 3, 4, 5, 6].map((month) => ({ + id: `${seasonId}:190:${month}`, + serverId: seasonId, + year: 190, + month, + kind: 'MONTH_END', + settlementsComplete: true, + hash: 'http-series-fixture', + })), + }); + await db.playAuditNation.createMany({ + data: [1, 2, 3, 4, 5, 6].map((month) => ({ + sampleId: `${seasonId}:190:${month}`, + nationId: ownerNationId, + data: { + id: ownerNationId, + name: `국가${month}`, + color: '#ffffff', + gold: month * 100, + rice: 200, + tech: 10, + appliedRate: 20, + incomeGold: month, + incomeRice: 0, + paidGold: 0, + paidRice: 0, + populations: { human: population, npc: population, troopNpc: population }, + }, + })), + }); + await db.worldState.update({ where: { id: fixtureWorldId }, data: { currentMonth: 7 } }); + const series = await get('nationSeries', admin, { + nationId: ownerNationId, + from: { year: 190, month: 1 }, + to: { year: 190, month: 6 }, + }); + expect(series.status).toBe(200); + expect(series.body).toMatchObject({ + result: { + data: { + nextCursor: null, + items: [ + { + year: 190, + month: 1, + complete: true, + stock: { gold: 600 }, + flows: { incomeGold: 21, incomeRice: 0 }, + }, + ], + }, + }, + }); + expect( + ( + await get('nationSeries', admin, { + nationId: ownerNationId, + resolution: 'month', + limit: 1, + from: { year: 190, month: 1 }, + to: { year: 190, month: 6 }, + }) + ).body + ).toMatchObject({ + result: { data: { nextCursor: { year: 190, month: 2 }, items: [{ stock: { gold: 100 } }] } }, + }); + expect( + ( + await get('nationSeries', admin, { + nationId: ownerNationId, + from: { year: 190, month: 7 }, + to: { year: 190, month: 7 }, + }) + ).body + ).toMatchObject({ + result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } }, + }); + await db.worldState.update({ where: { id: fixtureWorldId }, data: { diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index d671d6fb..89a03caf 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -75,7 +75,15 @@ cursor 조회하며 원문·장수 목록을 같이 싣지 않는다. 아직 마 no-general 허용, 무인증·일반 admin·다른 profile·제재 거부, 200 상한, 과거 이름/미수집, 새 기수 전환 후 이전 표본 차단과 flush 후 401을 확인했다. -국가 시계열/6개월 집계 API, 장수·도시 상세와 독립 로그, UI와 모든 조사 기능은 남았다. +`nationSeries`는 국가 1개의 월별 집계만 조회한다. 월/반기 해상도, 기간, 페이지 크기 +50 기본/200 최대를 받고 긴 기수는 다음 기간 cursor로 이어 읽는다. 한 번에 읽는 +월 header는 최대 1200개(200반기), 국가 집계도 그 범위의 해당 국가만 읽으며 +장수·도시 원본이나 전체 trace를 읽지 않는다. 기본 기간은 최근 6개월이고 반기는 +1~~6월/7~~12월 경계로 묶는다. 보유·기술·집단 평균은 마지막 수집 표본과 그 시점을 +반환하고 수입/급여만 기간 합산한다. 누락·국가 없음·불완전 정산의 흐름은 null, +관측한 정산 없음은 0이다. 기간 일부 요청은 from/to와 complete=false로 표시한다. + +장수·도시 상세와 독립 로그, 국가 시계열의 FINAL 별도 표시, UI와 모든 조사 기능은 남았다. ## 수집 지점과 쓰기 재검토