fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js';
|
||||
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
|
||||
import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js';
|
||||
import { InMemoryReservedTurnStore } from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '@sammo-ts/game-engine/turn/types.js';
|
||||
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
|
||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||
import { loadTurnWorldFromDatabase } from '@sammo-ts/game-engine/turn/worldLoader.js';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import {
|
||||
clearCoreTurnCommandPersistenceFixture,
|
||||
seedCoreTurnCommandPersistenceFixture,
|
||||
} from '../src/turn-differential/coreCommandPersistenceFixture.js';
|
||||
import {
|
||||
buildCoreTurnCommandWorldInput,
|
||||
createCoreTurnCommandProfile,
|
||||
runCoreTurnCommandTrace,
|
||||
} from '../src/turn-differential/coreCommandTrace.js';
|
||||
import { readCoreDatabaseSnapshot } from '../src/turn-differential/databaseSnapshot.js';
|
||||
import {
|
||||
addedFullLifecycleReferenceLogs,
|
||||
fullLifecycleSnapshotSelector,
|
||||
fullLifecycleTurnCommandRequest as request,
|
||||
projectFullLifecycleSnapshotGraph,
|
||||
} from '../src/turn-differential/fullLifecycleFixture.js';
|
||||
import { runReferenceFullLifecycleTrace } from '../src/turn-differential/fullLifecycleTrace.js';
|
||||
import { normalizeStoredTurnLogText, orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
|
||||
import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js';
|
||||
|
||||
const databaseUrl = process.env.TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL;
|
||||
const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const referenceSourceRoot = workspaceRoot
|
||||
? path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam'))
|
||||
: null;
|
||||
const hasFullLifecycleRunner =
|
||||
referenceSourceRoot !== null &&
|
||||
fs.existsSync(path.join(referenceSourceRoot, 'hwe/compare/turn_full_lifecycle_trace.php'));
|
||||
const databaseIntegration = describe.skipIf(!databaseUrl);
|
||||
const leaseOwner = 'turn-full-lifecycle-persistence-daemon';
|
||||
const dedicatedSuffix = 'turn_full_lifecycle_persistence';
|
||||
|
||||
export const assertDedicatedTurnFullLifecycleDatabase = (rawUrl: string): void => {
|
||||
const url = new URL(rawUrl);
|
||||
const schema = url.searchParams.get('schema');
|
||||
const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
||||
if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) {
|
||||
throw new Error(
|
||||
`Refusing to mutate non-dedicated turn full-lifecycle database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
describe('turn full-lifecycle persistence database guard', () => {
|
||||
it('rejects a shared database and schema before connecting', () => {
|
||||
expect(() =>
|
||||
assertDedicatedTurnFullLifecycleDatabase('postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public')
|
||||
).toThrow('Refusing to mutate non-dedicated turn full-lifecycle database');
|
||||
});
|
||||
|
||||
it('accepts only an explicitly dedicated schema or database name', () => {
|
||||
expect(() =>
|
||||
assertDedicatedTurnFullLifecycleDatabase(
|
||||
'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_turn_full_lifecycle_persistence'
|
||||
)
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertDedicatedTurnFullLifecycleDatabase(
|
||||
'postgresql://fixture:fixture@127.0.0.1:5432/ci_turn_full_lifecycle_persistence'
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence', () => {
|
||||
let db: GamePrismaClient | undefined;
|
||||
let disconnect: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedTurnFullLifecycleDatabase(databaseUrl!);
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
disconnect = () => connector.disconnect();
|
||||
await clearCoreTurnCommandPersistenceFixture(db);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (db) {
|
||||
await clearCoreTurnCommandPersistenceFixture(db);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (db) {
|
||||
await clearCoreTurnCommandPersistenceFixture(db);
|
||||
}
|
||||
} finally {
|
||||
await disconnect?.();
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!workspaceRoot || !hasFullLifecycleRunner || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1')(
|
||||
'commits nation then general, both queue shifts, ordered logs, and reloadable state in one flush',
|
||||
async () => {
|
||||
if (!db) {
|
||||
throw new Error('fixture database is not connected');
|
||||
}
|
||||
const reference = runReferenceFullLifecycleTrace(workspaceRoot!, {
|
||||
...request,
|
||||
generalAction: request.action,
|
||||
generalArgs: request.args,
|
||||
nationAction: 'che_국호변경',
|
||||
nationArgs: { nationName: '수명주기국' },
|
||||
} as unknown as Record<string, unknown>);
|
||||
const expected = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const unitSet = await loadUnitSetDefinitionByName('che');
|
||||
const map = await loadMapDefinitionByName('che');
|
||||
const worldInput = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map);
|
||||
|
||||
await seedCoreTurnCommandPersistenceFixture(db, {
|
||||
worldInput,
|
||||
scenarioCode: 'turn-full-lifecycle-persistence',
|
||||
generalTurns: reference.before.generalTurns,
|
||||
nationTurns: reference.before.nationTurns,
|
||||
});
|
||||
const before = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector);
|
||||
expect(projectFullLifecycleSnapshotGraph(before)).toEqual(
|
||||
projectFullLifecycleSnapshotGraph(reference.before)
|
||||
);
|
||||
expect(projectFullLifecycleSnapshotGraph(before)).toEqual(
|
||||
projectFullLifecycleSnapshotGraph(expected.before)
|
||||
);
|
||||
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(loaded.snapshot.scenarioConfig).toEqual(worldInput.snapshot.scenarioConfig);
|
||||
expect(loaded.snapshot.scenarioMeta).toEqual(worldInput.snapshot.scenarioMeta);
|
||||
const reservedTurns = new InMemoryReservedTurnStore(db, {
|
||||
maxGeneralTurns: 30,
|
||||
maxNationTurns: 12,
|
||||
leaseOwner,
|
||||
leaseDurationMs: 60_000,
|
||||
});
|
||||
await reservedTurns.loadAll();
|
||||
const loadedActor = loaded.snapshot.generals.find((general) => general.id === request.actorGeneralId);
|
||||
if (!loadedActor) {
|
||||
throw new Error('fixture actor is missing after database load');
|
||||
}
|
||||
await reservedTurns.prepareTurnsForExecution(loadedActor.id, {
|
||||
nationId: loadedActor.nationId,
|
||||
officerLevel: loadedActor.officerLevel,
|
||||
});
|
||||
|
||||
const lifecycleActions: Array<{ kind: string; requestedAction: string; usedFallback: boolean }> = [];
|
||||
let world: InMemoryTurnWorld | null = null;
|
||||
const handler = await createReservedTurnHandler({
|
||||
reservedTurns,
|
||||
scenarioConfig: loaded.snapshot.scenarioConfig,
|
||||
scenarioMeta: loaded.snapshot.scenarioMeta,
|
||||
map: loaded.snapshot.map,
|
||||
unitSet: loaded.snapshot.unitSet,
|
||||
getWorld: () => world,
|
||||
now: () => new Date(loaded.state.lastTurnTime),
|
||||
commandProfile: createCoreTurnCommandProfile(request),
|
||||
onActionResolved: (entry) => {
|
||||
lifecycleActions.push({
|
||||
kind: entry.kind,
|
||||
requestedAction: entry.requestedAction,
|
||||
usedFallback: entry.usedFallback,
|
||||
});
|
||||
},
|
||||
});
|
||||
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: {
|
||||
entries: [
|
||||
{
|
||||
startMinute: 0,
|
||||
tickMinutes: Math.max(1, Math.round(loaded.state.tickSeconds / 60)),
|
||||
},
|
||||
],
|
||||
},
|
||||
generalTurnHandler: handler,
|
||||
});
|
||||
const actor = world.getGeneralById(request.actorGeneralId);
|
||||
if (!actor) {
|
||||
throw new Error('fixture actor is missing from executable world');
|
||||
}
|
||||
world.executeGeneralTurn(actor);
|
||||
|
||||
expect(lifecycleActions).toEqual([
|
||||
{ kind: 'nation', requestedAction: 'che_국호변경', usedFallback: false },
|
||||
{ kind: 'general', requestedAction: 'che_훈련', usedFallback: false },
|
||||
]);
|
||||
expect(reservedTurns.getGeneralTurn(actor.id, 0).action).toBe('휴식');
|
||||
expect(reservedTurns.getNationTurn(actor.nationId, actor.officerLevel, 0).action).toBe('휴식');
|
||||
const dirtyBeforeFlush = world.peekDirtyState();
|
||||
expect(dirtyBeforeFlush.generals.map((entry) => entry.id)).toContain(actor.id);
|
||||
expect(dirtyBeforeFlush.nations.map((entry) => entry.id)).toContain(actor.nationId);
|
||||
expect(dirtyBeforeFlush.logs.length).toBeGreaterThan(0);
|
||||
|
||||
const databaseBeforeFlush = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector);
|
||||
expect(projectFullLifecycleSnapshotGraph(databaseBeforeFlush)).toEqual(
|
||||
projectFullLifecycleSnapshotGraph(before)
|
||||
);
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
|
||||
try {
|
||||
if (!hooks.hooks.flushChanges) {
|
||||
throw new Error('database turn hooks do not expose flushChanges');
|
||||
}
|
||||
await hooks.hooks.flushChanges({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 1,
|
||||
processedTurns: 1,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
expect(world.peekDirtyState().logs).toEqual([]);
|
||||
expect(reservedTurns.peekDirtyState()).toMatchObject({
|
||||
generalIds: [],
|
||||
nationKeys: [],
|
||||
});
|
||||
|
||||
const after = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector);
|
||||
expect(projectFullLifecycleSnapshotGraph(after)).toEqual(
|
||||
projectFullLifecycleSnapshotGraph(reference.after)
|
||||
);
|
||||
expect(projectFullLifecycleSnapshotGraph(after)).toEqual(projectFullLifecycleSnapshotGraph(expected.after));
|
||||
expect(orderedSemanticLogStreams(after.logs)).toEqual(
|
||||
orderedSemanticLogStreams(addedFullLifecycleReferenceLogs(reference.before, reference.after))
|
||||
);
|
||||
expect(orderedSemanticLogStreams(after.logs)).toEqual(orderedSemanticLogStreams(expected.after.logs));
|
||||
const persistedActionTexts = after.logs
|
||||
.filter((entry) => String(entry.category).toLowerCase() === 'action')
|
||||
.map((entry) => normalizeStoredTurnLogText(entry.text));
|
||||
expect(persistedActionTexts.findIndex((text) => text.includes('국호를'))).toBeLessThan(
|
||||
persistedActionTexts.findIndex((text) => text.includes('훈련치가'))
|
||||
);
|
||||
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const reloadedActor = reloaded.snapshot.generals.find((general) => general.id === request.actorGeneralId);
|
||||
const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId);
|
||||
expect(reloadedActor).toMatchObject({
|
||||
train: expectedActor?.train,
|
||||
atmos: expectedActor?.atmos,
|
||||
experience: expectedActor?.experience,
|
||||
dedication: expectedActor?.dedication,
|
||||
});
|
||||
expect(asRecord(reloadedActor?.meta)).toMatchObject({
|
||||
killturn: expectedActor?.killTurn,
|
||||
myset: expectedActor?.mySet,
|
||||
});
|
||||
expect(reloaded.snapshot.nations.find((nation) => nation.id === actor.nationId)?.name).toBe('수명주기국');
|
||||
|
||||
const reloadedReservedTurns = new InMemoryReservedTurnStore(db, {
|
||||
maxGeneralTurns: 30,
|
||||
maxNationTurns: 12,
|
||||
});
|
||||
await reloadedReservedTurns.loadAll();
|
||||
expect(reloadedReservedTurns.getGeneralTurn(actor.id, 0).action).toBe('휴식');
|
||||
expect(reloadedReservedTurns.getNationTurn(actor.nationId, actor.officerLevel, 0).action).toBe('휴식');
|
||||
},
|
||||
180_000
|
||||
);
|
||||
|
||||
it('persists thirty resting turns for every command-created volunteer and reloads them', async () => {
|
||||
if (!db) {
|
||||
throw new Error('fixture database is not connected');
|
||||
}
|
||||
const map = await loadMapDefinitionByName('che');
|
||||
const unitSet = await loadUnitSetDefinitionByName('che');
|
||||
const cityDefinition = map.cities.find((city) => city.id === 3) ?? map.cities[0];
|
||||
if (!cityDefinition) {
|
||||
throw new Error('fixture map has no city');
|
||||
}
|
||||
const actorId = 101;
|
||||
const nationId = 11;
|
||||
const actionTime = new Date('0190-01-01T00:00:00.000Z');
|
||||
const actor: TurnGeneral = {
|
||||
id: actorId,
|
||||
userId: null,
|
||||
name: '영속화의병장',
|
||||
nationId,
|
||||
cityId: cityDefinition.id,
|
||||
troopId: 0,
|
||||
stats: { leadership: 90, strength: 80, intelligence: 70 },
|
||||
experience: 1_000,
|
||||
dedication: 1_000,
|
||||
officerLevel: 12,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 100_000,
|
||||
rice: 100_000,
|
||||
crew: 1_000,
|
||||
crewTypeId: 1_100,
|
||||
train: 50,
|
||||
atmos: 50,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
bornYear: 160,
|
||||
deadYear: 260,
|
||||
affinity: 50,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: {
|
||||
killturn: 24,
|
||||
officer_city: cityDefinition.id,
|
||||
belong: 10,
|
||||
permission: 'normal',
|
||||
},
|
||||
turnTime: actionTime,
|
||||
};
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: actionTime,
|
||||
clockBaseTime: actionTime,
|
||||
clockTick: 0,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: actionTime,
|
||||
lastTurnTick: 0,
|
||||
meta: {
|
||||
hiddenSeed: 'turn-command-volunteer-persistence',
|
||||
killturn: 24,
|
||||
lastTurnTime: actionTime.toISOString(),
|
||||
},
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {
|
||||
develCost: 100,
|
||||
openingPartYear: 3,
|
||||
defaultMaxGeneral: 500,
|
||||
initialNationGenLimit: 10,
|
||||
defaultNpcGold: 1_000,
|
||||
defaultNpcRice: 1_000,
|
||||
defaultCrewTypeId: 1_100,
|
||||
retirementYear: 80,
|
||||
randGenFirstName: ['가'],
|
||||
randGenMiddleName: [''],
|
||||
randGenLastName: ['가'],
|
||||
availablePersonality: ['che_안전'],
|
||||
},
|
||||
environment: { mapName: map.id, unitSet: unitSet.id },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: '명령 생성 장수 예약 턴 영속화',
|
||||
startYear: 180,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map,
|
||||
unitSet,
|
||||
nations: [
|
||||
{
|
||||
id: nationId,
|
||||
name: '의병국',
|
||||
color: '#777777',
|
||||
capitalCityId: cityDefinition.id,
|
||||
chiefGeneralId: actorId,
|
||||
gold: 1_000_000,
|
||||
rice: 1_000_000,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_명가',
|
||||
meta: {
|
||||
gennum: 1,
|
||||
tech: 1_000,
|
||||
strategic_cmd_limit: 0,
|
||||
turn_last_12: { command: '의병모집', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
],
|
||||
cities: [
|
||||
{
|
||||
id: cityDefinition.id,
|
||||
name: cityDefinition.name,
|
||||
nationId,
|
||||
level: cityDefinition.level,
|
||||
state: 0,
|
||||
population: cityDefinition.initial.population,
|
||||
populationMax: cityDefinition.max.population,
|
||||
agriculture: cityDefinition.initial.agriculture,
|
||||
agricultureMax: cityDefinition.max.agriculture,
|
||||
commerce: cityDefinition.initial.commerce,
|
||||
commerceMax: cityDefinition.max.commerce,
|
||||
security: cityDefinition.initial.security,
|
||||
securityMax: cityDefinition.max.security,
|
||||
supplyState: map.defaults?.supplyState ?? 1,
|
||||
frontState: map.defaults?.frontState ?? 0,
|
||||
defence: cityDefinition.initial.defence,
|
||||
defenceMax: cityDefinition.max.defence,
|
||||
wall: cityDefinition.initial.wall,
|
||||
wallMax: cityDefinition.max.wall,
|
||||
conflict: {},
|
||||
meta: {
|
||||
trust: map.defaults?.trust ?? 50,
|
||||
trade: map.defaults?.trade ?? 100,
|
||||
region: cityDefinition.region,
|
||||
},
|
||||
},
|
||||
],
|
||||
generals: [actor],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
};
|
||||
await seedCoreTurnCommandPersistenceFixture(db, {
|
||||
worldInput: { state, snapshot, map },
|
||||
scenarioCode: 'turn-command-volunteer-persistence',
|
||||
generalTurns: Array.from({ length: 30 }, (_, turnIndex) => ({
|
||||
generalId: actorId,
|
||||
turnIndex,
|
||||
action: '휴식',
|
||||
args: {},
|
||||
})),
|
||||
nationTurns: Array.from({ length: 12 }, (_, turnIndex) => ({
|
||||
nationId,
|
||||
officerLevel: 12,
|
||||
turnIndex,
|
||||
action: turnIndex === 0 ? 'che_의병모집' : '휴식',
|
||||
args: {},
|
||||
})),
|
||||
});
|
||||
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const reservedTurns = new InMemoryReservedTurnStore(db, {
|
||||
maxGeneralTurns: 30,
|
||||
maxNationTurns: 12,
|
||||
leaseOwner: `${leaseOwner}-volunteer`,
|
||||
leaseDurationMs: 60_000,
|
||||
});
|
||||
await reservedTurns.loadAll();
|
||||
const loadedActor = loaded.snapshot.generals.find((general) => general.id === actorId);
|
||||
if (!loadedActor) {
|
||||
throw new Error('volunteer fixture actor is missing after database load');
|
||||
}
|
||||
await reservedTurns.prepareTurnsForExecution(actorId, { nationId, officerLevel: 12 });
|
||||
|
||||
let world: InMemoryTurnWorld | null = null;
|
||||
const handler = await createReservedTurnHandler({
|
||||
reservedTurns,
|
||||
scenarioConfig: loaded.snapshot.scenarioConfig,
|
||||
scenarioMeta: loaded.snapshot.scenarioMeta,
|
||||
map: loaded.snapshot.map,
|
||||
unitSet: loaded.snapshot.unitSet,
|
||||
getWorld: () => world,
|
||||
now: () => new Date(loaded.state.lastTurnTime),
|
||||
commandProfile: {
|
||||
general: ['휴식'],
|
||||
nation: ['che_의병모집', '휴식'],
|
||||
},
|
||||
});
|
||||
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
generalTurnHandler: handler,
|
||||
});
|
||||
const executableActor = world.getGeneralById(actorId);
|
||||
if (!executableActor) {
|
||||
throw new Error('volunteer fixture actor is missing from executable world');
|
||||
}
|
||||
world.executeGeneralTurn(executableActor);
|
||||
|
||||
const createdIds = world
|
||||
.peekDirtyState()
|
||||
.createdGenerals.map((general) => general.id)
|
||||
.sort((left, right) => left - right);
|
||||
expect(createdIds).toEqual([102, 103, 104]);
|
||||
expect(reservedTurns.peekDirtyState().generalInitializationIds.sort((left, right) => left - right)).toEqual(
|
||||
createdIds
|
||||
);
|
||||
const restingTurns = Array.from({ length: 30 }, () => ({ action: '휴식', args: {} }));
|
||||
for (const generalId of createdIds) {
|
||||
expect(reservedTurns.getGeneralTurns(generalId)).toEqual(restingTurns);
|
||||
}
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
|
||||
try {
|
||||
if (!hooks.hooks.flushChanges) {
|
||||
throw new Error('database turn hooks do not expose flushChanges');
|
||||
}
|
||||
await hooks.hooks.flushChanges({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 1,
|
||||
processedTurns: 1,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
const persistedTurns = await db.generalTurn.findMany({
|
||||
where: { generalId: { in: createdIds } },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
});
|
||||
expect(persistedTurns).toHaveLength(createdIds.length * 30);
|
||||
for (const generalId of createdIds) {
|
||||
expect(
|
||||
persistedTurns
|
||||
.filter((turn) => turn.generalId === generalId)
|
||||
.map((turn) => ({ turnIdx: turn.turnIdx, action: turn.actionCode, args: turn.arg }))
|
||||
).toEqual(Array.from({ length: 30 }, (_, turnIdx) => ({ turnIdx, action: '휴식', args: {} })));
|
||||
}
|
||||
|
||||
const persistedNation = await db.nation.findUnique({ where: { id: nationId }, select: { meta: true } });
|
||||
expect(persistedNation?.meta).toMatchObject({ gennum: 4 });
|
||||
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(
|
||||
reloaded.snapshot.generals
|
||||
.filter((general) => createdIds.includes(general.id))
|
||||
.map((general) => general.id)
|
||||
.sort((left, right) => left - right)
|
||||
).toEqual(createdIds);
|
||||
expect(reloaded.snapshot.nations.find((nation) => nation.id === nationId)?.meta).toMatchObject({ gennum: 4 });
|
||||
const reloadedReservedTurns = new InMemoryReservedTurnStore(db, {
|
||||
maxGeneralTurns: 30,
|
||||
maxNationTurns: 12,
|
||||
});
|
||||
await reloadedReservedTurns.loadAll();
|
||||
for (const generalId of createdIds) {
|
||||
expect(reloadedReservedTurns.getGeneralTurns(generalId)).toEqual(restingTurns);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
Reference in New Issue
Block a user