feat: 플레이 감사 월별 수집과 원자적 배치 저장 연결

This commit is contained in:
2026-09-16 02:37:38 +00:00
parent 4de3175279
commit 35155e6b59
14 changed files with 594 additions and 12 deletions
@@ -0,0 +1,83 @@
import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.js';
import { buildAuditSnapshot, type AuditSettlement } from './snapshot.js';
interface MonthlyFlows {
year: number;
month: number;
complete: boolean;
entries: Record<string, AuditSettlement>;
}
const readFlows = (world: InMemoryTurnWorld): MonthlyFlows => {
const state = world.getState();
const raw = asRecord(state.meta.playAuditFlows);
const entries: Record<string, AuditSettlement> = {};
const matches = raw.year === state.currentYear && raw.month === state.currentMonth;
if (matches) {
for (const [key, value] of Object.entries(asRecord(raw.entries))) {
const row = asRecord(value);
if (
typeof row.nationId === 'number' &&
(row.resource === 'gold' || row.resource === 'rice') &&
typeof row.income === 'number' &&
Number.isFinite(row.income) &&
typeof row.paid === 'number' &&
Number.isFinite(row.paid)
) {
entries[key] = { nationId: row.nationId, resource: row.resource, income: row.income, paid: row.paid };
}
}
}
return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries };
};
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void => {
if (typeof world.getState().meta.serverId !== 'string') return;
const flows = readFlows(world);
const key = `${settlement.nationId}:${settlement.resource}`;
const previous = flows.entries[key];
flows.entries[key] = {
...settlement,
income: (previous?.income ?? 0) + settlement.income,
paid: (previous?.paid ?? 0) + settlement.paid,
};
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
world.updateWorldMeta({ playAuditFlows: flows });
};
export const queueAuditMonth = (world: InMemoryTurnWorld, kind: 'MONTH_END' | 'FINAL' = 'MONTH_END'): void => {
const state = world.getState();
const serverId = state.meta.serverId;
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
if (typeof serverId !== 'string' || !serverId.trim()) return;
const flows = readFlows(world);
const snapshot = buildAuditSnapshot({
nations: world.listNations(),
cities: world.listCities(),
generals: world.listGenerals(),
settlements: Object.values(flows.entries),
settlementsComplete: flows.complete,
});
world.queueAuditMonth({
...snapshot,
serverId,
year: state.currentYear,
month: state.currentMonth,
tick: state.lastTurnTick ?? null,
kind,
settlementsComplete: flows.complete,
});
};
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
beforeMonthChanged: (context) => {
const world = getWorld();
if (!world) return;
queueAuditMonth(world);
// 다음 달의 정산보다 먼저 활성화한다. 도입 당월은 complete=false로 남긴다.
world.updateWorldMeta({
playAuditFlows: { year: context.currentYear, month: context.currentMonth, complete: true, entries: {} },
});
},
});
@@ -0,0 +1,82 @@
import { createHash } from 'node:crypto';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import type { AuditCitySnapshot, AuditGeneralSnapshot, AuditNationSnapshot } from './snapshot.js';
export interface PendingAuditMonth {
serverId: string;
year: number;
month: number;
kind: 'MONTH_END' | 'FINAL';
tick: number | null;
settlementsComplete: boolean;
nations: AuditNationSnapshot[];
cities: AuditCitySnapshot[];
generals: AuditGeneralSnapshot[];
}
// JSON parameter 한 번에 전체 기수나 world를 전송하지 않는다.
const BATCH_SIZE = 200;
const asJson = (value: AuditNationSnapshot | AuditCitySnapshot | AuditGeneralSnapshot): InputJsonValue =>
JSON.parse(JSON.stringify(value)) as InputJsonValue;
export const persistAuditMonth = async (
tx: GamePrisma.TransactionClient,
snapshot: PendingAuditMonth
): Promise<void> => {
if (
!snapshot.serverId.trim() ||
!Number.isInteger(snapshot.year) ||
!Number.isInteger(snapshot.month) ||
snapshot.month < 1 ||
snapshot.month > 12
) {
throw new Error('Invalid play audit month identity');
}
const id = JSON.stringify([snapshot.serverId, snapshot.year, snapshot.month, snapshot.kind]);
const hash = createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
const saved = await tx.playAuditMonth.upsert({
where: { id },
create: {
id,
serverId: snapshot.serverId,
year: snapshot.year,
month: snapshot.month,
kind: snapshot.kind,
tick: snapshot.tick,
settlementsComplete: snapshot.settlementsComplete,
hash,
},
update: {},
select: { hash: true },
});
if (saved.hash !== hash) throw new Error('Play audit month replay payload conflict');
for (let offset = 0; offset < snapshot.nations.length; offset += BATCH_SIZE) {
await tx.playAuditNation.createMany({
skipDuplicates: true,
data: snapshot.nations
.slice(offset, offset + BATCH_SIZE)
.map((nation) => ({ sampleId: id, nationId: nation.id, data: asJson(nation) })),
});
}
for (let offset = 0; offset < snapshot.cities.length; offset += BATCH_SIZE) {
await tx.playAuditCity.createMany({
skipDuplicates: true,
data: snapshot.cities
.slice(offset, offset + BATCH_SIZE)
.map((city) => ({ sampleId: id, cityId: city.id, nationId: city.nationId, data: asJson(city) })),
});
}
for (let offset = 0; offset < snapshot.generals.length; offset += BATCH_SIZE) {
await tx.playAuditGeneral.createMany({
skipDuplicates: true,
data: snapshot.generals.slice(offset, offset + BATCH_SIZE).map((general) => ({
sampleId: id,
generalId: general.id,
nationId: general.nationId,
cityId: general.cityId,
npcState: general.npcState,
data: asJson(general),
})),
});
}
};
@@ -1,3 +1,4 @@
import { persistAuditMonth } from '../playAudit/persistence.js';
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
import { areSeasonRecordsFinalized } from './seasonRecords.js';
import {
@@ -1138,6 +1139,7 @@ export const createDatabaseTurnHooks = async (
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
pendingAuditMonths,
pendingUnificationFinalizations,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -1867,6 +1869,9 @@ export const createDatabaseTurnHooks = async (
data: pendingLogRows,
});
}
for (const snapshot of pendingAuditMonths) {
await persistAuditMonth(prisma, snapshot);
}
for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot);
}
+13
View File
@@ -1,3 +1,4 @@
import type { PendingAuditMonth } from '../playAudit/persistence.js';
import type {
City,
LogEntryDraft,
@@ -199,6 +200,7 @@ export interface TurnWorldChanges {
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingAuditMonths: PendingAuditMonth[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
}
@@ -239,6 +241,7 @@ export interface InMemoryTurnWorldStateSnapshot {
pendingNationBettingOpens: PendingNationBettingOpen[];
pendingNationBettingFinishes: PendingNationBettingFinish[];
pendingYearbookSnapshots: PendingYearbookSnapshot[];
pendingAuditMonths: PendingAuditMonth[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
pendingRealtimeBacklogShiftTicks: number;
}
@@ -543,6 +546,7 @@ export class InMemoryTurnWorld {
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = [];
private readonly pendingAuditMonths: PendingAuditMonth[] = [];
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private pendingRealtimeBacklogShiftTicks = 0;
private readonly scenarioConfig: ScenarioConfig;
@@ -1091,6 +1095,7 @@ export class InMemoryTurnWorld {
pendingNationBettingOpens: this.pendingNationBettingOpens,
pendingNationBettingFinishes: this.pendingNationBettingFinishes,
pendingYearbookSnapshots: this.pendingYearbookSnapshots,
pendingAuditMonths: this.pendingAuditMonths,
pendingUnificationFinalizations: this.pendingUnificationFinalizations,
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
} satisfies InMemoryTurnWorldStateSnapshot);
@@ -1137,6 +1142,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens);
this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes);
this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots);
this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths);
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
}
@@ -1352,6 +1358,10 @@ export class InMemoryTurnWorld {
});
}
queueAuditMonth(snapshot: PendingAuditMonth): void {
this.pendingAuditMonths.push(structuredClone(snapshot));
}
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
this.pendingYearbookSnapshots.push(structuredClone(snapshot));
}
@@ -2194,6 +2204,7 @@ export class InMemoryTurnWorld {
turnTime: new Date(entry.turnTime.getTime()),
}));
const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots);
const pendingAuditMonths = structuredClone(this.pendingAuditMonths);
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
(left, right) => left - right
@@ -2226,6 +2237,7 @@ export class InMemoryTurnWorld {
pendingNationBettingOpens,
pendingNationBettingFinishes,
pendingYearbookSnapshots,
pendingAuditMonths,
pendingUnificationFinalizations,
};
}
@@ -2264,6 +2276,7 @@ export class InMemoryTurnWorld {
this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length);
this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length);
this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length);
this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length);
this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length);
}
@@ -1,3 +1,4 @@
import { recordAuditSettlement } from '../playAudit/collection.js';
import { asNumber, asRecord } from '@sammo-ts/common';
import {
ActionLogger,
@@ -175,8 +176,10 @@ const processIncomeForNation = (
const incomeText = Math.round(incomeValue).toLocaleString('en-US');
const incomeLog =
type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
let paid = 0;
for (const general of nationGenerals) {
const pay = Math.round(getBill(general.dedication) * ratio);
paid += pay;
if (
process.env.SEED_PARITY_MONTHLY_RESOURCE_TRACE === '1' &&
(process.env.AI_TRACE_GENERAL_IDS ?? '').split(',').includes(String(general.id))
@@ -203,6 +206,7 @@ const processIncomeForNation = (
logger.pushGeneralActionLog(payLog, LogFormat.PLAIN);
pushLogs(world, logger.flush());
}
recordAuditSettlement(world, { nationId: nation.id, resource: type, income: incomeValue, paid });
};
export interface IncomeHandler extends TurnCalendarHandler {
+2
View File
@@ -1,3 +1,4 @@
import { createPlayAuditHandler } from '../playAudit/collection.js';
import { randomUUID } from 'node:crypto';
import { createRuntimePauseGate } from './runtimePauseGate.js';
@@ -480,6 +481,7 @@ const createMonthlyCalendarRuntime = async (options: {
options.monthlyEventHandler,
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
createPlayAuditHandler(options.getWorld),
monthlyBoundaryPreHandler,
createNationTurnMonthlyHandler({ getWorld: options.getWorld }),
monthlyNationStatsHandler,
@@ -1,3 +1,4 @@
import { queueAuditMonth } from '../playAudit/collection.js';
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createHash } from 'node:crypto';
@@ -177,6 +178,7 @@ export const createUnificationHandler = (options: {
}
queueYearbookSnapshot(world, options.profileName, context.currentYear, context.currentMonth);
queueAuditMonth(world, 'FINAL');
world.queueUnificationFinalization({
generationKey: `unification:${serverId}`,
serverId,
@@ -1,3 +1,4 @@
import { createPlayAuditHandler } from '../src/playAudit/collection.js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { LogCategory, LogScope, type TurnCommandEnv } from '@sammo-ts/logic';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
@@ -35,6 +36,7 @@ integration('monthly pre-update persistence', () => {
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } });
await db.yearbookHistory.deleteMany({ where: { profileName: { in: [yearbookProfile, yearbookServerId] } } });
await db.playAuditMonth.deleteMany({ where: { serverId: yearbookServerId } });
await db.logEntry.deleteMany({ where: { text: { in: archivedLogTexts } } });
});
@@ -46,6 +48,7 @@ integration('monthly pre-update persistence', () => {
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } });
await db.yearbookHistory.deleteMany({ where: { profileName: { in: [yearbookProfile, yearbookServerId] } } });
await db.playAuditMonth.deleteMany({ where: { serverId: yearbookServerId } });
await db.logEntry.deleteMany({ where: { text: { in: archivedLogTexts } } });
await closeDb?.();
});
@@ -184,7 +187,12 @@ integration('monthly pre-update persistence', () => {
});
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: composeCalendarHandlers(yearbook.handler, boundary, nations),
calendarHandler: composeCalendarHandlers(
yearbook.handler,
createPlayAuditHandler(() => world),
boundary,
nations
),
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName: yearbookProfile });
try {
@@ -227,6 +235,17 @@ integration('monthly pre-update persistence', () => {
currentMonth: 1,
meta: expect.objectContaining({ develcost: 40 }),
});
const audit = await db.playAuditMonth.findFirstOrThrow({
where: { serverId: yearbookServerId },
include: { nations: true, generals: true, cities: true },
});
expect(audit).toMatchObject({ year: 200, month: 12, kind: 'MONTH_END', settlementsComplete: false });
expect(audit.nations.find((row) => row.nationId === nationId)?.data).toMatchObject({ appliedRate: 10 });
expect(audit.generals).toHaveLength(generalIds.length);
expect(audit.cities).toHaveLength(cityIds.length);
expect(world.peekDirtyState().pendingAuditMonths).toEqual([]);
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded!.state.meta.playAuditFlows).toMatchObject({ year: 201, month: 1, complete: true });
const cityRows = await db.city.findMany({
where: { id: { in: cityIds } },
orderBy: { id: 'asc' },
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createPlayAuditHandler, queueAuditMonth, recordAuditSettlement } from '../src/playAudit/collection.js';
const turnTime = new Date('0200-01-01T00:00:00.000Z');
const buildGeneral = (id: number, nationId: number): TurnGeneral => ({
id,
name: `장수${id}`,
nationId,
cityId: nationId,
troopId: 0,
stats: { leadership: 80, strength: 70, intelligence: 60 },
experience: 1_000,
dedication: 900,
officerLevel: 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 2_000,
rice: 2_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: nationId === 0 ? 2 : 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
turnTime,
});
const buildCity = (id: number, nationId: number): City => ({
id,
name: `도시${id}`,
nationId,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
meta: {},
});
const buildNation = (id: number, power: number, meta: Nation['meta']): Nation => ({
id,
name: id === 0 ? '재야' : `국가${id}`,
color: '#777777',
capitalCityId: id === 0 ? null : id,
chiefGeneralId: null,
gold: 10_000,
rice: 20_000,
power,
level: id === 0 ? 0 : 1,
typeCode: 'che_중립',
meta,
});
const buildWorld = () => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: turnTime,
meta: { serverId: 'yearbook-projection-test' },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'test' },
},
scenarioMeta: {
title: '연감 테스트',
startYear: 200,
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
},
map: { id: 'test', name: 'test', cities: [] },
nations: [
{
...buildNation(0, 90, { gennum: 90, tech: 90 }),
name: '오염된 재야',
color: '#ffffff',
level: 9,
},
buildNation(1, 777, { gennum: 9, tech: 100 }),
buildNation(2, 0, { tech: 100 }),
],
cities: [buildCity(0, 0), buildCity(1, 1), buildCity(2, 2)],
generals: [buildGeneral(1, 0), buildGeneral(2, 0), buildGeneral(3, 1), buildGeneral(4, 2), buildGeneral(5, 2)],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
return world;
};
describe('play audit collection durability state', () => {
it('restores pending snapshots and monthly flows on rollback and acknowledges only persisted rows', () => {
const world = buildWorld();
const before = world.captureState();
recordAuditSettlement(world, { nationId: 1, resource: 'gold', income: 943.5, paid: 123 });
queueAuditMonth(world);
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(1);
world.restoreState(before);
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(0);
expect(world.getState().meta.playAuditFlows).toBeUndefined();
queueAuditMonth(world);
const saved = world.peekDirtyState();
queueAuditMonth(world, 'FINAL');
world.acknowledgeDirtyState(saved);
expect(world.peekDirtyState().pendingAuditMonths.map((row) => row.kind)).toEqual(['FINAL']);
});
it('keeps partial adoption unknown then attributes income to the new month across reload state', async () => {
const world = buildWorld();
const handler = createPlayAuditHandler(() => world);
await handler.beforeMonthChanged!({
previousYear: 200,
previousMonth: 1,
currentYear: 200,
currentMonth: 2,
turnTime,
});
expect(world.peekDirtyState().pendingAuditMonths[0]!.settlementsComplete).toBe(false);
const next = world.captureState();
next.state.currentMonth = 2;
world.restoreState(next);
recordAuditSettlement(world, { nationId: 1, resource: 'gold', income: 943.5, paid: 123 });
const reloaded = buildWorld();
reloaded.restoreState(world.captureState());
queueAuditMonth(reloaded);
const feb = reloaded.peekDirtyState().pendingAuditMonths.at(-1)!;
expect(feb.month).toBe(2);
expect(feb.settlementsComplete).toBe(true);
expect(feb.nations.find((row) => row.id === 1)).toMatchObject({
incomeGold: 943.5,
paidGold: 123,
incomeRice: 0,
});
});
it('does not replace missing season identity with a profile or create a false snapshot', () => {
const world = buildWorld();
const state = world.captureState();
delete state.state.meta.serverId;
world.restoreState(state);
queueAuditMonth(world);
expect(world.peekDirtyState().pendingAuditMonths).toEqual([]);
});
});
@@ -0,0 +1,74 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { persistAuditMonth, type PendingAuditMonth } from '../src/playAudit/persistence.js';
import { buildAuditSnapshot } from '../src/playAudit/snapshot.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const serverId = 'play-audit-persistence-fixture-20260916';
integration('play audit transactional month persistence', () => {
let db: GamePrismaClient;
let close: () => Promise<void>;
const snapshot: PendingAuditMonth = {
serverId,
year: 200,
month: 1,
tick: 10,
kind: 'MONTH_END',
settlementsComplete: true,
...buildAuditSnapshot({
nations: [
{
id: 1,
name: '감사국',
color: '#ffffff',
capitalCityId: null,
chiefGeneralId: null,
gold: 100,
rice: 200,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: {},
},
],
cities: [],
generals: [],
settlements: [],
settlementsComplete: true,
}),
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
});
afterAll(async () => {
await db.playAuditMonth.deleteMany({ where: { serverId } });
await close();
});
it('rolls back all audit rows, reloads exact data, rejects conflicting replay and deduplicates retries', async () => {
await expect(
db.$transaction(async (tx) => {
await tx.nation.create({ data: { id: 999_916, name: 'rollback audit', color: '#ffffff' } });
await persistAuditMonth(tx, snapshot);
throw new Error('fixture rollback');
})
).rejects.toThrow('fixture rollback');
expect(await db.playAuditMonth.count({ where: { serverId } })).toBe(0);
expect(await db.nation.findUnique({ where: { id: 999_916 } })).toBeNull();
await db.$transaction((tx) => persistAuditMonth(tx, snapshot));
await db.$transaction((tx) => persistAuditMonth(tx, snapshot));
const saved = await db.playAuditMonth.findFirstOrThrow({ where: { serverId }, include: { nations: true } });
expect(saved.nations.map((row) => row.data)).toEqual(snapshot.nations);
expect(await db.playAuditMonth.count({ where: { serverId } })).toBe(1);
await expect(db.$transaction((tx) => persistAuditMonth(tx, { ...snapshot, tick: 11 }))).rejects.toThrow(
'replay payload conflict'
);
expect((await db.playAuditMonth.findUniqueOrThrow({ where: { id: saved.id } })).tick).toBe(10);
await db.playAuditMonth.delete({ where: { id: saved.id } });
expect(await db.playAuditNation.count({ where: { sampleId: saved.id } })).toBe(0);
});
});
@@ -116,6 +116,7 @@ describe('durable read-model change journal mapping', () => {
pendingNationBettingOpens: [],
pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [],
pendingAuditMonths: [],
pendingUnificationFinalizations: [],
} satisfies TurnWorldChanges;
const readModelChanges = createEmptyRealtimeReadModelChanges();
+38 -11
View File
@@ -1,7 +1,8 @@
# 플레이 감사 구현 기록과 수집 inventory
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
아래 순수 projection은 아직 runtime 수집·DB·API·화면에 연결되지 않았다.
월별 projection runtime 수집·DB transaction 연결을 구현했다. API·화면은 아직 미구현이다.
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
## 현재 구현
@@ -21,20 +22,42 @@ triggerState, credential과 전체 world는 복사하지 않는다.
국가 전후값·적용 세율·보정액은 이후 원장 구현에서 보존해야 하며 이 projection만으로
R1을 완료했다고 판단하지 않는다.
## 월별 저장 구현
`playAudit/collection.ts``beforeMonthChanged`에 이전 월 표본을 queue한다.
`incomeHandler`에서 이미 계산한 수입·급여를 관측하여 작은 국가별 월합계를
world meta에 함께 저장한다. 월중 재시작에도 집계가 유지되며, 수집 도입 월은
불완전으로 표시하고 다음 월부터 완전 수집한다. 기수 identity가 없는 설치에서는
profile명으로 대체하지 않는다. 해당 구간의 API coverage 안내는 아직 구현해야 한다.
`InMemoryTurnWorld`의 capture/restore/peek/acknowledge에 pending 표본을 포함하고
`databaseHooks.persistChanges`에서 gameplay와 같은 transaction으로 저장한다.
`unificationHandler`는 월말과 구분되는 FINAL 표본을 queue한다.
`PlayAuditMonth/Nation/City/General` 네 테이블에 명시적 projection을 보존한다.
국가·도시별 장수 검색 index를 두고 child insert를 200행씩 분할한다. 표본 ID와
payload hash가 같은 재시도는 중복 저장하지 않고, 내용이 다르면 transaction을 실패시킨다.
200은 초기 batch 설정이며 payload bytes/WAL/heap 실측을 통한 최종 선정은 남아 있다.
새 migration은 기존 행을 backfill하지 않는다. 전용 PostgreSQL에서 빈 설치 전체 적용,
기존 49 migration 이후 새 migration 증분 적용, 두 번째 deploy no-op을 검증했다.
업무 데이터와 감사 표본의 transaction rollback, 재시도/충돌, 월 경계의 전월 세율과
world meta reload도 확인했다. 이전 기수 차단/정리, 최종 표본 전체 종료 경로,
정산 전후값의 별도 사건 원장은 아직 남아 있다.
## 수집 지점과 쓰기 재검토
기준 Core commit은 `5ac961dfd17738dc4c39e6401f975296f39403a5`이다.
SQL/bytes는 아직 실측하지 않았으며 아래는 현재 소스에서 확인한 연결 지점과 구현 경계다.
| 자료 | 실제 source / 관측할 값 | 구현·비용 결정 | 남은 검증 |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| 월별 장수·도시·국가 | `turn/yearbookHandler.ts``beforeMonthChanged`; `playAudit/snapshot.ts`의 필드 allowlist | 메모리 한 순회, 추가 SELECT 없이 수집. 상세는 도시/국가/장수로 페이지 조회 가능한 행에 batch 저장하며 큰 월 JSON 전체를 목록 조회하지 않음 | 월 pending/rollback 연결, migration, DB reload, 종료 부분 표본 |
| 세율 적용 수입·급여 | `turn/incomeHandler.ts``applyIncome`, `incomeValue`, `current`, `next`, `ratio`, 장수별 `pay`; `turn/nationTaxRate.ts` | 이미 계산한 수치만 관측. 국가 수입과 실제 급여 합계 분리, 과거 metadata 재누적 금지. 정산 원장을 월집계 입력으로 재사용 | 정수화·최저 자원 보정, 원장/집계 원자 저장, 도입 월 coverage |
| 월별 내구성 | `turn/inMemoryWorld.ts`의 capture/restore, peek/acknowledge와 pending yearbook; `turn/databaseHooks.ts``persistChanges` | 별도 audit pending을 같은 transaction과 savepoint에 포함. 기존 연감의 장기보존 테이블에 상세 감사를 넣지 않음 | 실패·중복·재시작, bounded 삭제 |
| 기수 identity | `scenario/scenarioSeeder.ts``install.serverId`, `GameHistory` 충돌 검사 | profile명으로 대체하지 않음. 외부 install 입력을 만드는 지점과 RESET 전체 경로를 추가 추적한 뒤 수집 활성화 | 신규 identity 생성, 재시도, 기존 설치에 identity 누락 시 처리 |
| 외교 | game-api `router/diplomacy/index.ts`, engine 월간 외교 처리 | 불변 문서는 참조, 갱신되는 내용만 당시 버전 저장. 현재 상태 월복사만으로 사건을 대신하지 않음 | 모든 API/engine mutation별 inventory |
| NPC 정책 | `turn/worldCommandHandler.ts``turn/npcPolicyMutation.ts` | CAS 성공하고 실제 값이 달라진 경우에만 불변 버전. 무변경/거부는 적용 버전에서 제외 | 초기 버전, actor/직책, 국방 mutation inventory |
| 권한 | Gateway `adminCapabilities.ts`, `adminAuth.ts`; game-api `trpc.ts` 인증·제재 middleware | scoped 감사 권한과 공통 계정 추가 권한 분리. `getMyGeneral` 요구 없이 서버에서 검사 | catalog/token/flush/HTTP matrix 전체 연결 |
| 자료 | 실제 source / 관측할 값 | 구현·비용 결정 | 남은 검증 |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- |
| 월별 장수·도시·국가 | `turn/yearbookHandler.ts``beforeMonthChanged`; `playAudit/snapshot.ts`의 필드 allowlist | 메모리 한 순회, 추가 SELECT 없이 수집. 상세는 도시/국가/장수로 페이지 조회 가능한 행에 batch 저장하며 큰 월 JSON 전체를 목록 조회하지 않음 | 단위/DB 연결 확인; 전체 종료 경로·coverage·비용 gate는 남음 |
| 세율 적용 수입·급여 | `turn/incomeHandler.ts``applyIncome`, `incomeValue`, `current`, `next`, `ratio`, 장수별 `pay`; `turn/nationTaxRate.ts` | 이미 계산한 수치만 관측. 국가 수입과 실제 급여 합계 분리, 과거 metadata 재누적 금지. 정산 원장을 월집계 입력으로 재사용 | 정수화·최저 자원 보정, 원장/집계 원자 저장, 도입 월 coverage |
| 월별 내구성 | `turn/inMemoryWorld.ts`의 capture/restore, peek/acknowledge와 pending yearbook; `turn/databaseHooks.ts``persistChanges` | 별도 audit pending을 같은 transaction과 savepoint에 포함. 기존 연감의 장기보존 테이블에 상세 감사를 넣지 않음 | 실패·중복·재시작, bounded 삭제 |
| 기수 identity | `scenario/scenarioSeeder.ts``install.serverId`, `GameHistory` 충돌 검사 | profile명으로 대체하지 않음. 외부 install 입력을 만드는 지점과 RESET 전체 경로를 추가 추적한 뒤 수집 활성화 | 신규 identity 생성, 재시도, 기존 설치에 identity 누락 시 처리 |
| 외교 | game-api `router/diplomacy/index.ts`, engine 월간 외교 처리 | 불변 문서는 참조, 갱신되는 내용만 당시 버전 저장. 현재 상태 월복사만으로 사건을 대신하지 않음 | 모든 API/engine mutation별 inventory |
| NPC 정책 | `turn/worldCommandHandler.ts``turn/npcPolicyMutation.ts` | CAS 성공하고 실제 값이 달라진 경우에만 불변 버전. 무변경/거부는 적용 버전에서 제외 | 초기 버전, actor/직책, 국방 mutation inventory |
| 권한 | Gateway `adminCapabilities.ts`, `adminAuth.ts`; game-api `trpc.ts` 인증·제재 middleware | scoped 감사 권한과 공통 계정 추가 권한 분리. `getMyGeneral` 요구 없이 서버에서 검사 | catalog/token/flush/HTTP matrix 전체 연결 |
월간 실행은 이전 월 snapshot → 달 변경 → 새달 `onMonthChanged` 순서다.
1월 금/7월 쌀 정산은 새로 진입한 월의 흐름으로 누적하고 그 월 마감에 집계한다.
@@ -48,6 +71,10 @@ SQL/bytes는 아직 실측하지 않았으며 아래는 현재 소스에서 확
- 수입: `monthlySemiAnnualPersistence.integration.test.ts`, `monthlyWarIncomePersistence.integration.test.ts`.
- 원자성: `inputEventAtomicity.test.ts`, `readModelChangeJournalPersistence.integration.test.ts`.
현재 순수 fixture는 분모, 0/null, 소수 수입, 미수집, 외국 주둔, 과거 값의 독립성,
순수 fixture는 분모, 0/null, 소수 수입, 미수집, 외국 주둔, 과거 값의 독립성,
민감 meta 제외와 단일 순회를 검증한다. PostgreSQL SQL count/WAL/실행계획,
권한 HTTP, CHE/HWE Chromium과 전체 source inventory는 아직 남아 있다.
월 저장 검증: `playAuditCollection.test.ts`, `playAuditPersistence.integration.test.ts`
확장한 `monthlyBoundaryPrePersistence.integration.test.ts`. 정확한 명령·결과는
상위 보고서 `2026-09-16-플레이-감사-월별-저장.md`에 기록한다.
+57
View File
@@ -1080,3 +1080,60 @@ model VoteComment {
@@index([voteId, createdAt])
@@map("vote_comment")
}
// 현재 기수 플레이 감사. gameplay 엔티티 삭제 뒤에도 당시 ID/이름을 유지한다.
model PlayAuditMonth {
id String @id
serverId String @map("server_id")
year Int
month Int
kind String
tick Int?
schemaVersion Int @default(1) @map("schema_version")
settlementsComplete Boolean @map("settlements_complete")
hash String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
nations PlayAuditNation[]
cities PlayAuditCity[]
generals PlayAuditGeneral[]
@@unique([serverId, year, month, kind])
@@map("play_audit_month")
}
model PlayAuditNation {
sampleId String @map("sample_id")
nationId Int @map("nation_id")
data Json
sample PlayAuditMonth @relation(fields: [sampleId], references: [id], onDelete: Cascade)
@@id([sampleId, nationId])
@@map("play_audit_nation")
}
model PlayAuditCity {
sampleId String @map("sample_id")
cityId Int @map("city_id")
nationId Int @map("nation_id")
data Json
sample PlayAuditMonth @relation(fields: [sampleId], references: [id], onDelete: Cascade)
@@id([sampleId, cityId])
@@index([sampleId, nationId, cityId])
@@map("play_audit_city")
}
model PlayAuditGeneral {
sampleId String @map("sample_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
cityId Int @map("city_id")
npcState Int @map("npc_state")
data Json
sample PlayAuditMonth @relation(fields: [sampleId], references: [id], onDelete: Cascade)
@@id([sampleId, generalId])
@@index([sampleId, nationId, generalId])
@@index([sampleId, cityId, generalId])
@@map("play_audit_general")
}
@@ -0,0 +1,38 @@
CREATE TABLE "play_audit_month" (
"id" TEXT NOT NULL PRIMARY KEY,
"server_id" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL CHECK ("month" BETWEEN 1 AND 12),
"kind" TEXT NOT NULL CHECK ("kind" IN ('MONTH_END', 'FINAL')),
"tick" INTEGER,
"schema_version" INTEGER NOT NULL DEFAULT 1,
"settlements_complete" BOOLEAN NOT NULL,
"hash" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX "play_audit_month_server_id_year_month_kind_key" ON "play_audit_month" ("server_id", "year", "month", "kind");
CREATE TABLE "play_audit_nation" (
"sample_id" TEXT NOT NULL REFERENCES "play_audit_month"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"nation_id" INTEGER NOT NULL,
"data" JSONB NOT NULL,
PRIMARY KEY ("sample_id", "nation_id")
);
CREATE TABLE "play_audit_city" (
"sample_id" TEXT NOT NULL REFERENCES "play_audit_month"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"city_id" INTEGER NOT NULL,
"nation_id" INTEGER NOT NULL,
"data" JSONB NOT NULL,
PRIMARY KEY ("sample_id", "city_id")
);
CREATE INDEX "play_audit_city_sample_id_nation_id_city_id_idx" ON "play_audit_city" ("sample_id", "nation_id", "city_id");
CREATE TABLE "play_audit_general" (
"sample_id" TEXT NOT NULL REFERENCES "play_audit_month"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"general_id" INTEGER NOT NULL,
"nation_id" INTEGER NOT NULL,
"city_id" INTEGER NOT NULL,
"npc_state" INTEGER NOT NULL,
"data" JSONB NOT NULL,
PRIMARY KEY ("sample_id", "general_id")
);
CREATE INDEX "play_audit_general_sample_id_nation_id_general_id_idx" ON "play_audit_general" ("sample_id", "nation_id", "general_id");
CREATE INDEX "play_audit_general_sample_id_city_id_general_id_idx" ON "play_audit_general" ("sample_id", "city_id", "general_id");