165 lines
7.8 KiB
TypeScript
165 lines
7.8 KiB
TypeScript
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);
|
|
});
|