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,252 @@
import { describe, expect, it } from 'vitest';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js';
import {
buildCoreTurnCommandWorldInput,
runCoreTurnCommandTrace,
type TurnCommandFixtureRequest,
} from '../src/turn-differential/coreCommandTrace.js';
const cityStats = {
population: 1_000,
agriculture: 100,
commerce: 100,
security: 100,
defence: 100,
wall: 100,
};
const map: MapDefinition = {
id: 'clock-projection-test',
name: 'clock projection test',
cities: [
{
id: 1,
name: '테스트시',
level: 5,
region: 1,
position: { x: 0, y: 0 },
connections: [],
initial: cityStats,
max: cityStats,
},
],
};
const unitSet: UnitSetDefinition = {
id: 'clock-projection-test',
name: 'clock projection test',
defaultCrewTypeId: 1100,
crewTypes: [],
};
const referenceBefore: CanonicalTurnSnapshot = {
schemaVersion: 1,
engine: 'ref',
world: {
year: 185,
month: 1,
tickMinutes: 60,
lastTurnTick: 24_229_750_000,
// Ref snapshots use MySQL's timezone-less microsecond representation.
turnTime: '2026-08-22 14:02:55.000000',
gameNow: '2026-08-22 14:02:55.000000',
},
generals: [
{
id: 1,
name: '장수',
nationId: 0,
cityId: 1,
troopId: 0,
officerLevel: 0,
turnTick: 24_245_250_000,
turnTime: '2026-08-22 14:28:45.000000',
},
],
rankData: [],
cities: [{ id: 1, name: '테스트시', nationId: 0, level: 5 }],
nations: [],
troops: [],
diplomacy: [],
generalTurns: [],
nationTurns: [],
logs: [],
messages: [],
watermarks: { logId: 0, historyLogId: 0, messageId: 0 },
};
describe('turn command fixture GameClock projection', () => {
it('preserves Ref absolute dates when materializing persisted ticks', () => {
const request: TurnCommandFixtureRequest = {
kind: 'general',
actorGeneralId: 1,
action: '휴식',
};
const input = buildCoreTurnCommandWorldInput(request, referenceBefore, unitSet, map);
const world = new InMemoryTurnWorld(input.state, input.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
});
expect(world.getState().lastTurnTime.toISOString()).toBe('2026-08-22T14:02:55.000Z');
expect(world.getGeneralById(1)?.turnTime.toISOString()).toBe('2026-08-22T14:28:45.000Z');
});
it('injects Ref gameNow instead of the later actor turn time into command messages', async () => {
const commandSnapshot: CanonicalTurnSnapshot = {
...referenceBefore,
world: {
...referenceBefore.world,
initYear: 180,
initMonth: 1,
develCost: 100,
gameNow: '2026-08-22 14:02:55.123456',
},
generals: [
{
...referenceBefore.generals[0],
nationId: 1,
cityId: 3,
officerLevel: 12,
gold: 100_000,
rice: 100_000,
},
{
...referenceBefore.generals[0],
id: 2,
name: '수신자',
nationId: 2,
cityId: 70,
officerLevel: 1,
},
],
cities: [
{
id: 3,
name: '아국도시',
nationId: 1,
level: 5,
population: 100_000,
populationMax: 200_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 0,
state: 0,
trust: 80,
trade: 100,
},
{
id: 70,
name: '타국도시',
nationId: 2,
level: 5,
population: 100_000,
populationMax: 200_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 0,
state: 0,
trust: 80,
trade: 100,
},
],
nations: [
{
id: 1,
name: '아국',
color: '#111111',
capitalCityId: 3,
gold: 1_000_000,
rice: 1_000_000,
power: 1_000,
level: 1,
typeCode: 'che_중립',
},
{
id: 2,
name: '타국',
color: '#222222',
capitalCityId: 70,
gold: 1_000_000,
rice: 1_000_000,
power: 1_000,
level: 1,
typeCode: 'che_중립',
},
],
};
const request: TurnCommandFixtureRequest = {
kind: 'general',
actorGeneralId: 1,
action: 'che_등용',
args: { destGeneralID: 2 },
setup: {
isolateWorld: true,
world: { startYear: 180, year: 185, month: 1, freezeClock: true },
},
observe: { generalIds: [1, 2], cityIds: [3, 70], nationIds: [1, 2], messageAfterId: 0 },
};
const trace = await runCoreTurnCommandTrace(request, commandSnapshot);
expect(trace.after.world.gameNow).toBe('2026-08-22 14:02:55.123456');
expect(trace.after.messages.map((message) => message.createdAt)).toEqual(['2026-08-22T14:02:55.123Z']);
});
it('keeps a stale nation gennum visible instead of masking an update omission with live membership', async () => {
const snapshot: CanonicalTurnSnapshot = {
...referenceBefore,
generals: Array.from({ length: 4 }, (_, index) => ({
...referenceBefore.generals[0],
id: index + 1,
name: `장수${index + 1}`,
nationId: 1,
cityId: 1,
officerLevel: index === 0 ? 12 : 1,
gold: 1_000,
rice: 1_000,
})),
cities: [{ ...referenceBefore.cities[0], nationId: 1 }],
nations: [
{
id: 1,
name: '위',
color: '#111111',
capitalCityId: 1,
gold: 1_000,
rice: 1_000,
power: 1_000,
level: 1,
typeCode: 'che_중립',
// Simulate a command that created three members but omitted
// the denormalized nation counter update.
generalCount: 1,
},
],
};
const trace = await runCoreTurnCommandTrace(
{
kind: 'general',
actorGeneralId: 1,
action: '휴식',
observe: { allGenerals: true, allNations: true },
},
snapshot
);
expect(trace.before.generals.filter((general) => general.nationId === 1)).toHaveLength(4);
expect(trace.before.nations).toEqual([expect.objectContaining({ id: 1, generalCount: 1 })]);
expect(trace.after.nations).toEqual([expect.objectContaining({ id: 1, generalCount: 1 })]);
});
});