fix: 커맨드 차등 생명주기와 로그 그래프를 보강

장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다.

실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
2026-08-23 21:48:29 +00:00
parent 85591c68ad
commit c63a49bd07
128 changed files with 8615 additions and 848 deletions
@@ -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);
});