248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { readOfficeRequest, type TurnDaemonCommand } from '@sammo-ts/common';
|
|
import { createGamePostgresConnector, type GamePrismaClient, type GamePrisma } from '@sammo-ts/infra';
|
|
import { buildWorld } from './officeRequestFixture.js';
|
|
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
|
import { EngineStateManager } from '../src/turn/engineStateManager.js';
|
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
|
import { createCityOfficeRequestCalendarHandler } from '../src/turn/cityOfficeRequests.js';
|
|
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
|
|
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
|
const integration = describe.skipIf(!databaseUrl);
|
|
const worldId = 992951;
|
|
const generalId = 992953;
|
|
const requestId = 'integration:city-office-request';
|
|
const constraint = 'city_office_request_rollback_test';
|
|
integration('city office request durable persistence', () => {
|
|
let db: GamePrismaClient;
|
|
let disconnect: (() => Promise<void>) | undefined;
|
|
let hooks: DatabaseTurnHooks | undefined;
|
|
beforeAll(async () => {
|
|
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
|
if (!schema || !/^(ci_|office_requests)/.test(schema)) throw new Error('Dedicated integration schema required');
|
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
|
await connector.connect();
|
|
db = connector.prisma;
|
|
disconnect = () => connector.disconnect();
|
|
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT IF EXISTS ${constraint}`);
|
|
await db.inputEvent.deleteMany({ where: { requestId } });
|
|
await db.logEntry.deleteMany({
|
|
where: { OR: [{ nationId: worldId }, { generalId: { in: [worldId, worldId + 1, generalId] } }] },
|
|
});
|
|
await db.rankData.deleteMany({ where: { generalId: { in: [worldId, worldId + 1, generalId] } } });
|
|
await db.general.deleteMany({ where: { id: { in: [worldId, worldId + 1, generalId] } } });
|
|
await db.city.deleteMany({ where: { id: worldId } });
|
|
await db.nation.deleteMany({ where: { id: worldId } });
|
|
await db.worldState.deleteMany();
|
|
});
|
|
afterAll(async () => {
|
|
await hooks?.close();
|
|
await disconnect?.();
|
|
});
|
|
it('rolls back request and journal together, reloads pending state, then persists automatic appointment once', async () => {
|
|
const base = buildWorld({});
|
|
const state = {
|
|
...base.state,
|
|
id: worldId,
|
|
clockBaseTime: base.state.lastTurnTime,
|
|
clockTick: 0,
|
|
lastTurnTick: 0,
|
|
clockMode: 'manual' as const,
|
|
clockPhase: 'MANUAL' as const,
|
|
clockRevision: 1,
|
|
deadlineGeneration: 1,
|
|
clockWallAnchor: base.state.lastTurnTime,
|
|
};
|
|
const snapshot = {
|
|
...base.snapshot,
|
|
generals: base.snapshot.generals.map((g) => ({
|
|
...g,
|
|
id: 992950 + g.id,
|
|
nationId: worldId,
|
|
cityId: worldId,
|
|
userId: `user-${992950 + g.id}`,
|
|
})),
|
|
cities: base.snapshot.cities.map((c) => ({ ...c, id: worldId, nationId: worldId })),
|
|
nations: base.snapshot.nations.map((n) => ({
|
|
...n,
|
|
id: worldId,
|
|
capitalCityId: worldId,
|
|
chiefGeneralId: worldId,
|
|
})),
|
|
scenarioConfig: { ...base.snapshot.scenarioConfig!, environment: { mapName: 'che', unitSet: 'che' } },
|
|
};
|
|
await db.worldState.create({
|
|
data: {
|
|
id: worldId,
|
|
scenarioCode: 'office-requests',
|
|
currentYear: state.currentYear,
|
|
currentMonth: state.currentMonth,
|
|
tickSeconds: 600,
|
|
clockBaseTime: state.clockBaseTime,
|
|
clockTick: 0n,
|
|
lastTurnTick: 0n,
|
|
clockMode: 'manual',
|
|
clockPhase: 'MANUAL',
|
|
clockWallAnchor: state.clockWallAnchor,
|
|
clockRevision: 1n,
|
|
deadlineGeneration: 1n,
|
|
config: JSON.parse(JSON.stringify(snapshot.scenarioConfig)) as GamePrisma.InputJsonValue,
|
|
meta: state.meta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.nation.create({
|
|
data: {
|
|
id: worldId,
|
|
name: '위',
|
|
color: '#777777',
|
|
level: 3,
|
|
gold: 10000,
|
|
rice: 10000,
|
|
tech: 0,
|
|
typeCode: 'che_중립',
|
|
capitalCityId: worldId,
|
|
meta: {},
|
|
},
|
|
});
|
|
await db.city.create({
|
|
data: {
|
|
id: worldId,
|
|
name: '허창',
|
|
nationId: worldId,
|
|
level: 7,
|
|
population: 1000,
|
|
populationMax: 2000,
|
|
agriculture: 1000,
|
|
agricultureMax: 2000,
|
|
commerce: 1000,
|
|
commerceMax: 2000,
|
|
security: 1000,
|
|
securityMax: 2000,
|
|
defence: 1000,
|
|
defenceMax: 2000,
|
|
wall: 1000,
|
|
wallMax: 2000,
|
|
trust: 80,
|
|
trade: 100,
|
|
region: 1,
|
|
meta: {},
|
|
},
|
|
});
|
|
await db.general.createMany({
|
|
data: snapshot.generals.map((g) => ({
|
|
id: g.id,
|
|
userId: g.userId,
|
|
name: g.name,
|
|
nationId: g.nationId,
|
|
cityId: g.cityId,
|
|
npcState: g.npcState,
|
|
officerLevel: g.officerLevel,
|
|
leadership: g.stats.leadership,
|
|
strength: g.stats.strength,
|
|
intel: g.stats.intelligence,
|
|
gold: g.gold,
|
|
rice: g.rice,
|
|
crew: g.crew,
|
|
crewTypeId: g.crewTypeId,
|
|
train: g.train,
|
|
atmos: g.atmos,
|
|
age: g.age,
|
|
turnTime: g.turnTime,
|
|
meta: g.meta,
|
|
})),
|
|
});
|
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
|
});
|
|
const handler = createTurnDaemonCommandHandler({ world });
|
|
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
|
const manager = new EngineStateManager();
|
|
manager.register('world', {
|
|
capture: () => world.captureState(),
|
|
restore: (saved) => world.restoreState(saved),
|
|
});
|
|
const command: Extract<TurnDaemonCommand, { type: 'cityOfficeRequest' }> = {
|
|
type: 'cityOfficeRequest',
|
|
requestId,
|
|
userId: `user-${generalId}`,
|
|
generalId,
|
|
action: 'request',
|
|
officerLevel: 4,
|
|
};
|
|
await db.inputEvent.create({
|
|
data: {
|
|
requestId,
|
|
target: 'ENGINE',
|
|
eventType: command.type,
|
|
actorUserId: command.userId,
|
|
status: 'PROCESSING',
|
|
lockedBy: 'office-test',
|
|
leaseUntil: new Date('2099-01-01'),
|
|
attempts: 1,
|
|
payload: command,
|
|
acceptedGameTick: 0n,
|
|
acceptedClockRevision: 1n,
|
|
acceptedDeadlineGeneration: 1n,
|
|
processingGameTick: 0n,
|
|
processingClockRevision: 1n,
|
|
processingDeadlineGeneration: 1n,
|
|
},
|
|
});
|
|
const execute = () =>
|
|
manager.transaction(() =>
|
|
hooks!.hooks.executeCommand!(requestId, async (context) => {
|
|
const result = await handler.handle(command, context);
|
|
if (!result) throw new Error('Missing result');
|
|
return result;
|
|
})
|
|
);
|
|
await db.$executeRawUnsafe(
|
|
`ALTER TABLE input_event ADD CONSTRAINT ${constraint} CHECK (request_id <> '${requestId}' OR status <> 'SUCCEEDED')`
|
|
);
|
|
await expect(execute()).rejects.toThrow();
|
|
expect(readOfficeRequest(world.getGeneralById(generalId)?.meta)).toBeNull();
|
|
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)).toBeNull();
|
|
expect(hooks.takeCommittedReadModelChanges()).toBeNull();
|
|
await db.$executeRawUnsafe(`ALTER TABLE input_event DROP CONSTRAINT ${constraint}`);
|
|
await expect(execute()).resolves.toMatchObject({ type: 'cityOfficeRequest', ok: true });
|
|
expect((await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).status).toBe('SUCCEEDED');
|
|
expect(readOfficeRequest((await db.general.findUniqueOrThrow({ where: { id: generalId } })).meta)?.status).toBe(
|
|
'pending'
|
|
);
|
|
expect(hooks.takeCommittedReadModelChanges()).toMatchObject({ frontStatusChanged: true });
|
|
await hooks.close();
|
|
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
|
const reloaded: InMemoryTurnWorld = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
|
calendarHandler: createCityOfficeRequestCalendarHandler(() => reloaded),
|
|
});
|
|
const pending = readOfficeRequest(reloaded.getGeneralById(generalId)?.meta)!;
|
|
expect(pending.status).toBe('pending');
|
|
hooks = await createDatabaseTurnHooks(databaseUrl!, reloaded);
|
|
const turnTime = reloaded.gameTickToDate(pending.dueTick);
|
|
await reloaded.advanceMonth(turnTime);
|
|
await hooks.hooks.flushChanges?.({
|
|
lastTurnTime: turnTime.toISOString(),
|
|
processedGenerals: 0,
|
|
processedTurns: 0,
|
|
durationMs: 0,
|
|
partial: false,
|
|
});
|
|
const saved = await db.general.findUniqueOrThrow({ where: { id: generalId } });
|
|
expect(saved.officerLevel).toBe(4);
|
|
expect(readOfficeRequest(saved.meta)?.status).toBe('approved');
|
|
expect((await db.city.findUniqueOrThrow({ where: { id: worldId } })).meta).toMatchObject({ officer_set: 16 });
|
|
const count = await db.logEntry.count({ where: { generalId } });
|
|
await hooks.hooks.flushChanges?.({
|
|
lastTurnTime: turnTime.toISOString(),
|
|
processedGenerals: 0,
|
|
processedTurns: 0,
|
|
durationMs: 0,
|
|
partial: false,
|
|
});
|
|
expect(await db.logEntry.count({ where: { generalId } })).toBe(count);
|
|
});
|
|
});
|