feat: 서버 준비 전에 감사 초기 상태와 정책 기준을 저장
This commit is contained in:
@@ -46,7 +46,10 @@ export const recordAuditSettlement = (world: InMemoryTurnWorld, settlement: Audi
|
||||
world.updateWorldMeta({ playAuditFlows: flows });
|
||||
};
|
||||
|
||||
export const queueAuditMonth = (world: InMemoryTurnWorld, kind: 'MONTH_END' | 'FINAL' = 'MONTH_END'): void => {
|
||||
export const queueAuditMonth = (
|
||||
world: InMemoryTurnWorld,
|
||||
kind: 'MONTH_END' | 'FINAL' | 'INITIAL' = 'MONTH_END'
|
||||
): void => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
// identity 없는 레거시 fixture/설치에서 profile명으로 가짜 기수를 만들지 않는다.
|
||||
@@ -64,12 +67,52 @@ export const queueAuditMonth = (world: InMemoryTurnWorld, kind: 'MONTH_END' | 'F
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: state.lastTurnTick ?? null,
|
||||
tick: kind === 'INITIAL' ? world.getGameClockState().tick : (state.lastTurnTick ?? null),
|
||||
kind,
|
||||
settlementsComplete: flows.complete,
|
||||
});
|
||||
};
|
||||
|
||||
/** 도입 당시 상태는 월말로 가장하지 않고 기수별 최초 기준으로 한 번 고정한다. */
|
||||
export const initializeAuditCollection = (world: InMemoryTurnWorld, observedAt = new Date()): boolean => {
|
||||
const state = world.getState();
|
||||
const serverId = state.meta.serverId;
|
||||
if (typeof serverId !== 'string' || !serverId.trim()) return false;
|
||||
const previous = asRecord(state.meta.playAuditCollection);
|
||||
if (previous.serverId === serverId) {
|
||||
if (
|
||||
previous.schemaVersion !== 1 ||
|
||||
typeof previous.year !== 'number' ||
|
||||
!Number.isInteger(previous.year) ||
|
||||
previous.year < 0 ||
|
||||
typeof previous.month !== 'number' ||
|
||||
!Number.isInteger(previous.month) ||
|
||||
previous.month < 1 ||
|
||||
previous.month > 12 ||
|
||||
typeof previous.tick !== 'number' ||
|
||||
!Number.isSafeInteger(previous.tick) ||
|
||||
previous.tick < 0 ||
|
||||
typeof previous.observedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(previous.observedAt)) ||
|
||||
previous.year * 12 + previous.month > state.currentYear * 12 + state.currentMonth
|
||||
)
|
||||
throw new Error('Invalid play audit collection boundary');
|
||||
return false;
|
||||
}
|
||||
queueAuditMonth(world, 'INITIAL');
|
||||
world.updateWorldMeta({
|
||||
playAuditCollection: {
|
||||
schemaVersion: 1,
|
||||
serverId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
tick: world.getGameClockState().tick,
|
||||
observedAt: observedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const createPlayAuditHandler = (getWorld: () => InMemoryTurnWorld | null): TurnCalendarHandler => ({
|
||||
beforeMonthChanged: (context) => {
|
||||
const world = getWorld();
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface PendingAuditMonth {
|
||||
serverId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
kind: 'MONTH_END' | 'FINAL';
|
||||
kind: 'MONTH_END' | 'FINAL' | 'INITIAL';
|
||||
tick: number | null;
|
||||
settlementsComplete: boolean;
|
||||
nations: AuditNationSnapshot[];
|
||||
|
||||
@@ -74,6 +74,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
|
||||
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
flushChanges(): Promise<void>;
|
||||
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
|
||||
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
|
||||
close(): Promise<void>;
|
||||
@@ -2078,12 +2079,13 @@ export const createDatabaseTurnHooks = async (
|
||||
};
|
||||
};
|
||||
|
||||
const flushChanges = async (): Promise<void> => {
|
||||
const committed = await persistChanges();
|
||||
committed.acknowledge();
|
||||
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
|
||||
};
|
||||
const hooks: TurnDaemonHooks = {
|
||||
flushChanges: async () => {
|
||||
const committed = await persistChanges();
|
||||
committed.acknowledge();
|
||||
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
|
||||
},
|
||||
flushChanges,
|
||||
commitCommand: async (requestId, result) => {
|
||||
const committed = await persistChanges(undefined, { requestId, result });
|
||||
committed.acknowledge();
|
||||
@@ -2127,6 +2129,7 @@ export const createDatabaseTurnHooks = async (
|
||||
|
||||
return {
|
||||
hooks,
|
||||
flushChanges,
|
||||
takeCommittedReadModelChanges: () => {
|
||||
return takeCommittedReceipt()?.changes ?? null;
|
||||
},
|
||||
|
||||
@@ -1377,6 +1377,10 @@ export class InMemoryTurnWorld {
|
||||
this.pendingAuditPolicies.push(structuredClone(policy));
|
||||
}
|
||||
|
||||
hasPendingAuditRecords(): boolean {
|
||||
return this.pendingAuditPolicies.length > 0 || this.pendingAuditMonths.length > 0;
|
||||
}
|
||||
|
||||
queueAuditMonth(snapshot: PendingAuditMonth): void {
|
||||
this.pendingAuditMonths.push(structuredClone(snapshot));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initializeAuditPolicies } from '../playAudit/policy.js';
|
||||
import { startAuditRetentionWorker } from '../playAudit/retentionWorker.js';
|
||||
import { createPlayAuditHandler } from '../playAudit/collection.js';
|
||||
import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/collection.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRuntimePauseGate } from './runtimePauseGate.js';
|
||||
|
||||
@@ -804,7 +804,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
};
|
||||
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
|
||||
worldRef = world;
|
||||
initializeAuditPolicies(world);
|
||||
if (!databaseFlushEnabled) {
|
||||
initializeAuditPolicies(world);
|
||||
initializeAuditCollection(world, new Date(clock.nowMs()));
|
||||
}
|
||||
|
||||
const stateManager = new EngineStateManager();
|
||||
stateManager.register('world', {
|
||||
@@ -923,6 +926,14 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
});
|
||||
try {
|
||||
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
|
||||
// 복구된 clock에서 기준을 고정하고 readiness 공개 전에 원자적으로 저장한다.
|
||||
// 명령 없는 PREOPEN도 기록하며 input_event나 게임 RNG를 만들지 않는다.
|
||||
initializeAuditPolicies(world);
|
||||
initializeAuditCollection(world, new Date(clock.nowMs()));
|
||||
if (world.hasPendingAuditRecords()) {
|
||||
await dbHooks.flushChanges();
|
||||
dbHooks.takeCommittedReadModelChangeReceipt();
|
||||
}
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
dbHooks.close(),
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createPlayAuditHandler, queueAuditMonth, recordAuditSettlement } from '../src/playAudit/collection.js';
|
||||
import {
|
||||
createPlayAuditHandler,
|
||||
initializeAuditCollection,
|
||||
queueAuditMonth,
|
||||
recordAuditSettlement,
|
||||
} from '../src/playAudit/collection.js';
|
||||
const turnTime = new Date('0200-01-01T00:00:00.000Z');
|
||||
|
||||
const buildGeneral = (id: number, nationId: number): TurnGeneral => ({
|
||||
@@ -122,6 +127,31 @@ const buildWorld = () => {
|
||||
return world;
|
||||
};
|
||||
describe('play audit collection durability state', () => {
|
||||
it('freezes the initial observation separately from month-end and restores its marker on rollback', () => {
|
||||
const world = buildWorld();
|
||||
const before = world.captureState();
|
||||
const observedAt = new Date('2026-09-16T00:00:00.000Z');
|
||||
expect(initializeAuditCollection(world, observedAt)).toBe(true);
|
||||
expect(world.peekDirtyState().pendingAuditMonths).toMatchObject([
|
||||
{ kind: 'INITIAL', year: 200, month: 1, settlementsComplete: false },
|
||||
]);
|
||||
const marker = world.getState().meta.playAuditCollection;
|
||||
expect(initializeAuditCollection(world, new Date(observedAt.getTime() + 1000))).toBe(false);
|
||||
expect(world.getState().meta.playAuditCollection).toEqual(marker);
|
||||
expect(world.peekDirtyState().pendingAuditMonths).toHaveLength(1);
|
||||
const reloaded = buildWorld();
|
||||
reloaded.restoreState(world.captureState());
|
||||
expect(initializeAuditCollection(reloaded)).toBe(false);
|
||||
world.restoreState(before);
|
||||
expect(world.hasPendingAuditRecords()).toBe(false);
|
||||
expect(world.getState().meta.playAuditCollection).toBeUndefined();
|
||||
expect(initializeAuditCollection(world, observedAt)).toBe(true);
|
||||
queueAuditMonth(world);
|
||||
expect(world.peekDirtyState().pendingAuditMonths.map((sample) => sample.kind)).toEqual([
|
||||
'INITIAL',
|
||||
'MONTH_END',
|
||||
]);
|
||||
});
|
||||
it('restores pending snapshots and monthly flows on rollback and acknowledges only persisted rows', () => {
|
||||
const world = buildWorld();
|
||||
const before = world.captureState();
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js';
|
||||
import { createTurnDaemonRuntime, type TurnDaemonRuntime } from '../src/turn/turnDaemon.js';
|
||||
|
||||
const databaseUrl = process.env.PLAY_AUDIT_STARTUP_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const profile = 'hwe:903';
|
||||
const serverId = 'audit-startup-fixture';
|
||||
|
||||
integration('initial audit durability before runtime readiness', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: () => Promise<void>;
|
||||
let runtime: TurnDaemonRuntime | undefined;
|
||||
const start = () =>
|
||||
createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableDatabaseFlush: true,
|
||||
enableLeaseHeartbeat: false,
|
||||
leaseOwnerId: 'audit-startup-fixture',
|
||||
});
|
||||
const clock = () =>
|
||||
db.worldState.findFirstOrThrow({
|
||||
select: {
|
||||
clockPhase: true,
|
||||
clockTick: true,
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
clockWallAnchor: true,
|
||||
lastTurnTick: true,
|
||||
currentYear: true,
|
||||
currentMonth: true,
|
||||
},
|
||||
});
|
||||
beforeAll(async () => {
|
||||
if (!new URL(databaseUrl!).searchParams.get('schema')?.endsWith('_audit_startup_fixture'))
|
||||
throw new Error('Initial audit test requires its dedicated fixture schema');
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.playAuditMonth.deleteMany();
|
||||
await db.playAuditPolicy.deleteMany();
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 903,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date(),
|
||||
installOptions: {
|
||||
openAt: new Date(Date.now() + 86_400_000),
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 2,
|
||||
showImgLevel: 3,
|
||||
serverId,
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await runtime?.close();
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('rolls initial policies, sample and collection marker back before readiness on persistence failure', async () => {
|
||||
const before = await clock();
|
||||
await db.$executeRawUnsafe(
|
||||
"CREATE OR REPLACE FUNCTION audit_initial_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture initial audit failure'; END $$"
|
||||
);
|
||||
await db.$executeRawUnsafe(
|
||||
"CREATE TRIGGER audit_initial_fail BEFORE INSERT ON play_audit_month FOR EACH ROW WHEN (NEW.kind = 'INITIAL') EXECUTE FUNCTION audit_initial_fail()"
|
||||
);
|
||||
try {
|
||||
let error: unknown;
|
||||
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.playAuditPolicy.count()).toBe(0);
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined();
|
||||
expect(
|
||||
(await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined)
|
||||
).toBe(true);
|
||||
expect((await db.turnDaemonLease.findMany()).every((lease) => !lease.clockReady)).toBe(true);
|
||||
expect(await clock()).toEqual(before);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS audit_initial_fail()');
|
||||
}
|
||||
});
|
||||
|
||||
it('persists PREOPEN baseline without starting the lifecycle and reuses it after restart', async () => {
|
||||
const beforeClock = await clock();
|
||||
const beforeGenerals = await db.general.findMany({ orderBy: { id: 'asc' } });
|
||||
const beforeInputs = await db.inputEvent.count();
|
||||
expect(beforeClock.clockPhase).toBe('PREOPEN');
|
||||
runtime = await start();
|
||||
expect(await clock()).toEqual(beforeClock);
|
||||
expect(await db.general.findMany({ orderBy: { id: 'asc' } })).toEqual(beforeGenerals);
|
||||
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
||||
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
|
||||
const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } });
|
||||
const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } });
|
||||
expect(policies).toHaveLength((await db.nation.count()) * 4);
|
||||
expect(
|
||||
policies.every(
|
||||
(policy) =>
|
||||
policy.source === 'BASELINE' &&
|
||||
policy.tick === Number(beforeClock.clockTick) &&
|
||||
policy.inputSequence === null
|
||||
)
|
||||
).toBe(true);
|
||||
expect(await db.playAuditGeneral.count({ where: { sampleId: initial.id } })).toBe(beforeGenerals.length);
|
||||
const marker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection;
|
||||
expect(marker).toMatchObject({
|
||||
serverId,
|
||||
schemaVersion: 1,
|
||||
year: beforeClock.currentYear,
|
||||
month: beforeClock.currentMonth,
|
||||
});
|
||||
expect(runtime.world.hasPendingAuditRecords()).toBe(false);
|
||||
await runtime.close();
|
||||
runtime = undefined;
|
||||
runtime = await start();
|
||||
expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies);
|
||||
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toEqual(marker);
|
||||
expect(await clock()).toEqual(beforeClock);
|
||||
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
||||
await expect(
|
||||
db.playAuditMonth.create({
|
||||
data: { ...initial, id: 'second-initial', month: initial.month === 12 ? 1 : initial.month + 1 },
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'P2002' });
|
||||
}, 30_000);
|
||||
|
||||
it('captures newly observed policies after durable clock recovery without replacing the initial sample', async () => {
|
||||
await runtime?.close();
|
||||
runtime = undefined;
|
||||
const original = await db.worldState.findFirstOrThrow();
|
||||
const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } });
|
||||
await db.nation.create({ data: { id: 91992, name: '복구 관측국', color: '#ffffff' } });
|
||||
await db.worldState.update({
|
||||
where: { id: original.id },
|
||||
data: {
|
||||
clockPhase: 'RUNNING',
|
||||
clockMode: 'realtime',
|
||||
clockTick: BigInt(GAME_TICKS_PER_TURN / 6),
|
||||
clockWallAnchor: new Date(Date.now() - 115 * 60_000),
|
||||
lastTurnTick: 0n,
|
||||
},
|
||||
});
|
||||
runtime = await start();
|
||||
const recovered = await db.worldState.findFirstOrThrow();
|
||||
expect(recovered.clockRevision).toBeGreaterThan(original.clockRevision);
|
||||
const policies = await db.playAuditPolicy.findMany({ where: { serverId, nationId: 91992 } });
|
||||
expect(policies).toHaveLength(4);
|
||||
expect(policies.every((policy) => policy.tick === runtime!.world.getGameClockState().tick)).toBe(true);
|
||||
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user