feat: 플레이 감사 국가 월별 반기 시계열 조회 구현
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { nationSeries } from './nationSeries.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { canReadPlayAuditAccounts } from '@sammo-ts/common';
|
import { canReadPlayAuditAccounts } from '@sammo-ts/common';
|
||||||
import { router } from '../../trpc.js';
|
import { router } from '../../trpc.js';
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
} from './projection.js';
|
} from './projection.js';
|
||||||
|
|
||||||
export const playAuditRouter = router({
|
export const playAuditRouter = router({
|
||||||
|
nationSeries,
|
||||||
capabilities: auditProcedure.query(({ ctx }) => ({
|
capabilities: auditProcedure.query(({ ctx }) => ({
|
||||||
profileName: ctx.profile.name,
|
profileName: ctx.profile.name,
|
||||||
read: true,
|
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'] } },
|
serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } },
|
||||||
});
|
});
|
||||||
expect((await get('capabilities', blocked)).status).toBe(403);
|
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({
|
await db.worldState.update({
|
||||||
where: { id: fixtureWorldId },
|
where: { id: fixtureWorldId },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -75,7 +75,15 @@ cursor 조회하며 원문·장수 목록을 같이 싣지 않는다. 아직 마
|
|||||||
no-general 허용, 무인증·일반 admin·다른 profile·제재 거부, 200 상한,
|
no-general 허용, 무인증·일반 admin·다른 profile·제재 거부, 200 상한,
|
||||||
과거 이름/미수집, 새 기수 전환 후 이전 표본 차단과 flush 후 401을 확인했다.
|
과거 이름/미수집, 새 기수 전환 후 이전 표본 차단과 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