감사 수집과 저장 장애를 게임 진행에서 분리한다

This commit is contained in:
2026-09-26 16:26:02 +00:00
parent daf7a87799
commit 6b65633324
22 changed files with 1108 additions and 550 deletions
+31 -17
View File
@@ -1,5 +1,10 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { persistAuditDiplomacyEvents, type AuditDiplomacyEventDraft, type GamePrisma } from '@sammo-ts/infra'; import {
withPlayAuditSavepoint,
persistAuditDiplomacyEvents,
type AuditDiplomacyEventDraft,
type GamePrisma,
} from '@sammo-ts/infra';
import type { ApiInputExecutionContext } from '../inputEventBoundary.js'; import type { ApiInputExecutionContext } from '../inputEventBoundary.js';
import { asRecord, JosaUtil } from '@sammo-ts/common'; import { asRecord, JosaUtil } from '@sammo-ts/common';
@@ -104,6 +109,7 @@ const persistEffects = async (
} }
): Promise<void> => { ): Promise<void> => {
const changes: AuditDiplomacyEventDraft[] = []; const changes: AuditDiplomacyEventDraft[] = [];
let collectionFailed = false;
const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row])); const states = new Map(audit?.before.map((row) => [`${row.srcNationId}:${row.destNationId}`, row]));
const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({ const project = (row: GamePrisma.DiplomacyGetPayload<Record<string, never>>) => ({
state: row.stateCode, state: row.stateCode,
@@ -129,22 +135,26 @@ const persistEffects = async (
}, },
}); });
if (audit) { if (audit) {
const key = `${effect.srcNationId}:${effect.destNationId}`; try {
const previous = states.get(key); const key = `${effect.srcNationId}:${effect.destNationId}`;
if (!previous) throw new Error('Missing locked diplomacy audit state'); const previous = states.get(key);
const before = project(previous); if (!previous) throw new Error('Missing locked diplomacy audit state');
const after = project(updated); const before = project(previous);
if (JSON.stringify(before) !== JSON.stringify(after)) { const after = project(updated);
changes.push({ if (JSON.stringify(before) !== JSON.stringify(after)) {
...audit.base, changes.push({
srcNationId: effect.srcNationId, ...audit.base,
destNationId: effect.destNationId, srcNationId: effect.srcNationId,
ordinal: changes.length + 1, destNationId: effect.destNationId,
before, ordinal: changes.length + 1,
after, before,
}); after,
});
}
states.set(key, updated);
} catch {
collectionFailed = true;
} }
states.set(key, updated);
} }
} else if (effect.type === 'nation:patch' && effect.targetId !== undefined) { } else if (effect.type === 'nation:patch' && effect.targetId !== undefined) {
const patch = effect.patch; const patch = effect.patch;
@@ -159,7 +169,11 @@ const persistEffects = async (
} }
} }
await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId); await persistLogs(db, orderLegacyActionLoggerFlush(logs), year, month, at, serverId);
await persistAuditDiplomacyEvents(db, changes); if (changes.length || collectionFailed)
await withPlayAuditSavepoint(db, async (auditDb) => {
if (collectionFailed) throw new Error('Audit relation collection failed');
await persistAuditDiplomacyEvents(auditDb, changes);
});
}; };
const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => { const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise<number[]> => {
@@ -117,6 +117,12 @@ export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
startYear, startYear,
startMonth, startMonth,
collectionStart, collectionStart,
historyGap: (() => {
const gap = asRecord(meta.playAuditGap);
return gap.serverId === serverId && Number.isInteger(gap.firstYear) && Number.isInteger(gap.firstMonth)
? { firstYear: Number(gap.firstYear), firstMonth: Number(gap.firstMonth) }
: null;
})(),
tick: world.lastTurnTick?.toString() ?? null, tick: world.lastTurnTick?.toString() ?? null,
asOf: new Date().toISOString(), asOf: new Date().toISOString(),
}; };
+45 -36
View File
@@ -3,6 +3,7 @@ import {
GamePrisma, GamePrisma,
hashAuditDiplomacyDocument, hashAuditDiplomacyDocument,
persistAuditDiplomacyEvents, persistAuditDiplomacyEvents,
withPlayAuditSavepoint,
projectAuditDocumentState, projectAuditDocumentState,
readTurnRuntimeReady, readTurnRuntimeReady,
type AuditDiplomacyEventDraft, type AuditDiplomacyEventDraft,
@@ -83,6 +84,7 @@ const readCoordinate = async (ctx: GameApiContext) => {
}; };
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => { export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
let collectionFailed = false;
const changes: { const changes: {
letter: Letter; letter: Letter;
before: Record<string, unknown> | null; before: Record<string, unknown> | null;
@@ -92,48 +94,55 @@ export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: General
return { return {
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => { record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
if (!ctx.auditInput) return; if (!ctx.auditInput) return;
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType }); try {
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
} catch {
collectionFailed = true;
}
}, },
flush: async (): Promise<void> => { flush: async (): Promise<void> => {
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다. // 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
const input = ctx.auditInput; const input = ctx.auditInput;
if (!input || !changes.length) return; if (!input || (!changes.length && !collectionFailed)) return;
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id) if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
throw new Error('Play audit diplomacy actor mismatch'); throw new Error('Play audit diplomacy actor mismatch');
const coordinate = await readCoordinate(ctx); await withPlayAuditSavepoint(ctx.db, async (auditDb) => {
if (!coordinate) return; if (collectionFailed) throw new Error('Audit document collection failed');
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({ const coordinate = await readCoordinate({ ...ctx, db: auditDb });
schemaVersion: 1, if (!coordinate) return;
serverId: coordinate.serverId, const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
srcNationId: change.letter.srcNationId, schemaVersion: 1,
destNationId: change.letter.destNationId, serverId: coordinate.serverId,
category: 'DOCUMENT', srcNationId: change.letter.srcNationId,
source: 'API', destNationId: change.letter.destNationId,
eventType: change.eventType, category: 'DOCUMENT',
documentId: change.letter.id, source: 'API',
documentHash: hashAuditDiplomacyDocument(change.letter), eventType: change.eventType,
previousDocumentId: change.letter.prevId, documentId: change.letter.id,
year: coordinate.year, documentHash: hashAuditDiplomacyDocument(change.letter),
month: coordinate.month, previousDocumentId: change.letter.prevId,
tick: coordinate.tick, year: coordinate.year,
clockRevision: coordinate.clockRevision, month: coordinate.month,
executionId: `api:${input.requestId}`, tick: coordinate.tick,
ordinal: index + 1, clockRevision: coordinate.clockRevision,
requestId: input.requestId, executionId: `api:${input.requestId}`,
inputSequence: input.sequence, ordinal: index + 1,
actor: { requestId: input.requestId,
userId: actor.userId, inputSequence: input.sequence,
generalId: actor.id, actor: {
name: actor.name, userId: actor.userId,
nationId: actor.nationId, generalId: actor.id,
officerLevel: actor.officerLevel, name: actor.name,
npcState: actor.npcState, nationId: actor.nationId,
permission, officerLevel: actor.officerLevel,
}, npcState: actor.npcState,
before: change.before, permission,
after: change.after, },
})); before: change.before,
await persistAuditDiplomacyEvents(ctx.db, events); after: change.after,
}));
await persistAuditDiplomacyEvents(auditDb, events);
});
}, },
}; };
}; };
@@ -1,3 +1,4 @@
import { asRecord } from '@sammo-ts/common';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common';
@@ -742,7 +743,7 @@ integration('diplomacy document message persistence', () => {
} }
); );
it('rolls back the document and notices if the audit insert fails', async () => { it('commits the document and notices with a gap when audit insert fails', async () => {
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`); BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
@@ -754,15 +755,19 @@ integration('diplomacy document message persistence', () => {
brief: '감사 실패', brief: '감사 실패',
detail: '롤백', detail: '롤백',
}) })
).rejects.toThrow('injected audit insert failure'); ).resolves.toBeDefined();
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0); expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(1);
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0); expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBeGreaterThan(0);
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0); expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
expect( expect(
await db.inputEvent.findUniqueOrThrow({ await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` }, where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
}) })
).toMatchObject({ status: 'FAILED', attempts: 1 }); ).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(
asRecord((await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldStateId } })).meta)
.playAuditGap
).toMatchObject({ serverId: requestPrefix });
} finally { } finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event'); await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()'); await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
@@ -0,0 +1,34 @@
import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
/** A bounded, sticky notice: later successful writes cannot make history complete again. */
export const markAuditGap = (world: InMemoryTurnWorld, stage: string): void => {
const state = world.getState();
const previous = asRecord(state.meta.playAuditGap);
world.updateWorldMeta({
playAuditGap: {
serverId: state.meta.serverId ?? null,
firstYear:
previous.serverId === state.meta.serverId
? (previous.firstYear ?? state.currentYear)
: state.currentYear,
firstMonth:
previous.serverId === state.meta.serverId
? (previous.firstMonth ?? state.currentMonth)
: state.currentMonth,
lastYear: state.currentYear,
lastMonth: state.currentMonth,
stage,
},
});
console.warn(`[play-audit] ${stage}: history gap recorded; gameplay continues.`);
};
export const collectPlayAudit = <T>(world: InMemoryTurnWorld, stage: string, collect: () => T, fallback: T): T => {
try {
return collect();
} catch {
markAuditGap(world, stage);
return fallback;
}
};
+92 -73
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from './bestEffort.js';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.js'; import type { InMemoryTurnWorld, TurnCalendarHandler } from '../turn/inMemoryWorld.js';
import { buildAuditSnapshot, type AuditSettlement } from './snapshot.js'; import { buildAuditSnapshot, type AuditSettlement } from './snapshot.js';
@@ -32,86 +33,104 @@ const readFlows = (world: InMemoryTurnWorld): MonthlyFlows => {
return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries }; return { year: state.currentYear, month: state.currentMonth, complete: matches && raw.complete === true, entries };
}; };
export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void => { export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: AuditSettlement): void =>
if (typeof world.getState().meta.serverId !== 'string') return; collectPlayAudit(
const flows = readFlows(world); world,
const key = `${settlement.nationId}:${settlement.resource}`; 'recordAuditSettlement',
const previous = flows.entries[key]; () => {
flows.entries[key] = { if (typeof world.getState().meta.serverId !== 'string') return;
...settlement, const flows = readFlows(world);
income: (previous?.income ?? 0) + settlement.income, const key = `${settlement.nationId}:${settlement.resource}`;
paid: (previous?.paid ?? 0) + settlement.paid, const previous = flows.entries[key];
}; flows.entries[key] = {
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다. ...settlement,
world.updateWorldMeta({ playAuditFlows: flows }); income: (previous?.income ?? 0) + settlement.income,
}; paid: (previous?.paid ?? 0) + settlement.paid,
};
// 월내 flush/reload에도 누적값을 잃지 않도록 작은 국가별 합계만 world meta에 보존한다.
world.updateWorldMeta({ playAuditFlows: flows });
},
undefined
);
export const queueAuditMonth = ( export const queueAuditMonth = (
world: InMemoryTurnWorld, world: InMemoryTurnWorld,
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END' kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
): void => { ): void =>
const state = world.getState(); collectPlayAudit(
const serverId = state.meta.serverId; world,
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다. 'queueAuditMonth',
if (typeof serverId !== 'string' || !serverId.trim()) return; () => {
const flows = readFlows(world); const state = world.getState();
const snapshot = buildAuditSnapshot({ const serverId = state.meta.serverId;
nations: world.listNations(), // identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
cities: world.listCities(), if (typeof serverId !== 'string' || !serverId.trim()) return;
generals: world.listGenerals(), const flows = readFlows(world);
settlements: Object.values(flows.entries), const snapshot = buildAuditSnapshot({
settlementsComplete: flows.complete, nations: world.listNations(),
}); cities: world.listCities(),
world.queueAuditMonth({ generals: world.listGenerals(),
...snapshot, settlements: Object.values(flows.entries),
serverId, settlementsComplete: flows.complete,
year: state.currentYear, });
month: state.currentMonth, world.queueAuditMonth({
tick: kind === 'INITIAL' ? world.getGameClockState().tick : (state.lastTurnTick ?? null), ...snapshot,
kind, serverId,
settlementsComplete: flows.complete, year: state.currentYear,
}); month: state.currentMonth,
}; tick: kind === 'INITIAL' ? world.getGameClockState().tick : (state.lastTurnTick ?? null),
kind,
settlementsComplete: flows.complete,
});
},
undefined
);
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */ /** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => { export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
const state = world.getState(); collectPlayAudit(
const serverId = state.meta.serverId; world,
if (typeof serverId !== 'string' || !serverId.trim()) return false; 'initializeAuditCollection',
const previous = asRecord(state.meta.playAuditCollection); () => {
if (previous.serverId === serverId) { const state = world.getState();
if ( const serverId = state.meta.serverId;
previous.schemaVersion !== 1 || if (typeof serverId !== 'string' || !serverId.trim()) return false;
typeof previous.year !== 'number' || const previous = asRecord(state.meta.playAuditCollection);
!Number.isInteger(previous.year) || if (previous.serverId === serverId) {
previous.year < 0 || if (
typeof previous.month !== 'number' || previous.schemaVersion !== 1 ||
!Number.isInteger(previous.month) || typeof previous.year !== 'number' ||
previous.month < 1 || !Number.isInteger(previous.year) ||
previous.month > 12 || previous.year < 0 ||
typeof previous.tick !== 'number' || typeof previous.month !== 'number' ||
!Number.isSafeInteger(previous.tick) || !Number.isInteger(previous.month) ||
previous.tick < 0 || previous.month < 1 ||
typeof previous.observedAt !== 'string' || previous.month > 12 ||
!Number.isFinite(Date.parse(previous.observedAt)) || typeof previous.tick !== 'number' ||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth !Number.isSafeInteger(previous.tick) ||
) previous.tick < 0 ||
throw new Error('Invalid play audit collection boundary'); typeof previous.observedAt !== 'string' ||
return false; !Number.isFinite(Date.parse(previous.observedAt)) ||
} previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth
queueAuditMonth(world, 'INITIAL'); )
world.updateWorldMeta({ throw new Error('Invalid play audit collection boundary');
playAuditCollection: { return false;
schemaVersion: 1, }
serverId, queueAuditMonth(world, 'INITIAL');
year: state.currentYear, world.updateWorldMeta({
month: state.currentMonth, playAuditCollection: {
tick: world.getGameClockState().tick, schemaVersion: 1,
observedAt: observedAt.toISOString(), serverId,
year: state.currentYear,
month: state.currentMonth,
tick: world.getGameClockState().tick,
observedAt: observedAt.toISOString(),
},
});
return true;
}, },
}); false
return true; );
};
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({ export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
beforeMonthChanged: (context) => { beforeMonthChanged: (context) => {
+221 -191
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from './bestEffort.js';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
import type { TurnDiplomacy } from '../turn/types.js'; import type { TurnDiplomacy } from '../turn/types.js';
@@ -7,46 +8,52 @@ export const recordMonthlyAuditDiplomacy = (
world: InMemoryTurnWorld, world: InMemoryTurnWorld,
before: readonly TurnDiplomacy[], before: readonly TurnDiplomacy[],
afterByKey: ReadonlyMap<string, TurnDiplomacy> afterByKey: ReadonlyMap<string, TurnDiplomacy>
): void => { ): void =>
const state = world.getState(); collectPlayAudit(
const serverId = state.meta.serverId; world,
if (typeof serverId !== 'string' || !serverId.trim()) return; 'recordMonthlyAuditDiplomacy',
const clock = world.getGameClockState(); () => {
const project = (entry: TurnDiplomacy) => ({ state: entry.state, term: entry.term, dead: entry.dead }); const state = world.getState();
let ordinal = 0; const serverId = state.meta.serverId;
for (const entry of [...before].sort( if (typeof serverId !== 'string' || !serverId.trim()) return;
(left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId const clock = world.getGameClockState();
)) { const project = (entry: TurnDiplomacy) => ({ state: entry.state, term: entry.term, dead: entry.dead });
const next = afterByKey.get(`${entry.fromNationId}:${entry.toNationId}`); let ordinal = 0;
if (!next) continue; for (const entry of [...before].sort(
const previousState = project(entry); (left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId
const nextState = project(next); )) {
if (JSON.stringify(previousState) === JSON.stringify(nextState)) continue; const next = afterByKey.get(`${entry.fromNationId}:${entry.toNationId}`);
world.queueAuditDiplomacy({ if (!next) continue;
schemaVersion: 1, const previousState = project(entry);
serverId, const nextState = project(next);
srcNationId: entry.fromNationId, if (JSON.stringify(previousState) === JSON.stringify(nextState)) continue;
destNationId: entry.toNationId, world.queueAuditDiplomacy({
category: 'RELATION', schemaVersion: 1,
source: 'ENGINE', serverId,
eventType: 'MONTHLY_RELATION_CHANGED', srcNationId: entry.fromNationId,
documentId: null, destNationId: entry.toNationId,
documentHash: null, category: 'RELATION',
previousDocumentId: null, source: 'ENGINE',
year: state.currentYear, eventType: 'MONTHLY_RELATION_CHANGED',
month: state.currentMonth, documentId: null,
tick: BigInt(clock.tick), documentHash: null,
clockRevision: BigInt(clock.revision), previousDocumentId: null,
executionId: `monthly:${state.currentYear}:${state.currentMonth}:${clock.revision}`, year: state.currentYear,
ordinal: ++ordinal, month: state.currentMonth,
requestId: null, tick: BigInt(clock.tick),
inputSequence: null, clockRevision: BigInt(clock.revision),
actor: null, executionId: `monthly:${state.currentYear}:${state.currentMonth}:${clock.revision}`,
before: previousState, ordinal: ++ordinal,
after: nextState, requestId: null,
}); inputSequence: null,
} actor: null,
}; before: previousState,
after: nextState,
});
}
},
undefined
);
export interface AuditDiplomacyAction { export interface AuditDiplomacyAction {
actionKey: string; actionKey: string;
@@ -68,116 +75,133 @@ export const recordTurnAuditDiplomacy = (
after: TurnDiplomacy, after: TurnDiplomacy,
action: AuditDiplomacyAction, action: AuditDiplomacyAction,
turn: { generalId: number; tick: number; ordinal: number } turn: { generalId: number; tick: number; ordinal: number }
): void => { ): void =>
const state = world.getState(); collectPlayAudit(
const serverId = state.meta.serverId; world,
if (typeof serverId !== 'string' || !serverId.trim()) return; 'recordTurnAuditDiplomacy',
const previousState = { state: before.state, term: before.term, dead: before.dead }; () => {
const nextState = { state: after.state, term: after.term, dead: after.dead }; const state = world.getState();
if (JSON.stringify(previousState) === JSON.stringify(nextState)) return; const serverId = state.meta.serverId;
const clock = world.getGameClockState(); if (typeof serverId !== 'string' || !serverId.trim()) return;
world.queueAuditDiplomacy({ const previousState = { state: before.state, term: before.term, dead: before.dead };
schemaVersion: 1, const nextState = { state: after.state, term: after.term, dead: after.dead };
serverId, if (JSON.stringify(previousState) === JSON.stringify(nextState)) return;
srcNationId: before.fromNationId, const clock = world.getGameClockState();
destNationId: before.toNationId, world.queueAuditDiplomacy({
category: 'RELATION', schemaVersion: 1,
source: 'ENGINE', serverId,
eventType: 'TURN_RELATION_CHANGED', srcNationId: before.fromNationId,
documentId: null, destNationId: before.toNationId,
documentHash: null, category: 'RELATION',
previousDocumentId: null, source: 'ENGINE',
year: state.currentYear, eventType: 'TURN_RELATION_CHANGED',
month: state.currentMonth, documentId: null,
tick: BigInt(turn.tick), documentHash: null,
clockRevision: BigInt(clock.revision), previousDocumentId: null,
executionId: `turn:${turn.generalId}:${turn.tick}:${clock.revision}`, year: state.currentYear,
ordinal: turn.ordinal, month: state.currentMonth,
requestId: null, tick: BigInt(turn.tick),
inputSequence: null, clockRevision: BigInt(clock.revision),
actor: { ...action.actor, actionKey: action.actionKey, kind: action.kind, actionOrdinal: action.actionOrdinal }, executionId: `turn:${turn.generalId}:${turn.tick}:${clock.revision}`,
before: previousState, ordinal: turn.ordinal,
after: nextState, requestId: null,
}); inputSequence: null,
}; actor: {
...action.actor,
actionKey: action.actionKey,
kind: action.kind,
actionOrdinal: action.actionOrdinal,
},
before: previousState,
after: nextState,
});
},
undefined
);
/** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */ /** 현재 로드된 관계를 도입 시 한 번만 고정한다. 과거 발생 원인/주체는 추정하지 않는다. */
export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => { export const initializeAuditDiplomacy = (world: InMemoryTurnWorld, observedAt = new Date()): boolean =>
const state = world.getState(); collectPlayAudit(
const serverId = state.meta.serverId; world,
if (typeof serverId !== 'string' || !serverId.trim()) return false; 'initializeAuditDiplomacy',
const previous = asRecord(state.meta.playAuditDiplomacy); () => {
if (previous.serverId === serverId) { const state = world.getState();
if ( const serverId = state.meta.serverId;
previous.schemaVersion !== 1 || if (typeof serverId !== 'string' || !serverId.trim()) return false;
typeof previous.year !== 'number' || const previous = asRecord(state.meta.playAuditDiplomacy);
previous.year < 0 || if (previous.serverId === serverId) {
!Number.isInteger(previous.year) || if (
!Number.isInteger(previous.month) || previous.schemaVersion !== 1 ||
typeof previous.month !== 'number' || typeof previous.year !== 'number' ||
previous.month < 1 || previous.year < 0 ||
previous.month > 12 || !Number.isInteger(previous.year) ||
typeof previous.tick !== 'number' || !Number.isInteger(previous.month) ||
!Number.isSafeInteger(previous.tick) || typeof previous.month !== 'number' ||
previous.tick < 0 || previous.month < 1 ||
typeof previous.clockRevision !== 'number' || previous.month > 12 ||
!Number.isSafeInteger(previous.clockRevision) || typeof previous.tick !== 'number' ||
previous.clockRevision < 0 || !Number.isSafeInteger(previous.tick) ||
typeof previous.relationCount !== 'number' || previous.tick < 0 ||
!Number.isInteger(previous.relationCount) || typeof previous.clockRevision !== 'number' ||
previous.relationCount < 0 || !Number.isSafeInteger(previous.clockRevision) ||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth || previous.clockRevision < 0 ||
typeof previous.observedAt !== 'string' || typeof previous.relationCount !== 'number' ||
!Number.isFinite(Date.parse(previous.observedAt)) !Number.isInteger(previous.relationCount) ||
) previous.relationCount < 0 ||
throw new Error('Invalid play audit diplomacy boundary'); previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth ||
return false; typeof previous.observedAt !== 'string' ||
} !Number.isFinite(Date.parse(previous.observedAt))
const clock = world.getGameClockState(); )
const observedAtIso = observedAt.toISOString(); throw new Error('Invalid play audit diplomacy boundary');
const relations = world return false;
.listDiplomacy() }
.filter((entry) => entry.fromNationId > 0 && entry.toNationId > 0) const clock = world.getGameClockState();
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId); const observedAtIso = observedAt.toISOString();
for (const [index, entry] of relations.entries()) { const relations = world
world.queueAuditDiplomacy({ .listDiplomacy()
schemaVersion: 1, .filter((entry) => entry.fromNationId > 0 && entry.toNationId > 0)
serverId, .sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
srcNationId: entry.fromNationId, for (const [index, entry] of relations.entries()) {
destNationId: entry.toNationId, world.queueAuditDiplomacy({
category: 'RELATION', schemaVersion: 1,
source: 'BASELINE', serverId,
eventType: 'RELATION_BASELINE', srcNationId: entry.fromNationId,
documentId: null, destNationId: entry.toNationId,
documentHash: null, category: 'RELATION',
previousDocumentId: null, source: 'BASELINE',
year: state.currentYear, eventType: 'RELATION_BASELINE',
month: state.currentMonth, documentId: null,
tick: BigInt(clock.tick), documentHash: null,
clockRevision: BigInt(clock.revision), previousDocumentId: null,
executionId: 'relation-baseline', year: state.currentYear,
ordinal: index + 1, month: state.currentMonth,
requestId: null, tick: BigInt(clock.tick),
inputSequence: null, clockRevision: BigInt(clock.revision),
actor: null, executionId: 'relation-baseline',
before: null, ordinal: index + 1,
after: { state: entry.state, term: entry.term, dead: entry.dead }, requestId: null,
}); inputSequence: null,
} actor: null,
world.updateWorldMeta({ before: null,
playAuditDiplomacy: { after: { state: entry.state, term: entry.term, dead: entry.dead },
schemaVersion: 1, });
serverId, }
year: state.currentYear, world.updateWorldMeta({
month: state.currentMonth, playAuditDiplomacy: {
tick: clock.tick, schemaVersion: 1,
clockRevision: clock.revision, serverId,
observedAt: observedAtIso, year: state.currentYear,
relationCount: relations.length, month: state.currentMonth,
tick: clock.tick,
clockRevision: clock.revision,
observedAt: observedAtIso,
relationCount: relations.length,
},
});
return true;
}, },
}); false
return true; );
};
/** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */ /** 국가 생멸로 생성/제거된 관계를 기록한다. 행위자나 명령은 관측하지 못했다면 추정하지 않는다. */
export const recordNationAuditDiplomacy = ( export const recordNationAuditDiplomacy = (
@@ -186,47 +210,53 @@ export const recordNationAuditDiplomacy = (
relations: readonly TurnDiplomacy[], relations: readonly TurnDiplomacy[],
operation: 'CREATED' | 'REMOVED', operation: 'CREATED' | 'REMOVED',
observedTick?: number observedTick?: number
): void => { ): void =>
const state = world.getState(); collectPlayAudit(
const serverId = state.meta.serverId; world,
if (typeof serverId !== 'string' || !serverId.trim()) return; 'recordNationAuditDiplomacy',
const targets = new Set(nationIds.filter((id) => id > 0)); () => {
const observed = relations const state = world.getState();
.filter( const serverId = state.meta.serverId;
(entry) => if (typeof serverId !== 'string' || !serverId.trim()) return;
entry.fromNationId > 0 && const targets = new Set(nationIds.filter((id) => id > 0));
entry.toNationId > 0 && const observed = relations
(targets.has(entry.fromNationId) || targets.has(entry.toNationId)) .filter(
) (entry) =>
.sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId); entry.fromNationId > 0 &&
if (!observed.length) return; entry.toNationId > 0 &&
// 전역 순번은 실행 identity에만 사용한다. 한 사건 묶음 안의 순서는 방향별 local ordinal이다. (targets.has(entry.fromNationId) || targets.has(entry.toNationId))
const executionId = `nation-relations:${world.nextAuditOrdinal()}`; )
const clock = world.getGameClockState(); .sort((left, right) => left.fromNationId - right.fromNationId || left.toNationId - right.toNationId);
for (const [index, entry] of observed.entries()) { if (!observed.length) return;
const value = { state: entry.state, term: entry.term, dead: entry.dead }; // 전역 순번은 실행 identity에만 사용한다. 한 사건 묶음 안의 순서는 방향별 local ordinal이다.
world.queueAuditDiplomacy({ const executionId = `nation-relations:${world.nextAuditOrdinal()}`;
schemaVersion: 1, const clock = world.getGameClockState();
serverId, for (const [index, entry] of observed.entries()) {
srcNationId: entry.fromNationId, const value = { state: entry.state, term: entry.term, dead: entry.dead };
destNationId: entry.toNationId, world.queueAuditDiplomacy({
category: 'RELATION', schemaVersion: 1,
source: 'ENGINE', serverId,
eventType: `NATION_RELATION_${operation}`, srcNationId: entry.fromNationId,
documentId: null, destNationId: entry.toNationId,
documentHash: null, category: 'RELATION',
previousDocumentId: null, source: 'ENGINE',
year: state.currentYear, eventType: `NATION_RELATION_${operation}`,
month: state.currentMonth, documentId: null,
tick: BigInt(observedTick ?? clock.tick), documentHash: null,
clockRevision: BigInt(clock.revision), previousDocumentId: null,
executionId, year: state.currentYear,
ordinal: index + 1, month: state.currentMonth,
requestId: null, tick: BigInt(observedTick ?? clock.tick),
inputSequence: null, clockRevision: BigInt(clock.revision),
actor: null, executionId,
before: operation === 'REMOVED' ? value : null, ordinal: index + 1,
after: operation === 'CREATED' ? value : null, requestId: null,
}); inputSequence: null,
} actor: null,
}; before: operation === 'REMOVED' ? value : null,
after: operation === 'CREATED' ? value : null,
});
}
},
undefined
);
+91 -78
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from './bestEffort.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import type { Nation } from '@sammo-ts/logic'; import type { Nation } from '@sammo-ts/logic';
@@ -70,85 +71,97 @@ export const recordAuditPolicyChange = (options: {
actor?: TurnGeneral; actor?: TurnGeneral;
permission?: number; permission?: number;
requestId?: string; requestId?: string;
}): { _playAuditPolicy?: Record<string, PolicyHead> } => { }): { _playAuditPolicy?: Record<string, PolicyHead> } =>
const { world, nation, area } = options; collectPlayAudit(
const state = world.getState(); options.world,
const serverId = state.meta.serverId; 'recordAuditPolicyChange',
if (typeof serverId !== 'string' || !serverId.trim()) return {}; () => {
const rawHeads = asRecord(nation.meta._playAuditPolicy); const { world, nation, area } = options;
const heads: Record<string, PolicyHead> = {}; const state = world.getState();
for (const key of AUDIT_POLICY_AREAS) { const serverId = state.meta.serverId;
const value = asRecord(rawHeads[key]); if (typeof serverId !== 'string' || !serverId.trim()) return {};
if ( const rawHeads = asRecord(nation.meta._playAuditPolicy);
value.serverId === serverId && const heads: Record<string, PolicyHead> = {};
typeof value.id === 'string' && for (const key of AUDIT_POLICY_AREAS) {
typeof value.revision === 'number' && const value = asRecord(rawHeads[key]);
Number.isSafeInteger(value.revision) && if (
value.revision > 0 && value.serverId === serverId &&
typeof value.hash === 'string' && typeof value.id === 'string' &&
value.id === auditPolicyHash([serverId, nation.id, key, value.revision]) typeof value.revision === 'number' &&
) { Number.isSafeInteger(value.revision) &&
heads[key] = { id: value.id, revision: value.revision, hash: value.hash, serverId }; value.revision > 0 &&
} typeof value.hash === 'string' &&
} value.id === auditPolicyHash([serverId, nation.id, key, value.revision])
let head: PolicyHead | null = heads[area] ?? null; ) {
const before = projectAuditPolicy(nation.meta, area); heads[key] = { id: value.id, revision: value.revision, hash: value.hash, serverId };
const after = projectAuditPolicy(options.nextMeta, area); }
const append = (source: PendingAuditPolicy['source'], old: PolicyData | null, value: PolicyData) => { }
const revision = (head?.revision ?? 0) + 1; let head: PolicyHead | null = heads[area] ?? null;
const id = auditPolicyHash([serverId, nation.id, area, revision]); const before = projectAuditPolicy(nation.meta, area);
const actor = options.actor; const after = projectAuditPolicy(options.nextMeta, area);
world.queueAuditPolicy({ const append = (source: PendingAuditPolicy['source'], old: PolicyData | null, value: PolicyData) => {
schemaVersion: 1, const revision = (head?.revision ?? 0) + 1;
id, const id = auditPolicyHash([serverId, nation.id, area, revision]);
serverId, const actor = options.actor;
nationId: nation.id, world.queueAuditPolicy({
area, schemaVersion: 1,
revision, id,
previousId: head?.id ?? null, serverId,
source, nationId: nation.id,
year: state.currentYear, area,
month: state.currentMonth, revision,
tick: world.getGameClockState().tick, previousId: head?.id ?? null,
requestId: source === 'CHANGE' ? (options.requestId ?? null) : null, source,
ordinal: world.nextAuditOrdinal(), year: state.currentYear,
actor: month: state.currentMonth,
source === 'CHANGE' && actor tick: world.getGameClockState().tick,
? { requestId: source === 'CHANGE' ? (options.requestId ?? null) : null,
userId: actor.userId ?? null, ordinal: world.nextAuditOrdinal(),
generalId: actor.id, actor:
name: actor.name, source === 'CHANGE' && actor
nationId: actor.nationId, ? {
officerLevel: actor.officerLevel, userId: actor.userId ?? null,
npcState: actor.npcState, generalId: actor.id,
permission: options.permission ?? 0, name: actor.name,
} nationId: actor.nationId,
: null, officerLevel: actor.officerLevel,
before: old, npcState: actor.npcState,
after: value, permission: options.permission ?? 0,
}); }
head = { id, revision, hash: auditPolicyHash(value), serverId }; : null,
}; before: old,
if (!head) append('BASELINE', null, before); after: value,
else if (head.hash !== auditPolicyHash(before)) append('OBSERVED_GAP', null, before); });
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after); head = { id, revision, hash: auditPolicyHash(value), serverId };
if (!head) throw new Error('Play audit policy baseline missing'); };
return { _playAuditPolicy: { ...heads, [area]: head } }; if (!head) append('BASELINE', null, before);
}; else if (head.hash !== auditPolicyHash(before)) append('OBSERVED_GAP', null, before);
if (auditPolicyHash(before) !== auditPolicyHash(after)) append('CHANGE', before, after);
if (!head) throw new Error('Play audit policy baseline missing');
return { _playAuditPolicy: { ...heads, [area]: head } };
},
{}
);
export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void => { export const initializeNationAuditPolicies = (world: InMemoryTurnWorld, nationId: number): void =>
let nation = world.getNationById(nationId); collectPlayAudit(
if (!nation) return; world,
for (const area of AUDIT_POLICY_AREAS) { 'initializeNationAuditPolicies',
const patch = recordAuditPolicyChange({ world, nation, area, nextMeta: nation.meta }); () => {
if ( let nation = world.getNationById(nationId);
Object.keys(patch).length && if (!nation) return;
auditPolicyHash(patch._playAuditPolicy) !== auditPolicyHash(nation.meta._playAuditPolicy ?? {}) for (const area of AUDIT_POLICY_AREAS) {
) { const patch = recordAuditPolicyChange({ world, nation, area, nextMeta: nation.meta });
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!; if (
} Object.keys(patch).length &&
} auditPolicyHash(patch._playAuditPolicy) !== auditPolicyHash(nation.meta._playAuditPolicy ?? {})
}; ) {
nation = world.updateNation(nation.id, { meta: { ...nation.meta, ...patch } })!;
}
}
},
undefined
);
export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => { export const initializeAuditPolicies = (world: InMemoryTurnWorld): void => {
for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id); for (const nation of world.listNations()) initializeNationAuditPolicies(world, nation.id);
+55 -11
View File
@@ -1,5 +1,6 @@
import { persistAuditDecisions } from '../playAudit/decisionPersistence.js'; import { persistAuditDecisions } from '../playAudit/decisionPersistence.js';
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra'; import { collectPlayAudit, markAuditGap } from '../playAudit/bestEffort.js';
import { persistAuditDiplomacyEvents, withPlayAuditSavepoint } from '@sammo-ts/infra';
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js'; import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
import { persistAuditPolicies, restoreMissingAuditPolicyHeads } from '../playAudit/policyPersistence.js'; import { persistAuditPolicies, restoreMissingAuditPolicyHeads } from '../playAudit/policyPersistence.js';
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js'; import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
@@ -1236,6 +1237,7 @@ export const createDatabaseTurnHooks = async (
} }
const persistedClock = await prisma.$queryRaw< const persistedClock = await prisma.$queryRaw<
Array<{ Array<{
audit_gap: unknown;
clock_phase: string; clock_phase: string;
clock_revision: bigint; clock_revision: bigint;
deadline_generation: bigint; deadline_generation: bigint;
@@ -1243,7 +1245,7 @@ export const createDatabaseTurnHooks = async (
opening_reached: boolean; opening_reached: boolean;
}> }>
>(GamePrisma.sql` >(GamePrisma.sql`
SELECT clock_phase, SELECT meta->'playAuditGap' AS audit_gap, clock_phase,
clock_revision, clock_revision,
deadline_generation, deadline_generation,
clock_base_time IS NOT NULL clock_base_time IS NOT NULL
@@ -1259,6 +1261,21 @@ export const createDatabaseTurnHooks = async (
if (!durableClock) { if (!durableClock) {
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`); throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
} }
const durableGap = asRecord(durableClock.audit_gap);
if (durableGap.serverId && durableGap.serverId === state.meta.serverId) {
const localGap = asRecord(world.getState().meta.playAuditGap);
const gap =
localGap.serverId === durableGap.serverId
? {
...durableGap,
...localGap,
firstYear: durableGap.firstYear,
firstMonth: durableGap.firstMonth,
}
: durableGap;
world.updateWorldMeta({ playAuditGap: gap });
worldStateUpdate.meta = asJson(world.getState().meta);
}
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'); const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
const expectedRevision = BigInt(state.clockRevision ?? 1); const expectedRevision = BigInt(state.clockRevision ?? 1);
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1); const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
@@ -1895,11 +1912,25 @@ export const createDatabaseTurnHooks = async (
data: pendingLogRows, data: pendingLogRows,
}); });
} }
await persistAuditPolicies(prisma, pendingAuditPolicies, auditCommand); if (
await persistAuditDiplomacyEvents(prisma, pendingAuditDiplomacy); pendingAuditPolicies.length ||
await persistAuditDecisions(prisma, pendingAuditDecisions); pendingAuditDiplomacy.length ||
for (const snapshot of pendingAuditMonths) { pendingAuditDecisions.length ||
await persistAuditMonth(prisma, snapshot); pendingAuditMonths.length
) {
const audit = await withPlayAuditSavepoint(prisma, async (auditDb) => {
await persistAuditPolicies(auditDb, pendingAuditPolicies, auditCommand);
await persistAuditDiplomacyEvents(auditDb, pendingAuditDiplomacy);
await persistAuditDecisions(auditDb, pendingAuditDecisions);
for (const snapshot of pendingAuditMonths) await persistAuditMonth(auditDb, snapshot);
});
if (!audit.ok) {
markAuditGap(world, 'persistence');
await prisma.worldState.update({
where: { id: state.id },
data: { meta: world.getState().meta as GamePrisma.InputJsonValue },
});
}
} }
for (const snapshot of pendingYearbookSnapshots) { for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot); await persistYearbookSnapshot(prisma, snapshot);
@@ -2094,8 +2125,8 @@ export const createDatabaseTurnHooks = async (
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite); enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
}; };
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => { const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
if (hasAuditDocumentBaseline(world)) { if (collectPlayAudit(world, 'document-boundary', () => hasAuditDocumentBaseline(world), false)) {
if (force || world.hasPendingAuditRecords()) await flushChanges(); if (force || world.hasPendingAuditRecords() || world.getState().meta.playAuditGap) await flushChanges();
return; return;
} }
const checkpoint = world.captureState(); const checkpoint = world.captureState();
@@ -2108,7 +2139,10 @@ export const createDatabaseTurnHooks = async (
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK); await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK); await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world); await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
await persistAuditDocumentBaseline(transaction, world, observedAt); const audit = await withPlayAuditSavepoint(transaction, (auditDb) =>
persistAuditDocumentBaseline(auditDb, world, observedAt)
);
if (!audit.ok) markAuditGap(world, 'document-baseline');
return persistChanges(transaction); return persistChanges(transaction);
}, transactionOptions); }, transactionOptions);
} catch (error) { } catch (error) {
@@ -2165,7 +2199,17 @@ export const createDatabaseTurnHooks = async (
hooks, hooks,
flushChanges, flushChanges,
flushInitialAudit, flushInitialAudit,
restoreMissingAuditPolicyHeads: () => restoreMissingAuditPolicyHeads(prisma, world), restoreMissingAuditPolicyHeads: async () => {
const result = await prisma.$transaction(
(tx) => withPlayAuditSavepoint(tx, (auditDb) => restoreMissingAuditPolicyHeads(auditDb, world)),
transactionOptions
);
if (!result.ok) {
markAuditGap(world, 'policy-head-recovery');
return false;
}
return result.value;
},
takeCommittedReadModelChanges: () => { takeCommittedReadModelChanges: () => {
return takeCommittedReceipt()?.changes ?? null; return takeCommittedReceipt()?.changes ?? null;
}, },
+33 -4
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from '../playAudit/bestEffort.js';
import type { PendingAuditDecision } from '../playAudit/decision.js'; import type { PendingAuditDecision } from '../playAudit/decision.js';
import { import {
recordTurnAuditDiplomacy, recordTurnAuditDiplomacy,
@@ -1393,15 +1394,36 @@ export class InMemoryTurnWorld {
} }
queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void { queueAuditDiplomacy(event: AuditDiplomacyEventDraft): void {
this.pendingAuditDiplomacy.push(structuredClone(event)); collectPlayAudit(
this,
'queueDiplomacy',
() => {
this.pendingAuditDiplomacy.push(structuredClone(event));
},
undefined
);
} }
queueAuditDecision(decision: PendingAuditDecision): void { queueAuditDecision(decision: PendingAuditDecision): void {
this.pendingAuditDecisions.push(structuredClone(decision)); collectPlayAudit(
this,
'queueDecision',
() => {
this.pendingAuditDecisions.push(structuredClone(decision));
},
undefined
);
} }
queueAuditPolicy(policy: PendingAuditPolicy): void { queueAuditPolicy(policy: PendingAuditPolicy): void {
this.pendingAuditPolicies.push(structuredClone(policy)); collectPlayAudit(
this,
'queuePolicy',
() => {
this.pendingAuditPolicies.push(structuredClone(policy));
},
undefined
);
} }
hasPendingAuditRecords(): boolean { hasPendingAuditRecords(): boolean {
@@ -1414,7 +1436,14 @@ export class InMemoryTurnWorld {
} }
queueAuditMonth(snapshot: PendingAuditMonth): void { queueAuditMonth(snapshot: PendingAuditMonth): void {
this.pendingAuditMonths.push(structuredClone(snapshot)); collectPlayAudit(
this,
'queueMonth',
() => {
this.pendingAuditMonths.push(structuredClone(snapshot));
},
undefined
);
} }
queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void { queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void {
+117 -93
View File
@@ -1,3 +1,4 @@
import { collectPlayAudit } from '../playAudit/bestEffort.js';
import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js'; import { auditDecisionIdentity, normalizeAuditCodeVersion, type PendingAuditDecision } from '../playAudit/decision.js';
import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js'; import { auditPolicyHash, AUDIT_POLICY_AREAS } from '../playAudit/policy.js';
import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js'; import type { AiDecisionTraceEvent, AiExecutionAttempt, AiExecutionCheck } from './ai/generalAi/trace.js';
@@ -1085,46 +1086,61 @@ export const createReservedTurnHandler = async (options: {
const onDecisionTrace: AiDecisionTraceObserver | undefined = const onDecisionTrace: AiDecisionTraceObserver | undefined =
collectDecisions || options.onDecisionTrace collectDecisions || options.onDecisionTrace
? (event) => { ? (event) => {
if (collectDecisions) { if (collectDecisions && worldRef)
if (event.kind === 'DECISION_START') { collectPlayAudit(
decisionSteps.set(event.phase, []); worldRef,
const heads = asRecord(currentNation?.meta._playAuditPolicy); 'decision-trace',
decisionPolicyRefs.set( () => {
event.phase, if (event.kind === 'DECISION_START') {
Object.fromEntries( decisionSteps.set(event.phase, []);
AUDIT_POLICY_AREAS.flatMap((area) => { const heads = asRecord(currentNation?.meta._playAuditPolicy);
const head = asRecord(heads[area]); decisionPolicyRefs.set(
return head.serverId === auditServerId && typeof head.id === 'string' event.phase,
? [[area, head.id]] 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), sequence: storedDecisionSequence++ }); )
} );
}
decisionSteps
.get(event.phase)
?.push({ ...structuredClone(event), sequence: storedDecisionSequence++ });
},
undefined
);
options.onDecisionTrace?.(event); options.onDecisionTrace?.(event);
} }
: undefined; : undefined;
const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => { const recordExecution = (phase: 'general' | 'nation', attempt: AiExecutionAttempt): void => {
const steps = decisionSteps.get(phase); if (!worldRef) return;
const first = steps?.[0]; collectPlayAudit(
if (!collectDecisions || !first || !steps) return; worldRef,
const { generalId, nationId, cityId, npcState, year, month, tick } = first; 'decision-summary',
steps.push({ () => {
...attempt, const steps = decisionSteps.get(phase);
sequence: storedDecisionSequence++, const first = steps?.[0];
phase, if (!collectDecisions || !first || !steps) return;
generalId, const { generalId, nationId, cityId, npcState, year, month, tick } = first;
nationId, steps.push({
cityId, ...attempt,
npcState, sequence: storedDecisionSequence++,
year, phase,
month, generalId,
tick, nationId,
}); cityId,
npcState,
year,
month,
tick,
});
},
undefined
);
}; };
const finishDecision = ( const finishDecision = (
phase: 'general' | 'nation', phase: 'general' | 'nation',
@@ -1135,64 +1151,72 @@ export const createReservedTurnHandler = async (options: {
blockedReason?: string; blockedReason?: string;
} }
): void => { ): void => {
const steps = decisionSteps.get(phase); if (!worldRef) return;
const first = steps?.[0]; collectPlayAudit(
const last = steps?.find((step) => step.kind === 'DECISION_END'); worldRef,
if ( 'decision-summary',
!collectDecisions || () => {
!auditServerId || const steps = decisionSteps.get(phase);
!worldRef || const first = steps?.[0];
!steps || const last = steps?.find((step) => step.kind === 'DECISION_END');
first?.kind !== 'DECISION_START' || if (
last?.kind !== 'DECISION_END' !collectDecisions ||
) !auditServerId ||
return; !worldRef ||
const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime); !steps ||
const revision = worldRef.getGameClockState().revision; first?.kind !== 'DECISION_START' ||
const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision); last?.kind !== 'DECISION_END'
const execution = steps.at(-1); )
auditDecisions.push({ return;
id: auditPolicyHash([executionId, phase]), const tick = context.general.turnTick ?? worldRef.dateToGameTick(context.general.turnTime);
serverId: auditServerId, const revision = worldRef.getGameClockState().revision;
executionId, const executionId = auditDecisionIdentity(auditServerId, context.general.id, tick, revision);
phase, const execution = steps.at(-1);
generalId: first.generalId, auditDecisions.push({
nationId: first.nationId, id: auditPolicyHash([executionId, phase]),
cityId: first.cityId, serverId: auditServerId,
npcState: first.npcState, executionId,
year: first.year, phase,
month: first.month, generalId: first.generalId,
tick, nationId: first.nationId,
summary: { cityId: first.cityId,
schemaVersion: 1, npcState: first.npcState,
coverage: 'PROCEDURES', year: first.year,
executionCoverage: 'ATTEMPTS', month: first.month,
executionStatus: tick,
execution?.kind === 'EXECUTION_ATTEMPT' && execution.preparation summary: {
? 'PREPARING' schemaVersion: 1,
: execution?.kind === 'EXECUTION_ATTEMPT' && coverage: 'PROCEDURES',
execution.checks.some((check) => check.stage === 'BLOCK') executionCoverage: 'ATTEMPTS',
? 'BLOCKED' executionStatus:
: 'RESOLVED', execution?.kind === 'EXECUTION_ATTEMPT' && execution.preparation
clockRevision: revision, ? 'PREPARING'
codeVersion: auditCodeVersion ?? null, : execution?.kind === 'EXECUTION_ATTEMPT' &&
policyRefs: decisionPolicyRefs.get(phase) ?? {}, execution.checks.some((check) => check.stage === 'BLOCK')
requestedAction: first.reservedAction, ? 'BLOCKED'
selectedAction: last.action, : 'RESOLVED',
selectedReason: last.reason, clockRevision: revision,
executedAction: outcome.actionKey, codeVersion: auditCodeVersion ?? null,
completed: outcome.completed ?? null, policyRefs: decisionPolicyRefs.get(phase) ?? {},
usedFallback: requestedAction: first.reservedAction,
outcome.usedFallback || selectedAction: last.action,
steps.some( selectedReason: last.reason,
(step) => executedAction: outcome.actionKey,
step.kind === 'EXECUTION_ATTEMPT' && completed: outcome.completed ?? null,
(step.usedFallback || step.alternativeAction !== null) usedFallback:
), outcome.usedFallback ||
blockedReason: outcome.blockedReason ?? null, steps.some(
(step) =>
step.kind === 'EXECUTION_ATTEMPT' &&
(step.usedFallback || step.alternativeAction !== null)
),
blockedReason: outcome.blockedReason ?? null,
},
steps,
});
}, },
steps, undefined
}); );
}; };
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다. // Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
@@ -157,6 +157,23 @@ integration('monthly diplomacy persistence', () => {
}); });
const hooks = await createDatabaseTurnHooks(databaseUrl!, world); const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try { try {
// An API writer may have recorded a gap since this daemon loaded its world.
await db.worldState.update({
where: { id: worldRow.id },
data: {
meta: {
...world.getState().meta,
playAuditGap: {
serverId: scenarioCode,
firstYear: 192,
firstMonth: 12,
lastYear: 192,
lastMonth: 12,
stage: 'persistence',
},
},
},
});
const checkpoint = world.captureState(); const checkpoint = world.captureState();
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
const queued = world.peekDirtyState().pendingAuditDiplomacy; const queued = world.peekDirtyState().pendingAuditDiplomacy;
@@ -170,11 +187,11 @@ integration('monthly diplomacy persistence', () => {
await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event await db.$executeRawUnsafe(`CREATE TRIGGER reject_monthly_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`); FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`);
try { try {
await expect(hooks.flushChanges()).rejects.toThrow('monthly audit fixture failure'); await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
expect(await db.playAuditDiplomacyEvent.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 } })).toMatchObject({ expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
currentMonth: 1, currentMonth: 2,
}); });
expect( expect(
await db.diplomacy.findUniqueOrThrow({ await db.diplomacy.findUniqueOrThrow({
@@ -185,7 +202,7 @@ integration('monthly diplomacy persistence', () => {
}, },
}, },
}) })
).toMatchObject({ stateCode: 1, term: 1 }); ).toMatchObject({ stateCode: 0, term: 6 });
} finally { } finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event'); await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()'); await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
@@ -193,18 +210,25 @@ integration('monthly diplomacy persistence', () => {
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode); const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
world.queueAuditDecision(decision); 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(
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()`); `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 { try {
await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback'); await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]); expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]);
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0); expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0);
expect(await db.playAuditDiplomacyEvent.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); expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(2);
} finally { } finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk'); await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()'); await db.$executeRawUnsafe('DROP FUNCTION reject_decision_flush_fixture()');
} }
// New observations after recovery can be stored; discarded batches do not retry themselves.
for (const event of queued) world.queueAuditDiplomacy(event);
world.queueAuditDecision(decision);
await hooks.hooks.flushChanges?.({ await hooks.hooks.flushChanges?.({
lastTurnTime: '0193-02-01T00:00:00.000Z', lastTurnTime: '0193-02-01T00:00:00.000Z',
processedGenerals: 0, processedGenerals: 0,
@@ -313,9 +337,9 @@ integration('monthly diplomacy persistence', () => {
await db.$executeRawUnsafe(`CREATE TRIGGER reject_lifecycle_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event await db.$executeRawUnsafe(`CREATE TRIGGER reject_lifecycle_audit_fixture BEFORE INSERT ON play_audit_diplomacy_event
FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`); FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`);
try { try {
await expect(hooks.flushChanges()).rejects.toThrow('lifecycle audit fixture failure'); await expect(hooks.flushChanges()).resolves.toBeUndefined();
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1); expect(await db.nation.count({ where: { id: endingNation } })).toBe(0);
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(removals); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
} finally { } finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event'); await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()'); await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()');
@@ -331,7 +355,7 @@ integration('monthly diplomacy persistence', () => {
await db.playAuditDiplomacyEvent.count({ await db.playAuditDiplomacyEvent.count({
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' }, where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' },
}) })
).toBe(6); ).toBe(0);
expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true); expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true);
await hooks.flushChanges(); await hooks.flushChanges();
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1); expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
@@ -349,6 +373,18 @@ integration('monthly diplomacy persistence', () => {
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_CREATED' }, where: { serverId: scenarioCode, eventType: 'NATION_RELATION_CREATED' },
}) })
).toBe(6); ).toBe(6);
const savedDecision = await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } });
const nation = world.getNationById(nationIds[0]!)!;
world.updateNation(nation.id, { gold: nation.gold + 1 });
world.queueAuditDecision({
...decision,
summary: { ...decision.summary, selectedReason: 'changed after update' },
});
await hooks.flushChanges();
expect(await db.playAuditDecision.findUniqueOrThrow({ where: { id: decision.id } })).toEqual(savedDecision);
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
expect(world.hasPendingAuditRecords()).toBe(false);
expect(world.getState().meta.playAuditGap).toMatchObject({ firstYear: 192, firstMonth: 12 });
} finally { } finally {
await hooks.close(); await hooks.close();
} }
@@ -1,6 +1,6 @@
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js'; import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.js';
import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js'; import { initializeAuditDiplomacy } from '../src/playAudit/diplomacy.js';
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { City, Nation } from '@sammo-ts/logic'; import type { City, Nation } from '@sammo-ts/logic';
import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
@@ -130,6 +130,29 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
return world; return world;
}; };
describe('play audit collection durability state', () => { describe('play audit collection durability state', () => {
it('discards collector failures without changing gameplay state and can collect again', () => {
const world = buildWorld();
const nations = structuredClone(world.listNations());
const broken = vi.spyOn(world, 'listGenerals').mockImplementationOnce(() => {
throw new Error('audit projection');
});
expect(() => queueAuditMonth(world)).not.toThrow();
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(0);
expect(world.listNations()).toEqual(nations);
expect(world.getState().meta.playAuditGap).toMatchObject({
stage: 'queueAuditMonth',
firstYear: 200,
firstMonth: 1,
});
broken.mockRestore();
queueAuditMonth(world);
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(1);
expect(world.getState().meta.playAuditGap).toBeDefined();
world.updateWorldMeta({ playAuditCollection: { serverId: 'yearbook-projection-test', schemaVersion: 999 } });
expect(initializeAuditCollection(world)).toBe(false);
expect(world.getState().meta.playAuditGap).toMatchObject({ stage: 'initializeAuditCollection' });
});
it('restores decision buffers and acknowledges only the committed prefix', () => { it('restores decision buffers and acknowledges only the committed prefix', () => {
const world = buildWorld(); const world = buildWorld();
const first = buildAuditDecisionFixture('first'); const first = buildAuditDecisionFixture('first');
@@ -207,7 +230,8 @@ describe('play audit collection durability state', () => {
world.removeNation(1); world.removeNation(1);
world.removeNation(2); world.removeNation(2);
world.updateWorldMeta({ serverId: 'yearbook-projection-test' }); world.updateWorldMeta({ serverId: 'yearbook-projection-test' });
expect(() => initializeAuditDiplomacy(world, new Date('invalid'))).toThrow(RangeError); expect(initializeAuditDiplomacy(world, new Date('invalid'))).toBe(false);
expect(world.getState().meta.playAuditGap).toMatchObject({ stage: 'initializeAuditDiplomacy' });
expect(world.getState().meta.playAuditDiplomacy).toBeUndefined(); expect(world.getState().meta.playAuditDiplomacy).toBeUndefined();
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]); expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
expect(initializeAuditDiplomacy(world)).toBe(true); expect(initializeAuditDiplomacy(world)).toBe(true);
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
withPlayAuditSavepoint,
hashAuditDiplomacyDocument, hashAuditDiplomacyDocument,
type GamePrismaClient, type GamePrismaClient,
type GamePrisma, type GamePrisma,
@@ -92,7 +93,7 @@ integration('initial audit durability before runtime readiness', () => {
await closeDb?.(); await closeDb?.();
}); });
it('rolls initial policies, sample and collection marker back before readiness on persistence failure', async () => { it('keeps readiness and gameplay when initial audit persistence fails', async () => {
const before = await clock(); const before = await clock();
await db.$executeRawUnsafe( await db.$executeRawUnsafe(
"CREATE OR REPLACE FUNCTION audit_initial_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture initial audit failure'; END $$" "CREATE OR REPLACE FUNCTION audit_initial_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture initial audit failure'; END $$"
@@ -101,27 +102,33 @@ integration('initial audit durability before runtime readiness', () => {
"CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()" "CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()"
); );
try { try {
let error: unknown; runtime = await start();
try {
runtime = await start();
} catch (cause) {
error = cause;
}
expect(String(error)).toContain('fixture initial audit failure');
expect(await db.playAuditMonth.count()).toBe(0); expect(await db.playAuditMonth.count()).toBe(0);
expect(await db.playAuditPolicy.count()).toBe(0); expect(await db.playAuditPolicy.count()).toBe(0);
expect(await db.playAuditDiplomacyEvent.count()).toBe(0); expect(runtime.world.hasPendingAuditRecords()).toBe(false);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toBeUndefined(); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditGap).toMatchObject({
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toBeUndefined(); serverId,
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined(); stage: 'persistence',
expect( });
(await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined) expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
).toBe(true);
expect((await db.turnDaemonLease.findMany()).every((lease) => !lease.clockReady)).toBe(true);
expect(await clock()).toEqual(before); expect(await clock()).toEqual(before);
await runtime.close();
runtime = undefined;
// Restart under the same broken audit table must not pause or accumulate retries.
runtime = await start();
expect(runtime.world.hasPendingAuditRecords()).toBe(false);
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
} finally { } finally {
await runtime?.close();
runtime = undefined;
await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month'); await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month');
await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS audit_initial_fail()'); await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS audit_initial_fail()');
// Independent baseline fixture for the following normal-start tests.
await db.playAuditDiplomacyEvent.deleteMany();
await db.$executeRawUnsafe(
"UPDATE world_state SET meta = meta - 'playAuditCollection' - 'playAuditDocuments' - 'playAuditDiplomacy' - 'playAuditGap'"
);
await db.$executeRawUnsafe("UPDATE nation SET meta = meta - '_playAuditPolicy'");
} }
}); });
@@ -337,4 +344,34 @@ integration('initial audit durability before runtime readiness', () => {
runtime = await start(); runtime = await start();
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker); expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker);
}, 30_000); }, 30_000);
it('isolates missing audit tables and bounded SQL waits but propagates core write failures', async () => {
await runtime?.close();
runtime = undefined;
const nation = await db.nation.findUniqueOrThrow({ where: { id: 91990 } });
await db.$transaction(async (tx) => {
await tx.nation.update({ where: { id: nation.id }, data: { gold: nation.gold + 1 } });
const missing = await withPlayAuditSavepoint(tx, () =>
tx.$queryRawUnsafe('SELECT * FROM nonexistent_audit_fixture_table')
);
expect(missing.ok).toBe(false);
const [settings] = await tx.$queryRaw<
Array<{ timeout: string }>
>`SELECT current_setting('statement_timeout') AS timeout`;
const slow = await withPlayAuditSavepoint(tx, () => tx.$queryRawUnsafe('SELECT pg_sleep(2)::text'));
expect(slow.ok).toBe(false);
const [restored] = await tx.$queryRaw<
Array<{ timeout: string }>
>`SELECT current_setting('statement_timeout') AS timeout`;
expect(restored).toEqual(settings);
});
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
await expect(
db.$transaction(async (tx) => {
await tx.nation.update({ where: { id: nation.id }, data: { gold: nation.gold + 2 } });
expect((await withPlayAuditSavepoint(tx, async () => 'audit-ok')).ok).toBe(true);
throw new Error('core game write failed');
})
).rejects.toThrow('core game write failed');
expect((await db.nation.findUniqueOrThrow({ where: { id: nation.id } })).gold).toBe(nation.gold + 1);
});
}); });
+90 -3
View File
@@ -86,7 +86,8 @@ const install = async (
denied = false, denied = false,
baseline: boolean | 'document' | 'created' | 'removed' = false, baseline: boolean | 'document' | 'created' | 'removed' = false,
executionStatus?: 'PREPARING' | 'BLOCKED', executionStatus?: 'PREPARING' | 'BLOCKED',
dashboard = false dashboard = false,
historyGap = false
) => { ) => {
const requests: { operation: string; input: Record<string, unknown> }[] = []; const requests: { operation: string; input: Record<string, unknown> }[] = [];
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
@@ -166,7 +167,59 @@ const install = async (
{ {
ordinal: input.cursor === undefined ? 0 : 1, ordinal: input.cursor === undefined ? 0 : 1,
steps: [ steps: [
...(input.cursor === undefined ? [{ phase: 'general', generalId: 1, nationId: 2, cityId: 3, npcState: 2, year: 190, month: 6, tick: 100, sequence: 0, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5]}} }] : []), ...(input.cursor === undefined
? [
{
phase: 'general',
generalId: 1,
nationId: 2,
cityId: 3,
npcState: 2,
year: 190,
month: 6,
tick: 100,
sequence: 0,
kind: 'DECISION_START',
reservedAction: '휴식',
effectivePolicy: {
schemaVersion: 1,
general: {
priority: ['징병'],
flags: { 징병: true, 출병: false },
},
nation: {
priority: ['천도'],
flags: { 천도: true },
values: {
reqNationGold: 4321,
reqNationRice: 100,
reqHumanWarUrgentGold: 100,
reqHumanWarUrgentRice: 100,
reqHumanWarRecommandGold: 100,
reqHumanWarRecommandRice: 100,
reqHumanDevelGold: 100,
reqHumanDevelRice: 100,
reqNpcWarGold: 100,
reqNpcWarRice: 100,
reqNpcDevelGold: 100,
reqNpcDevelRice: 100,
minimumResourceActionAmount: 100,
maximumResourceActionAmount: 100,
minNpcWarLeadership: 100,
minWarCrew: 100,
minNpcRecruitCityPopulation: 100,
safeRecruitCityPopulationRatio: 100,
properWarTrainAtmos: 100,
cureThreshold: 100,
},
combatForce: { '1': [2, 3] },
supportForce: [4],
developForce: [5],
},
},
},
]
: []),
{ {
phase: 'general', phase: 'general',
generalId: 1, generalId: 1,
@@ -441,7 +494,13 @@ const install = async (
}); });
} }
case 'playAudit.coverage': case 'playAudit.coverage':
return result({ ...world, status: 'COLLECTED', samples: [], nextCursor: null }); return result({
...world,
historyGap: historyGap ? { firstYear: 190, firstMonth: 4 } : null,
status: 'COLLECTED',
samples: [],
nextCursor: null,
});
case 'playAudit.nations': case 'playAudit.nations':
return result({ return result({
...world, ...world,
@@ -1521,3 +1580,31 @@ for (const width of [1280, 390]) {
await capture(page, `audit-ranked-${width}`); await capture(page, `audit-ranked-${width}`);
}); });
} }
for (const width of [390, 1280]) {
test(`audit history gap stays visible while browsing at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await install(page, false, false, undefined, false, true);
await page.goto(gamePath('/play-audit'));
const notice = page.getByRole('status').filter({ hasText: '감사 이력에 누락된 구간' });
await expect(notice).toContainText('190년 4월');
await page.getByRole('link', { name: '장수', exact: true }).click();
await expect(notice).toBeVisible();
await page.evaluate(() => document.fonts.ready);
const geometry = await notice.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
width: rect.width,
height: rect.height,
fontSize: style.fontSize,
viewport: innerWidth,
};
});
expect(geometry.x).toBeGreaterThanOrEqual(0);
expect(geometry.x + geometry.width).toBeLessThanOrEqual(width);
await writeFile(testInfo.outputPath('audit-gap-geometry.json'), JSON.stringify(geometry));
await page.screenshot({ path: testInfo.outputPath('audit-gap.png'), fullPage: true });
});
}
@@ -475,6 +475,10 @@ onMounted(async () => {
현재 {{ coverage.year }}년 {{ coverage.month }}월 · {{ scopeLabel }} · 현재 기수의 수집 자료를 현재 {{ coverage.year }}년 {{ coverage.month }}월 · {{ scopeLabel }} · 현재 기수의 수집 자료를
조회합니다. 조회합니다.
</p> </p>
<p v-if="coverage.historyGap" role="status">
{{ coverage.historyGap.firstYear }}년 {{ coverage.historyGap.firstMonth }}월 이후 감사 이력에 누락된
구간이 있습니다. 수집된 기록만 표시하며 게임은 계속 진행됩니다.
</p>
<p v-if="coverage.status === 'IDENTITY_MISSING'"> <p v-if="coverage.status === 'IDENTITY_MISSING'">
기수 식별자가 없어 과거 자료를 수집하지 못했습니다. 현재 상태만 확인할 수 있습니다. 기수 식별자가 없어 과거 자료를 수집하지 못했습니다. 현재 상태만 확인할 수 있습니다.
</p> </p>
+4
View File
@@ -1,3 +1,7 @@
> 2026-09-26 정책 변경: 아래 단계별 검증 기록의 감사 실패 시 gameplay rollback은 당시 계약이다.
> 현재는 [기록·실패 계약](./play-audit.md#_6-1-공통-식별자와-내구성)에 따라 감사 savepoint만
> 취소하고 게임을 계속한다. 이력 누락·업데이트 경계의 단절을 허용하며 기존 원장은 수정하지 않는다.
# 플레이 감사 구현 기록과 수집 inventory # 플레이 감사 구현 기록과 수집 inventory
[확정 설계](play-audit.md)의 P1~P6 구현 기록이다. 현재 월별 상태·국가 시계열, [확정 설계](play-audit.md)의 P1~P6 구현 기록이다. 현재 월별 상태·국가 시계열,
+12 -2
View File
@@ -298,7 +298,9 @@ NPC 결정은 적용한 불변 정책 버전을 참조하고 현재 정책으로
engine은 기존 메모리 상태·mutation 결과에서 수집해 pending 감사 자료를 engine은 기존 메모리 상태·mutation 결과에서 수집해 pending 감사 자료를
`EngineStateManager` rollback과 dirty acknowledgement에 포함한다. gameplay flush와 `EngineStateManager` rollback과 dirty acknowledgement에 포함한다. gameplay flush와
감사 insert가 같은 PostgreSQL transaction에서 commit되고 실패 시 둘 다 rollback된다. 감사 insert는 같은 PostgreSQL transaction의 별도 savepoint에서 수행한다. 2026-09-26 사용자
결정으로 감사만 실패하면 해당 감사 묶음을 rollback하고 gameplay를 commit한다. 성공한 game
commit 뒤에는 실패한 pending도 acknowledge하여 무한 재시도·메모리 누적을 막는다.
행위/월별 snapshot/정책 버전에는 실행 ID와 종류·순번 기반 unique key로 중복을 막는다. 행위/월별 snapshot/정책 버전에는 실행 ID와 종류·순번 기반 unique key로 중복을 막는다.
알림은 commit 뒤 보내며 `ChangeJournal`/Redis를 감사 원장으로 재구성하지 않는다. 알림은 commit 뒤 보내며 `ChangeJournal`/Redis를 감사 원장으로 재구성하지 않는다.
@@ -306,7 +308,15 @@ API 즉시 mutation도 해당 transaction에서 사건을 기록한다. 인증
rollback된 시도의 진단은 별도 시도/오류 기록으로 남기되 gameplay 성공 사건으로 만들지 rollback된 시도의 진단은 별도 시도/오류 기록으로 남기되 gameplay 성공 사건으로 만들지
않는다. 외부 transaction rollback 전송 실패까지 '모든 시도가 반드시 기록됨'으로 않는다. 외부 transaction rollback 전송 실패까지 '모든 시도가 반드시 기록됨'으로
주장하지 않는다. 관측하지 못한 구간은 coverage/운영 오류로 표시한다. 주장하지 않는다. 관측하지 못한 구간은 coverage/운영 오류로 표시한다.
확정 gameplay 감사 저장 실패는 성공으로 숨기지 않고 기존 transaction 실패·복구 경계를 따른다. 감사 저장·수집 장애와 업데이트에 따른 이력 단절을 허용한다. `world_state.meta.playAuditGap`은
현재 기수의 최초 누락 연월을 보존하고, `playAudit.coverage` 및 감사 화면에 이를 표시한다.
정책 head가 저장되지 않은 버전을 가리킬 수 있으며 없는 버전은 복원·조작하지 않는다.
unique/hash 충돌 검사는 유지하고 기존 이력을 덮어쓰지 않는다. 다음 정상 수집은 재개한다.
감사 SQL은 1초 statement 제한과 250ms lock 제한(기존 제한이 더 짧으면 유지), 전체 1초
수집/쓰기 budget을 적용한다. 실행 중인 마지막 SQL 때문에 최대 약 2초가 걸릴 수 있다.
DB 연결/outer transaction 복구 실패, gameplay 저장, 권한, lease/fencing 및 clock 검증은
계속 실패를 전파한다. 필수 migration·배포 실패를 우회하거나 RESET하지 않는다.
이 정책은 플레이 감사에 한정하며 Gateway 운영/인증 감사와 input_event 처리 원장은 제외한다.
### 6.2 초기 도입·종료·초기화 ### 6.2 초기 도입·종료·초기화
+19 -6
View File
@@ -83,10 +83,10 @@ Gateway의 DB 보존 일괄 업데이트로 같은 고정 commit을 순차 적
Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway 구성요소를 업데이트한다. Gateway의 새 진입/권한 catalog도 사용하려면 같은 commit의 Gateway 구성요소를 업데이트한다.
적용 전 기존 backup/운영 보호 절차를 따르고, migration 후 API/engine/frontend를 같은 적용 전 기존 backup/운영 보호 절차를 따르고, migration 후 API/engine/frontend를 같은
버전으로 전환한다. 엔진은 clock 복구 후 조회 준비를 공개하기 전에 INITIAL·정책·관계· 버전으로 전환한다. 엔진은 clock 복구 후 INITIAL·정책·관계·보유 문서의 기준을 수집한다.
보유 문서의 기준을 같은 fenced transaction에 저장한다. 문서 원문은200건씩 읽어 hash만 문서 원문은 200건씩 읽어 hash만 사건에 남긴다. 감사 전용 오류는 savepoint만 되돌리고
사건에 남긴다. 이 단계가 실패하면 정상 준비 상태로 숨기지 않는다. 원인을 해결하고 게임 준비를 계속한다. 누락된 감사 묶음은 버리며 게임 상태와 누락 표시를 저장한다.
재시작하면 전체 transaction 재시도가 가능하다. 기존 migration을 되돌리거나 수정하지 않는다. tick 확장 migration은 기존 감사 행의 이전 구간의 자동 재구성이나 완전한 이력 연결을 보장하지 않는다. 기존 migration을 되돌리거나 수정하지 않는다. tick 확장 migration은 기존 감사 행의
값과 hash를 보존하지만 열 형식 변경의 테이블 잠금/재작성 비용이 있다. 첫 감사 도입에서는 값과 hash를 보존하지만 열 형식 변경의 테이블 잠금/재작성 비용이 있다. 첫 감사 도입에서는
앞 migration이 만든 빈 테이블에 적용되며, 시험판 감사 기록이 이미 많다면 기존 업데이트 앞 migration이 만든 빈 테이블에 적용되며, 시험판 감사 기록이 이미 많다면 기존 업데이트
유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다. 월별 결정 인덱스 교체도 기존 결정 기록이 많으면 인덱스 생성 시간과 잠금을 업데이트 구간에 고려한다. 유지보수 구간에서 적용 시간을 확인한다. API의 tick은 정밀도 손실을 막기 위해 문자열로 반환한다. 월별 결정 인덱스 교체도 기존 결정 기록이 많으면 인덱스 생성 시간과 잠금을 업데이트 구간에 고려한다.
@@ -130,6 +130,19 @@ writer와 rollback·정리 경계를 함께 추가하는 정식 migration으로
실제 replay payload 충돌 검사는 계속 적용한다. 실제 replay payload 충돌 검사는 계속 적용한다.
`/healthz`는 clock reconciliation뿐 아니라 해당 profile의 만료되지 않은 `/healthz`는 clock reconciliation뿐 아니라 해당 profile의 만료되지 않은
`clock_ready=true` 데몬 lease까지 확인한다. 감사 startup 실패 중인 API는 503을 `clock_ready=true` 데몬 lease까지 확인한다. 게임 clock·lease 초기화 실패는 503이지만
반환하므로 profile 배포 readiness가 이를 성공으로 처리하지 않는다. PREOPEN과 PAUSED도 감사만 실패한 경우 게임 준비 완료를 허용한다. 감사 누락은 `playAuditGap`과 감사 화면,
`[play-audit]` 운영 로그로 구분한다. PREOPEN과 PAUSED도
데몬 초기화가 완료되면 준비 완료이며, 턴 진행 여부와 준비 상태는 별개다. 데몬 초기화가 완료되면 준비 완료이며, 턴 진행 여부와 준비 상태는 별개다.
## 감사 장애 시 게임 지속 (2026-09-26)
현재 정책은 [실패·내구성 계약](./design/play-audit.md#_6-1-공통-식별자와-내구성)을 따른다.
정책·외교·NPC 결정·월말/최종 표본과 startup 수집이 대상이다. 같은 flush의 감사 묶음은
하나라도 실패하면 모두 취소하지만, 연감·통일 처리·게임 상태·입력 처리 원장은 그대로
검증하고 저장한다. API 외교 문서/관계도 감사만 취소하고 문서·알림을 정상 처리한다.
누락 표시는 기수별 sticky 상태이며 이후 성공해도 지우지 않는다. 감사 writer가 복구되면
다음 수집부터 다시 기록한다. 배포로 로직이 바뀐 과거 구간을 수정하거나 hash를 맞추지 않는다.
missing table/SQL 오류/충돌/수집 형식 오류를 게임 오류로 승격하지 않지만, DB 연결 단절이나
필수 core 행 저장 실패처럼 game commit 자체를 보장할 수 없는 상황은 기존 보호 동작을 유지한다.
+1
View File
@@ -14,3 +14,4 @@ export * from './inputEventClock.js';
export * from './playAuditDiplomacy.js'; export * from './playAuditDiplomacy.js';
export * from './messageEnvelope.js'; export * from './messageEnvelope.js';
export * from './webPushOutbox.js'; export * from './webPushOutbox.js';
export * from './playAuditBestEffort.js';
+65
View File
@@ -0,0 +1,65 @@
import type { GamePrisma } from './gamePrisma.js';
/** Only audit work belongs here. A broken outer transaction/connection still propagates. */
export const withPlayAuditSavepoint = async <
T,
Db extends Pick<GamePrisma.TransactionClient, '$executeRaw' | '$queryRaw'>,
>(
db: Db,
collect: (auditDb: Db) => Promise<T>
): Promise<{ ok: true; value: T } | { ok: false }> => {
await db.$executeRaw`SAVEPOINT play_audit_optional`;
try {
const [settings] = await db.$queryRaw<Array<{ statement: string; lock: string }>>`
SELECT current_setting('statement_timeout') AS statement, current_setting('lock_timeout') AS lock
`;
// Bound audit lock waits and individual statements, without relaxing tighter caller limits.
await db.$queryRaw`
SELECT set_config('statement_timeout',
CASE WHEN current_setting('statement_timeout') = '0' THEN '1000ms'
ELSE LEAST(EXTRACT(EPOCH FROM current_setting('statement_timeout')::interval) * 1000, 1000)::text || 'ms' END, true),
set_config('lock_timeout', CASE WHEN current_setting('lock_timeout') = '0' THEN '250ms' ELSE LEAST(EXTRACT(EPOCH FROM current_setting('lock_timeout')::interval) * 1000, 250)::text || 'ms' END, true)
`;
const deadline = performance.now() + 1000;
const checkBudget = () => {
if (performance.now() >= deadline) throw new Error('Audit budget exhausted');
};
const bound = <ObjectType extends object>(target: ObjectType): ObjectType =>
new Proxy(target, {
get(object, property) {
const value: unknown = Reflect.get(object, property);
if (typeof value === 'function')
return async (...args: unknown[]) => {
checkBudget();
const result: unknown = await Reflect.apply(value, object, args);
checkBudget();
return result;
};
return value && typeof value === 'object' ? bound(value) : value;
},
});
const value = await collect(bound(db));
checkBudget();
await db.$queryRaw`SELECT set_config('statement_timeout', ${settings!.statement}, true), set_config('lock_timeout', ${settings!.lock}, true)`;
await db.$executeRaw`RELEASE SAVEPOINT play_audit_optional`;
return { ok: true, value };
} catch (error) {
// SQL errors poison PostgreSQL transactions. Catch alone is not sufficient.
await db.$executeRaw`ROLLBACK TO SAVEPOINT play_audit_optional`;
await db.$executeRaw`RELEASE SAVEPOINT play_audit_optional`;
// This notice uses the core row, not the possibly unavailable audit schema.
await db.$executeRaw`UPDATE world_state SET meta = jsonb_set(COALESCE(meta, '{}'::jsonb), '{playAuditGap}',
jsonb_build_object('serverId', meta->'serverId',
'firstYear', CASE WHEN meta->'playAuditGap'->>'serverId' = meta->>'serverId' THEN COALESCE(meta->'playAuditGap'->'firstYear', to_jsonb(current_year)) ELSE to_jsonb(current_year) END,
'firstMonth', CASE WHEN meta->'playAuditGap'->>'serverId' = meta->>'serverId' THEN COALESCE(meta->'playAuditGap'->'firstMonth', to_jsonb(current_month)) ELSE to_jsonb(current_month) END,
'lastYear', current_year, 'lastMonth', current_month, 'stage', 'persistence'))
WHERE id = (SELECT id FROM world_state ORDER BY id LIMIT 1)`;
// Never log payloads, SQL or actor details here.
const code =
error && typeof error === 'object' && 'code' in error && /^[A-Z0-9_]{1,20}$/.test(String(error.code))
? String(error.code)
: 'AUDIT_ERROR';
console.warn(`[play-audit] ${code}: audit batch discarded; gameplay continues with a history gap.`);
return { ok: false };
}
};
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '../src/gamePrisma.js';
import { withPlayAuditSavepoint } from '../src/playAuditBestEffort.js';
describe('optional gameplay audit transaction', () => {
it('propagates broken transaction recovery instead of claiming a safe gameplay commit', async () => {
const db = {
$queryRaw: vi.fn().mockResolvedValue([{ statement: '0', lock: '0' }]),
$executeRaw: vi.fn().mockImplementation(async (sql: TemplateStringsArray) => {
if (sql[0]!.startsWith('ROLLBACK')) throw new Error('connection lost');
return 0;
}),
} as unknown as GamePrisma.TransactionClient;
await expect(
withPlayAuditSavepoint(db, async () => {
throw new Error('audit insert');
})
).rejects.toThrow('connection lost');
});
it('stops subsequent batches when the whole audit budget is spent', async () => {
let now = 0;
const timer = vi.spyOn(performance, 'now').mockImplementation(() => now);
const write = vi.fn().mockImplementation(async () => {
now += 600;
return { count: 1 };
});
const db = {
$queryRaw: vi.fn().mockResolvedValue([{ statement: '0', lock: '0' }]),
$executeRaw: vi.fn().mockResolvedValue(0),
playAuditPolicy: { createMany: write },
} as unknown as GamePrisma.TransactionClient;
try {
const result = await withPlayAuditSavepoint(db, async (auditDb) => {
for (let i = 0; i < 100; i++) await auditDb.playAuditPolicy.createMany({ data: [] });
});
expect(result.ok).toBe(false);
expect(write).toHaveBeenCalledTimes(2);
expect(
vi
.mocked(db.$executeRaw)
.mock.calls.some(
([sql]) => Array.isArray(sql) && sql[0] === 'ROLLBACK TO SAVEPOINT play_audit_optional'
)
).toBe(true);
} finally {
timer.mockRestore();
}
});
});