feat: 플레이 감사 국가 월별 반기 시계열 조회 구현
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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와 모든 조사 기능은 남았다.
|
||||
|
||||
## 수집 지점과 쓰기 재검토
|
||||
|
||||
|
||||
Reference in New Issue
Block a user