Files
core2026/app/game-engine/test/inputEventAtomicity.test.ts
T

307 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
InMemoryControlQueue,
ManualClock,
TurnDaemonLifecycle,
type TurnDaemonCommandResult,
type TurnProcessor,
type TurnStateStore,
} from '../src/index.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
const createStateStore = (): TurnStateStore => ({
loadLastTurnTime: async () => new Date('2026-01-01T00:00:00.000Z'),
loadNextGeneralTurnTime: async () => null,
saveLastTurnTime: async () => {},
loadCheckpoint: async () => undefined,
saveCheckpoint: async () => {},
});
const processor: TurnProcessor = {
run: async () => {
throw new Error('scheduled turn must not run in this test');
},
};
describe('input event atomicity', () => {
it('keeps reserved-turn dirty state when persistence fails', async () => {
let failCreate = true;
const revisionUpsert = vi.fn(async () => ({ revision: 1 }));
const prisma = {
generalTurn: {
findMany: vi.fn(async () => []),
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => {
if (failCreate) {
throw new Error('injected write failure');
}
return { count: 1 };
}),
},
generalTurnRevision: {
upsert: revisionUpsert,
},
nationTurn: {
findMany: vi.fn(async () => []),
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
};
const store = new InMemoryReservedTurnStore(prisma, {
maxGeneralTurns: 1,
maxNationTurns: 1,
});
store.shiftGeneralTurns(7, -1);
store.ensureGeneralTurns(8);
await expect(store.flushChanges()).rejects.toThrow('injected write failure');
expect(store.peekDirtyState()).toEqual({
generalIds: [7],
generalInitializationIds: [8],
nationKeys: [],
nationInitializationKeys: [],
});
failCreate = false;
await store.flushChanges();
expect(revisionUpsert).toHaveBeenCalledOnce();
expect(revisionUpsert).toHaveBeenCalledWith({
where: { generalId: 7 },
create: { generalId: 7, revision: 1 },
update: { revision: { increment: 1 } },
});
expect(store.peekDirtyState()).toEqual({
generalIds: [],
generalInitializationIds: [],
nationKeys: [],
nationInitializationKeys: [],
});
});
it('dispatches registry mutations that the old lifecycle switch dropped, then commits before responding', async () => {
const queue = new InMemoryControlQueue();
const order: string[] = [];
const result: TurnDaemonCommandResult = {
type: 'auctionBid',
ok: true,
auctionId: 3,
closeAt: '2026-01-01T00:10:00.000Z',
};
let resolveResponse: (() => void) | undefined;
const responded = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T01:00:00.000Z'),
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async (command) => {
order.push(`handle:${command.type}`);
return result;
},
},
hooks: {
commitCommand: async (requestId, committedResult) => {
order.push(`commit:${requestId}:${committedResult.type}`);
},
},
commandResponder: {
publishStatus: async () => {},
publishCommandResult: async (requestId, response) => {
order.push(`respond:${requestId}:${response.type}`);
resolveResponse?.();
},
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
queue.enqueue({
type: 'auctionBid',
requestId: 'event-1',
auctionId: 3,
generalId: 7,
amount: 1000,
});
const loop = lifecycle.start();
await responded;
expect(order).toEqual(['handle:auctionBid', 'commit:event-1:auctionBid', 'respond:event-1:auctionBid']);
await lifecycle.stop('done');
await loop;
});
it('runs a mutation inside the database-owned execution boundary before responding', async () => {
const queue = new InMemoryControlQueue();
const order: string[] = [];
let resolveResponse: (() => void) | undefined;
const responded = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T01:00:00.000Z'),
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async (command) => {
order.push(`handle:${command.type}`);
return { type: 'vacation', ok: true, generalId: 7 };
},
},
hooks: {
executeCommand: async (requestId, execute) => {
order.push(`transaction:${requestId}:begin`);
const result = await execute({});
order.push(`transaction:${requestId}:commit`);
return result;
},
commitCommand: async () => {
order.push('legacy-commit');
},
},
commandResponder: {
publishStatus: async () => {},
publishCommandResult: async () => {
order.push('respond');
resolveResponse?.();
},
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-uow', generalId: 7 });
const loop = lifecycle.start();
await responded;
expect(order).toEqual([
'transaction:event-uow:begin',
'handle:vacation',
'transaction:event-uow:commit',
'respond',
]);
await lifecycle.stop('done');
await loop;
});
it('pauses without publishing success when the atomic command commit fails', async () => {
const queue = new InMemoryControlQueue();
let resolveError: (() => void) | undefined;
const errorObserved = new Promise<void>((resolve) => {
resolveError = resolve;
});
const publishCommandResult = vi.fn(async () => {});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T01:00:00.000Z'),
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async () => ({ type: 'vacation', ok: true, generalId: 7 }),
},
hooks: {
commitCommand: async () => {
throw new Error('injected commit failure');
},
onRunError: async () => {
resolveError?.();
},
},
commandResponder: {
publishStatus: async () => {},
publishCommandResult,
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-2', generalId: 7 });
const loop = lifecycle.start();
await errorObserved;
expect(lifecycle.getStatus()).toMatchObject({
state: 'paused',
paused: true,
lastError: 'injected commit failure',
});
expect(publishCommandResult).not.toHaveBeenCalled();
await lifecycle.stop('done');
await loop;
});
it('pauses without committing or acknowledging a handler that throws after changing memory', async () => {
const queue = new InMemoryControlQueue();
let resolveError: (() => void) | undefined;
const errorObserved = new Promise<void>((resolve) => {
resolveError = resolve;
});
const commitCommand = vi.fn(async () => {});
const publishCommandResult = vi.fn(async () => {});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(new Date('2026-01-01T00:00:00.000Z').getTime()),
controlQueue: queue,
getNextTickTime: () => new Date('2026-01-01T01:00:00.000Z'),
stateStore: createStateStore(),
processor,
commandHandler: {
handle: async () => {
throw new Error('injected handler failure');
},
},
hooks: {
commitCommand,
onRunError: async () => {
resolveError?.();
},
},
commandResponder: {
publishStatus: async () => {},
publishCommandResult,
},
},
{
profile: 'test',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
queue.enqueue({ type: 'vacation', requestId: 'event-3', generalId: 7 });
const loop = lifecycle.start();
await errorObserved;
expect(lifecycle.getStatus()).toMatchObject({
state: 'paused',
paused: true,
lastError: 'injected handler failure',
});
expect(commitCommand).not.toHaveBeenCalled();
expect(publishCommandResult).not.toHaveBeenCalled();
await lifecycle.stop('done');
await loop;
});
});