test: compare reserved commands against legacy
This commit is contained in:
@@ -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
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user