feat: 서버 준비 전에 감사 초기 상태와 정책 기준을 저장

This commit is contained in:
2026-09-16 05:22:01 +00:00
parent 3d60e6122d
commit 8faab62866
20 changed files with 481 additions and 41 deletions
@@ -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);
});