NPC 결정 요약과 상세를 턴 transaction에 저장하고 기수별 정리 연결

This commit is contained in:
2026-09-16 07:55:12 +00:00
parent 9f31b28297
commit 63907d95cc
18 changed files with 521 additions and 10 deletions
+34
View File
@@ -0,0 +1,34 @@
import { auditPolicyHash } from './policy.js';
import type { AiDecisionTraceEvent } from '../turn/ai/generalAi/trace.js';
export interface PendingAuditDecision {
id: string;
serverId: string;
executionId: string;
phase: 'general' | 'nation';
generalId: number;
nationId: number;
cityId: number;
npcState: number;
year: number;
month: number;
tick: number;
summary: {
schemaVersion: 1;
coverage: 'PROCEDURES';
clockRevision: number;
codeVersion: string | null;
policyRefs: Record<string, string>;
requestedAction: string;
selectedAction: string | null;
selectedReason: string | null;
executedAction: string;
completed: boolean | null;
usedFallback: boolean;
blockedReason: string | null;
};
steps: AiDecisionTraceEvent[];
}
export const auditDecisionIdentity = (serverId: string, generalId: number, tick: number, revision: number) =>
auditPolicyHash([serverId, generalId, tick, revision]);
@@ -0,0 +1,58 @@
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { auditPolicyHash } from './policy.js';
import type { PendingAuditDecision } from './decision.js';
export const AUDIT_DECISION_CHUNK_STEPS = 128;
const BATCH = 200;
/** 요약과 모든 chunk를 기존 gameplay transaction에 저장한다. 후보별 SQL은 발행하지 않는다. */
export const persistAuditDecisions = async (
tx: GamePrisma.TransactionClient,
decisions: readonly PendingAuditDecision[]
): Promise<void> => {
for (let offset = 0; offset < decisions.length; offset += BATCH) {
const batch = decisions.slice(offset, offset + BATCH);
const headers = batch.map(({ steps, ...decision }) => {
if (!Number.isSafeInteger(decision.tick) || decision.tick < 0 || !steps.length)
throw new Error('Invalid play audit decision');
if (steps[0]?.kind !== 'DECISION_START' || steps.at(-1)?.kind !== 'DECISION_END')
throw new Error('Incomplete play audit decision');
if (
steps.some(
(step, index) =>
step.phase !== decision.phase || (index > 0 && step.sequence <= steps[index - 1]!.sequence)
)
)
throw new Error('Invalid play audit decision order');
return {
...decision,
tick: BigInt(decision.tick),
stepCount: steps.length,
summary: decision.summary as InputJsonValue,
hash: auditPolicyHash({ ...decision, steps }),
};
});
await tx.playAuditDecision.createMany({ data: headers, skipDuplicates: true });
const saved = await tx.playAuditDecision.findMany({
where: { id: { in: headers.map((row) => row.id) } },
select: { id: true, hash: true },
});
const hashes = new Map(saved.map((row) => [row.id, row.hash]));
if (headers.some((row) => hashes.get(row.id) !== row.hash))
throw new Error('Play audit decision replay conflict');
let chunks: GamePrisma.PlayAuditDecisionChunkCreateManyInput[] = [];
for (const decision of batch) {
for (let start = 0; start < decision.steps.length; start += AUDIT_DECISION_CHUNK_STEPS) {
chunks.push({
decisionId: decision.id,
ordinal: start / AUDIT_DECISION_CHUNK_STEPS,
steps: decision.steps.slice(start, start + AUDIT_DECISION_CHUNK_STEPS) as InputJsonValue,
});
if (chunks.length === BATCH) {
await tx.playAuditDecisionChunk.createMany({ data: chunks, skipDuplicates: true });
chunks = [];
}
}
}
if (chunks.length) await tx.playAuditDecisionChunk.createMany({ data: chunks, skipDuplicates: true });
}
};
@@ -32,6 +32,30 @@ export const prunePreviousAuditBatch = async (
});
return { status: 'progress', deleted: deleted.count };
}
const [decision] = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM play_audit_decision WHERE id = COALESCE(
(SELECT id FROM play_audit_decision WHERE server_id < ${expectedServerId}
ORDER BY server_id, general_id, tick, id LIMIT 1),
(SELECT id FROM play_audit_decision WHERE server_id > ${expectedServerId}
ORDER BY server_id, general_id, tick, id LIMIT 1)
) FOR UPDATE
`;
if (decision) {
const chunks = await tx.playAuditDecisionChunk.findMany({
where: { decisionId: decision.id },
orderBy: { ordinal: 'asc' },
take: AUDIT_RETENTION_BATCH_SIZE,
select: { ordinal: true },
});
if (chunks.length) {
const deleted = await tx.playAuditDecisionChunk.deleteMany({
where: { decisionId: decision.id, ordinal: { in: chunks.map((row) => row.ordinal) } },
});
return { status: 'progress', deleted: deleted.count };
}
await tx.playAuditDecision.delete({ where: { id: decision.id } });
return { status: 'progress', deleted: 1 };
}
const policies = await tx.playAuditPolicy.findMany({
where: { serverId: { not: expectedServerId } },
orderBy: { id: 'asc' },
@@ -1,3 +1,4 @@
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
@@ -1152,6 +1153,7 @@ export const createDatabaseTurnHooks = async (
pendingAuditMonths,
pendingAuditPolicies,
pendingAuditDiplomacy,
pendingAuditDecisions,
pendingUnificationFinalizations,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -1894,6 +1896,7 @@ export const createDatabaseTurnHooks = async (
}
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand);
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy);
await persistAuditDecisions(prisma, pendingAuditDecisions);
for (const snapshot of pendingAuditMonths) {
await persistAuditMonth(prisma, snapshot);
}
+18 -1
View File
@@ -1,3 +1,4 @@
import type { PendingAuditDecision } from '../playAudit/decision.js';
import {
recordTurnAuditDiplomacy,
recordNationAuditDiplomacy,
@@ -60,6 +61,7 @@ export interface GeneralTurnContext {
}
export interface GeneralTurnResult {
auditDecisions?: PendingAuditDecision[];
general?: TurnGeneral;
city?: City;
nation?: Nation | null;
@@ -211,6 +213,7 @@ export interface TurnWorldChanges {
pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[];
pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
pendingAuditDecisions: PendingAuditDecision[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
}
@@ -254,6 +257,7 @@ export interface InMemoryTurnWorldStateSnapshot {
pendingAuditMonths: PendingAuditMonth[];
pendingAuditPolicies: PendingAuditPolicy[];
pendingAuditDiplomacy: AuditDiplomacyEventDraft[];
pendingAuditDecisions: PendingAuditDecision[];
pendingUnificationFinalizations: PendingUnificationFinalization[];
pendingRealtimeBacklogShiftTicks: number;
}
@@ -561,6 +565,7 @@ export class InMemoryTurnWorld {
private readonly pendingAuditMonths: PendingAuditMonth[] = [];
private readonly pendingAuditPolicies: PendingAuditPolicy[] = [];
private readonly pendingAuditDiplomacy: AuditDiplomacyEventDraft[] = [];
private readonly pendingAuditDecisions: PendingAuditDecision[] = [];
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
private pendingRealtimeBacklogShiftTicks = 0;
private readonly scenarioConfig: ScenarioConfig;
@@ -1112,6 +1117,7 @@ export class InMemoryTurnWorld {
pendingAuditMonths: this.pendingAuditMonths,
pendingAuditPolicies: this.pendingAuditPolicies,
pendingAuditDiplomacy: this.pendingAuditDiplomacy,
pendingAuditDecisions: this.pendingAuditDecisions,
pendingUnificationFinalizations: this.pendingUnificationFinalizations,
pendingRealtimeBacklogShiftTicks: this.pendingRealtimeBacklogShiftTicks,
} satisfies InMemoryTurnWorldStateSnapshot);
@@ -1161,6 +1167,7 @@ export class InMemoryTurnWorld {
this.replaceArray(this.pendingAuditMonths, restored.pendingAuditMonths);
this.replaceArray(this.pendingAuditPolicies, restored.pendingAuditPolicies);
this.replaceArray(this.pendingAuditDiplomacy, restored.pendingAuditDiplomacy);
this.replaceArray(this.pendingAuditDecisions, restored.pendingAuditDecisions);
this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations);
this.pendingRealtimeBacklogShiftTicks = restored.pendingRealtimeBacklogShiftTicks ?? 0;
}
@@ -1389,6 +1396,10 @@ export class InMemoryTurnWorld {
this.pendingAuditDiplomacy.push(structuredClone(event));
}
queueAuditDecision(decision: PendingAuditDecision): void {
this.pendingAuditDecisions.push(structuredClone(decision));
}
queueAuditPolicy(policy: PendingAuditPolicy): void {
this.pendingAuditPolicies.push(structuredClone(policy));
}
@@ -1397,7 +1408,8 @@ export class InMemoryTurnWorld {
return (
this.pendingAuditPolicies.length > 0 ||
this.pendingAuditMonths.length > 0 ||
this.pendingAuditDiplomacy.length > 0
this.pendingAuditDiplomacy.length > 0 ||
this.pendingAuditDecisions.length > 0
);
}
@@ -1964,6 +1976,8 @@ export class InMemoryTurnWorld {
schedule: this.schedule,
});
if (result.auditDecisions?.length) this.pendingAuditDecisions.push(...structuredClone(result.auditDecisions));
let nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule);
if (!result.deleted?.general) {
const resolvedGeneral = result.general ?? currentGeneral;
@@ -2274,6 +2288,7 @@ export class InMemoryTurnWorld {
const pendingAuditMonths = structuredClone(this.pendingAuditMonths);
const pendingAuditPolicies = structuredClone(this.pendingAuditPolicies);
const pendingAuditDiplomacy = structuredClone(this.pendingAuditDiplomacy);
const pendingAuditDecisions = structuredClone(this.pendingAuditDecisions);
const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations);
const accessScoreResetGeneralIds = Array.from(this.accessScoreResetGeneralIds).sort(
(left, right) => left - right
@@ -2309,6 +2324,7 @@ export class InMemoryTurnWorld {
pendingAuditMonths,
pendingAuditPolicies,
pendingAuditDiplomacy,
pendingAuditDecisions,
pendingUnificationFinalizations,
};
}
@@ -2350,6 +2366,7 @@ export class InMemoryTurnWorld {
this.pendingAuditMonths.splice(0, changes.pendingAuditMonths.length);
this.pendingAuditPolicies.splice(0, changes.pendingAuditPolicies.length);
this.pendingAuditDiplomacy.splice(0, changes.pendingAuditDiplomacy.length);
this.pendingAuditDecisions.splice(0, changes.pendingAuditDecisions.length);
this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length);
}
@@ -1,3 +1,6 @@
import { auditDecisionIdentity, type PendingAuditDecision } from '../playAudit/decision.js';
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
import type { AiDecisionTraceEvent } from './ai/generalAi/trace.js';
import type { AiDecisionTraceObserver } from './ai/generalAi/trace.js';
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import type {
@@ -902,6 +905,8 @@ export const createReservedTurnHandler = async (options: {
currentMonth: number
) => Nation['meta'] | null;
onDecisionTrace?: AiDecisionTraceObserver;
collectAuditDecisions?: boolean;
auditCodeVersion?: string;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
generalId: number;
@@ -1068,6 +1073,90 @@ export const createReservedTurnHandler = async (options: {
let currentGeneral = context.general;
let currentCity = context.city;
let currentNation = context.nation ?? null;
const auditServerId = typeof context.world.meta.serverId === 'string' ? context.world.meta.serverId : null;
const collectDecisions =
options.collectAuditDecisions !== false && Boolean(auditServerId?.trim()) && Boolean(worldRef);
const auditDecisions: PendingAuditDecision[] = [];
const decisionSteps = new Map<'general' | 'nation', AiDecisionTraceEvent[]>();
const decisionPolicyRefs = new Map<'general' | 'nation', Record<string, string>>();
const onDecisionTrace: AiDecisionTraceObserver | undefined =
collectDecisions || options.onDecisionTrace
? (event) => {
if (collectDecisions) {
if (event.kind === 'DECISION_START') {
decisionSteps.set(event.phase, []);
const heads = asRecord(currentNation?.meta._playAuditPolicy);
decisionPolicyRefs.set(
event.phase,
Object.fromEntries(
AUDIT_POLICY_AREAS.flatMap((area) => {
const head = asRecord(heads[area]);
return head.serverId === auditServerId && typeof head.id === 'string'
? [[area, head.id]]
: [];
})
)
);
}
decisionSteps.get(event.phase)?.push(structuredClone(event));
}
options.onDecisionTrace?.(event);
}
: undefined;
const finishDecision = (
phase: 'general' | 'nation',
outcome: {
actionKey: string;
usedFallback: boolean;
completed?: boolean;
blockedReason?: string;
}
): void => {
const steps = decisionSteps.get(phase);
const first = steps?.[0];
const last = steps?.at(-1);
if (
!collectDecisions ||
!auditServerId ||
!worldRef ||
!steps ||
first?.kind !== 'DECISION_START' ||
last?.kind !== 'DECISION_END'
)
return;
const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime);
const revision = worldRef.getGameClockState().revision;
const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision);
auditDecisions.push({
id: auditPolicyHash([executionId, phase]),
serverId: auditServerId,
executionId,
phase,
generalId: first.generalId,
nationId: first.nationId,
cityId: first.cityId,
npcState: first.npcState,
year: first.year,
month: first.month,
tick,
summary: {
schemaVersion: 1,
coverage: 'PROCEDURES',
clockRevision: revision,
codeVersion: options.auditCodeVersion ?? null,
policyRefs: decisionPolicyRefs.get(phase) ?? {},
requestedAction: first.reservedAction,
selectedAction: last.action,
selectedReason: last.reason,
executedAction: outcome.actionKey,
completed: outcome.completed ?? null,
usedFallback: outcome.usedFallback,
blockedReason: outcome.blockedReason ?? null,
},
steps,
});
};
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
// 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다.
const legacyStaticNationName = currentNation?.name ?? '재야';
@@ -1936,7 +2025,7 @@ export const createReservedTurnHandler = async (options: {
nationUsedAi = true;
const aiStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
sharedAi = new GeneralAI({
onDecisionTrace: options.onDecisionTrace,
onDecisionTrace,
general: currentGeneral,
city: currentCity,
nation: currentNation,
@@ -2009,6 +2098,7 @@ export const createReservedTurnHandler = async (options: {
}
const nationActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n;
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
finishDecision('nation', nationResult);
const nationActionDurationNs = options.onActionProfiled
? process.hrtime.bigint() - nationActionStartedAt
: 0n;
@@ -2093,7 +2183,7 @@ export const createReservedTurnHandler = async (options: {
const ai =
sharedAi ??
new GeneralAI({
onDecisionTrace: options.onDecisionTrace,
onDecisionTrace,
general: currentGeneral,
city: currentCity,
nation: currentNation,
@@ -2179,6 +2269,7 @@ export const createReservedTurnHandler = async (options: {
blockedReason: '블럭 대상자입니다.',
}
: runAction('general', generalDefinitions, generalFallback, generalCommand, true);
finishDecision('general', generalResult);
const generalActionDurationNs = options.onActionProfiled
? process.hrtime.bigint() - generalActionStartedAt
: 0n;
@@ -2411,6 +2502,7 @@ export const createReservedTurnHandler = async (options: {
}
const result: GeneralTurnResult = {
...(auditDecisions.length ? { auditDecisions } : {}),
general: currentGeneral,
city: currentCity,
nation: currentNation,
+45
View File
@@ -0,0 +1,45 @@
import type { PendingAuditDecision } from '../../src/playAudit/decision.js';
export const buildAuditDecisionFixture = (id: string, serverId = 'decision-old'): PendingAuditDecision => {
const base = {
phase: 'general' as const,
generalId: 990321,
nationId: 990321,
cityId: 1,
npcState: 2,
year: 190,
month: 1,
tick: 4_320_000_000,
};
return {
...base,
id,
serverId,
executionId: id,
summary: {
schemaVersion: 1,
coverage: 'PROCEDURES',
clockRevision: 1,
codeVersion: null,
policyRefs: {},
requestedAction: '휴식',
selectedAction: 'che_징병',
selectedReason: '징병',
executedAction: '휴식',
completed: false,
usedFallback: true,
blockedReason: '자원 부족',
},
steps: [
{ ...base, sequence: 0, kind: 'DECISION_START', reservedAction: '휴식' },
...Array.from({ length: 300 }, (_, index) => ({
...base,
sequence: index + 1,
kind: 'RNG' as const,
method: 'nextBool',
parameters: [0.5],
result: true,
})),
{ ...base, sequence: 301, kind: 'DECISION_END', action: 'che_징병', reason: '징병' },
],
};
};
@@ -1,3 +1,4 @@
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import type { Nation } from '@sammo-ts/logic';
@@ -49,6 +50,8 @@ integration('monthly diplomacy persistence', () => {
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: scenarioCode } } });
await db.playAuditDecision.deleteMany({ where: { serverId: scenarioCode } });
await db.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } });
await db.diplomacy.deleteMany({
where: {
@@ -62,6 +65,8 @@ integration('monthly diplomacy persistence', () => {
afterAll(async () => {
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: scenarioCode } });
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: scenarioCode } } });
await db.playAuditDecision.deleteMany({ where: { serverId: scenarioCode } });
await db.playAuditPolicy.deleteMany({ where: { serverId: scenarioCode } });
await db.diplomacy.deleteMany({
where: {
@@ -186,6 +191,20 @@ integration('monthly diplomacy persistence', () => {
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
}
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
world.queueAuditDecision(decision);
await db.$executeRawUnsafe(`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`);
await db.$executeRawUnsafe(`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`);
try {
await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback');
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]);
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0);
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(1);
} finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()');
}
await hooks.hooks.flushChanges?.({
lastTurnTime: '0193-02-01T00:00:00.000Z',
processedGenerals: 0,
@@ -211,6 +230,8 @@ integration('monthly diplomacy persistence', () => {
});
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]);
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(1);
const events = await db.playAuditDiplomacyEvent.findMany({
where: { serverId: scenarioCode },
orderBy: { sequence: 'asc' },
@@ -1,3 +1,4 @@
import type { PendingAuditDecision } from '../src/playAudit/decision.js';
import type { AiDecisionTraceEvent } from '../src/turn/ai/generalAi/trace.js';
import { describe, expect, it, vi } from 'vitest';
import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
@@ -247,8 +248,14 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
};
const decisionTrace: AiDecisionTraceEvent[] = [];
const savedDecisions: PendingAuditDecision[] = [];
const { runUntil } = await createTurnTestHarness({
onDecisionTrace: auditEnabled ? (event) => decisionTrace.push(event) : undefined,
wrapGeneralTurnHandler: (handler) => ({ execute: (context) => {
const result = handler.execute(context);
savedDecisions.push(...(result.auditDecisions ?? []));
return result;
} }),
snapshot,
state,
schedule,
@@ -439,6 +446,11 @@ describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
}
expect(dispatchCount).toBeGreaterThan(0);
if (auditEnabled) {
expect(savedDecisions.length).toBeGreaterThan(0);
expect(new Set(savedDecisions.map((row) => row.id)).size).toBe(savedDecisions.length);
expect(savedDecisions.some((row) => row.phase === 'nation' && row.summary.executedAction === 'che_선전포고')).toBe(true);
expect(savedDecisions.some((row) => row.phase === 'general' && row.summary.executedAction === 'che_출병')).toBe(true);
expect(savedDecisions.every((row) => row.steps[0]?.kind === 'DECISION_START' && row.steps.at(-1)?.kind === 'DECISION_END')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'DECISION_START' && step.phase === 'nation')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'DECISION_END' && step.phase === 'general')).toBe(true);
expect(decisionTrace.some((step) => step.kind === 'RNG')).toBe(true);
@@ -1,3 +1,4 @@
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js';
import { describe, expect, it } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic';
@@ -129,6 +130,22 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
return world;
};
describe('play audit collection durability state', () => {
it('restores decision buffers and acknowledges only the committed prefix', () => {
const world = buildWorld();
const first = buildAuditDecisionFixture('first');
world.queueAuditDecision(first);
const checkpoint = world.captureState();
const committed = world.peekDirtyState();
world.queueAuditDecision(buildAuditDecisionFixture('second'));
world.acknowledgeDirtyState(committed);
expect(world.peekDirtyState().pendingAuditDecisions.map((row) => row.id)).toEqual(['second']);
world.restoreState(checkpoint);
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([first]);
const peeked = world.peekDirtyState();
peeked.pendingAuditDecisions[0]!.steps.pop();
expect(world.peekDirtyState().pendingAuditDecisions[0]!.steps).toHaveLength(302);
});
it('captures direct reserved-turn nation batches once and initializes their policies', () => {
const world = buildWorld({
execute: () => ({ created: { generals: [], nations: [buildNation(3, 0, {}), buildNation(4, 0, {})] } }),
@@ -0,0 +1,100 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { persistAuditDecisions } from '../src/playAudit/decisionPersistence.js';
import { buildAuditDecisionFixture as draft } from './fixtures/playAuditDecision.js';
import { prunePreviousAuditBatch } from '../src/playAudit/retention.js';
const databaseUrl = process.env.PLAY_AUDIT_DECISION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
integration('decision persistence and bounded retention', () => {
let db: GamePrismaClient;
let close: () => Promise<void>;
beforeAll(async () => {
if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('_decision_fixture'))
throw new Error('Dedicated decision fixture required');
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
await db.playAuditDecisionChunk.deleteMany();
await db.playAuditDecision.deleteMany();
await db.nation.deleteMany({ where: { id: 990321 } });
await db.worldState.deleteMany();
await db.worldState.create({
data: {
scenarioCode: 'decision',
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: { serverId: 'decision-current' },
},
});
});
afterAll(async () => {
await close?.();
});
it('rolls back gameplay/header/chunks on insert failure, retries and rejects divergent replay', async () => {
const decision = draft('decision-one');
await db.$executeRawUnsafe(
`CREATE OR REPLACE FUNCTION decision_fixture_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.ordinal=1 THEN RAISE EXCEPTION 'decision chunk failure'; END IF; RETURN NEW; END $$`
);
await db.$executeRawUnsafe(
`CREATE TRIGGER decision_fixture_failure BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION decision_fixture_failure()`
);
try {
await expect(
db.$transaction(async (tx) => {
await tx.nation.create({ data: { id: 990321, name: '결정국', color: '#fff' } });
await persistAuditDecisions(tx, [decision]);
})
).rejects.toThrow('decision chunk failure');
} finally {
await db.$executeRawUnsafe('DROP TRIGGER decision_fixture_failure ON play_audit_decision_chunk');
}
expect(await db.nation.findUnique({ where: { id: 990321 } })).toBeNull();
expect(await db.playAuditDecision.count()).toBe(0);
expect(await db.playAuditDecisionChunk.count()).toBe(0);
await db.$transaction(async (tx) => {
await tx.nation.create({ data: { id: 990321, name: '결정국', color: '#fff' } });
await persistAuditDecisions(tx, [decision]);
});
await db.$transaction((tx) => persistAuditDecisions(tx, [decision]));
const header = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
expect(header).toMatchObject({ tick: 4_320_000_000n, stepCount: 302 });
const chunks = await db.playAuditDecisionChunk.findMany({
where: { decisionId: decision.id },
orderBy: { ordinal: 'asc' },
});
expect(chunks).toHaveLength(3);
expect(chunks.flatMap((chunk) => chunk.steps)).toEqual(decision.steps);
await expect(
db.$transaction((tx) =>
persistAuditDecisions(tx, [{ ...decision, summary: { ...decision.summary, completed: true } }])
)
).rejects.toThrow('replay conflict');
await expect(db.playAuditDecision.delete({ where: { id: decision.id } })).rejects.toThrow();
expect(await db.playAuditDecision.count()).toBe(1);
});
it('deletes at most 200 chunks before a header and preserves current season', async () => {
const active = draft('decision-active', 'decision-current');
await db.$transaction((tx) => persistAuditDecisions(tx, [active]));
await db.playAuditDecisionChunk.createMany({
data: Array.from({ length: 401 }, (_, index) => ({
decisionId: 'decision-one',
ordinal: index + 3,
steps: [],
})),
});
expect(await prunePreviousAuditBatch(db, 'wrong')).toEqual({ status: 'identityChanged', deleted: 0 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 });
expect(await db.playAuditDecisionChunk.count({ where: { decisionId: 'decision-one' } })).toBe(204);
expect(await db.playAuditDecision.count({ where: { id: 'decision-one' } })).toBe(1);
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 200 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 4 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'progress', deleted: 1 });
expect(await prunePreviousAuditBatch(db, 'decision-current')).toEqual({ status: 'complete', deleted: 0 });
expect(await db.playAuditDecision.count()).toBe(1);
expect(await db.playAuditDecisionChunk.count()).toBe(3);
});
});
@@ -118,7 +118,8 @@ describe('durable read-model change journal mapping', () => {
pendingYearbookSnapshots: [],
pendingAuditMonths: [],
pendingAuditPolicies: [],
pendingAuditDiplomacy: [],
pendingAuditDiplomacy: [],
pendingAuditDecisions: [],
pendingUnificationFinalizations: [],
} satisfies TurnWorldChanges;
const readModelChanges = createEmptyRealtimeReadModelChanges();