개인턴과 수뇌턴 공통 로그 생성 경로에서 generalId를 필수로 전달해 PostgreSQL 영속화 단계의 누락을 막는다. 비교 도구의 소유자 추론을 제거하고 단위, PostgreSQL, Chromium 회귀 검증을 추가한다.
922 lines
39 KiB
TypeScript
922 lines
39 KiB
TypeScript
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
|
import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
|
|
import {
|
|
GENERAL_TURN_COMMAND_KEYS,
|
|
NATION_TURN_COMMAND_KEYS,
|
|
normalizeScenarioEffect,
|
|
readLegacyCityTrust,
|
|
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;
|
|
scenarioEffect?: string | null;
|
|
staticEventHandlers?: Record<string, 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>>;
|
|
generalTurns?: Array<Record<string, unknown>>;
|
|
nationTurns?: 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'),
|
|
userId: row.hasOwner === true ? 'turn-differential-owner' : null,
|
|
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,
|
|
...(request.setup?.world?.staticEventHandlers
|
|
? { staticEventHandlers: request.setup.world.staticEventHandlers }
|
|
: {}),
|
|
},
|
|
environment: {
|
|
mapName: map.id,
|
|
unitSet: unitSet.id,
|
|
...(request.setup?.world?.scenarioEffect !== undefined
|
|
? { scenarioEffect: normalizeScenarioEffect(request.setup.world.scenarioEffect) }
|
|
: {}),
|
|
},
|
|
},
|
|
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,
|
|
hasOwner: Boolean(general.userId),
|
|
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'),
|
|
// The reference snapshot observes MariaDB FLOAT through its text
|
|
// protocol. Project the in-memory binary32 value at that same
|
|
// read boundary before comparing state deltas.
|
|
trust: readLegacyCityTrust(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: readLegacyStoredFloat(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 buildSnapshotReservedTurnDatabaseClient = (snapshot: CanonicalTurnSnapshot) => ({
|
|
generalTurn: {
|
|
findMany: async () =>
|
|
snapshot.generalTurns.map((turn, index) => ({
|
|
id: index + 1,
|
|
generalId: readNumber(turn, 'generalId'),
|
|
turnIdx: readNumber(turn, 'turnIndex'),
|
|
actionCode: readString(turn, 'action', '휴식'),
|
|
arg: asRecord(turn.args),
|
|
createdAt: new Date(0),
|
|
})),
|
|
deleteMany: async () => ({ count: 0 }),
|
|
createMany: async () => ({ count: 0 }),
|
|
},
|
|
nationTurn: {
|
|
findMany: async () =>
|
|
snapshot.nationTurns.map((turn, index) => ({
|
|
id: index + 1,
|
|
nationId: readNumber(turn, 'nationId'),
|
|
officerLevel: readNumber(turn, 'officerLevel'),
|
|
turnIdx: readNumber(turn, 'turnIndex'),
|
|
actionCode: readString(turn, 'action', '휴식'),
|
|
arg: asRecord(turn.args),
|
|
createdAt: new Date(0),
|
|
})),
|
|
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(
|
|
buildSnapshotReservedTurnDatabaseClient(referenceBefore) 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: Math.max(1, Math.round(state.tickSeconds / 60)) }],
|
|
},
|
|
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,
|
|
// Keep the product draft unchanged. Inferring an owner here hid
|
|
// GENERAL logs that finalizeLogEntry would reject in production.
|
|
generalId: log.generalId,
|
|
nationId: log.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,
|
|
};
|
|
};
|