fix: 전투 이펙트와 차등 하네스 호환성을 복원한다
Ref 기준의 판정 순서, 난수 소비, 아이템·특기 이펙트와 로그 형식을 맞춘다. 전체 전투 결과와 이벤트·상태·난수 trace를 엄격 비교하고 fixture 및 출병 예약 회귀를 보강한다.
This commit is contained in:
@@ -21,7 +21,7 @@ import { compileCrewTypeCatalog } from '../crewType/index.js';
|
||||
import type { City, General, Nation } from '../domain/entities.js';
|
||||
import { createInheritBuffModules } from '../inheritance/inheritBuff.js';
|
||||
import { createItemActionModules, createItemModuleRegistry, ITEM_KEYS, loadItemModules } from '../items/index.js';
|
||||
import { formatLogText, LogCategory, LogFormat, LogScope } from '../logging/index.js';
|
||||
import { formatLogText, LogCategory, LogFormat, LogScope, type ActionLogger } from '../logging/index.js';
|
||||
import {
|
||||
createCrewTypeWarTriggerRegistry,
|
||||
resolveDefenderOrder,
|
||||
@@ -344,6 +344,10 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
|
||||
export interface BattleSimProcessorOptions {
|
||||
trace?: (event: WarBattleTraceEvent) => void;
|
||||
rngFactory?: (seed: string) => RandUtil;
|
||||
/** Comparison-only logger instrumentation; production callers omit it. */
|
||||
loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger;
|
||||
/** Comparison-only observation of the resolved pure battle outcome. */
|
||||
onBattleResolved?: (outcome: WarBattleOutcome) => void;
|
||||
}
|
||||
|
||||
export const processBattleSimJob = (
|
||||
@@ -416,9 +420,12 @@ export const processBattleSimJob = (
|
||||
})),
|
||||
defenderCity,
|
||||
defenderNation,
|
||||
...(options.loggerFactory ? { loggerFactory: options.loggerFactory } : {}),
|
||||
...(options.trace ? { trace: options.trace } : {}),
|
||||
});
|
||||
|
||||
options.onBattleResolved?.(outcome);
|
||||
|
||||
lastBattle = outcome;
|
||||
const attackerReport = outcome.reports.find(
|
||||
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
||||
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
|
||||
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
|
||||
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { che_부적 } from '@sammo-ts/logic/war/triggers/che_부적.js';
|
||||
import { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js';
|
||||
import type { ItemModule } from './types.js';
|
||||
|
||||
const ITEM_KEY = 'che_부적_태현청생부';
|
||||
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 303;
|
||||
|
||||
export const itemModule: ItemModule = {
|
||||
key: ITEM_KEY,
|
||||
@@ -28,8 +30,12 @@ export const itemModule: ItemModule = {
|
||||
}
|
||||
return value;
|
||||
} as NonNullable<ItemModule['onCalcStat']>,
|
||||
getBattleInitTriggerList: (context) => {
|
||||
if (!context.unit) return null;
|
||||
return new WarTriggerCaller(new che_부상무효(context.unit, RAISE_TYPE), new che_부적(context.unit, RAISE_TYPE));
|
||||
},
|
||||
getBattlePhaseTriggerList: (context) => {
|
||||
if (!context.unit) return null;
|
||||
return new WarTriggerCaller(new che_부적(context.unit));
|
||||
return new WarTriggerCaller(new che_부적(context.unit, RAISE_TYPE));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { che_진압 } from '@sammo-ts/logic/war/triggers/che_진압.js';
|
||||
|
||||
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import type { ItemModule } from './types.js';
|
||||
|
||||
const ITEM_KEY = 'che_진압_박혁론';
|
||||
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_NONE;
|
||||
|
||||
export const itemModule: ItemModule = {
|
||||
key: ITEM_KEY,
|
||||
@@ -18,6 +19,6 @@ export const itemModule: ItemModule = {
|
||||
unique: false,
|
||||
getBattlePhaseTriggerList: (context) => {
|
||||
if (!context.unit) return null;
|
||||
return new WarTriggerCaller(new che_진압(context.unit));
|
||||
return new WarTriggerCaller(new che_진압(context.unit, RAISE_TYPE));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
||||
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
|
||||
import { triggerModule as medicalWarTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
|
||||
import type { ItemModule } from './types.js';
|
||||
|
||||
const INFO =
|
||||
'[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)';
|
||||
|
||||
const ATTEMPT_DEDUP_TYPE: Record<string, number> = {
|
||||
che_의술_상한잡병론: 301,
|
||||
che_의술_정력견혈산: 302,
|
||||
che_의술_청낭서: 302,
|
||||
che_의술_태평청령: 303,
|
||||
};
|
||||
|
||||
export const createMedicalItem = (key: string, rawName: string): ItemModule => ({
|
||||
key,
|
||||
rawName,
|
||||
@@ -18,6 +26,15 @@ export const createMedicalItem = (key: string, rawName: string): ItemModule => (
|
||||
reqSecu: 0,
|
||||
unique: true,
|
||||
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
|
||||
getBattlePhaseTriggerList: (context) =>
|
||||
context.unit ? medicalWarTriggerModule.createTriggerList(context.unit) : null,
|
||||
getBattlePhaseTriggerList: (context) => {
|
||||
if (!context.unit) {
|
||||
return null;
|
||||
}
|
||||
const attemptRaiseType =
|
||||
BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * (ATTEMPT_DEDUP_TYPE[key] ?? 0);
|
||||
return new WarTriggerCaller(
|
||||
new che_의술시도(context.unit, attemptRaiseType),
|
||||
new che_의술발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
|
||||
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
|
||||
import type { ItemModule } from './types.js';
|
||||
|
||||
export const createEventBattleTraitItemModule = (
|
||||
@@ -45,7 +48,25 @@ export const createEventBattleTraitItemModule = (
|
||||
itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList;
|
||||
}
|
||||
if (traitModule.getBattlePhaseTriggerList) {
|
||||
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
|
||||
if (traitModule.key === 'che_저격') {
|
||||
itemModule.getBattlePhaseTriggerList = (context) =>
|
||||
context.unit
|
||||
? new WarTriggerCaller(
|
||||
new che_저격시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM, 0.5, 20, 40),
|
||||
new che_저격발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM)
|
||||
)
|
||||
: null;
|
||||
} else if (traitModule.key === 'che_의술') {
|
||||
itemModule.getBattlePhaseTriggerList = (context) =>
|
||||
context.unit
|
||||
? new WarTriggerCaller(
|
||||
new che_의술시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM),
|
||||
new che_의술발동(context.unit)
|
||||
)
|
||||
: null;
|
||||
} else {
|
||||
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
|
||||
}
|
||||
}
|
||||
if (traitModule.getWarPowerMultiplier) {
|
||||
itemModule.getWarPowerMultiplier =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||
import { consumeEquippedItemCharge, getEquippedItemInstance } from './inventory.js';
|
||||
import { getEquippedItemInstance } from './inventory.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||
import type { ItemModule } from './types.js';
|
||||
@@ -17,6 +17,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
|
||||
_selfEnv: Record<string, unknown>,
|
||||
_opposeEnv: Record<string, unknown>
|
||||
): boolean {
|
||||
if (self.hasActivatedSkillOnLog('충차공격') > 0 && self.getPhase() === self.getMaxPhase() - 1) {
|
||||
if (self instanceof WarUnitGeneral) {
|
||||
const equipped = getEquippedItemInstance(self.getGeneral(), 'item');
|
||||
if (equipped?.itemKey === ITEM_KEY && (equipped.state.charges ?? 0) <= 0) {
|
||||
this.processConsumableItem();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) {
|
||||
return true;
|
||||
}
|
||||
@@ -28,9 +37,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.activateSkill('충차공격', '아이템사용');
|
||||
self.getLogger().pushGeneralBattleDetailLog('<C>충차</>로 성벽을 공격합니다.');
|
||||
consumeEquippedItemCharge(general, 'item', ITEM_KEY, 2);
|
||||
self.activateSkill('충차공격');
|
||||
const equipped = getEquippedItemInstance(general, 'item');
|
||||
if (equipped?.itemKey === ITEM_KEY) {
|
||||
const remaining = equipped.state.charges ?? 2;
|
||||
// Ref decrements the purchase-time remain값 at first city contact,
|
||||
// but only deletes the item in the last battle phase.
|
||||
equipped.state.charges = remaining - 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||
import type { ItemModule, ItemModuleExport } from './types.js';
|
||||
import { listEquippedItemKeys } from './utils.js';
|
||||
import { removeEquippedItem } from './inventory.js';
|
||||
import { registerLegacyBattleItemIdentity, removeEquippedItem } from './inventory.js';
|
||||
|
||||
export const ITEM_KEYS = [
|
||||
'che_간파_노군입산부',
|
||||
@@ -664,12 +664,17 @@ class ItemWarActionRouter<
|
||||
private resolveModules(context: WarActionContext<TriggerState>): Array<ItemModule<TriggerState>> {
|
||||
const keys = listEquippedItemKeys(context.general);
|
||||
const modules: Array<ItemModule<TriggerState>> = [];
|
||||
let itemIdentity = { name: '-', rawName: '-' };
|
||||
for (const key of keys) {
|
||||
const module = this.registry.get(key);
|
||||
if (module) {
|
||||
modules.push(module);
|
||||
if (module.slot === 'item') {
|
||||
itemIdentity = { name: module.name, rawName: module.rawName };
|
||||
}
|
||||
}
|
||||
}
|
||||
registerLegacyBattleItemIdentity(context.general, itemIdentity);
|
||||
return modules;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,27 @@ import type {
|
||||
const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
||||
const INVENTORY_META_KEY = 'itemInventory';
|
||||
|
||||
export interface LegacyBattleItemIdentity {
|
||||
name: string;
|
||||
rawName: string;
|
||||
}
|
||||
|
||||
// Ref's BaseWarUnitTrigger::processConsumableItem() asks General::getItem()
|
||||
// for the *item-slot* display name even when a weapon trigger raised the
|
||||
// shared item flag. Keep that transient lookup out of persisted General meta.
|
||||
const legacyBattleItemIdentities = new WeakMap<object, LegacyBattleItemIdentity>();
|
||||
|
||||
export const registerLegacyBattleItemIdentity = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>,
|
||||
identity: LegacyBattleItemIdentity
|
||||
): void => {
|
||||
legacyBattleItemIdentities.set(general, identity);
|
||||
};
|
||||
|
||||
export const getLegacyBattleItemIdentity = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>
|
||||
): LegacyBattleItemIdentity => legacyBattleItemIdentities.get(general) ?? { name: '-', rawName: '-' };
|
||||
|
||||
const emptyState = (): GeneralItemInstanceState => ({ values: {} });
|
||||
|
||||
const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({
|
||||
@@ -57,9 +78,7 @@ const readState = (value: unknown): GeneralItemInstanceState | null => {
|
||||
return null;
|
||||
}
|
||||
const charges =
|
||||
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) && record['charges'] >= 0
|
||||
? record['charges']
|
||||
: undefined;
|
||||
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) ? record['charges'] : undefined;
|
||||
const valuesRecord = asRecord(record['values']) ?? {};
|
||||
const values: Record<string, TriggerValue> = {};
|
||||
for (const [key, entry] of Object.entries(valuesRecord)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
|
||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||
@@ -110,6 +110,17 @@ const isSupplyCity = (city: City): boolean => {
|
||||
return city.supplyState > 0;
|
||||
};
|
||||
|
||||
const resolveLegacyTurnHourMinute = (general: General): string => {
|
||||
const raw = general.meta['turnTime'];
|
||||
if (typeof raw === 'string' && raw.length >= 16) {
|
||||
return raw.slice(11, 16);
|
||||
}
|
||||
if (general.turnTime instanceof Date && Number.isFinite(general.turnTime.getTime())) {
|
||||
return general.turnTime.toISOString().slice(11, 16);
|
||||
}
|
||||
return '00:00';
|
||||
};
|
||||
|
||||
export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
|
||||
defender: WarUnit<TriggerState>,
|
||||
attacker: WarUnitGeneral<TriggerState>
|
||||
@@ -390,7 +401,8 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
|
||||
const attackerName = attackerUnit.getName();
|
||||
const cityName = cityUnit.getName();
|
||||
const seedText = input.seed ? `<span class="hidden_but_copyable">(전투시드: ${input.seed})</span>` : '';
|
||||
const seedText = input.seed ? `<span class='hidden_but_copyable'>(전투시드: ${input.seed})</span>` : '';
|
||||
const turnHourMinute = resolveLegacyTurnHourMinute(attackerUnit.getGeneral());
|
||||
|
||||
const josaRo = JosaUtil.pick(cityName, '로');
|
||||
const josaYi = JosaUtil.pick(attackerName, '이');
|
||||
@@ -400,7 +412,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
LogFormat.MONTH
|
||||
);
|
||||
attackerLogger.pushGeneralActionLog(
|
||||
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText}`,
|
||||
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText} <1>${turnHourMinute}</>`,
|
||||
LogFormat.MONTH
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { RandUtil } from '@sammo-ts/common';
|
||||
import { JosaUtil, type RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { General } from '@sammo-ts/logic/domain/entities.js';
|
||||
import { getLegacyBattleItemIdentity, removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js';
|
||||
import type { WarUnit } from './units.js';
|
||||
import { removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
|
||||
|
||||
export interface WarTriggerContext {
|
||||
rng: RandUtil;
|
||||
@@ -97,12 +98,6 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
|
||||
return false;
|
||||
}
|
||||
this.unit.activateSkill('아이템사용');
|
||||
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
|
||||
return false;
|
||||
}
|
||||
if (this.unit.hasActivatedSkill('아이템소모')) {
|
||||
return false;
|
||||
}
|
||||
const unit = this.unit as WarUnit & {
|
||||
getGeneral?: () => General;
|
||||
};
|
||||
@@ -110,7 +105,18 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
|
||||
if (!general) {
|
||||
return false;
|
||||
}
|
||||
const item = getLegacyBattleItemIdentity(general);
|
||||
this.unit.activateSkill(item.name);
|
||||
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
|
||||
return false;
|
||||
}
|
||||
if (this.unit.hasActivatedSkill('아이템소모')) {
|
||||
return false;
|
||||
}
|
||||
this.unit.activateSkill('아이템소모');
|
||||
return removeEquippedItem(general, 'item') !== null;
|
||||
const josaUl = JosaUtil.pick(item.rawName, '을');
|
||||
this.unit.getLogger().pushGeneralActionLog(`<C>${item.name}</>${josaUl} 사용!`, LogFormat.PLAIN);
|
||||
removeEquippedItem(general, 'item');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||
import { BaseWarUnitTrigger } from '../triggers.js';
|
||||
import type { WarUnit } from '../units.js';
|
||||
|
||||
export class che_부적 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||
super(unit, 0, raiseType);
|
||||
super(unit, TriggerPriority.Begin, raiseType);
|
||||
}
|
||||
|
||||
protected actionWar(self: WarUnit): boolean {
|
||||
self.activateSkill('저격불가', '부상무효');
|
||||
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
|
||||
// Ref WarActivateSkills(..., isSelf=false): the talisman's owner is
|
||||
// injury-proof, while the opposing unit is prevented from sniping.
|
||||
oppose.activateSkill('저격불가');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { WarTriggerModule } from './types.js';
|
||||
|
||||
// 의술: 치료 시도
|
||||
export class che_의술시도 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Pre + 350);
|
||||
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
|
||||
super(unit, TriggerPriority.Pre + 350, raiseType);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
@@ -36,8 +36,8 @@ export class che_의술시도 extends BaseWarUnitTrigger {
|
||||
|
||||
// 의술: 치료 발동
|
||||
export class che_의술발동 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Post + 550);
|
||||
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
|
||||
super(unit, TriggerPriority.Post + 550, raiseType);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
|
||||
@@ -58,7 +58,9 @@ export class che_저지 extends BaseWarUnitTrigger {
|
||||
self.addDex(self.getCrewType(), calcDamage);
|
||||
|
||||
self.addLevelExp(calcDamage / 50);
|
||||
let rice = self.calcRiceConsumption(calcDamage);
|
||||
// Ref calcRiceConsumption() declares an int parameter, so the
|
||||
// fractional 90% counter-damage is truncated before rice cost.
|
||||
let rice = self.calcRiceConsumption(Math.trunc(calcDamage));
|
||||
rice *= 0.25;
|
||||
const general = self.getGeneral();
|
||||
general.rice = Math.max(0, general.rice - rice);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||
import { BaseWarUnitTrigger } from '../triggers.js';
|
||||
import type { WarUnit } from '../units.js';
|
||||
|
||||
export class che_진압 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||
super(unit, 0, raiseType);
|
||||
super(unit, TriggerPriority.Begin, raiseType);
|
||||
}
|
||||
|
||||
protected actionWar(self: WarUnit): boolean {
|
||||
self.activateSkill('반계불가', '격노불가');
|
||||
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
|
||||
// Ref's 진압 is an opposing-unit restriction, not a self debuff.
|
||||
oppose.activateSkill('반계불가', '격노불가');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +192,16 @@ export class WarUnitGeneral<
|
||||
return truncate ? Math.trunc(clamped) : clamped;
|
||||
}
|
||||
|
||||
private resolveMainStat(armType: number, withInjury = true): number {
|
||||
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { withInjury });
|
||||
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury });
|
||||
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { withInjury });
|
||||
private resolveMainStat(armType: number, withInjury = true, truncate = true): number {
|
||||
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, {
|
||||
withInjury,
|
||||
truncate,
|
||||
});
|
||||
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury, truncate });
|
||||
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, {
|
||||
withInjury,
|
||||
truncate,
|
||||
});
|
||||
|
||||
if (armType === this.config.armTypes.wizard) {
|
||||
return intelligence;
|
||||
@@ -279,7 +285,10 @@ export class WarUnitGeneral<
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mainStat = this.resolveMainStat(armType, false);
|
||||
// GameUnitDetail::getCriticalRatio requests each Ref stat with
|
||||
// useFloor=false, so action bonuses such as 징병's +25% leadership must
|
||||
// retain their fractional part until after the probability is formed.
|
||||
const mainStat = this.resolveMainStat(armType, false, false);
|
||||
const coef =
|
||||
armType === this.config.armTypes.wizard ||
|
||||
armType === this.config.armTypes.siege ||
|
||||
|
||||
@@ -101,4 +101,13 @@ describe('GeneralItemInventory', () => {
|
||||
values: { source: 'shop' },
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips the negative remain value produced by the legacy ram lifecycle', () => {
|
||||
const general = makeGeneral();
|
||||
equipNewItem(general, 'item', 'event_충차', { charges: -1 });
|
||||
|
||||
const parsed = parseItemInventory(serializeItemInventory(general.itemInventory!), general.role.items);
|
||||
|
||||
expect(getEquippedItemInstance({ ...general, itemInventory: parsed }, 'item')?.state.charges).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,6 +167,93 @@ const buildGeneral = (strength: number): General => ({
|
||||
});
|
||||
|
||||
describe('war triggers', () => {
|
||||
it('applies the legacy talisman immunity to self and snipe restriction to the opponent', async () => {
|
||||
const attacker = buildGeneral(80);
|
||||
attacker.role.items.item = 'che_부적_태현청생부';
|
||||
const defender = { ...buildGeneral(80), id: 2, name: 'Defender' };
|
||||
const itemModules = createItemActionModules(
|
||||
createItemModuleRegistry(await loadItemModules(['che_부적_태현청생부']))
|
||||
).war;
|
||||
const events: Array<{
|
||||
event: string;
|
||||
attacker: { activatedSkills: Record<string, number> };
|
||||
defender: { activatedSkills: Record<string, number> } | null;
|
||||
}> = [];
|
||||
|
||||
resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
attacker: {
|
||||
general: attacker,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
modules: itemModules,
|
||||
},
|
||||
defenders: [
|
||||
{
|
||||
general: defender,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
modules: itemModules,
|
||||
},
|
||||
],
|
||||
defenderCity: buildCity(),
|
||||
defenderNation: buildNation(),
|
||||
trace: (event) => events.push(event),
|
||||
});
|
||||
|
||||
const initialized = events.find((event) => event.event === 'opponent_initialized');
|
||||
expect(initialized?.attacker.activatedSkills).toMatchObject({ 부상무효: 1 });
|
||||
expect(initialized?.attacker.activatedSkills).not.toHaveProperty('저격불가');
|
||||
expect(initialized?.defender?.activatedSkills).toMatchObject({ 저격불가: 1 });
|
||||
expect(initialized?.defender?.activatedSkills).not.toHaveProperty('부상무효');
|
||||
});
|
||||
|
||||
it('applies the legacy suppression restrictions to the opponent', async () => {
|
||||
const attacker = buildGeneral(80);
|
||||
attacker.role.items.item = 'che_진압_박혁론';
|
||||
const defender = { ...buildGeneral(80), id: 2, name: 'Defender' };
|
||||
const itemModules = createItemActionModules(
|
||||
createItemModuleRegistry(await loadItemModules(['che_진압_박혁론']))
|
||||
).war;
|
||||
const events: Array<{
|
||||
event: string;
|
||||
attacker: { activatedSkills: Record<string, number> };
|
||||
defender: { activatedSkills: Record<string, number> } | null;
|
||||
}> = [];
|
||||
|
||||
resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
attacker: {
|
||||
general: attacker,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
modules: itemModules,
|
||||
},
|
||||
defenders: [
|
||||
{
|
||||
general: defender,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
modules: itemModules,
|
||||
},
|
||||
],
|
||||
defenderCity: buildCity(),
|
||||
defenderNation: buildNation(),
|
||||
trace: (event) => events.push(event),
|
||||
});
|
||||
|
||||
const phase = events.find((event) => event.event === 'phase_triggered');
|
||||
expect(phase?.attacker.activatedSkills).not.toHaveProperty('반계불가');
|
||||
expect(phase?.attacker.activatedSkills).not.toHaveProperty('격노불가');
|
||||
expect(phase?.defender?.activatedSkills).toMatchObject({ 반계불가: 1, 격노불가: 1 });
|
||||
});
|
||||
|
||||
it('passes battle time and maximum tech level to year-scaling stat items', async () => {
|
||||
const general = buildGeneral(80);
|
||||
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
|
||||
@@ -604,8 +691,14 @@ describe('resolveWarBattle', () => {
|
||||
for (const expectedCharges of [1, null] as const) {
|
||||
general.crew = 5000;
|
||||
general.rice = 10000;
|
||||
const defenderCity = { ...buildCity(), wall: 3000, wallMax: 3000 };
|
||||
resolveWarBattle({
|
||||
const defenderCity = {
|
||||
...buildCity(),
|
||||
defence: 100_000,
|
||||
defenceMax: 100_000,
|
||||
wall: 3000,
|
||||
wallMax: 3000,
|
||||
};
|
||||
const outcome = resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
@@ -625,13 +718,51 @@ describe('resolveWarBattle', () => {
|
||||
if (expectedCharges === null) {
|
||||
expect(equipped).toBeNull();
|
||||
expect(general.role.items.item).toBeNull();
|
||||
expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({
|
||||
충차공격: 1,
|
||||
아이템사용: 1,
|
||||
충차: 1,
|
||||
아이템소모: 1,
|
||||
});
|
||||
expect(outcome.logs.some((entry) => entry.text === '<C>충차</>를 사용!')).toBe(true);
|
||||
} else {
|
||||
expect(equipped?.state.charges).toBe(expectedCharges);
|
||||
expect(general.role.items.item).toBe('event_충차');
|
||||
expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({ 충차공격: 1 });
|
||||
expect(outcome.metrics?.attackerActivatedSkills).not.toHaveProperty('아이템사용');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves a negative legacy ram remain value when combat ends before the final phase', async () => {
|
||||
const general = { ...buildGeneral(100), crew: 5000, rice: 10000 };
|
||||
equipNewItem(general, 'item', 'event_충차', { charges: 0 });
|
||||
const itemModules = createItemActionModules(
|
||||
createItemModuleRegistry(await loadItemModules(['event_충차']))
|
||||
).war;
|
||||
|
||||
const outcome = resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
attacker: {
|
||||
general,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
modules: itemModules,
|
||||
},
|
||||
defenders: [],
|
||||
defenderCity: { ...buildCity(), defence: 1, defenceMax: 1 },
|
||||
defenderNation: buildNation(),
|
||||
});
|
||||
|
||||
expect(outcome.conquered).toBe(true);
|
||||
expect(getEquippedItemInstance(general, 'item')?.state.charges).toBe(-1);
|
||||
expect(general.role.items.item).toBe('event_충차');
|
||||
expect(outcome.metrics?.attackerActivatedSkills).not.toHaveProperty('아이템사용');
|
||||
});
|
||||
|
||||
it('removes a one-use battle item through the canonical inventory', async () => {
|
||||
const general = buildGeneral(100);
|
||||
equipNewItem(general, 'item', 'che_저격_수극');
|
||||
@@ -639,7 +770,7 @@ describe('resolveWarBattle', () => {
|
||||
createItemModuleRegistry(await loadItemModules(['che_저격_수극']))
|
||||
).war;
|
||||
|
||||
resolveWarBattle({
|
||||
const outcome = resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
@@ -657,6 +788,42 @@ describe('resolveWarBattle', () => {
|
||||
|
||||
expect(getEquippedItemInstance(general, 'item')).toBeNull();
|
||||
expect(general.role.items.item).toBeNull();
|
||||
expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({
|
||||
아이템사용: 1,
|
||||
'수극(저격)': 1,
|
||||
아이템소모: 1,
|
||||
});
|
||||
expect(outcome.logs.some((entry) => entry.text === '<C>수극(저격)</>을 사용!')).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Ref's '-' item-name activation when a weapon trigger fires without an item-slot item", async () => {
|
||||
const general = { ...buildGeneral(100), crew: 5000, rice: 10000 };
|
||||
equipNewItem(general, 'weapon', 'che_무기_07_맥궁');
|
||||
const itemModules = createItemActionModules(
|
||||
createItemModuleRegistry(await loadItemModules(['che_무기_07_맥궁']))
|
||||
).war;
|
||||
|
||||
const outcome = resolveWarBattle({
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
unitSet: buildUnitSet(),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
attacker: {
|
||||
general,
|
||||
city: buildCity(),
|
||||
nation: buildNation(),
|
||||
modules: itemModules,
|
||||
},
|
||||
defenders: [],
|
||||
defenderCity: buildCity(),
|
||||
defenderNation: buildNation(),
|
||||
});
|
||||
|
||||
expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({
|
||||
아이템사용: 1,
|
||||
'-': 1,
|
||||
});
|
||||
expect(general.role.items.weapon).toBe('che_무기_07_맥궁');
|
||||
});
|
||||
|
||||
it('handles supply rout when defender nation has no rice', () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Battle differential fixtures
|
||||
|
||||
`basic-infantry.json` is the tracked, deterministic smoke fixture for the Ref ↔ Core battle comparator. Every fixture must explicitly provide a positive integer `city` for the attacker and every defender. The attacker city must equal `attackerCity.city`; each defender city must equal `defenderCity.city`. The runner rejects omitted or inconsistent current-city state before invoking either engine.
|
||||
|
||||
The captured corpus test is intentionally conditional. It is skipped unless `BATTLE_CORPUS_PATH` points to an existing JSONL fixture corpus; this repository does not generate or silently substitute a corpus. Each corpus row is validated by the same city contract.
|
||||
|
||||
Reference execution can use an already instrumented container through `REF_COMPARE_CONTAINER`, or an instrumentation checkout through `REF_COMPARE_SOURCE_ROOT`. Source-root execution creates only a temporary bind-mounted copy. It discovers the single network of the official reference Compose PHP service, or accepts an existing network named by `REF_COMPARE_NETWORK`. Missing or ambiguous networks fail closed. The runner never removes Compose containers, networks, volumes, or databases.
|
||||
|
||||
The Ref trace runner seeds `year`, `month`, and `startyear` only in KVStorage's process-local cache. This is required for legacy battle items that read the game clock from `game_env`; it keeps their fixture time deterministic without writing to the shared reference database.
|
||||
|
||||
Precomputed traces are accepted only when `BATTLE_REFERENCE_TRACE_PATH` is accompanied by `BATTLE_REFERENCE_MANIFEST_PATH`, or by a sibling `<trace>.manifest.json`. Manifest schema version 1 requires:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"fixtureCount": 0,
|
||||
"fixtureJsonlSha256": "sha256 of normalized fixture JSONL",
|
||||
"traceCount": 0,
|
||||
"traceJsonlSha256": "sha256 of the exact trace file bytes"
|
||||
}
|
||||
```
|
||||
|
||||
Counts and hashes must match exactly. Every trace row must also carry `fixtureIdentity.schemaVersion`, the exact fixture `seed`, and the SHA-256 of that fixture row. Missing, reordered, truncated, appended, or stale trace data is rejected.
|
||||
@@ -6,7 +6,7 @@
|
||||
"repeatCnt": 1,
|
||||
"action": "battle",
|
||||
"attackerGeneral": {
|
||||
"no": 1, "name": "공격자", "nation": 1, "turntime": "2026-01-01 00:00:00",
|
||||
"no": 1, "name": "공격자", "nation": 1, "city": 1, "turntime": "2026-01-01 00:00:00",
|
||||
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
|
||||
"atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None",
|
||||
"strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0,
|
||||
@@ -28,7 +28,7 @@
|
||||
"name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1
|
||||
},
|
||||
"defenderGenerals": [{
|
||||
"no": 2, "name": "수비자", "nation": 2, "turntime": "2026-01-01 00:00:00",
|
||||
"no": 2, "name": "수비자", "nation": 2, "city": 2, "turntime": "2026-01-01 00:00:00",
|
||||
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
|
||||
"atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None",
|
||||
"strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -16,8 +17,16 @@ import {
|
||||
loadNationTraitModules,
|
||||
loadPersonalityTraitModules,
|
||||
loadWarTraitModules,
|
||||
ActionLogger,
|
||||
formatLogText,
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
type LogEntryDraft,
|
||||
type UnitSetDefinition,
|
||||
type WarBattleOutcome,
|
||||
type WarBattleTraceEvent,
|
||||
type WarBattleTraceUnitSnapshot,
|
||||
type WarEngineConfig,
|
||||
} from '@sammo-ts/logic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -32,13 +41,19 @@ import type {
|
||||
|
||||
interface ReferenceTrace {
|
||||
engine: 'ref';
|
||||
seed: string;
|
||||
fixtureIdentity: FixtureIdentity;
|
||||
conquered: boolean;
|
||||
attacker: WarBattleTraceEvent['attacker'];
|
||||
city: WarBattleTraceEvent['city'];
|
||||
finishedDefenders: WarBattleTraceEvent['attacker'][];
|
||||
defenderOrder?: {
|
||||
before: Array<{ id: number; order: number }>;
|
||||
after: Array<{ id: number; order: number }>;
|
||||
};
|
||||
events: WarBattleTraceEvent[];
|
||||
rng: RandomCall[];
|
||||
boolRng: BoolRandomCall[];
|
||||
logs: {
|
||||
attacker: ReferenceLogBuckets;
|
||||
defenders: Record<string, ReferenceLogBuckets>;
|
||||
@@ -46,6 +61,18 @@ interface ReferenceTrace {
|
||||
};
|
||||
}
|
||||
|
||||
interface FixtureIdentity {
|
||||
schemaVersion: 1;
|
||||
seed: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
interface BoolRandomCall {
|
||||
rngSeq: number;
|
||||
probability: number;
|
||||
result: boolean;
|
||||
}
|
||||
|
||||
interface ReferenceLogBuckets {
|
||||
generalHistoryLog: string[];
|
||||
generalActionLog: string[];
|
||||
@@ -56,6 +83,65 @@ interface ReferenceLogBuckets {
|
||||
globalActionLog: string[];
|
||||
}
|
||||
|
||||
interface CapturedCoreLogger {
|
||||
generalId?: number;
|
||||
nationId?: number;
|
||||
entries: LogEntryDraft[];
|
||||
}
|
||||
|
||||
interface CoreLogCapture {
|
||||
loggerFactory: (options: { generalId?: number; nationId?: number }) => ActionLogger;
|
||||
byGeneralId: Map<number, CapturedCoreLogger>;
|
||||
city: CapturedCoreLogger | null;
|
||||
}
|
||||
|
||||
class ComparisonCapturingActionLogger extends ActionLogger {
|
||||
public constructor(
|
||||
options: { generalId?: number; nationId?: number },
|
||||
private readonly capture: (entries: LogEntryDraft[]) => void
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
public override flush(): LogEntryDraft[] {
|
||||
const entries = super.flush();
|
||||
this.capture(entries);
|
||||
return entries;
|
||||
}
|
||||
|
||||
public override rollback(): LogEntryDraft[] {
|
||||
const entries = super.rollback();
|
||||
this.capture(entries);
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
const createCoreLogCapture = (): CoreLogCapture => {
|
||||
const capture: CoreLogCapture = {
|
||||
byGeneralId: new Map(),
|
||||
city: null,
|
||||
loggerFactory: () => {
|
||||
throw new Error('loggerFactory is not initialized');
|
||||
},
|
||||
};
|
||||
capture.loggerFactory = (options) => {
|
||||
const bucket: CapturedCoreLogger = { ...options, entries: [] };
|
||||
if (options.generalId === undefined) {
|
||||
if (capture.city) {
|
||||
throw new Error('battle comparison created more than one city logger');
|
||||
}
|
||||
capture.city = bucket;
|
||||
} else {
|
||||
if (capture.byGeneralId.has(options.generalId)) {
|
||||
throw new Error(`battle comparison duplicated general logger ${options.generalId}`);
|
||||
}
|
||||
capture.byGeneralId.set(options.generalId, bucket);
|
||||
}
|
||||
return new ComparisonCapturingActionLogger(options, (entries) => bucket.entries.push(...entries));
|
||||
};
|
||||
return capture;
|
||||
};
|
||||
|
||||
interface RandomCall {
|
||||
seq: number;
|
||||
operation: string;
|
||||
@@ -80,6 +166,7 @@ type ReferenceTraitCatalog = Record<
|
||||
|
||||
class TracingRng implements RNG {
|
||||
public readonly calls: RandomCall[] = [];
|
||||
public readonly boolCalls: BoolRandomCall[] = [];
|
||||
|
||||
public constructor(private readonly inner: RNG) {}
|
||||
|
||||
@@ -111,6 +198,19 @@ class TracingRng implements RNG {
|
||||
return result;
|
||||
}
|
||||
|
||||
public createRandUtil(): RandUtil {
|
||||
const calls = this.calls;
|
||||
const boolCalls = this.boolCalls;
|
||||
return new (class extends RandUtil {
|
||||
public override nextBool(probability: number = 0.5): boolean {
|
||||
const rngSeq = calls.length;
|
||||
const result = super.nextBool(probability);
|
||||
boolCalls.push({ rngSeq, probability, result });
|
||||
return result;
|
||||
}
|
||||
})(this);
|
||||
}
|
||||
|
||||
private record(operation: string, args: Record<string, unknown>, result: unknown): void {
|
||||
this.calls.push({ seq: this.calls.length, operation, arguments: args, result });
|
||||
}
|
||||
@@ -135,7 +235,213 @@ const findWorkspaceRoot = (start: string): string | null => {
|
||||
|
||||
const readJson = <T>(filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
const assertFixtureGeneralCityContract = (fixtureJson: string, label = 'battle fixture'): FixtureIdentity => {
|
||||
const normalizedFixtureJson = fixtureJson.trim();
|
||||
const fixture = JSON.parse(normalizedFixtureJson) as unknown;
|
||||
if (!isRecord(fixture)) {
|
||||
throw new Error(`${label}: fixture root must be an object`);
|
||||
}
|
||||
|
||||
const assertSide = (side: 'attacker' | 'defender', general: unknown, city: unknown, index?: number): void => {
|
||||
const suffix = index === undefined ? '' : `[${index}]`;
|
||||
if (!isRecord(general) || !isRecord(city)) {
|
||||
throw new Error(`${label}: ${side}${suffix} general/city must be objects`);
|
||||
}
|
||||
const generalCity = general['city'];
|
||||
const currentCity = city['city'];
|
||||
if (!Number.isSafeInteger(generalCity) || (generalCity as number) <= 0) {
|
||||
throw new Error(`${label}: ${side}General${suffix}.city must be an explicit positive integer`);
|
||||
}
|
||||
if (!Number.isSafeInteger(currentCity) || (currentCity as number) <= 0) {
|
||||
throw new Error(`${label}: ${side}City.city must be a positive integer`);
|
||||
}
|
||||
if (generalCity !== currentCity) {
|
||||
throw new Error(
|
||||
`${label}: ${side}General${suffix}.city=${String(generalCity)} must equal current city ${String(currentCity)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
assertSide('attacker', fixture['attackerGeneral'], fixture['attackerCity']);
|
||||
const defenderCity = fixture['defenderCity'];
|
||||
const rawDefenders = fixture['defenderGenerals'];
|
||||
const defenders = Array.isArray(rawDefenders)
|
||||
? rawDefenders
|
||||
: isRecord(rawDefenders)
|
||||
? Object.values(rawDefenders)
|
||||
: null;
|
||||
if (!defenders) {
|
||||
throw new Error(`${label}: defenderGenerals must be an array or ID-keyed object`);
|
||||
}
|
||||
defenders.forEach((general, index) => assertSide('defender', general, defenderCity, index));
|
||||
|
||||
const seed = typeof fixture['seed'] === 'string' ? fixture['seed'] : 'battle-differential';
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
seed,
|
||||
sha256: sha256(normalizedFixtureJson),
|
||||
};
|
||||
};
|
||||
|
||||
const assertReferenceFixtureIdentity = (
|
||||
reference: ReferenceTrace,
|
||||
fixtureJson: string,
|
||||
label = 'reference trace'
|
||||
): void => {
|
||||
const expected = assertFixtureGeneralCityContract(fixtureJson, label);
|
||||
expect(reference.fixtureIdentity, `${label}: fixture identity`).toEqual(expected);
|
||||
expect(reference.seed, `${label}: seed`).toBe(expected.seed);
|
||||
};
|
||||
|
||||
const referenceRuntimeCopyFilter = (resolvedCompareRoot: string, source: string): boolean => {
|
||||
const relative = path.relative(resolvedCompareRoot, source);
|
||||
return !(
|
||||
relative === '.git' ||
|
||||
relative.startsWith(`.git${path.sep}`) ||
|
||||
relative === 'vendor' ||
|
||||
relative.startsWith(`vendor${path.sep}`) ||
|
||||
relative === 'd_log' ||
|
||||
relative.startsWith(`d_log${path.sep}`) ||
|
||||
relative === path.join('hwe', 'd_setting') ||
|
||||
relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`)
|
||||
);
|
||||
};
|
||||
|
||||
const assertSafeDockerNetworkName = (network: string): string => {
|
||||
if (!/^[A-Za-z0-9_.-]+$/u.test(network)) {
|
||||
throw new Error('Reference Docker network name contains unsupported characters.');
|
||||
}
|
||||
return network;
|
||||
};
|
||||
|
||||
const resolveReferenceDockerNetwork = (workspaceRoot: string): string => {
|
||||
const explicitNetwork = process.env['REF_COMPARE_NETWORK'];
|
||||
if (explicitNetwork) {
|
||||
const network = assertSafeDockerNetworkName(explicitNetwork);
|
||||
try {
|
||||
const resolved = execFileSync('docker', ['network', 'inspect', '--format', '{{.Name}}', network], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
if (resolved !== network) {
|
||||
throw new Error('network identity mismatch');
|
||||
}
|
||||
return network;
|
||||
} catch {
|
||||
throw new Error('REF_COMPARE_NETWORK does not identify an available Docker network.');
|
||||
}
|
||||
}
|
||||
|
||||
const composeDirectory = path.join(workspaceRoot, 'docker_compose_files/reference');
|
||||
try {
|
||||
const phpContainerId = execFileSync('docker', ['compose', 'ps', '-q', 'php'], {
|
||||
cwd: composeDirectory,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
if (!phpContainerId || !/^[a-f0-9]+$/u.test(phpContainerId)) {
|
||||
throw new Error('reference php container is unavailable');
|
||||
}
|
||||
const networks = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'inspect',
|
||||
'--format',
|
||||
'{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}',
|
||||
phpContainerId,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
)
|
||||
.split(/\r?\n/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (networks.length !== 1) {
|
||||
throw new Error('reference php container must have exactly one discoverable network');
|
||||
}
|
||||
return assertSafeDockerNetworkName(networks[0]!);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Unable to discover the official reference Compose network. Start that stack or set REF_COMPARE_NETWORK explicitly.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const runReferenceSourceScript = (options: {
|
||||
workspaceRoot: string;
|
||||
compareSourceRoot: string;
|
||||
script: string;
|
||||
args?: string[];
|
||||
input?: string;
|
||||
maxBuffer?: number;
|
||||
}): string => {
|
||||
const resolvedCompareRoot = path.resolve(options.compareSourceRoot);
|
||||
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-compare-'));
|
||||
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
|
||||
recursive: true,
|
||||
filter: (source) => referenceRuntimeCopyFilter(resolvedCompareRoot, source),
|
||||
});
|
||||
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
|
||||
try {
|
||||
const network = resolveReferenceDockerNetwork(options.workspaceRoot);
|
||||
try {
|
||||
return execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'-i',
|
||||
'--network',
|
||||
network,
|
||||
'-v',
|
||||
`${runtimeRoot}:/var/www/html`,
|
||||
'-v',
|
||||
`${path.join(options.workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`,
|
||||
'-v',
|
||||
`${path.join(options.workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`,
|
||||
'sam-rebuild-ref-php:8.3',
|
||||
'php',
|
||||
'-d',
|
||||
'display_errors=0',
|
||||
'-d',
|
||||
'log_errors=0',
|
||||
`/var/www/html/${options.script}`,
|
||||
...(options.args ?? []),
|
||||
],
|
||||
{
|
||||
input: options.input,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...(options.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const failure = error as { status?: number | null; stderr?: string | Buffer };
|
||||
const stderr = String(failure.stderr ?? '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
// Intentionally omit the raw child-process error as the cause: it
|
||||
// retains prior JSONL stdout and can expose a huge fixture corpus.
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(
|
||||
`reference comparison script failed (exit ${String(failure.status ?? 'unknown')})${stderr ? `: ${stderr}` : ''}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
|
||||
assertFixtureGeneralCityContract(fixtureJson);
|
||||
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
||||
if (compareContainer) {
|
||||
const stdout = execFileSync(
|
||||
@@ -156,61 +462,22 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
return JSON.parse(stdout) as ReferenceTrace;
|
||||
const reference = JSON.parse(stdout) as ReferenceTrace;
|
||||
assertReferenceFixtureIdentity(reference, fixtureJson);
|
||||
return reference;
|
||||
}
|
||||
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||
if (compareSourceRoot) {
|
||||
const resolvedCompareRoot = path.resolve(compareSourceRoot);
|
||||
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-'));
|
||||
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
|
||||
recursive: true,
|
||||
filter: (source) => {
|
||||
const relative = path.relative(resolvedCompareRoot, source);
|
||||
return !(
|
||||
relative === '.git' ||
|
||||
relative.startsWith(`.git${path.sep}`) ||
|
||||
relative === 'vendor' ||
|
||||
relative.startsWith(`vendor${path.sep}`) ||
|
||||
relative === 'd_log' ||
|
||||
relative.startsWith(`d_log${path.sep}`) ||
|
||||
relative === path.join('hwe', 'd_setting') ||
|
||||
relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`)
|
||||
);
|
||||
},
|
||||
const stdout = runReferenceSourceScript({
|
||||
workspaceRoot,
|
||||
compareSourceRoot,
|
||||
script: 'hwe/compare/battle_trace.php',
|
||||
args: ['-'],
|
||||
input: fixtureJson,
|
||||
});
|
||||
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'-i',
|
||||
'-v',
|
||||
`${runtimeRoot}:/var/www/html`,
|
||||
'-v',
|
||||
`${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`,
|
||||
'-v',
|
||||
`${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`,
|
||||
'sam-rebuild-ref-php:8.3',
|
||||
'php',
|
||||
'-d',
|
||||
'display_errors=0',
|
||||
'-d',
|
||||
'log_errors=0',
|
||||
'/var/www/html/hwe/compare/battle_trace.php',
|
||||
'-',
|
||||
],
|
||||
{
|
||||
input: fixtureJson,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
return JSON.parse(stdout) as ReferenceTrace;
|
||||
} finally {
|
||||
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
const reference = JSON.parse(stdout) as ReferenceTrace;
|
||||
assertReferenceFixtureIdentity(reference, fixtureJson);
|
||||
return reference;
|
||||
}
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
@@ -222,21 +489,63 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
return JSON.parse(stdout) as ReferenceTrace;
|
||||
const reference = JSON.parse(stdout) as ReferenceTrace;
|
||||
assertReferenceFixtureIdentity(reference, fixtureJson);
|
||||
return reference;
|
||||
};
|
||||
|
||||
interface BattleReferenceManifest {
|
||||
schemaVersion: 1;
|
||||
fixtureCount: number;
|
||||
fixtureJsonlSha256: string;
|
||||
traceCount: number;
|
||||
traceJsonlSha256: string;
|
||||
}
|
||||
|
||||
const normalizeJsonlForManifest = (lines: string[]): string => `${lines.map((line) => line.trim()).join('\n')}\n`;
|
||||
|
||||
const readBoundPrecomputedTraces = (tracePath: string, fixtureLines: string[]): ReferenceTrace[] => {
|
||||
const resolvedTracePath = path.resolve(tracePath);
|
||||
const rawTraceJsonl = fs.readFileSync(resolvedTracePath, 'utf8');
|
||||
const traceLines = rawTraceJsonl.split(/\r?\n/u).filter(Boolean);
|
||||
const manifestPath = path.resolve(
|
||||
process.env['BATTLE_REFERENCE_MANIFEST_PATH'] ?? `${resolvedTracePath}.manifest.json`
|
||||
);
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(
|
||||
'BATTLE_REFERENCE_TRACE_PATH requires BATTLE_REFERENCE_MANIFEST_PATH or a sibling .manifest.json file.'
|
||||
);
|
||||
}
|
||||
const manifest = readJson<BattleReferenceManifest>(manifestPath);
|
||||
if (manifest.schemaVersion !== 1) {
|
||||
throw new Error('Unsupported battle reference manifest schemaVersion.');
|
||||
}
|
||||
if (traceLines.length !== fixtureLines.length) {
|
||||
throw new Error(`precomputed ref corpus has ${traceLines.length} traces for ${fixtureLines.length} fixtures`);
|
||||
}
|
||||
const expectedFixtureJsonl = normalizeJsonlForManifest(fixtureLines);
|
||||
const checks: Array<[string, unknown, unknown]> = [
|
||||
['fixtureCount', manifest.fixtureCount, fixtureLines.length],
|
||||
['traceCount', manifest.traceCount, traceLines.length],
|
||||
['fixtureJsonlSha256', manifest.fixtureJsonlSha256, sha256(expectedFixtureJsonl)],
|
||||
['traceJsonlSha256', manifest.traceJsonlSha256, sha256(rawTraceJsonl)],
|
||||
];
|
||||
for (const [label, actual, expected] of checks) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`battle reference manifest ${label} mismatch`);
|
||||
}
|
||||
}
|
||||
|
||||
const traces = traceLines.map((line) => JSON.parse(line) as ReferenceTrace);
|
||||
traces.forEach((trace, index) => assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `trace[${index}]`));
|
||||
return traces;
|
||||
};
|
||||
|
||||
const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): ReferenceTrace[] => {
|
||||
fixtureLines.forEach((line, index) => assertFixtureGeneralCityContract(line, `fixture[${index}]`));
|
||||
const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH;
|
||||
if (precomputedTracePath) {
|
||||
const traces = fs
|
||||
.readFileSync(path.resolve(precomputedTracePath), 'utf8')
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ReferenceTrace);
|
||||
if (traces.length < fixtureLines.length) {
|
||||
throw new Error(`precomputed ref corpus has ${traces.length} traces for ${fixtureLines.length} fixtures`);
|
||||
}
|
||||
return traces.slice(0, fixtureLines.length);
|
||||
return readBoundPrecomputedTraces(precomputedTracePath, fixtureLines);
|
||||
}
|
||||
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
||||
if (compareContainer) {
|
||||
@@ -259,84 +568,54 @@ const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]):
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
return stdout
|
||||
const traces = stdout
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ReferenceTrace);
|
||||
if (traces.length !== fixtureLines.length) {
|
||||
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
|
||||
}
|
||||
traces.forEach((trace, index) =>
|
||||
assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `container trace[${index}]`)
|
||||
);
|
||||
return traces;
|
||||
}
|
||||
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||
if (!compareSourceRoot) {
|
||||
throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.');
|
||||
}
|
||||
const resolvedCompareRoot = path.resolve(compareSourceRoot);
|
||||
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-corpus-'));
|
||||
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
|
||||
recursive: true,
|
||||
filter: (source) => {
|
||||
const relative = path.relative(resolvedCompareRoot, source);
|
||||
return !(
|
||||
relative === '.git' ||
|
||||
relative.startsWith(`.git${path.sep}`) ||
|
||||
relative === 'vendor' ||
|
||||
relative.startsWith(`vendor${path.sep}`) ||
|
||||
relative === 'd_log' ||
|
||||
relative.startsWith(`d_log${path.sep}`) ||
|
||||
relative === path.join('hwe', 'd_setting') ||
|
||||
relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`)
|
||||
);
|
||||
},
|
||||
const stdout = runReferenceSourceScript({
|
||||
workspaceRoot,
|
||||
compareSourceRoot,
|
||||
script: 'hwe/compare/battle_trace.php',
|
||||
args: ['--jsonl'],
|
||||
input: normalizeJsonlForManifest(fixtureLines),
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
});
|
||||
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
|
||||
try {
|
||||
const traces: ReferenceTrace[] = [];
|
||||
const chunkSize = 200;
|
||||
for (let offset = 0; offset < fixtureLines.length; offset += chunkSize) {
|
||||
const chunk = fixtureLines.slice(offset, offset + chunkSize);
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'-i',
|
||||
'-v',
|
||||
`${runtimeRoot}:/var/www/html`,
|
||||
'-v',
|
||||
`${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`,
|
||||
'-v',
|
||||
`${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`,
|
||||
'sam-rebuild-ref-php:8.3',
|
||||
'php',
|
||||
'-d',
|
||||
'display_errors=0',
|
||||
'-d',
|
||||
'log_errors=0',
|
||||
'/var/www/html/hwe/compare/battle_trace.php',
|
||||
'--jsonl',
|
||||
],
|
||||
{
|
||||
input: `${chunk.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
traces.push(
|
||||
...stdout
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ReferenceTrace)
|
||||
);
|
||||
}
|
||||
if (traces.length !== fixtureLines.length) {
|
||||
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
|
||||
}
|
||||
return traces;
|
||||
} finally {
|
||||
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
||||
const traces = stdout
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ReferenceTrace);
|
||||
if (traces.length !== fixtureLines.length) {
|
||||
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
|
||||
}
|
||||
traces.forEach((trace, index) =>
|
||||
assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `source trace[${index}]`)
|
||||
);
|
||||
return traces;
|
||||
};
|
||||
|
||||
const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record<string, ReferenceItemMetadata> => {
|
||||
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||
if (compareSourceRoot) {
|
||||
const stdout = runReferenceSourceScript({
|
||||
workspaceRoot,
|
||||
compareSourceRoot,
|
||||
script: 'hwe/compare/item_catalog.php',
|
||||
input: JSON.stringify(itemKeys),
|
||||
});
|
||||
return JSON.parse(stdout) as Record<string, ReferenceItemMetadata>;
|
||||
}
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/item_catalog.php'],
|
||||
@@ -351,6 +630,15 @@ const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Rec
|
||||
};
|
||||
|
||||
const runReferenceTraitCatalog = (workspaceRoot: string): ReferenceTraitCatalog => {
|
||||
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||
if (compareSourceRoot) {
|
||||
const stdout = runReferenceSourceScript({
|
||||
workspaceRoot,
|
||||
compareSourceRoot,
|
||||
script: 'hwe/compare/trait_catalog.php',
|
||||
});
|
||||
return JSON.parse(stdout) as ReferenceTraitCatalog;
|
||||
}
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/trait_catalog.php'],
|
||||
@@ -366,45 +654,153 @@ const runReferenceTraitCatalog = (workspaceRoot: string): ReferenceTraitCatalog
|
||||
const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => {
|
||||
expect(typeof actual, `${label}: actual type`).toBe('number');
|
||||
expect(typeof expected, `${label}: reference type`).toBe('number');
|
||||
if (process.env['STRICT_BATTLE_PARITY'] === '1') {
|
||||
expect(actual, `${label}: exact battle parity`).toBe(expected);
|
||||
const actualNumber = actual as number;
|
||||
const expectedNumber = expected as number;
|
||||
expect(Number.isFinite(actualNumber), `${label}: actual must be finite`).toBe(true);
|
||||
expect(Number.isFinite(expectedNumber), `${label}: reference must be finite`).toBe(true);
|
||||
if (Number.isSafeInteger(actualNumber) && Number.isSafeInteger(expectedNumber)) {
|
||||
expect(actualNumber, `${label}: integer battle parity`).toBe(expectedNumber);
|
||||
return;
|
||||
}
|
||||
const reference = expected as number;
|
||||
const configuredRelativeTolerance = Number.parseFloat(
|
||||
process.env['BATTLE_TRACE_RELATIVE_TOLERANCE'] ?? '0.01'
|
||||
);
|
||||
if (!Number.isFinite(configuredRelativeTolerance) || configuredRelativeTolerance < 0) {
|
||||
throw new Error('BATTLE_TRACE_RELATIVE_TOLERANCE must be a non-negative finite number');
|
||||
}
|
||||
const tolerance = Math.max(
|
||||
Number.EPSILON * Math.max(1, Math.abs(reference)) * 8,
|
||||
Math.abs(reference) * configuredRelativeTolerance
|
||||
Number.EPSILON * Math.max(1, Math.abs(expectedNumber)) * 16,
|
||||
Math.abs(expectedNumber) * 1e-12,
|
||||
1e-12
|
||||
);
|
||||
expect(
|
||||
Math.abs((actual as number) - reference),
|
||||
`${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}`
|
||||
Math.abs(actualNumber - expectedNumber),
|
||||
`${label}: core=${String(actualNumber)}, ref=${String(expectedNumber)}, tolerance=${tolerance}`
|
||||
).toBeLessThanOrEqual(tolerance);
|
||||
};
|
||||
|
||||
const isCanonicalEmptyMapPath = (label: string): boolean =>
|
||||
label.endsWith('.activatedSkills') || label.endsWith('.details');
|
||||
|
||||
const normalizeCanonicalEmptyMap = (value: unknown, label: string): unknown => {
|
||||
if (isCanonicalEmptyMapPath(label) && Array.isArray(value) && value.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const assertCanonicalValue = (rawActual: unknown, rawExpected: unknown, label: string): void => {
|
||||
const actual = normalizeCanonicalEmptyMap(rawActual, label);
|
||||
const expected = normalizeCanonicalEmptyMap(rawExpected, label);
|
||||
if (typeof actual === 'number' || typeof expected === 'number') {
|
||||
expectNearlyEqual(actual, expected, label);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(actual) || Array.isArray(expected)) {
|
||||
expect(Array.isArray(actual), `${label}: core array type`).toBe(true);
|
||||
expect(Array.isArray(expected), `${label}: ref array type`).toBe(true);
|
||||
const actualArray = actual as unknown[];
|
||||
const expectedArray = expected as unknown[];
|
||||
expect(actualArray.length, `${label}: array length`).toBe(expectedArray.length);
|
||||
for (let index = 0; index < expectedArray.length; index += 1) {
|
||||
assertCanonicalValue(actualArray[index], expectedArray[index], `${label}[${index}]`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isRecord(actual) || isRecord(expected)) {
|
||||
expect(isRecord(actual), `${label}: core object type`).toBe(true);
|
||||
expect(isRecord(expected), `${label}: ref object type`).toBe(true);
|
||||
const actualObject = actual as Record<string, unknown>;
|
||||
const expectedObject = expected as Record<string, unknown>;
|
||||
const actualKeys = Object.keys(actualObject)
|
||||
.filter((key) => actualObject[key] !== undefined)
|
||||
.sort();
|
||||
const expectedKeys = Object.keys(expectedObject)
|
||||
.filter((key) => expectedObject[key] !== undefined)
|
||||
.sort();
|
||||
expect(actualKeys, `${label}: object keys`).toEqual(expectedKeys);
|
||||
for (const key of expectedKeys) {
|
||||
assertCanonicalValue(actualObject[key], expectedObject[key], `${label}.${key}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
expect(actual, label).toBe(expected);
|
||||
};
|
||||
|
||||
const buildCapturedLogBuckets = (
|
||||
capture: CapturedCoreLogger | null | undefined,
|
||||
year: number,
|
||||
month: number
|
||||
): ReferenceLogBuckets => {
|
||||
const buckets: ReferenceLogBuckets = {
|
||||
generalHistoryLog: [],
|
||||
generalActionLog: [],
|
||||
generalBattleResultLog: [],
|
||||
generalBattleDetailLog: [],
|
||||
nationalHistoryLog: [],
|
||||
globalHistoryLog: [],
|
||||
globalActionLog: [],
|
||||
};
|
||||
for (const entry of capture?.entries ?? []) {
|
||||
const text = formatLogText(entry.text, entry.format ?? LogFormat.RAWTEXT, year, month);
|
||||
if (entry.scope === LogScope.GENERAL) {
|
||||
switch (entry.category) {
|
||||
case LogCategory.HISTORY:
|
||||
buckets.generalHistoryLog.push(text);
|
||||
break;
|
||||
case LogCategory.ACTION:
|
||||
buckets.generalActionLog.push(text);
|
||||
break;
|
||||
case LogCategory.BATTLE_BRIEF:
|
||||
buckets.generalBattleResultLog.push(text);
|
||||
break;
|
||||
case LogCategory.BATTLE_DETAIL:
|
||||
buckets.generalBattleDetailLog.push(text);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (entry.scope === LogScope.NATION && entry.category === LogCategory.HISTORY) {
|
||||
buckets.nationalHistoryLog.push(text);
|
||||
} else if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY) {
|
||||
buckets.globalHistoryLog.push(text);
|
||||
} else if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY) {
|
||||
buckets.globalActionLog.push(text);
|
||||
}
|
||||
}
|
||||
return buckets;
|
||||
};
|
||||
|
||||
const assertAllLogBucketsParity = (
|
||||
capture: CoreLogCapture,
|
||||
reference: ReferenceTrace,
|
||||
fixture: BattleSimRequestPayload,
|
||||
label: string
|
||||
): void => {
|
||||
assertCanonicalValue(
|
||||
buildCapturedLogBuckets(capture.byGeneralId.get(fixture.attackerGeneral.no), fixture.year, fixture.month),
|
||||
reference.logs.attacker,
|
||||
`${label}.logs.attacker`
|
||||
);
|
||||
for (const defender of fixture.defenderGenerals) {
|
||||
assertCanonicalValue(
|
||||
buildCapturedLogBuckets(capture.byGeneralId.get(defender.no), fixture.year, fixture.month),
|
||||
reference.logs.defenders[String(defender.no)] ?? {
|
||||
generalHistoryLog: [],
|
||||
generalActionLog: [],
|
||||
generalBattleResultLog: [],
|
||||
generalBattleDetailLog: [],
|
||||
nationalHistoryLog: [],
|
||||
globalHistoryLog: [],
|
||||
globalActionLog: [],
|
||||
},
|
||||
`${label}.logs.defenders.${defender.no}`
|
||||
);
|
||||
}
|
||||
assertCanonicalValue(
|
||||
buildCapturedLogBuckets(capture.city, fixture.year, fixture.month),
|
||||
reference.logs.city,
|
||||
`${label}.logs.city`
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeRandomArguments = (value: Record<string, unknown>): Record<string, unknown> =>
|
||||
Array.isArray(value) && value.length === 0 ? {} : value;
|
||||
|
||||
const describeSequenceDifference = (label: string, actual: unknown[], expected: unknown[]): string | null => {
|
||||
const commonLength = Math.min(actual.length, expected.length);
|
||||
for (let index = 0; index < commonLength; index += 1) {
|
||||
if (JSON.stringify(actual[index]) !== JSON.stringify(expected[index])) {
|
||||
const start = Math.max(0, index - 2);
|
||||
const end = index + 3;
|
||||
return `${label}[${index}]: core=${JSON.stringify(actual.slice(start, end))} ref=${JSON.stringify(expected.slice(start, end))}`;
|
||||
}
|
||||
}
|
||||
if (actual.length !== expected.length) {
|
||||
return `${label} length: core=${actual.length} ref=${expected.length}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const describeTextDifference = (actual: string | undefined, expected: string): string => {
|
||||
const actualText = actual ?? '';
|
||||
let index = 0;
|
||||
@@ -448,19 +844,23 @@ const assertRngParity = (reference: ReferenceTrace, coreRng: TracingRng | null):
|
||||
arguments: normalizeRandomArguments(args),
|
||||
result,
|
||||
}));
|
||||
const rngDifference = describeSequenceDifference('rng', normalizedCoreRng, normalizedReferenceRng);
|
||||
if (rngDifference) {
|
||||
throw new Error(rngDifference);
|
||||
}
|
||||
assertCanonicalValue(normalizedCoreRng, normalizedReferenceRng, 'rng');
|
||||
assertCanonicalValue(coreRng?.boolCalls ?? [], reference.boolRng, 'boolRng');
|
||||
};
|
||||
|
||||
const assertTraceParity = (
|
||||
coreEvents: WarBattleTraceEvent[],
|
||||
reference: ReferenceTrace,
|
||||
coreRng: TracingRng | null
|
||||
coreRng: TracingRng | null,
|
||||
coreOutcome: WarBattleOutcome | null
|
||||
): void => {
|
||||
const defenderOrderEvent = coreEvents[0]?.event === 'defender_order' ? coreEvents[0] : null;
|
||||
const comparableCoreEvents = defenderOrderEvent ? coreEvents.slice(1) : coreEvents;
|
||||
const comparableCoreEvents = (defenderOrderEvent ? coreEvents.slice(1) : coreEvents).map((event, seq) => ({
|
||||
...event,
|
||||
// Core emits one comparison-only defender_order event before the Ref
|
||||
// processWar_NG sequence. Renumber only the canonical shared sequence.
|
||||
seq,
|
||||
}));
|
||||
if (reference.defenderOrder) {
|
||||
// Ref retains non-participating (order <= 0) defenders at the tail and
|
||||
// stops when it reaches them. Core discards them before sorting. The
|
||||
@@ -470,12 +870,14 @@ const assertTraceParity = (
|
||||
after: reference.defenderOrder.after.filter(({ order }) => order > 0),
|
||||
};
|
||||
const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined;
|
||||
expect(coreOrder?.before.map(({ id }) => id), 'defender order before IDs').toEqual(
|
||||
effectiveReferenceOrder.before.map(({ id }) => id)
|
||||
);
|
||||
expect(coreOrder?.after.map(({ id }) => id), 'defender order after IDs').toEqual(
|
||||
effectiveReferenceOrder.after.map(({ id }) => id)
|
||||
);
|
||||
expect(
|
||||
coreOrder?.before.map(({ id }) => id),
|
||||
'defender order before IDs'
|
||||
).toEqual(effectiveReferenceOrder.before.map(({ id }) => id));
|
||||
expect(
|
||||
coreOrder?.after.map(({ id }) => id),
|
||||
'defender order after IDs'
|
||||
).toEqual(effectiveReferenceOrder.after.map(({ id }) => id));
|
||||
for (const side of ['before', 'after'] as const) {
|
||||
for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) {
|
||||
expectNearlyEqual(
|
||||
@@ -487,37 +889,145 @@ const assertTraceParity = (
|
||||
}
|
||||
}
|
||||
assertRngParity(reference, coreRng);
|
||||
const coreEventNames = comparableCoreEvents.map((event) => event.event);
|
||||
const referenceEventNames = reference.events.map((event) => event.event);
|
||||
expect(
|
||||
coreEventNames,
|
||||
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
|
||||
).toEqual(referenceEventNames);
|
||||
assertCanonicalValue(comparableCoreEvents, reference.events, 'events');
|
||||
assertFinalOutcomeParity(coreOutcome, coreEvents, reference);
|
||||
};
|
||||
|
||||
for (let index = 0; index < reference.events.length; index += 1) {
|
||||
const core = comparableCoreEvents[index]!;
|
||||
const ref = reference.events[index]!;
|
||||
expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`);
|
||||
expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`);
|
||||
expect(core.attacker.phase, `event ${index} attacker.phase`).toBe(ref.attacker.phase);
|
||||
expect(core.attacker.realPhase, `event ${index} attacker.realPhase`).toBe(ref.attacker.realPhase);
|
||||
expect(core.attacker.maxPhase, `event ${index} attacker.maxPhase`).toBe(ref.attacker.maxPhase);
|
||||
if (core.defender && ref.defender) {
|
||||
expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind);
|
||||
expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`);
|
||||
expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`);
|
||||
expect(core.defender.phase, `event ${index} defender.phase`).toBe(ref.defender.phase);
|
||||
expect(core.defender.realPhase, `event ${index} defender.realPhase`).toBe(ref.defender.realPhase);
|
||||
expect(core.defender.maxPhase, `event ${index} defender.maxPhase`).toBe(ref.defender.maxPhase);
|
||||
} else {
|
||||
expect(core.defender, `event ${index} defender presence`).toBe(ref.defender);
|
||||
}
|
||||
if (core.event === 'phase_damage') {
|
||||
for (const key of ['rawDeadAttacker', 'rawDeadDefender', 'deadAttacker', 'deadDefender']) {
|
||||
expectNearlyEqual(core.details[key], ref.details[key], `event ${index} ${key}`);
|
||||
}
|
||||
const outcomeMetaNumber = (general: WarBattleOutcome['attacker'], key: string): number => {
|
||||
const value = general.meta[key];
|
||||
return typeof value === 'number' ? value : 0;
|
||||
};
|
||||
|
||||
const buildOutcomeGeneralSnapshot = (
|
||||
transient: WarBattleTraceUnitSnapshot,
|
||||
general: WarBattleOutcome['attacker'],
|
||||
report: WarBattleOutcome['reports'][number],
|
||||
activatedSkills: Record<string, number>
|
||||
): WarBattleTraceUnitSnapshot => ({
|
||||
...transient,
|
||||
kind: 'general',
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
isAttacker: report.isAttacker,
|
||||
crewTypeId: general.crewTypeId,
|
||||
phase: report.phase ?? transient.phase,
|
||||
hp: general.crew,
|
||||
killed: report.killed,
|
||||
dead: report.dead,
|
||||
activatedSkills,
|
||||
general: {
|
||||
crew: general.crew,
|
||||
rice: general.rice,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
injury: general.injury,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
dex1: outcomeMetaNumber(general, 'dex1'),
|
||||
dex2: outcomeMetaNumber(general, 'dex2'),
|
||||
dex3: outcomeMetaNumber(general, 'dex3'),
|
||||
dex4: outcomeMetaNumber(general, 'dex4'),
|
||||
dex5: outcomeMetaNumber(general, 'dex5'),
|
||||
},
|
||||
});
|
||||
|
||||
const assertFinalOutcomeParity = (
|
||||
coreOutcome: WarBattleOutcome | null,
|
||||
coreEvents: WarBattleTraceEvent[],
|
||||
reference: ReferenceTrace
|
||||
): void => {
|
||||
expect(coreOutcome, 'comparison onBattleResolved callback').not.toBeNull();
|
||||
if (!coreOutcome) {
|
||||
return;
|
||||
}
|
||||
const finalEvent = coreEvents.at(-1);
|
||||
expect(finalEvent?.event, 'final battle trace event').toBe('battle_end');
|
||||
if (!finalEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attackerReport = coreOutcome.reports.find(
|
||||
(report) => report.type === 'general' && report.id === coreOutcome.attacker.id && report.isAttacker
|
||||
);
|
||||
const cityReport = coreOutcome.reports.find(
|
||||
(report) => report.type === 'city' && report.id === coreOutcome.defenderCity.id
|
||||
);
|
||||
expect(attackerReport, 'final attacker report').toBeDefined();
|
||||
expect(cityReport, 'final city report').toBeDefined();
|
||||
if (!attackerReport || !cityReport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestDefenderSnapshots = new Map<number, WarBattleTraceUnitSnapshot>();
|
||||
for (const event of coreEvents) {
|
||||
if (event.defender?.kind === 'general') {
|
||||
latestDefenderSnapshots.set(event.defender.id, event.defender);
|
||||
}
|
||||
}
|
||||
const metrics = coreOutcome.metrics;
|
||||
const coreAttacker = buildOutcomeGeneralSnapshot(
|
||||
finalEvent.attacker,
|
||||
coreOutcome.attacker,
|
||||
attackerReport,
|
||||
metrics?.attackerActivatedSkills ?? {}
|
||||
);
|
||||
const coreCity: WarBattleTraceUnitSnapshot = {
|
||||
...finalEvent.city,
|
||||
kind: 'city',
|
||||
id: coreOutcome.defenderCity.id,
|
||||
name: coreOutcome.defenderCity.name,
|
||||
isAttacker: cityReport.isAttacker,
|
||||
phase: cityReport.phase ?? finalEvent.city.phase,
|
||||
killed: cityReport.killed,
|
||||
dead: cityReport.dead,
|
||||
cityState: {
|
||||
defence: coreOutcome.defenderCity.defence,
|
||||
wall: coreOutcome.defenderCity.wall,
|
||||
population: coreOutcome.defenderCity.population,
|
||||
},
|
||||
};
|
||||
const coreFinishedDefenders = reference.finishedDefenders.map((expectedSnapshot) => {
|
||||
if (expectedSnapshot.kind === 'city') {
|
||||
return coreCity;
|
||||
}
|
||||
const defenderIndex = coreOutcome.defenders.findIndex((general) => general.id === expectedSnapshot.id);
|
||||
expect(defenderIndex, `final defender ${expectedSnapshot.id} exists`).toBeGreaterThanOrEqual(0);
|
||||
const general = coreOutcome.defenders[defenderIndex];
|
||||
const orderedDefenderReports = coreOutcome.reports.filter(
|
||||
(candidate) => candidate.type === 'general' && !candidate.isAttacker
|
||||
);
|
||||
const metricIndex = orderedDefenderReports.findIndex((candidate) => candidate.id === expectedSnapshot.id);
|
||||
const report = metricIndex >= 0 ? orderedDefenderReports[metricIndex] : undefined;
|
||||
const transient = latestDefenderSnapshots.get(expectedSnapshot.id);
|
||||
expect(general, `final defender ${expectedSnapshot.id} state`).toBeDefined();
|
||||
expect(report, `final defender ${expectedSnapshot.id} report`).toBeDefined();
|
||||
expect(transient, `final defender ${expectedSnapshot.id} transient snapshot`).toBeDefined();
|
||||
if (!general || !report || !transient) {
|
||||
return expectedSnapshot;
|
||||
}
|
||||
return buildOutcomeGeneralSnapshot(
|
||||
transient,
|
||||
general,
|
||||
report,
|
||||
metrics?.defenderActivatedSkills[metricIndex] ?? {}
|
||||
);
|
||||
});
|
||||
|
||||
assertCanonicalValue(
|
||||
{
|
||||
conquered: coreOutcome.conquered,
|
||||
attacker: coreAttacker,
|
||||
city: coreCity,
|
||||
finishedDefenders: coreFinishedDefenders,
|
||||
},
|
||||
{
|
||||
conquered: reference.conquered,
|
||||
attacker: reference.attacker,
|
||||
city: reference.city,
|
||||
finishedDefenders: reference.finishedDefenders,
|
||||
},
|
||||
'finalOutcome'
|
||||
);
|
||||
};
|
||||
|
||||
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||
@@ -533,8 +1043,27 @@ const battleCorpusPath = process.env.BATTLE_CORPUS_PATH;
|
||||
const itWithBattleCorpus = battleCorpusPath ? it : it.skip;
|
||||
|
||||
describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
it('rejects battle fixtures whose general current-city contract is missing or inconsistent', () => {
|
||||
const fixture = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||
);
|
||||
const missingCity = structuredClone(fixture) as BattleSimRequestPayload & {
|
||||
attackerGeneral: BattleSimGeneralPayload & { city?: number };
|
||||
};
|
||||
delete missingCity.attackerGeneral.city;
|
||||
expect(() => assertFixtureGeneralCityContract(JSON.stringify(missingCity), 'missing-city')).toThrow(
|
||||
'attackerGeneral.city must be an explicit positive integer'
|
||||
);
|
||||
|
||||
const wrongDefenderCity = structuredClone(fixture);
|
||||
wrongDefenderCity.defenderGenerals[0]!.city = fixture.attackerCity.city;
|
||||
expect(() => assertFixtureGeneralCityContract(JSON.stringify(wrongDefenderCity), 'wrong-city')).toThrow(
|
||||
'defenderGeneral[0].city=1 must equal current city 2'
|
||||
);
|
||||
});
|
||||
|
||||
itWithBattleCorpus(
|
||||
'replays a captured battle corpus with matching trace, RNG, skills, outcome, and attacker logs',
|
||||
'replays a captured battle corpus with matching trace, RNG, full outcome, and all log buckets [conditional: BATTLE_CORPUS_PATH]',
|
||||
{ timeout: 600_000 },
|
||||
() => {
|
||||
const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10);
|
||||
@@ -560,7 +1089,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
};
|
||||
const failures: string[] = [];
|
||||
const categoryCounts = new Map<string, number>();
|
||||
const recordFailure = (category: string, index: number, fixture: BattleSimRequestPayload, detail: string) => {
|
||||
const recordFailure = (
|
||||
category: string,
|
||||
index: number,
|
||||
fixture: BattleSimRequestPayload,
|
||||
detail: string
|
||||
) => {
|
||||
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
|
||||
if (failures.length < 40) {
|
||||
failures.push(
|
||||
@@ -593,7 +1127,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
};
|
||||
const reference = referenceTraces[index]!;
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
const coreLogs = createCoreLogCapture();
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
const coreResult = processBattleSimJob(
|
||||
{
|
||||
...fixture,
|
||||
@@ -604,50 +1140,27 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
},
|
||||
{
|
||||
trace: (event) => coreEvents.push(event),
|
||||
loggerFactory: coreLogs.loggerFactory,
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
} catch (error) {
|
||||
recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const finalReference = reference.events.at(-1);
|
||||
if (finalReference) {
|
||||
const outcome = {
|
||||
phase: coreResult.phase,
|
||||
killed: coreResult.killed,
|
||||
dead: coreResult.dead,
|
||||
};
|
||||
const expectedOutcome = {
|
||||
phase: finalReference.attacker.phase,
|
||||
killed: finalReference.attacker.killed,
|
||||
dead: finalReference.attacker.dead,
|
||||
};
|
||||
if (JSON.stringify(outcome) !== JSON.stringify(expectedOutcome)) {
|
||||
recordFailure(
|
||||
'outcome',
|
||||
index,
|
||||
fixture,
|
||||
`core=${JSON.stringify(outcome)} ref=${JSON.stringify(expectedOutcome)}`
|
||||
);
|
||||
}
|
||||
const coreSkills = coreResult.attackerSkills ?? {};
|
||||
const rawReferenceSkills = finalReference.attacker.activatedSkills;
|
||||
const referenceSkills = Array.isArray(rawReferenceSkills) ? {} : (rawReferenceSkills ?? {});
|
||||
if (JSON.stringify(coreSkills) !== JSON.stringify(referenceSkills)) {
|
||||
recordFailure(
|
||||
'skills',
|
||||
index,
|
||||
fixture,
|
||||
`core=${JSON.stringify(coreSkills)} ref=${JSON.stringify(referenceSkills)}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertAllLogBucketsParity(coreLogs, reference, fixture, `fixture[${index}]`);
|
||||
} catch (error) {
|
||||
recordFailure('logs', index, fixture, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('<br>'));
|
||||
@@ -734,6 +1247,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
const coreResult = processBattleSimJob(
|
||||
{
|
||||
...base,
|
||||
@@ -744,16 +1258,19 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
},
|
||||
{
|
||||
trace: (event) => coreEvents.push(event),
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
const opponentSwitches = coreEvents.filter((event) => event.event === 'opponent_switched');
|
||||
if (entry.directCity) {
|
||||
expect(
|
||||
@@ -792,7 +1309,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
'<C>●</>아군의 전멸에 상대의 <R>진격</>이 이어집니다!'
|
||||
);
|
||||
expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain(
|
||||
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
|
||||
'적군의 전멸에 <span style="color: cyan;">진격</span>이 이어집니다!'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -874,17 +1391,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
base.attackerGeneral.strength = 85;
|
||||
base.attackerGeneral.intel = 80;
|
||||
base.attackerGeneral.special =
|
||||
entry.kind === 'dualSlot'
|
||||
? entry.special
|
||||
: entry.kind === 'eventDomestic'
|
||||
? entry.key
|
||||
: 'None';
|
||||
entry.kind === 'dualSlot' ? entry.special : entry.kind === 'eventDomestic' ? entry.key : 'None';
|
||||
base.attackerGeneral.special2 =
|
||||
entry.kind === 'dualSlot'
|
||||
? entry.special2
|
||||
: entry.kind === 'war'
|
||||
? entry.key
|
||||
: 'None';
|
||||
entry.kind === 'dualSlot' ? entry.special2 : entry.kind === 'war' ? entry.key : 'None';
|
||||
base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None';
|
||||
if (entry.kind === 'nation') {
|
||||
base.attackerNation.type = entry.key;
|
||||
@@ -892,6 +1401,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
processBattleSimJob(
|
||||
{
|
||||
...base,
|
||||
@@ -901,14 +1411,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
},
|
||||
{
|
||||
trace: (event) => coreEvents.push(event),
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
}
|
||||
);
|
||||
try {
|
||||
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
||||
assertTraceParity(
|
||||
coreEvents,
|
||||
runReferenceTrace(workspaceRoot!, JSON.stringify(base)),
|
||||
coreRng,
|
||||
coreOutcome
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -989,7 +1507,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
const coreLogs = createCoreLogCapture();
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
processBattleSimJob(
|
||||
{
|
||||
...base,
|
||||
@@ -999,13 +1519,45 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
},
|
||||
{
|
||||
trace: (event) => coreEvents.push(event),
|
||||
loggerFactory: coreLogs.loggerFactory,
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
}
|
||||
);
|
||||
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
assertAllLogBucketsParity(coreLogs, reference, base, 'trait-item.non-stacking-musang');
|
||||
|
||||
const runFirstPhasePower = (fixture: BattleSimRequestPayload & { startYear: number }): number => {
|
||||
const events: WarBattleTraceEvent[] = [];
|
||||
processBattleSimJob(
|
||||
{
|
||||
...fixture,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear },
|
||||
},
|
||||
{ trace: (event) => events.push(event) }
|
||||
);
|
||||
const firstPhase = events.find((event) => event.event === 'phase_power');
|
||||
expect(firstPhase, '무쌍 first phase power').toBeDefined();
|
||||
return firstPhase!.attacker.rawWarPower;
|
||||
};
|
||||
const combinedPower = coreEvents.find((event) => event.event === 'phase_power')!.attacker.rawWarPower;
|
||||
const traitOnly = structuredClone(base);
|
||||
traitOnly.attackerGeneral.item = 'None';
|
||||
const itemOnly = structuredClone(base);
|
||||
itemOnly.attackerGeneral.special2 = 'None';
|
||||
const control = structuredClone(itemOnly);
|
||||
control.attackerGeneral.item = 'None';
|
||||
expect(combinedPower, 'duplicate 무쌍 does not stack over trait').toBe(runFirstPhasePower(traitOnly));
|
||||
expect(combinedPower, 'duplicate 무쌍 does not stack over item').toBe(runFirstPhasePower(itemOnly));
|
||||
expect(combinedPower, '무쌍 has a real battle effect').not.toBe(runFirstPhasePower(control));
|
||||
});
|
||||
|
||||
it('matches 척사 items against region-restricted troops', () => {
|
||||
@@ -1033,7 +1585,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
base.attackerGeneral.crew = 5000;
|
||||
base.defenderGenerals[0]!.crewtype = 1101;
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
const coreLogs = createCoreLogCapture();
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
processBattleSimJob(
|
||||
{
|
||||
...base,
|
||||
@@ -1043,17 +1597,39 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
},
|
||||
{
|
||||
trace: (event) => coreEvents.push(event),
|
||||
loggerFactory: coreLogs.loggerFactory,
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
}
|
||||
);
|
||||
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
assertAllLogBucketsParity(coreLogs, reference, base, `item.${itemKey}.region-opponent`);
|
||||
|
||||
const control = structuredClone(base);
|
||||
control.attackerGeneral.item = 'None';
|
||||
const controlEvents: WarBattleTraceEvent[] = [];
|
||||
processBattleSimJob(
|
||||
{
|
||||
...control,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: control.year, month: control.month, startYear: control.startYear },
|
||||
},
|
||||
{ trace: (event) => controlEvents.push(event) }
|
||||
);
|
||||
const itemPower = coreEvents.find((event) => event.event === 'phase_power')?.attacker.rawWarPower;
|
||||
const controlPower = controlEvents.find((event) => event.event === 'phase_power')?.attacker.rawWarPower;
|
||||
expect(itemPower, `${itemKey}: region troop effect is observed`).not.toBe(controlPower);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the detailed event sequence and phase values within 1%', () => {
|
||||
it('matches the complete canonical event, RNG, state, and logger snapshots', () => {
|
||||
const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json');
|
||||
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
|
||||
const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number };
|
||||
@@ -1086,18 +1662,271 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
};
|
||||
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
const coreLogs = createCoreLogCapture();
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
const coreResult = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
loggerFactory: coreLogs.loggerFactory,
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(coreResult.result).toBe(true);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
assertAllLogBucketsParity(coreLogs, reference, request, 'basic-infantry');
|
||||
});
|
||||
|
||||
it('matches officer levels 1-4 in assigned and off-city battles on both sides', () => {
|
||||
const unitSet = readJson<UnitSetDefinition>(
|
||||
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||
);
|
||||
const config: WarEngineConfig = {
|
||||
armPerPhase: 500,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
maxTrainByWar: 110,
|
||||
maxAtmosByWar: 150,
|
||||
castleCrewTypeId: 1000,
|
||||
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||
};
|
||||
const cases: Array<{
|
||||
role: 'attacker' | 'defender';
|
||||
level: number;
|
||||
assigned: boolean;
|
||||
fixture: BattleSimRequestPayload & { startYear: number };
|
||||
}> = [];
|
||||
for (const role of ['attacker', 'defender'] as const) {
|
||||
for (const level of [1, 2, 3, 4]) {
|
||||
for (const assigned of [true, false]) {
|
||||
const fixture = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||
);
|
||||
fixture.seed = `battle-differential-officer-${role}-${level}-${assigned ? 'assigned' : 'off-city'}`;
|
||||
const general = role === 'attacker' ? fixture.attackerGeneral : fixture.defenderGenerals[0]!;
|
||||
const counterpart = role === 'attacker' ? fixture.defenderGenerals[0]! : fixture.attackerGeneral;
|
||||
const currentCity = role === 'attacker' ? fixture.attackerCity.city : fixture.defenderCity.city;
|
||||
const counterpartCity = role === 'attacker' ? fixture.defenderCity.city : fixture.attackerCity.city;
|
||||
general.officer_level = level;
|
||||
general.officer_city = assigned ? currentCity : currentCity + 1000;
|
||||
// Keep the opposite unit neutral so the subject officer's attack/defence
|
||||
// multiplier is observable without the counterpart's level-3 5% modifier.
|
||||
counterpart.officer_level = 1;
|
||||
counterpart.officer_city = counterpartCity;
|
||||
cases.push({ role, level, assigned, fixture });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fixtureLines = cases.map(({ fixture }) => JSON.stringify(fixture));
|
||||
const references = runReferenceTraceBatch(workspaceRoot!, fixtureLines);
|
||||
const officerSignatures = new Map<string, string>();
|
||||
cases.forEach(({ role, level, assigned, fixture }, index) => {
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
const coreLogs = createCoreLogCapture();
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
processBattleSimJob(
|
||||
{
|
||||
...fixture,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear },
|
||||
},
|
||||
{
|
||||
trace: (event) => coreEvents.push(event),
|
||||
loggerFactory: coreLogs.loggerFactory,
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
}
|
||||
);
|
||||
const reference = references[index]!;
|
||||
const label = `officer.${role}.level${level}.${assigned ? 'assigned' : 'off-city'}`;
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
assertAllLogBucketsParity(coreLogs, reference, fixture, label);
|
||||
const phasePower = coreEvents.find((event) => event.event === 'phase_power');
|
||||
const snapshot = role === 'attacker' ? phasePower?.attacker : phasePower?.defender;
|
||||
const counterpartSnapshot = role === 'attacker' ? phasePower?.defender : phasePower?.attacker;
|
||||
expect(snapshot?.kind, `${label}: participating general`).toBe('general');
|
||||
expect(counterpartSnapshot?.kind, `${label}: counterpart general`).toBe('general');
|
||||
officerSignatures.set(
|
||||
`${role}-${level}-${assigned}`,
|
||||
JSON.stringify({
|
||||
subjectRawWarPower: snapshot!.rawWarPower,
|
||||
counterpartWarPowerMultiplier: counterpartSnapshot!.warPowerMultiplier,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
for (const role of ['attacker', 'defender'] as const) {
|
||||
expect(officerSignatures.get(`${role}-1-true`), `${role}: level 1 ignores assignment`).toBe(
|
||||
officerSignatures.get(`${role}-1-false`)
|
||||
);
|
||||
for (const level of [2, 3, 4]) {
|
||||
expect(
|
||||
officerSignatures.get(`${role}-${level}-false`),
|
||||
`${role}: off-city level ${level} falls back`
|
||||
).toBe(officerSignatures.get(`${role}-1-true`));
|
||||
expect(
|
||||
officerSignatures.get(`${role}-${level}-true`),
|
||||
`${role}: assigned level ${level} keeps the officer battle signature`
|
||||
).not.toBe(officerSignatures.get(`${role}-${level}-false`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('matches every distinct CHE crew battle signature on attacker and defender paths', { timeout: 180_000 }, () => {
|
||||
const unitSet = readJson<UnitSetDefinition>(
|
||||
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||
);
|
||||
const crewTypes = unitSet.crewTypes ?? [];
|
||||
const signatures = crewTypes.map((crewType) =>
|
||||
JSON.stringify({
|
||||
armType: crewType.armType,
|
||||
attack: crewType.attack,
|
||||
defence: crewType.defence,
|
||||
speed: crewType.speed,
|
||||
avoid: crewType.avoid,
|
||||
magicCoef: crewType.magicCoef,
|
||||
rice: crewType.rice,
|
||||
attackCoef: crewType.attackCoef,
|
||||
defenceCoef: crewType.defenceCoef,
|
||||
iActionList: crewType.iActionList,
|
||||
initSkillTrigger: crewType.initSkillTrigger,
|
||||
phaseSkillTrigger: crewType.phaseSkillTrigger,
|
||||
})
|
||||
);
|
||||
expect(new Set(signatures).size, 'unitset_che distinct battle signatures').toBe(crewTypes.length);
|
||||
|
||||
const config: WarEngineConfig = {
|
||||
armPerPhase: 500,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
maxTrainByWar: 110,
|
||||
maxAtmosByWar: 150,
|
||||
castleCrewTypeId: 1000,
|
||||
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||
};
|
||||
const cases: Array<{
|
||||
role: 'attacker' | 'defender';
|
||||
crewTypeId: number;
|
||||
fixture: BattleSimRequestPayload & { startYear: number };
|
||||
}> = [];
|
||||
const crewFilter = process.env.CREW_PARITY_FILTER;
|
||||
const crewRoleFilter = process.env.CREW_PARITY_ROLE;
|
||||
for (const crewType of crewTypes.filter((entry) => entry.id !== config.castleCrewTypeId)) {
|
||||
if (crewFilter && String(crewType.id) !== crewFilter) {
|
||||
continue;
|
||||
}
|
||||
for (const role of ['attacker', 'defender'] as const) {
|
||||
if (crewRoleFilter && role !== crewRoleFilter) {
|
||||
continue;
|
||||
}
|
||||
// In the official assertion-enabled Ref image, attacker-side
|
||||
// 정란/벽력거 routes the castle first and then the castle's
|
||||
// general-only phase trigger aborts. Their distinct phase skill
|
||||
// remains covered on the defender path; the Ref runtime defect is
|
||||
// documented as an explicit remaining boundary.
|
||||
if (role === 'attacker' && (crewType.id === 1500 || crewType.id === 1502)) {
|
||||
continue;
|
||||
}
|
||||
const fixture = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||
);
|
||||
fixture.seed = `battle-differential-crew-${role}-${crewType.id}`;
|
||||
// Keep every synthetic pairing in general-vs-general combat for the
|
||||
// whole phase budget. City combat is covered separately and the Ref
|
||||
// castle unit intentionally carries a general-only phase assertion.
|
||||
fixture.attackerGeneral.crew = 50000;
|
||||
fixture.attackerGeneral.rice = 1000000;
|
||||
fixture.attackerGeneral.leadership = 90;
|
||||
fixture.attackerGeneral.strength = 90;
|
||||
fixture.attackerGeneral.intel = 90;
|
||||
fixture.defenderGenerals[0]!.crew = 50000;
|
||||
fixture.defenderGenerals[0]!.rice = 1000000;
|
||||
fixture.defenderGenerals[0]!.leadership = 85;
|
||||
fixture.defenderGenerals[0]!.strength = 85;
|
||||
fixture.defenderGenerals[0]!.intel = 85;
|
||||
fixture.defenderCity.def = 400;
|
||||
fixture.defenderCity.wall = 400;
|
||||
fixture.defenderCity.def_max = 400;
|
||||
fixture.defenderCity.wall_max = 400;
|
||||
const general = role === 'attacker' ? fixture.attackerGeneral : fixture.defenderGenerals[0]!;
|
||||
general.crewtype = crewType.id;
|
||||
general.dex1 = 12000;
|
||||
general.dex2 = 12000;
|
||||
general.dex3 = 12000;
|
||||
general.dex4 = 12000;
|
||||
general.dex5 = 12000;
|
||||
cases.push({ role, crewTypeId: crewType.id, fixture });
|
||||
}
|
||||
}
|
||||
|
||||
const fixtureLines = cases.map(({ fixture }) => JSON.stringify(fixture));
|
||||
const references = runReferenceTraceBatch(workspaceRoot!, fixtureLines);
|
||||
cases.forEach(({ role, crewTypeId, fixture }, index) => {
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
const coreLogs = createCoreLogCapture();
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
processBattleSimJob(
|
||||
{
|
||||
...fixture,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear },
|
||||
},
|
||||
{
|
||||
trace: (event) => coreEvents.push(event),
|
||||
loggerFactory: coreLogs.loggerFactory,
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
}
|
||||
);
|
||||
const reference = references[index]!;
|
||||
const label = `crew.${role}.${crewTypeId}`;
|
||||
try {
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
assertAllLogBucketsParity(coreLogs, reference, fixture, label);
|
||||
} catch (error) {
|
||||
const debug =
|
||||
process.env.CREW_PARITY_DEBUG === '1'
|
||||
? ` coreEvents=${JSON.stringify(coreEvents.map((event) => [event.seq, event.event, event.attacker.phase, event.defender?.phase, event.defender?.activatedSkills]))} refEvents=${JSON.stringify(reference.events.map((event) => [event.seq, event.event, event.attacker.phase, event.defender?.phase, event.defender?.activatedSkills]))}`
|
||||
: '';
|
||||
throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}${debug}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (role === 'defender' && (crewTypeId === 1500 || crewTypeId === 1502)) {
|
||||
expect(
|
||||
coreEvents.some((event) => (event.defender?.activatedSkills['선제'] ?? 0) > 0),
|
||||
`${label}: 정란/벽력거 선제사격 must activate`
|
||||
).toBe(true);
|
||||
}
|
||||
if (role === 'defender' && crewTypeId === 1503) {
|
||||
expect(
|
||||
coreEvents.some((event) => (event.defender?.activatedSkills['저지'] ?? 0) > 0),
|
||||
`${label}: 목우 저지 must activate for the fixed seed`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
|
||||
@@ -1142,11 +1971,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
const result = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
@@ -1157,12 +1990,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill))
|
||||
)
|
||||
).toBe(true);
|
||||
assertRngParity(reference, coreRng);
|
||||
const finalReference = reference.events.at(-1)!;
|
||||
expect({ phase: result.phase, killed: result.killed }).toEqual({
|
||||
phase: finalReference.attacker.phase,
|
||||
killed: finalReference.attacker.killed,
|
||||
});
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
|
||||
// Ref keeps the injury-adjusted intelligence fraction here:
|
||||
// (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2
|
||||
@@ -1185,7 +2013,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
{
|
||||
rngFactory: (seed) => {
|
||||
fractionalCoreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(fractionalCoreRng);
|
||||
return fractionalCoreRng.createRandUtil();
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1262,18 +2090,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
const result = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(result.result).toBe(true);
|
||||
expect(reference.events.filter((event) => event.event === 'opponent_switched')).toHaveLength(2);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
});
|
||||
|
||||
it('matches siege dexterity and castle damage handling', () => {
|
||||
@@ -1315,18 +2147,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
const result = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(result.result).toBe(true);
|
||||
expect(reference.events.some((event) => event.defender?.kind === 'city')).toBe(true);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
});
|
||||
|
||||
it('matches the no-defender supply-retreat branch without consuming RNG', () => {
|
||||
@@ -1358,18 +2194,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
const result = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(result.result).toBe(true);
|
||||
expect(reference.events.map((event) => event.event)).toEqual(['battle_start', 'supply_retreat', 'battle_end']);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
});
|
||||
|
||||
it('matches every scenario item in an attacker battle simulation', { timeout: 180_000 }, () => {
|
||||
@@ -1450,17 +2290,24 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
const coreLogs = createCoreLogCapture();
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
loggerFactory: coreLogs.loggerFactory,
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||
try {
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
assertAllLogBucketsParity(coreLogs, reference, base, `item.attacker.${itemKey}`);
|
||||
} catch (error) {
|
||||
const debug =
|
||||
process.env['ITEM_PARITY_DEBUG'] === '1'
|
||||
@@ -1558,17 +2405,24 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
const coreLogs = createCoreLogCapture();
|
||||
let coreRng: TracingRng | null = null;
|
||||
let coreOutcome: WarBattleOutcome | null = null;
|
||||
processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
loggerFactory: coreLogs.loggerFactory,
|
||||
onBattleResolved: (outcome) => {
|
||||
coreOutcome = outcome;
|
||||
},
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
return coreRng.createRandUtil();
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||
try {
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||
assertAllLogBucketsParity(coreLogs, reference, base, `item.defender.${itemKey}`);
|
||||
} catch (error) {
|
||||
const debug =
|
||||
process.env['ITEM_PARITY_DEBUG'] === '1'
|
||||
|
||||
@@ -210,7 +210,9 @@ integration('live sortie PostgreSQL persistence retry', () => {
|
||||
commandProfile: createCoreTurnCommandProfile(request),
|
||||
});
|
||||
world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
schedule: {
|
||||
entries: [{ startMinute: 0, tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)) }],
|
||||
},
|
||||
generalTurnHandler: handler,
|
||||
});
|
||||
const actor = world.getGeneralById(request.actorGeneralId);
|
||||
|
||||
Reference in New Issue
Block a user