feat: 플레이 감사 국가 월별 반기 시계열 조회 구현

This commit is contained in:
2026-09-16 02:59:43 +00:00
parent ed2597aef3
commit 7d5ee1348d
5 changed files with 361 additions and 1 deletions
@@ -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,
@@ -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<typeof zAuditNation>;
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<typeof summarizeNationPeriod>[] = [];
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 };
})
);
@@ -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 },
});
});
});
@@ -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: {