test: compare reserved commands against legacy

This commit is contained in:
2026-07-25 14:17:04 +00:00
parent 1fef287b6b
commit 30cf395b55
8 changed files with 1265 additions and 3 deletions
@@ -21,8 +21,8 @@ canonical case
│ ├─ execute legacy GeneralCommand or NationCommand
│ └─ before/after snapshot + command RNG trace
└─ core2026 execution boundary
├─ snapshot selected PostgreSQL rows
├─ execute reserved turn or daemon command
├─ construct the in-memory turn world from the prepared ref snapshot
├─ execute the real reserved-turn handler
└─ before/after snapshot + command RNG trace
ref delta ─┐
@@ -52,9 +52,16 @@ normalized to the core `*Id` spelling before command identity comparison.
- Core integration tools
- `canonical.ts`: shared snapshot and trace contracts.
- `databaseSnapshot.ts`: PostgreSQL projection.
- `coreCommandTrace.ts`: real in-memory reserved-turn execution and
canonical projection.
- `trace.ts`: before/execute/after capture boundary.
- `compare.ts`: exact snapshot and delta comparison.
- `turnTraceFiles.integration.test.ts`: compares saved ref/core traces.
- `turnCommandGeneralMatrix.integration.test.ts`: 21 successful general
command paths, including the four-call `전투태세` completion path.
- `turnCommandNationMatrix.integration.test.ts`: 8 successful nation
command paths.
- `turnCommandCoreReference.integration.test.ts`: declaration and live
sortie fixtures.
The ref runner refuses mutation unless `TURN_DIFFERENTIAL_ENABLED=1` is present.
The wrapper injects it only into the disposable tool container. Direct
@@ -154,3 +161,14 @@ Compatibility is established per case only when:
3. the semantic delta comparison is empty;
4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence.
As of 2026-07-25, 21 general cases, 8 nation cases, declaration and live sortie
pass this boundary. Live sortie covers battle entry, conquest, defeated-general
neutralization and last-city nation collapse. This is 31 executable comparison
cases, not a claim that all 55 general and 38 nation command classes have been
dynamically compared.
The fixture runner also reports whether the requested legacy command reached
its completed execution path. For multi-turn commands this is derived from the
pre-execution `LastTurn`, because commands such as `전투태세` reset their result
term to `1` on the completion call.
@@ -113,6 +113,13 @@ export const projectCoreDatabaseSnapshot = (rows: {
experience: row.experience,
dedication: row.dedication,
officerLevel: row.officerLevel,
personality: row.personality ?? null,
specialDomestic: row.specialDomestic ?? null,
specialWar: row.specialWar ?? null,
itemHorse: row.itemHorse ?? null,
itemWeapon: row.itemWeapon ?? null,
itemBook: row.itemBook ?? null,
itemExtra: row.itemExtra ?? null,
injury: row.injury,
gold: row.gold,
rice: row.rice,
@@ -129,6 +136,11 @@ export const projectCoreDatabaseSnapshot = (rows: {
leadershipExp: readNumber(meta, 'leadership_exp'),
strengthExp: readNumber(meta, 'strength_exp'),
intelExp: readNumber(meta, 'intel_exp'),
dex1: readNumber(meta, 'dex1'),
dex2: readNumber(meta, 'dex2'),
dex3: readNumber(meta, 'dex3'),
dex4: readNumber(meta, 'dex4'),
dex5: readNumber(meta, 'dex5'),
killTurn: readNumber(meta, 'killturn'),
mySet: readNumber(meta, 'myset'),
};
@@ -175,6 +187,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
generalCount: readNumber(meta, 'gennum'),
power: readNumber(meta, 'power'),
war: readNumber(meta, 'war'),
diplomacyLimit: readNumber(meta, 'surlimit'),
meta,
};
});
@@ -63,6 +63,18 @@ const valuesEqual = (left: unknown, right: unknown, numericTolerance: number): b
if (typeof left === 'number' && typeof right === 'number') {
return Math.abs(left - right) <= numericTolerance;
}
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
return false;
}
return left.every((value, index) => valuesEqual(value, right[index], numericTolerance));
}
if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) {
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const keys = [...new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)])].sort();
return keys.every((key) => valuesEqual(leftRecord[key], rightRecord[key], numericTolerance));
}
return Object.is(left, right);
};
@@ -0,0 +1,663 @@
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import {
GENERAL_TURN_COMMAND_KEYS,
NATION_TURN_COMMAND_KEYS,
type MapDefinition,
type Nation,
type TurnCommandProfile,
type UnitSetDefinition,
} from '@sammo-ts/logic';
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 { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
import type {
TurnDiplomacy,
TurnGeneral,
TurnWorldSnapshot,
TurnWorldState,
} from '@sammo-ts/game-engine/turn/types.js';
import {
canonicalizeTurnCommandArgs,
type CanonicalTurnCommandTrace,
type CanonicalTurnSnapshot,
} from './canonical.js';
export interface TurnCommandFixtureRequest {
kind: 'general' | 'nation';
actorGeneralId: number;
action: string;
args?: unknown;
coreArgs?: unknown;
setup?: {
world?: {
startYear?: number;
year?: number;
month?: number;
hiddenSeed?: string;
};
isolateWorld?: boolean;
generals?: Array<Record<string, unknown>>;
nations?: Array<Record<string, unknown>>;
cities?: Array<Record<string, unknown>>;
diplomacy?: Array<Record<string, unknown>>;
};
observe?: {
generalIds?: number[];
cityIds?: number[];
nationIds?: number[];
logAfterId?: number;
messageAfterId?: number;
};
}
interface RandomCall {
seq: number;
operation: string;
arguments: Record<string, unknown>;
result: unknown;
}
class TracingRng implements RNG {
public readonly calls: RandomCall[] = [];
public constructor(private readonly inner: RNG) {}
public getMaxInt(): number {
return this.inner.getMaxInt();
}
public nextBytes(bytes: number): Uint8Array<ArrayBuffer> {
const result = this.inner.nextBytes(bytes);
this.record('nextBytes', { bytes }, Buffer.from(result).toString('hex'));
return result;
}
public nextBits(bits: number): Uint8Array<ArrayBuffer> {
const result = this.inner.nextBits(bits);
this.record('nextBits', { bits }, Buffer.from(result).toString('hex'));
return result;
}
public nextInt(max?: number): number {
const result = this.inner.nextInt(max);
this.record('nextInt', { maxInclusive: max ?? null }, result);
return result;
}
public nextFloat1(): number {
const result = this.inner.nextFloat1();
this.record('nextFloat1', {}, result);
return result;
}
private record(operation: string, args: Record<string, unknown>, result: unknown): void {
this.calls.push({
seq: this.calls.length,
operation,
arguments: args,
result,
});
}
}
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const readNumber = (record: Record<string, unknown>, key: string, fallback = 0): number => {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const readString = (record: Record<string, unknown>, key: string, fallback: string): string => {
const value = record[key];
return typeof value === 'string' ? value : fallback;
};
const readNullableString = (record: Record<string, unknown>, key: string): string | null => {
const value = record[key];
return typeof value === 'string' && value !== '' && value !== 'None' ? value : null;
};
const toDatabaseInt = (value: number): number => Math.round(value);
const COMMANDS_WITH_LEGACY_CORE_ARG_KEYS = new Set([
'che_장수대상임관',
'che_선양',
'che_증여',
'che_천도',
'che_몰수',
]);
const resolveCoreArgs = (request: TurnCommandFixtureRequest): Record<string, unknown> => {
const explicit = request.coreArgs;
if (explicit !== undefined) {
return asRecord(explicit);
}
if (COMMANDS_WITH_LEGACY_CORE_ARG_KEYS.has(request.action)) {
return asRecord(request.args);
}
return asRecord(canonicalizeTurnCommandArgs(request.args ?? {}));
};
const createCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandProfile => {
if (request.kind === 'general') {
if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) {
throw new Error(`Unknown general command: ${request.action}`);
}
return {
general: [request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number], '휴식'],
nation: ['휴식'],
};
}
if (!NATION_TURN_COMMAND_KEYS.includes(request.action as (typeof NATION_TURN_COMMAND_KEYS)[number])) {
throw new Error(`Unknown nation command: ${request.action}`);
}
return {
general: ['휴식'],
nation: [request.action as (typeof NATION_TURN_COMMAND_KEYS)[number], '휴식'],
};
};
const buildGeneral = (row: Record<string, unknown>, turnTime: Date): TurnGeneral => {
const meta = asRecord(row.meta);
const rawLastTurn = asRecord(row.lastTurn);
const lastTurn =
typeof rawLastTurn.command === 'string'
? {
command: rawLastTurn.command,
...(typeof rawLastTurn.term === 'number' ? { term: rawLastTurn.term } : {}),
...(typeof rawLastTurn.seq === 'number' ? { seq: rawLastTurn.seq } : {}),
...(Object.keys(asRecord(rawLastTurn.arg)).length > 0 ? { arg: asRecord(rawLastTurn.arg) } : {}),
}
: undefined;
return {
id: readNumber(row, 'id'),
name: readString(row, 'name', '장수'),
nationId: readNumber(row, 'nationId'),
cityId: readNumber(row, 'cityId'),
troopId: readNumber(row, 'troopId'),
stats: {
leadership: readNumber(row, 'leadership', 80),
strength: readNumber(row, 'strength', 70),
intelligence: readNumber(row, 'intelligence', 60),
},
experience: readNumber(row, 'experience'),
dedication: readNumber(row, 'dedication'),
officerLevel: readNumber(row, 'officerLevel', 1),
role: {
personality: readNullableString(row, 'personality'),
specialDomestic: readNullableString(row, 'specialDomestic'),
specialWar: readNullableString(row, 'specialWar'),
items: {
horse: readNullableString(row, 'itemHorse'),
weapon: readNullableString(row, 'itemWeapon'),
book: readNullableString(row, 'itemBook'),
item: readNullableString(row, 'itemExtra'),
},
},
injury: readNumber(row, 'injury'),
gold: readNumber(row, 'gold'),
rice: readNumber(row, 'rice'),
crew: readNumber(row, 'crew'),
crewTypeId: readNumber(row, 'crewTypeId', 1100),
train: readNumber(row, 'train'),
atmos: readNumber(row, 'atmos'),
age: readNumber(row, 'age', 30),
npcState: readNumber(row, 'npcState'),
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
...meta,
killturn: readNumber(row, 'killTurn', readNumber(meta, 'killturn', 24)),
leadership_exp: readNumber(row, 'leadershipExp', readNumber(meta, 'leadership_exp')),
strength_exp: readNumber(row, 'strengthExp', readNumber(meta, 'strength_exp')),
intel_exp: readNumber(row, 'intelExp', readNumber(meta, 'intel_exp')),
dex1: readNumber(row, 'dex1', readNumber(meta, 'dex1')),
dex2: readNumber(row, 'dex2', readNumber(meta, 'dex2')),
dex3: readNumber(row, 'dex3', readNumber(meta, 'dex3')),
dex4: readNumber(row, 'dex4', readNumber(meta, 'dex4')),
dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')),
explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')),
officerCityId: readNumber(row, 'officerCityId', readNumber(meta, 'officerCityId')),
block: readNumber(row, 'blockState', readNumber(meta, 'block')),
},
...(lastTurn ? { lastTurn } : {}),
turnTime,
recentWarTime: null,
};
};
const buildNation = (row: Record<string, unknown>, generals: TurnGeneral[]): Nation => {
const id = readNumber(row, 'id');
const meta = asRecord(row.meta);
return {
id,
name: readString(row, 'name', `국가${id}`),
color: readString(row, 'color', '#777777'),
capitalCityId: readNumber(row, 'capitalCityId') || null,
chiefGeneralId: generals.find((general) => general.nationId === id && general.officerLevel === 12)?.id ?? null,
gold: readNumber(row, 'gold'),
rice: readNumber(row, 'rice'),
power: readNumber(row, 'power'),
level: readNumber(row, 'level', 1),
typeCode: readString(row, 'typeCode', 'che_중립'),
meta: {
...meta,
tech: readNumber(row, 'tech', readNumber(meta, 'tech')),
gennum: readNumber(row, 'generalCount', readNumber(meta, 'gennum')),
war: readNumber(row, 'war', readNumber(meta, 'war')),
surlimit: readNumber(row, 'diplomacyLimit', readNumber(meta, 'surlimit')),
},
};
};
const buildWorldInput = (
request: TurnCommandFixtureRequest,
referenceBefore: CanonicalTurnSnapshot,
unitSet: UnitSetDefinition,
map: MapDefinition
): { state: TurnWorldState; snapshot: TurnWorldSnapshot; map: MapDefinition } => {
const year = readNumber(referenceBefore.world, 'year', request.setup?.world?.year ?? 185);
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
const nations = referenceBefore.nations.map((row) => buildNation(row, generals));
const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const));
const diplomacy: TurnDiplomacy[] = referenceBefore.diplomacy.map((row) => ({
fromNationId: readNumber(row, 'fromNationId'),
toNationId: readNumber(row, 'toNationId'),
state: readNumber(row, 'state', 3),
term: readNumber(row, 'term'),
dead: readNumber(row, 'dead'),
meta: {},
}));
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
develCost: readNumber(referenceBefore.world, 'develCost'),
trainDelta: 30,
atmosDelta: 30,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
openingPartYear: 3,
initialNationGenLimit: 10,
maxGeneral: 500,
baseGold: 0,
baseRice: 2_000,
maxResourceActionAmount: 10_000,
maxTechLevel: 12,
maxLevel: 255,
maxDedLevel: 30,
upgradeLimit: 30,
},
environment: { mapName: map.id, unitSet: unitSet.id },
},
scenarioMeta: {
title: '턴 명령 차등',
startYear: request.setup?.world?.startYear ?? Math.max(1, year - 5),
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
},
map,
unitSet,
nations,
cities: map.cities.map((definition) => {
const row = observedCityRows.get(definition.id) ?? {};
return {
id: definition.id,
name: readString(row, 'name', definition.name),
nationId: readNumber(row, 'nationId'),
level: readNumber(row, 'level', definition.level),
state: readNumber(row, 'state'),
population: readNumber(row, 'population', definition.initial.population),
populationMax: readNumber(row, 'populationMax', definition.max.population),
agriculture: readNumber(row, 'agriculture', definition.initial.agriculture),
agricultureMax: readNumber(row, 'agricultureMax', definition.max.agriculture),
commerce: readNumber(row, 'commerce', definition.initial.commerce),
commerceMax: readNumber(row, 'commerceMax', definition.max.commerce),
security: readNumber(row, 'security', definition.initial.security),
securityMax: readNumber(row, 'securityMax', definition.max.security),
supplyState: readNumber(row, 'supplyState', map.defaults?.supplyState ?? 1),
frontState: readNumber(row, 'frontState', map.defaults?.frontState ?? 0),
defence: readNumber(row, 'defence', definition.initial.defence),
defenceMax: readNumber(row, 'defenceMax', definition.max.defence),
wall: readNumber(row, 'wall', definition.initial.wall),
wallMax: readNumber(row, 'wallMax', definition.max.wall),
meta: {
trust: readNumber(row, 'trust', map.defaults?.trust ?? 50),
trade: readNumber(row, 'trade', map.defaults?.trade ?? 100),
term: readNumber(row, 'term'),
},
};
}),
generals,
troops: [],
diplomacy,
events: [],
initialEvents: [],
};
return {
state: {
id: 1,
currentYear: year,
currentMonth: month,
tickSeconds: readNumber(referenceBefore.world, 'tickMinutes', 10) * 60,
lastTurnTime: turnTime,
meta: {
hiddenSeed: request.setup?.world?.hiddenSeed ?? 'turn-command-differential-seed',
killturn: 24,
isUnited: readNumber(referenceBefore.world, 'isUnited'),
scenarioId: readNumber(referenceBefore.world, 'scenarioId'),
initYear: readNumber(referenceBefore.world, 'initYear', request.setup?.world?.startYear ?? year),
initMonth: readNumber(referenceBefore.world, 'initMonth', 1),
},
},
snapshot,
map,
};
};
const projectWorld = (
world: InMemoryTurnWorld,
reservedTurns: InMemoryReservedTurnStore,
logs: CanonicalTurnSnapshot['logs'],
messages: CanonicalTurnSnapshot['messages'],
selector: {
generalIds: Set<number>;
cityIds: Set<number>;
nationIds: Set<number>;
}
): CanonicalTurnSnapshot => {
const state = world.getState();
const generals = world
.listGenerals()
.filter((general) => selector.generalIds.has(general.id))
.map((general) => ({
id: general.id,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
leadership: general.stats.leadership,
strength: general.stats.strength,
intelligence: general.stats.intelligence,
experience: toDatabaseInt(general.experience),
dedication: toDatabaseInt(general.dedication),
expLevel: readNumber(general.meta, 'explevel'),
officerLevel: general.officerLevel,
personality: general.role.personality,
specialDomestic: general.role.specialDomestic,
specialWar: general.role.specialWar,
itemHorse: general.role.items.horse,
itemWeapon: general.role.items.weapon,
itemBook: general.role.items.book,
itemExtra: general.role.items.item,
injury: general.injury,
gold: toDatabaseInt(general.gold),
rice: toDatabaseInt(general.rice),
crew: toDatabaseInt(general.crew),
crewTypeId: general.crewTypeId,
train: toDatabaseInt(general.train),
atmos: toDatabaseInt(general.atmos),
age: general.age,
npcState: general.npcState,
turnTime: general.turnTime.toISOString(),
recentWarTime: general.recentWarTime?.toISOString() ?? null,
lastTurn: general.lastTurn ?? null,
meta: general.meta,
leadershipExp: toDatabaseInt(readNumber(general.meta, 'leadership_exp')),
strengthExp: toDatabaseInt(readNumber(general.meta, 'strength_exp')),
intelExp: toDatabaseInt(readNumber(general.meta, 'intel_exp')),
dex1: toDatabaseInt(readNumber(general.meta, 'dex1')),
dex2: toDatabaseInt(readNumber(general.meta, 'dex2')),
dex3: toDatabaseInt(readNumber(general.meta, 'dex3')),
dex4: toDatabaseInt(readNumber(general.meta, 'dex4')),
dex5: toDatabaseInt(readNumber(general.meta, 'dex5')),
killTurn: readNumber(general.meta, 'killturn'),
mySet: readNumber(general.meta, 'myset'),
}));
const nations = world.listNations();
return {
schemaVersion: 1,
engine: 'core2026',
world: {
year: state.currentYear,
month: state.currentMonth,
tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)),
turnTime: state.lastTurnTime.toISOString(),
isUnited: readNumber(state.meta, 'isUnited'),
},
generals,
cities: world
.listCities()
.filter((city) => selector.cityIds.has(city.id))
.map((city) => ({
id: city.id,
name: city.name,
nationId: city.nationId,
level: city.level,
population: toDatabaseInt(city.population),
populationMax: city.populationMax,
agriculture: toDatabaseInt(city.agriculture),
agricultureMax: city.agricultureMax,
commerce: toDatabaseInt(city.commerce),
commerceMax: city.commerceMax,
security: toDatabaseInt(city.security),
securityMax: city.securityMax,
supplyState: city.supplyState,
frontState: city.frontState,
defence: toDatabaseInt(city.defence),
defenceMax: city.defenceMax,
wall: toDatabaseInt(city.wall),
wallMax: city.wallMax,
state: city.state,
term: readNumber(city.meta, 'term'),
trust: readNumber(city.meta, 'trust'),
trade: readNumber(city.meta, 'trade'),
})),
nations: nations
.filter((nation) => selector.nationIds.has(nation.id))
.map((nation) => ({
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
gold: toDatabaseInt(nation.gold),
rice: toDatabaseInt(nation.rice),
tech: readNumber(nation.meta, 'tech'),
level: nation.level,
typeCode: nation.typeCode,
generalCount: world.listGenerals().filter((general) => general.nationId === nation.id).length,
power: nation.power,
war: readNumber(nation.meta, 'war'),
diplomacyLimit: readNumber(nation.meta, 'surlimit'),
meta: nation.meta,
})),
diplomacy: world
.listDiplomacy()
.filter((entry) => selector.nationIds.has(entry.fromNationId) && selector.nationIds.has(entry.toNationId))
.map((entry) => ({ ...entry })),
generalTurns: generals.flatMap((general) =>
reservedTurns.getGeneralTurns(Number(general.id)).map((turn, turnIndex) => ({
generalId: general.id,
turnIndex,
action: turn.action,
args: turn.args,
}))
),
nationTurns: nations.flatMap((nation) =>
[12, 11, 10, 9, 8, 7, 6, 5].flatMap((officerLevel) =>
reservedTurns.getNationTurns(nation.id, officerLevel).map((turn, turnIndex) => ({
nationId: nation.id,
officerLevel,
turnIndex,
action: turn.action,
args: turn.args,
}))
)
),
logs,
messages,
watermarks: { logId: logs.length, messageId: messages.length },
};
};
const emptyDatabaseClient = {
generalTurn: {
findMany: async () => [],
deleteMany: async () => ({ count: 0 }),
createMany: async () => ({ count: 0 }),
},
nationTurn: {
findMany: async () => [],
deleteMany: async () => ({ count: 0 }),
createMany: async () => ({ count: 0 }),
},
};
export const runCoreTurnCommandTrace = async (
request: TurnCommandFixtureRequest,
referenceBefore: CanonicalTurnSnapshot
): Promise<CanonicalTurnCommandTrace> => {
const unitSet = await loadUnitSetDefinitionByName('che');
const map = await loadMapDefinitionByName('che');
const worldInput = buildWorldInput(request, referenceBefore, unitSet, map);
const { state, snapshot } = worldInput;
const selector = {
generalIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))),
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
};
const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, {
maxGeneralTurns: 10,
maxNationTurns: 12,
});
await reservedTurns.loadAll();
const actor = snapshot.generals.find((general) => general.id === request.actorGeneralId);
if (!actor) {
throw new Error(`Missing actor general ${request.actorGeneralId}`);
}
const args = resolveCoreArgs(request);
if (request.kind === 'general') {
reservedTurns.getGeneralTurns(actor.id)[0] = { action: request.action, args };
} else {
reservedTurns.getNationTurns(actor.nationId, actor.officerLevel)[0] = {
action: request.action,
args,
};
}
let world: InMemoryTurnWorld | null = null;
let resolution:
| {
kind: 'nation' | 'general';
actionKey: string;
requestedAction: string;
usedFallback: boolean;
blockedReason?: string;
}
| undefined;
const commandRngCalls: RandomCall[] = [];
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
map,
unitSet,
getWorld: () => world,
commandProfile: createCommandProfile(request),
commandRngFactory: ({ kind, actionKey, seed }) => {
const tracing = new TracingRng(new LiteHashDRBG(seed));
if (kind === request.kind && actionKey === request.action) {
commandRngCalls.push(...tracing.calls);
return new RandUtil({
getMaxInt: () => tracing.getMaxInt(),
nextBytes: (bytes) => {
const result = tracing.nextBytes(bytes);
commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls);
return result;
},
nextBits: (bits) => {
const result = tracing.nextBits(bits);
commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls);
return result;
},
nextInt: (max) => {
const result = tracing.nextInt(max);
commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls);
return result;
},
nextFloat1: () => {
const result = tracing.nextFloat1();
commandRngCalls.splice(0, commandRngCalls.length, ...tracing.calls);
return result;
},
});
}
return new RandUtil(new LiteHashDRBG(seed));
},
onActionResolved: (payload) => {
if (payload.kind === request.kind && payload.requestedAction === request.action) {
resolution = payload;
}
},
});
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
});
const before = projectWorld(world, reservedTurns, [], [], selector);
world.executeGeneralTurn(actor);
const dirty = world.peekDirtyState();
const after = projectWorld(
world,
reservedTurns,
dirty.logs.map((log, index) => ({
id: index + 1,
scope: log.scope,
category: log.category,
generalId: log.generalId ?? actor.id,
nationId: log.nationId ?? actor.nationId,
year: state.currentYear,
month: state.currentMonth,
text: log.text,
})),
dirty.messages.map((message, index) => ({
id: index + 1,
payload: message,
})),
selector
);
return {
schemaVersion: 1,
engine: 'core2026',
execution: {
kind: request.kind,
actorGeneralId: request.actorGeneralId,
action: request.action,
args,
seedDomain: request.kind === 'general' ? 'generalCommand' : 'nationCommand',
outcome: resolution,
},
before,
after,
rng: commandRngCalls,
};
};
@@ -62,3 +62,22 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath:
});
return JSON.parse(stdout) as CanonicalTurnCommandTrace;
};
export const runReferenceTurnCommandTraceRequest = (
workspaceRoot: string,
request: Record<string, unknown>
): CanonicalTurnCommandTrace => {
const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference');
const runner = process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const stdout = execFileSync(runner, ['-'], {
cwd: stackDirectory,
input: JSON.stringify(request),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
},
});
return JSON.parse(stdout) as CanonicalTurnCommandTrace;
};
@@ -0,0 +1,84 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
} from '../src/turn-differential/referenceSnapshot.js';
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
const ignoredLifecyclePaths = [
/^generalTurns/,
/^nationTurns/,
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
/^nations\[[^\]]+\]\.meta(?:\.|$)/,
];
const readFixture = (relativePath: string): TurnCommandFixtureRequest => {
const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference');
const fixture = JSON.parse(
fs.readFileSync(path.join(stackRoot, relativePath), 'utf8')
) as TurnCommandFixtureRequest;
return {
...fixture,
setup: {
...fixture.setup,
world: {
...fixture.setup?.world,
hiddenSeed: 'turn-command-differential-seed',
},
generals: fixture.setup?.generals?.map((general) => ({
...general,
personality: 'None',
specialDomestic: 'None',
specialWar: 'None',
itemHorse: 'None',
itemWeapon: 'None',
itemBook: 'None',
itemExtra: 'None',
})),
},
};
};
integration('core ↔ legacy command-boundary differential', () => {
it.each([
['nation declaration', 'fixtures/turn-differential/nation-declaration.json'],
['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'],
])(
'%s matches command RNG and canonical state delta',
async (_label, fixturePath) => {
const request = readFixture(fixturePath);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(core.execution.outcome).toMatchObject({
requestedAction: request.action,
actionKey: request.action,
usedFallback: false,
});
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
@@ -0,0 +1,241 @@
import { describe, expect, it } from 'vitest';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
} from '../src/turn-differential/referenceSnapshot.js';
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
const ignoredLifecyclePaths = [
/^generalTurns/,
/^nationTurns/,
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
/^nations\[[^\]]+\]\.meta(?:\.|$)/,
];
const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record<string, unknown> => ({
id,
nationId,
cityId,
troopId: 0,
leadership: 90,
strength: 80,
intelligence: 70,
leadershipExp: 0,
strengthExp: 0,
intelExp: 0,
experience: 1000,
dedication: 1000,
expLevel: 0,
officerLevel,
officerCityId: officerLevel >= 5 ? cityId : 0,
injury: 0,
age: 30,
gold: 100_000,
rice: 100_000,
crew: 1_000,
crewTypeId: 1100,
train: 50,
atmos: 50,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
killTurn: 24,
npcState: 0,
blockState: 0,
personality: 'None',
specialDomestic: 'None',
specialWar: 'None',
itemHorse: 'None',
itemWeapon: 'None',
itemBook: 'None',
itemExtra: 'None',
meta: {},
});
const buildRequest = (
action: string,
args?: Record<string, unknown>,
actorPatch: Record<string, unknown> = {}
): TurnCommandFixtureRequest => ({
kind: 'general',
actorGeneralId: 1,
action,
...(args ? { args } : {}),
setup: {
isolateWorld: true,
world: {
startYear: 180,
year: 190,
month: 1,
hiddenSeed: 'turn-command-general-matrix-v1',
},
nations: [
{
id: 1,
name: '아국',
capitalCityId: 3,
gold: 1_000_000,
rice: 1_000_000,
tech: 1000,
level: 1,
typeCode: 'che_중립',
war: 0,
generalCount: 2,
meta: {},
},
{
id: 2,
name: '타국',
capitalCityId: 70,
gold: 1_000_000,
rice: 1_000_000,
tech: 1000,
level: 1,
typeCode: 'che_중립',
war: 0,
generalCount: 1,
meta: {},
},
],
cities: [
{
id: 3,
nationId: 1,
population: 100_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 0,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
{
id: 70,
nationId: 2,
population: 100_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 1,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
],
generals: [{ ...general(1, 1, 3, 12), ...actorPatch }, general(2, 2, 70, 12), general(3, 1, 3, 1)],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 0, term: 12, dead: 0 },
{ fromNationId: 2, toNationId: 1, state: 0, term: 12, dead: 0 },
],
},
observe: {
generalIds: [1, 2, 3],
cityIds: [3, 70],
nationIds: [1, 2],
logAfterId: 0,
messageAfterId: 0,
},
});
const cases: Array<[string, Record<string, unknown> | undefined, Record<string, unknown> | undefined]> = [
['휴식', undefined, undefined],
['che_훈련', undefined, undefined],
['cr_맹훈련', undefined, undefined],
['che_전투태세', undefined, { lastTurn: { command: '전투태세', term: 3 } }],
['che_단련', undefined, undefined],
['che_사기진작', undefined, undefined],
['che_요양', undefined, { injury: 30 }],
['che_견문', undefined, undefined],
['che_주민선정', undefined, undefined],
['che_정착장려', undefined, undefined],
['che_농지개간', undefined, undefined],
['che_상업투자', undefined, undefined],
['che_기술연구', undefined, undefined],
['che_치안강화', undefined, undefined],
['che_수비강화', undefined, undefined],
['che_성벽보수', undefined, undefined],
['che_인재탐색', undefined, undefined],
['che_소집해제', undefined, undefined],
['che_군량매매', { buyRice: true, amount: 100 }, undefined],
['che_물자조달', undefined, undefined],
['che_헌납', { isGold: true, amount: 100 }, undefined],
];
integration('general command success matrix', () => {
it.each(cases)(
'%s matches the legacy state delta and command RNG',
async (action, args, actorPatch) => {
const request = buildRequest(action, args, actorPatch);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') {
process.stderr.write(
`${JSON.stringify(
{
action,
referenceRng: reference.rng,
coreRng: core.rng,
referenceGeneralDelta: compareTurnSnapshotDeltas(
reference.before,
reference.after,
reference.before,
reference.before,
{ ignoredPathPatterns: ignoredLifecyclePaths }
).filter((entry) => entry.path.startsWith('generals')),
coreGeneralDelta: compareTurnSnapshotDeltas(
core.before,
core.after,
core.before,
core.before,
{ ignoredPathPatterns: ignoredLifecyclePaths }
).filter((entry) => entry.path.startsWith('generals')),
referenceGenerals: reference.after.generals,
coreGenerals: core.after.generals,
},
null,
2
)}\n`
);
}
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).not.toHaveProperty('blockedReason');
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
} from '../src/turn-differential/referenceSnapshot.js';
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
const ignoredLifecyclePaths = [
/^generalTurns/,
/^nationTurns/,
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
/^nations\[[^\]]+\]\.meta(?:\.|$)/,
];
const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record<string, unknown> => ({
id,
nationId,
cityId,
troopId: 0,
leadership: 90,
strength: 80,
intelligence: 70,
leadershipExp: 0,
strengthExp: 0,
intelExp: 0,
experience: 1000,
dedication: 1000,
expLevel: 0,
officerLevel,
officerCityId: officerLevel >= 5 ? cityId : 0,
injury: 0,
age: 30,
gold: 100_000,
rice: 100_000,
crew: 1_000,
crewTypeId: 1100,
train: 50,
atmos: 50,
killTurn: 24,
npcState: 0,
blockState: 0,
personality: 'None',
specialDomestic: 'None',
specialWar: 'None',
itemHorse: 'None',
itemWeapon: 'None',
itemBook: 'None',
itemExtra: 'None',
meta: {},
});
const buildRequest = (action: string, args?: Record<string, unknown>): TurnCommandFixtureRequest => ({
kind: 'nation',
actorGeneralId: 1,
action,
...(args ? { args } : {}),
setup: {
isolateWorld: true,
world: {
startYear: 180,
year: 190,
month: 1,
hiddenSeed: 'turn-command-nation-matrix-v1',
},
nations: [
{
id: 1,
name: '아국',
capitalCityId: 3,
gold: 1_000_000,
rice: 1_000_000,
tech: 1000,
level: 1,
typeCode: 'che_명가',
war: 0,
diplomacyLimit: 0,
generalCount: 2,
meta: { can_국호변경: 1, can_국기변경: 1, surlimit: 0 },
},
{
id: 2,
name: '타국',
capitalCityId: 70,
gold: 1_000_000,
rice: 1_000_000,
tech: 1000,
level: 1,
typeCode: 'che_명가',
war: 0,
diplomacyLimit: 0,
generalCount: 1,
meta: { surlimit: 0 },
},
],
cities: [
{
id: 3,
nationId: 1,
population: 100_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 0,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
{
id: 70,
nationId: 2,
population: 100_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 1,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
],
generals: [general(1, 1, 3, 12), general(2, 2, 70, 12), general(3, 1, 3, 1)],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 3, term: 0, dead: 0 },
{ fromNationId: 2, toNationId: 1, state: 3, term: 0, dead: 0 },
],
},
observe: {
generalIds: [1, 2, 3],
cityIds: [3, 70],
nationIds: [1, 2],
logAfterId: 0,
messageAfterId: 0,
},
});
const cases: Array<[string, Record<string, unknown> | undefined]> = [
['휴식', undefined],
['che_포상', { isGold: true, amount: 100, destGeneralID: 3 }],
['che_선전포고', { destNationID: 2 }],
['che_국호변경', { nationName: '신아국' }],
['che_국기변경', { colorType: 1 }],
['che_몰수', { isGold: true, amount: 100, destGeneralID: 3 }],
['che_물자원조', { destNationID: 2, amountList: [100, 200] }],
['che_불가침제의', { destNationID: 2, year: 191, month: 1 }],
];
integration('nation command success matrix', () => {
it.each(cases)(
'%s matches the legacy state delta and command RNG',
async (action, args) => {
const request = buildRequest(action, args);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') {
process.stderr.write(
`${JSON.stringify(
{
action,
referenceOutcome: reference.execution.outcome,
coreOutcome: core.execution.outcome,
differences: compareTurnSnapshotDeltas(
reference.before,
reference.after,
core.before,
core.after,
{ ignoredPathPatterns: ignoredLifecyclePaths }
),
},
null,
2
)}\n`
);
}
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).not.toHaveProperty('blockedReason');
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});