Files
core2026/tools/integration-tests/src/turn-differential/coreCommandTrace.ts
T

877 lines
37 KiB
TypeScript

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 { applyPersistedRankRowsToMeta, buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js';
import {
canonicalizeTurnCommandArgs,
type CanonicalTurnCommandTrace,
type CanonicalTurnSnapshot,
} from './canonical.js';
interface GeneralCooldownSelector {
generalId: number;
actionName: string;
}
interface NationCooldownSelector {
nationId: number;
actionName: string;
}
export interface TurnCommandFixtureRequest {
kind: 'general' | 'nation';
actorGeneralId: number;
action: string;
includeLifecycle?: boolean;
args?: unknown;
coreArgs?: unknown;
setup?: {
world?: {
startYear?: number;
initYear?: number;
initMonth?: number;
year?: number;
month?: number;
hiddenSeed?: string;
};
isolateWorld?: boolean;
generals?: Array<Record<string, unknown>>;
rankData?: Array<{ generalId: number; type: string; value: number }>;
nations?: Array<Record<string, unknown>>;
cities?: Array<Record<string, unknown>>;
troops?: Array<Record<string, unknown>>;
diplomacy?: Array<Record<string, unknown>>;
randomFoundingCandidateCityIds?: number[];
generalCooldowns?: Array<GeneralCooldownSelector & { nextAvailableTurn: number }>;
};
observe?: {
generalIds?: number[];
cityIds?: number[];
nationIds?: number[];
logAfterId?: number;
messageAfterId?: number;
includeNationHistoryLogs?: boolean;
includeGlobalHistoryLogs?: boolean;
generalCooldowns?: GeneralCooldownSelector[];
nationCooldowns?: NationCooldownSelector[];
diplomacyPairs?: Array<{ fromNationId: number; toNationId: 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 asNumberRecord = (value: unknown): Record<string, number> =>
Object.fromEntries(
Object.entries(asRecord(value)).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
);
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_몰수',
]);
export const resolveCoreTurnCommandArgs = (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 ?? {}));
};
export const createCoreTurnCommandProfile = (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}`);
}
const generalActions = [request.action, '휴식', 'che_인재탐색', 'che_해산', 'che_이동'] as Array<
(typeof GENERAL_TURN_COMMAND_KEYS)[number]
>;
return {
general: [...new Set(generalActions)],
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>, fallbackTurnTime: Date): TurnGeneral => {
const meta = asRecord(row.meta);
const rawTurnTime = row.turnTime;
const parsedTurnTime = typeof rawTurnTime === 'string' ? new Date(rawTurnTime) : fallbackTurnTime;
const turnTime = Number.isNaN(parsedTurnTime.getTime()) ? fallbackTurnTime : parsedTurnTime;
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'),
penalty: row.penalty,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
...meta,
specage: readNumber(row, 'specAge', readNumber(meta, 'specage')),
specage2: readNumber(row, 'specAge2', readNumber(meta, 'specage2')),
makelimit: readNumber(row, 'makeLimit', readNumber(meta, 'makelimit')),
killturn: readNumber(row, 'killTurn', readNumber(meta, 'killturn', 24)),
myset: readNumber(row, 'mySet', readNumber(meta, 'myset')),
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')),
betray: readNumber(row, 'betray', readNumber(meta, 'betray')),
officerCityId: readNumber(
row,
'officerCityId',
readNumber(meta, 'officerCityId', readNumber(meta, 'officerCity', readNumber(meta, 'officer_city')))
),
officerCity: readNumber(
row,
'officerCityId',
readNumber(meta, 'officerCity', readNumber(meta, 'officer_city'))
),
officer_city: readNumber(
row,
'officerCityId',
readNumber(meta, 'officer_city', readNumber(meta, 'officerCity'))
),
belong: readNumber(row, 'belong', readNumber(meta, 'belong')),
permission: readString(row, 'permission', readString(meta, 'permission', 'normal')),
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);
const turnLastByOfficerLevel = asRecord(row.turnLastByOfficerLevel);
return {
id,
name: readString(row, 'name', `국가${id}`),
color: readString(row, 'color', '#777777'),
capitalCityId: readNumber(row, 'capitalCityId'),
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,
...Object.fromEntries(
Object.entries(turnLastByOfficerLevel).map(([officerLevel, lastTurn]) => [
`turn_last_${officerLevel}`,
lastTurn,
])
),
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')),
capset: readNumber(row, 'capitalRevision', readNumber(meta, 'capset')),
strategic_cmd_limit: readNumber(row, 'strategicCommandLimit', readNumber(meta, 'strategic_cmd_limit')),
},
};
};
export const buildCoreTurnCommandWorldInput = (
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));
for (const general of generals) {
applyPersistedRankRowsToMeta(
general.meta,
referenceBefore.rankData
.filter((row) => readNumber(row, 'generalId') === general.id)
.map((row) => ({
type: readString(row, 'type', ''),
value: readNumber(row, 'value'),
}))
);
}
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
? referenceBefore.world.generalCooldowns
: [];
for (const rawCooldown of referenceGeneralCooldowns) {
const cooldown = asRecord(rawCooldown);
const generalId = readNumber(cooldown, 'generalId');
const actionName = readString(cooldown, 'actionName', '');
const nextAvailableTurn = cooldown.nextAvailableTurn;
const general = generals.find((entry) => entry.id === generalId);
if (general && actionName && typeof nextAvailableTurn === 'number' && Number.isFinite(nextAvailableTurn)) {
general.meta[`next_execute_${actionName}`] = nextAvailableTurn;
}
}
const fixtureNations = new Map((request.setup?.nations ?? []).map((row) => [readNumber(row, 'id'), row] as const));
const nations = referenceBefore.nations.map((row) =>
buildNation(
{
...row,
turnLastByOfficerLevel:
fixtureNations.get(readNumber(row, 'id'))?.coreTurnLastByOfficerLevel ??
fixtureNations.get(readNumber(row, 'id'))?.turnLastByOfficerLevel,
},
generals
)
);
const referenceNationCooldowns = Array.isArray(referenceBefore.world.nationCooldowns)
? referenceBefore.world.nationCooldowns
: [];
for (const rawCooldown of referenceNationCooldowns) {
const cooldown = asRecord(rawCooldown);
const nationId = readNumber(cooldown, 'nationId');
const actionName = readString(cooldown, 'actionName', '');
const nextAvailableTurn = cooldown.nextAvailableTurn;
const nation = nations.find((entry) => entry.id === nationId);
if (nation && actionName && typeof nextAvailableTurn === 'number' && Number.isFinite(nextAvailableTurn)) {
nation.meta[`next_execute_${actionName}`] = nextAvailableTurn;
}
}
const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const));
const fixtureCityRows = new Map((request.setup?.cities ?? []).map((row) => [readNumber(row, 'id'), row] as const));
const randomFoundingCandidateCityIds = request.setup?.randomFoundingCandidateCityIds
? new Set(request.setup.randomFoundingCandidateCityIds)
: null;
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: 75, 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,
generalMinimumGold: 0,
generalMinimumRice: 500,
npcSeizureMessageProb: 0.01,
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) ?? {};
const fixtureRow = fixtureCityRows.get(definition.id) ?? {};
const conflictEntries = Array.isArray(fixtureRow.conflictEntries)
? fixtureRow.conflictEntries.filter(
(entry): entry is [number, number] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'number' &&
Number.isInteger(entry[0]) &&
typeof entry[1] === 'number'
)
: [];
const conflict =
conflictEntries.length > 0 ? Object.fromEntries(conflictEntries) : asNumberRecord(row.conflict);
return {
id: definition.id,
name: readString(row, 'name', definition.name),
nationId: readNumber(row, 'nationId'),
level: observedCityRows.has(definition.id)
? readNumber(row, 'level', definition.level)
: randomFoundingCandidateCityIds === null
? definition.level
: randomFoundingCandidateCityIds.has(definition.id)
? 5
: 4,
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),
conflict,
meta: {
trust: readNumber(row, 'trust', map.defaults?.trust ?? 50),
trade: readNumber(row, 'trade', map.defaults?.trade ?? 100),
term: readNumber(row, 'term'),
officer_set: readNumber(row, 'officerSet'),
...(conflictEntries.length > 0
? { conflict_order: conflictEntries.map(([nationId]) => nationId) }
: {}),
},
};
}),
generals,
troops:
request.setup?.troops?.map((row) => {
const id = readNumber(row, 'id');
return {
id,
nationId: readNumber(row, 'nationId'),
name: readString(row, 'name', `부대${id}`),
};
}) ?? [],
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: readNumber(referenceBefore.world, 'killTurn', 24),
isUnited: readNumber(referenceBefore.world, 'isUnited'),
scenarioId: readNumber(referenceBefore.world, 'scenarioId'),
initYear: readNumber(
referenceBefore.world,
'initYear',
request.setup?.world?.initYear ?? request.setup?.world?.startYear ?? year
),
initMonth: readNumber(referenceBefore.world, 'initMonth', request.setup?.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>;
initialGeneralIds: Set<number>;
generalCooldowns: GeneralCooldownSelector[];
nationCooldowns: NationCooldownSelector[];
}
): 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,
officerCityId: readNumber(
general.meta,
'officer_city',
readNumber(general.meta, 'officerCity', readNumber(general.meta, 'officerCityId'))
),
belong: readNumber(general.meta, 'belong'),
permission: readString(general.meta, 'permission', 'normal'),
maxBelong: readNumber(general.meta, 'max_belong'),
maxDomesticCritical: readNumber(general.meta, 'max_domestic_critical'),
betray: readNumber(general.meta, 'betray'),
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')),
specAge: toDatabaseInt(readNumber(general.meta, 'specage')),
specAge2: toDatabaseInt(readNumber(general.meta, 'specage2')),
inheritActiveActionPoints: readNumber(general.meta, 'inherit_active_action') * 3,
makeLimit: toDatabaseInt(readNumber(general.meta, 'makelimit')),
penalty: asRecord(general.penalty),
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'),
generalCooldowns: selector.generalCooldowns.map(({ generalId, actionName }) => {
const general = world.getGeneralById(generalId);
const raw = general?.meta[`next_execute_${actionName}`];
return {
generalId,
actionName,
nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null,
};
}),
nationCooldowns: selector.nationCooldowns.map(({ nationId, actionName }) => {
const nation = world.getNationById(nationId);
const raw = nation?.meta[`next_execute_${actionName}`];
return {
nationId,
actionName,
nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null,
};
}),
},
generals,
rankData: world
.listGenerals()
.filter((general) => selector.generalIds.has(general.id))
.flatMap((general) =>
buildLegacyComparableRankRows(general).map((row) =>
selector.initialGeneralIds.has(general.id) ? row : { ...row, nationId: 0, value: 0 }
)
)
.map((row) => ({ ...row })),
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,
conflict: city.conflict ?? {},
state: city.state,
term: readNumber(city.meta, 'term'),
trust: readNumber(city.meta, 'trust'),
trade: readNumber(city.meta, 'trade'),
officerSet: readNumber(city.meta, 'officer_set'),
})),
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'),
capitalRevision: readNumber(nation.meta, 'capset'),
strategicCommandLimit: readNumber(nation.meta, 'strategic_cmd_limit'),
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, historyLogId: 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 = buildCoreTurnCommandWorldInput(request, referenceBefore, unitSet, map);
const { state, snapshot } = worldInput;
const selector = {
generalIds: new Set([
...referenceBefore.generals.map((row) => readNumber(row, 'id')),
...(request.observe?.generalIds ?? []),
]),
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
nationIds: new Set([
...referenceBefore.nations.map((row) => readNumber(row, 'id')),
...(request.observe?.nationIds ?? []),
]),
initialGeneralIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))),
generalCooldowns: request.observe?.generalCooldowns ?? [],
nationCooldowns: request.observe?.nationCooldowns ?? [],
};
const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, {
maxGeneralTurns: 30,
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 = resolveCoreTurnCommandArgs(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: createCoreTurnCommandProfile(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 ?? (log.scope === 'GENERAL' ? actor.id : undefined),
nationId: log.nationId ?? (log.scope === 'NATION' ? actor.nationId : undefined),
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,
};
};