인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user