플레이 감사를 3단 대시보드와 국가 추이 그래프로 개선한다
This commit is contained in:
@@ -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<typeof summarizeNationPeriod>[] = [];
|
||||
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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user