test: compare reserved commands against legacy
This commit is contained in:
@@ -21,8 +21,8 @@ canonical case
|
|||||||
│ ├─ execute legacy GeneralCommand or NationCommand
|
│ ├─ execute legacy GeneralCommand or NationCommand
|
||||||
│ └─ before/after snapshot + command RNG trace
|
│ └─ before/after snapshot + command RNG trace
|
||||||
└─ core2026 execution boundary
|
└─ core2026 execution boundary
|
||||||
├─ snapshot selected PostgreSQL rows
|
├─ construct the in-memory turn world from the prepared ref snapshot
|
||||||
├─ execute reserved turn or daemon command
|
├─ execute the real reserved-turn handler
|
||||||
└─ before/after snapshot + command RNG trace
|
└─ before/after snapshot + command RNG trace
|
||||||
|
|
||||||
ref delta ─┐
|
ref delta ─┐
|
||||||
@@ -52,9 +52,16 @@ normalized to the core `*Id` spelling before command identity comparison.
|
|||||||
- Core integration tools
|
- Core integration tools
|
||||||
- `canonical.ts`: shared snapshot and trace contracts.
|
- `canonical.ts`: shared snapshot and trace contracts.
|
||||||
- `databaseSnapshot.ts`: PostgreSQL projection.
|
- `databaseSnapshot.ts`: PostgreSQL projection.
|
||||||
|
- `coreCommandTrace.ts`: real in-memory reserved-turn execution and
|
||||||
|
canonical projection.
|
||||||
- `trace.ts`: before/execute/after capture boundary.
|
- `trace.ts`: before/execute/after capture boundary.
|
||||||
- `compare.ts`: exact snapshot and delta comparison.
|
- `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 ref runner refuses mutation unless `TURN_DIFFERENTIAL_ENABLED=1` is present.
|
||||||
The wrapper injects it only into the disposable tool container. Direct
|
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;
|
3. the semantic delta comparison is empty;
|
||||||
4. live sortie also passes the battle trace comparison;
|
4. live sortie also passes the battle trace comparison;
|
||||||
5. any ignored path is documented in the case evidence.
|
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,
|
experience: row.experience,
|
||||||
dedication: row.dedication,
|
dedication: row.dedication,
|
||||||
officerLevel: row.officerLevel,
|
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,
|
injury: row.injury,
|
||||||
gold: row.gold,
|
gold: row.gold,
|
||||||
rice: row.rice,
|
rice: row.rice,
|
||||||
@@ -129,6 +136,11 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
|||||||
leadershipExp: readNumber(meta, 'leadership_exp'),
|
leadershipExp: readNumber(meta, 'leadership_exp'),
|
||||||
strengthExp: readNumber(meta, 'strength_exp'),
|
strengthExp: readNumber(meta, 'strength_exp'),
|
||||||
intelExp: readNumber(meta, 'intel_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'),
|
killTurn: readNumber(meta, 'killturn'),
|
||||||
mySet: readNumber(meta, 'myset'),
|
mySet: readNumber(meta, 'myset'),
|
||||||
};
|
};
|
||||||
@@ -175,6 +187,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
|||||||
generalCount: readNumber(meta, 'gennum'),
|
generalCount: readNumber(meta, 'gennum'),
|
||||||
power: readNumber(meta, 'power'),
|
power: readNumber(meta, 'power'),
|
||||||
war: readNumber(meta, 'war'),
|
war: readNumber(meta, 'war'),
|
||||||
|
diplomacyLimit: readNumber(meta, 'surlimit'),
|
||||||
meta,
|
meta,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ const valuesEqual = (left: unknown, right: unknown, numericTolerance: number): b
|
|||||||
if (typeof left === 'number' && typeof right === 'number') {
|
if (typeof left === 'number' && typeof right === 'number') {
|
||||||
return Math.abs(left - right) <= numericTolerance;
|
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);
|
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;
|
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