감사 수집과 저장 장애를 게임 진행에서 분리한다
This commit is contained in:
@@ -157,6 +157,23 @@ integration('monthly diplomacy persistence', () => {
|
||||
});
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
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();
|
||||
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||
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
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_monthly_audit_fixture()`);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('monthly audit fixture failure');
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(queued);
|
||||
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
|
||||
currentMonth: 1,
|
||||
currentMonth: 2,
|
||||
});
|
||||
expect(
|
||||
await db.diplomacy.findUniqueOrThrow({
|
||||
@@ -185,7 +202,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({ stateCode: 1, term: 1 });
|
||||
).toMatchObject({ stateCode: 0, term: 6 });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_monthly_audit_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_monthly_audit_fixture()');
|
||||
@@ -193,18 +210,25 @@ integration('monthly diplomacy persistence', () => {
|
||||
|
||||
const decision = buildAuditDecisionFixture('monthly-decision', scenarioCode);
|
||||
world.queueAuditDecision(decision);
|
||||
await db.$executeRawUnsafe(`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`);
|
||||
await db.$executeRawUnsafe(`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`);
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE FUNCTION reject_decision_flush_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'decision flush rollback'; END; $$`
|
||||
);
|
||||
await db.$executeRawUnsafe(
|
||||
`CREATE TRIGGER reject_decision_flush_fixture BEFORE INSERT ON play_audit_decision_chunk FOR EACH ROW EXECUTE FUNCTION reject_decision_flush_fixture()`
|
||||
);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('decision flush rollback');
|
||||
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([decision]);
|
||||
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||
expect(world.peekDirtyState().pendingAuditDecisions).toEqual([]);
|
||||
expect(await db.playAuditDecision.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: scenarioCode } })).toBe(0);
|
||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(1);
|
||||
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).currentMonth).toBe(2);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_decision_flush_fixture ON play_audit_decision_chunk');
|
||||
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?.({
|
||||
lastTurnTime: '0193-02-01T00:00:00.000Z',
|
||||
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
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_lifecycle_audit_fixture()`);
|
||||
try {
|
||||
await expect(hooks.flushChanges()).rejects.toThrow('lifecycle audit fixture failure');
|
||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(1);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual(removals);
|
||||
await expect(hooks.flushChanges()).resolves.toBeUndefined();
|
||||
expect(await db.nation.count({ where: { id: endingNation } })).toBe(0);
|
||||
expect(world.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
} finally {
|
||||
await db.$executeRawUnsafe('DROP TRIGGER reject_lifecycle_audit_fixture ON play_audit_diplomacy_event');
|
||||
await db.$executeRawUnsafe('DROP FUNCTION reject_lifecycle_audit_fixture()');
|
||||
@@ -331,7 +355,7 @@ integration('monthly diplomacy persistence', () => {
|
||||
await db.playAuditDiplomacyEvent.count({
|
||||
where: { serverId: scenarioCode, eventType: 'NATION_RELATION_REMOVED' },
|
||||
})
|
||||
).toBe(6);
|
||||
).toBe(0);
|
||||
expect(world.addNation(buildNation(endingNation, '재건국', 1))).toBe(true);
|
||||
await hooks.flushChanges();
|
||||
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' },
|
||||
})
|
||||
).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 {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildAuditDecisionFixture } from './fixtures/playAuditDecision.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 { InMemoryTurnWorld, type GeneralTurnHandler } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
@@ -130,6 +130,29 @@ const buildWorld = (generalTurnHandler?: GeneralTurnHandler) => {
|
||||
return world;
|
||||
};
|
||||
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', () => {
|
||||
const world = buildWorld();
|
||||
const first = buildAuditDecisionFixture('first');
|
||||
@@ -207,7 +230,8 @@ describe('play audit collection durability state', () => {
|
||||
world.removeNation(1);
|
||||
world.removeNation(2);
|
||||
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.peekDirtyState().pendingAuditDiplomacy).toEqual([]);
|
||||
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 {
|
||||
createGamePostgresConnector,
|
||||
withPlayAuditSavepoint,
|
||||
hashAuditDiplomacyDocument,
|
||||
type GamePrismaClient,
|
||||
type GamePrisma,
|
||||
@@ -92,7 +93,7 @@ integration('initial audit durability before runtime readiness', () => {
|
||||
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();
|
||||
await db.$executeRawUnsafe(
|
||||
"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()"
|
||||
);
|
||||
try {
|
||||
let error: unknown;
|
||||
try {
|
||||
runtime = await start();
|
||||
} catch (cause) {
|
||||
error = cause;
|
||||
}
|
||||
expect(String(error)).toContain('fixture initial audit failure');
|
||||
runtime = await start();
|
||||
expect(await db.playAuditMonth.count()).toBe(0);
|
||||
expect(await db.playAuditPolicy.count()).toBe(0);
|
||||
expect(await db.playAuditDiplomacyEvent.count()).toBe(0);
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toBeUndefined();
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toBeUndefined();
|
||||
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(runtime.world.hasPendingAuditRecords()).toBe(false);
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditGap).toMatchObject({
|
||||
serverId,
|
||||
stage: 'persistence',
|
||||
});
|
||||
expect((await db.turnDaemonLease.findUniqueOrThrow({ where: { profile } })).clockReady).toBe(true);
|
||||
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 {
|
||||
await runtime?.close();
|
||||
runtime = undefined;
|
||||
await db.$executeRawUnsafe('DROP TRIGGER IF EXISTS audit_initial_fail ON play_audit_month');
|
||||
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();
|
||||
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker);
|
||||
}, 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user