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 type { City, General, Nation } from '../domain/entities.js';
|
||||||
import { createInheritBuffModules } from '../inheritance/inheritBuff.js';
|
import { createInheritBuffModules } from '../inheritance/inheritBuff.js';
|
||||||
import { createItemActionModules, createItemModuleRegistry, ITEM_KEYS, loadItemModules } from '../items/index.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 {
|
import {
|
||||||
createCrewTypeWarTriggerRegistry,
|
createCrewTypeWarTriggerRegistry,
|
||||||
resolveDefenderOrder,
|
resolveDefenderOrder,
|
||||||
@@ -344,6 +344,10 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
|
|||||||
export interface BattleSimProcessorOptions {
|
export interface BattleSimProcessorOptions {
|
||||||
trace?: (event: WarBattleTraceEvent) => void;
|
trace?: (event: WarBattleTraceEvent) => void;
|
||||||
rngFactory?: (seed: string) => RandUtil;
|
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 = (
|
export const processBattleSimJob = (
|
||||||
@@ -416,9 +420,12 @@ export const processBattleSimJob = (
|
|||||||
})),
|
})),
|
||||||
defenderCity,
|
defenderCity,
|
||||||
defenderNation,
|
defenderNation,
|
||||||
|
...(options.loggerFactory ? { loggerFactory: options.loggerFactory } : {}),
|
||||||
...(options.trace ? { trace: options.trace } : {}),
|
...(options.trace ? { trace: options.trace } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
options.onBattleResolved?.(outcome);
|
||||||
|
|
||||||
lastBattle = outcome;
|
lastBattle = outcome;
|
||||||
const attackerReport = outcome.reports.find(
|
const attackerReport = outcome.reports.find(
|
||||||
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
|
(report: WarUnitReport) => report.type === 'general' && report.isAttacker
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
|
||||||
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
|
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
|
||||||
import type { WarActionContext } from '@sammo-ts/logic/war/actions.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 { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
const ITEM_KEY = 'che_부적_태현청생부';
|
const ITEM_KEY = 'che_부적_태현청생부';
|
||||||
|
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 303;
|
||||||
|
|
||||||
export const itemModule: ItemModule = {
|
export const itemModule: ItemModule = {
|
||||||
key: ITEM_KEY,
|
key: ITEM_KEY,
|
||||||
@@ -28,8 +30,12 @@ export const itemModule: ItemModule = {
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
} as NonNullable<ItemModule['onCalcStat']>,
|
} 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) => {
|
getBattlePhaseTriggerList: (context) => {
|
||||||
if (!context.unit) return null;
|
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 { 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';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
const ITEM_KEY = 'che_진압_박혁론';
|
const ITEM_KEY = 'che_진압_박혁론';
|
||||||
|
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_NONE;
|
||||||
|
|
||||||
export const itemModule: ItemModule = {
|
export const itemModule: ItemModule = {
|
||||||
key: ITEM_KEY,
|
key: ITEM_KEY,
|
||||||
@@ -18,6 +19,6 @@ export const itemModule: ItemModule = {
|
|||||||
unique: false,
|
unique: false,
|
||||||
getBattlePhaseTriggerList: (context) => {
|
getBattlePhaseTriggerList: (context) => {
|
||||||
if (!context.unit) return null;
|
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 { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
|
||||||
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.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';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
const INFO =
|
const INFO =
|
||||||
'[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)';
|
'[군사] 매 턴마다 자신(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 => ({
|
export const createMedicalItem = (key: string, rawName: string): ItemModule => ({
|
||||||
key,
|
key,
|
||||||
rawName,
|
rawName,
|
||||||
@@ -18,6 +26,15 @@ export const createMedicalItem = (key: string, rawName: string): ItemModule => (
|
|||||||
reqSecu: 0,
|
reqSecu: 0,
|
||||||
unique: true,
|
unique: true,
|
||||||
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
|
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
|
||||||
getBattlePhaseTriggerList: (context) =>
|
getBattlePhaseTriggerList: (context) => {
|
||||||
context.unit ? medicalWarTriggerModule.createTriggerList(context.unit) : null,
|
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 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';
|
import type { ItemModule } from './types.js';
|
||||||
|
|
||||||
export const createEventBattleTraitItemModule = (
|
export const createEventBattleTraitItemModule = (
|
||||||
@@ -45,7 +48,25 @@ export const createEventBattleTraitItemModule = (
|
|||||||
itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList;
|
itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList;
|
||||||
}
|
}
|
||||||
if (traitModule.getBattlePhaseTriggerList) {
|
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) {
|
if (traitModule.getWarPowerMultiplier) {
|
||||||
itemModule.getWarPowerMultiplier =
|
itemModule.getWarPowerMultiplier =
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
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 { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
import type { ItemModule } from './types.js';
|
import type { ItemModule } from './types.js';
|
||||||
@@ -17,6 +17,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
|
|||||||
_selfEnv: Record<string, unknown>,
|
_selfEnv: Record<string, unknown>,
|
||||||
_opposeEnv: Record<string, unknown>
|
_opposeEnv: Record<string, unknown>
|
||||||
): boolean {
|
): 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)) {
|
if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -28,9 +37,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.activateSkill('충차공격', '아이템사용');
|
|
||||||
self.getLogger().pushGeneralBattleDetailLog('<C>충차</>로 성벽을 공격합니다.');
|
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;
|
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 { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||||
import type { ItemModule, ItemModuleExport } from './types.js';
|
import type { ItemModule, ItemModuleExport } from './types.js';
|
||||||
import { listEquippedItemKeys } from './utils.js';
|
import { listEquippedItemKeys } from './utils.js';
|
||||||
import { removeEquippedItem } from './inventory.js';
|
import { registerLegacyBattleItemIdentity, removeEquippedItem } from './inventory.js';
|
||||||
|
|
||||||
export const ITEM_KEYS = [
|
export const ITEM_KEYS = [
|
||||||
'che_간파_노군입산부',
|
'che_간파_노군입산부',
|
||||||
@@ -664,12 +664,17 @@ class ItemWarActionRouter<
|
|||||||
private resolveModules(context: WarActionContext<TriggerState>): Array<ItemModule<TriggerState>> {
|
private resolveModules(context: WarActionContext<TriggerState>): Array<ItemModule<TriggerState>> {
|
||||||
const keys = listEquippedItemKeys(context.general);
|
const keys = listEquippedItemKeys(context.general);
|
||||||
const modules: Array<ItemModule<TriggerState>> = [];
|
const modules: Array<ItemModule<TriggerState>> = [];
|
||||||
|
let itemIdentity = { name: '-', rawName: '-' };
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const module = this.registry.get(key);
|
const module = this.registry.get(key);
|
||||||
if (module) {
|
if (module) {
|
||||||
modules.push(module);
|
modules.push(module);
|
||||||
|
if (module.slot === 'item') {
|
||||||
|
itemIdentity = { name: module.name, rawName: module.rawName };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
registerLegacyBattleItemIdentity(context.general, itemIdentity);
|
||||||
return modules;
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,27 @@ import type {
|
|||||||
const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
||||||
const INVENTORY_META_KEY = 'itemInventory';
|
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 emptyState = (): GeneralItemInstanceState => ({ values: {} });
|
||||||
|
|
||||||
const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({
|
const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({
|
||||||
@@ -57,9 +78,7 @@ const readState = (value: unknown): GeneralItemInstanceState | null => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const charges =
|
const charges =
|
||||||
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) && record['charges'] >= 0
|
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) ? record['charges'] : undefined;
|
||||||
? record['charges']
|
|
||||||
: undefined;
|
|
||||||
const valuesRecord = asRecord(record['values']) ?? {};
|
const valuesRecord = asRecord(record['values']) ?? {};
|
||||||
const values: Record<string, TriggerValue> = {};
|
const values: Record<string, TriggerValue> = {};
|
||||||
for (const [key, entry] of Object.entries(valuesRecord)) {
|
for (const [key, entry] of Object.entries(valuesRecord)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
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 { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
|
||||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||||
import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.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;
|
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>(
|
export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
|
||||||
defender: WarUnit<TriggerState>,
|
defender: WarUnit<TriggerState>,
|
||||||
attacker: WarUnitGeneral<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 attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
|
||||||
const attackerName = attackerUnit.getName();
|
const attackerName = attackerUnit.getName();
|
||||||
const cityName = cityUnit.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 josaRo = JosaUtil.pick(cityName, '로');
|
||||||
const josaYi = JosaUtil.pick(attackerName, '이');
|
const josaYi = JosaUtil.pick(attackerName, '이');
|
||||||
@@ -400,7 +412,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
|||||||
LogFormat.MONTH
|
LogFormat.MONTH
|
||||||
);
|
);
|
||||||
attackerLogger.pushGeneralActionLog(
|
attackerLogger.pushGeneralActionLog(
|
||||||
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText}`,
|
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText} <1>${turnHourMinute}</>`,
|
||||||
LogFormat.MONTH
|
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 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 { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js';
|
||||||
import type { WarUnit } from './units.js';
|
import type { WarUnit } from './units.js';
|
||||||
import { removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
|
|
||||||
|
|
||||||
export interface WarTriggerContext {
|
export interface WarTriggerContext {
|
||||||
rng: RandUtil;
|
rng: RandUtil;
|
||||||
@@ -97,12 +98,6 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.unit.activateSkill('아이템사용');
|
this.unit.activateSkill('아이템사용');
|
||||||
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.unit.hasActivatedSkill('아이템소모')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const unit = this.unit as WarUnit & {
|
const unit = this.unit as WarUnit & {
|
||||||
getGeneral?: () => General;
|
getGeneral?: () => General;
|
||||||
};
|
};
|
||||||
@@ -110,7 +105,18 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
|
|||||||
if (!general) {
|
if (!general) {
|
||||||
return false;
|
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('아이템소모');
|
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 { BaseWarUnitTrigger } from '../triggers.js';
|
||||||
import type { WarUnit } from '../units.js';
|
import type { WarUnit } from '../units.js';
|
||||||
|
|
||||||
export class che_부적 extends BaseWarUnitTrigger {
|
export class che_부적 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||||
super(unit, 0, raiseType);
|
super(unit, TriggerPriority.Begin, raiseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected actionWar(self: WarUnit): boolean {
|
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
|
||||||
self.activateSkill('저격불가', '부상무효');
|
// Ref WarActivateSkills(..., isSelf=false): the talisman's owner is
|
||||||
|
// injury-proof, while the opposing unit is prevented from sniping.
|
||||||
|
oppose.activateSkill('저격불가');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { WarTriggerModule } from './types.js';
|
|||||||
|
|
||||||
// 의술: 치료 시도
|
// 의술: 치료 시도
|
||||||
export class che_의술시도 extends BaseWarUnitTrigger {
|
export class che_의술시도 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit) {
|
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
|
||||||
super(unit, TriggerPriority.Pre + 350);
|
super(unit, TriggerPriority.Pre + 350, raiseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected actionWar(
|
protected actionWar(
|
||||||
@@ -36,8 +36,8 @@ export class che_의술시도 extends BaseWarUnitTrigger {
|
|||||||
|
|
||||||
// 의술: 치료 발동
|
// 의술: 치료 발동
|
||||||
export class che_의술발동 extends BaseWarUnitTrigger {
|
export class che_의술발동 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit) {
|
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
|
||||||
super(unit, TriggerPriority.Post + 550);
|
super(unit, TriggerPriority.Post + 550, raiseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected actionWar(
|
protected actionWar(
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export class che_저지 extends BaseWarUnitTrigger {
|
|||||||
self.addDex(self.getCrewType(), calcDamage);
|
self.addDex(self.getCrewType(), calcDamage);
|
||||||
|
|
||||||
self.addLevelExp(calcDamage / 50);
|
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;
|
rice *= 0.25;
|
||||||
const general = self.getGeneral();
|
const general = self.getGeneral();
|
||||||
general.rice = Math.max(0, general.rice - rice);
|
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 { BaseWarUnitTrigger } from '../triggers.js';
|
||||||
import type { WarUnit } from '../units.js';
|
import type { WarUnit } from '../units.js';
|
||||||
|
|
||||||
export class che_진압 extends BaseWarUnitTrigger {
|
export class che_진압 extends BaseWarUnitTrigger {
|
||||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||||
super(unit, 0, raiseType);
|
super(unit, TriggerPriority.Begin, raiseType);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected actionWar(self: WarUnit): boolean {
|
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
|
||||||
self.activateSkill('반계불가', '격노불가');
|
// Ref's 진압 is an opposing-unit restriction, not a self debuff.
|
||||||
|
oppose.activateSkill('반계불가', '격노불가');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,10 +192,16 @@ export class WarUnitGeneral<
|
|||||||
return truncate ? Math.trunc(clamped) : clamped;
|
return truncate ? Math.trunc(clamped) : clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveMainStat(armType: number, withInjury = true): number {
|
private resolveMainStat(armType: number, withInjury = true, truncate = true): number {
|
||||||
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { withInjury });
|
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, {
|
||||||
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury });
|
withInjury,
|
||||||
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { 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) {
|
if (armType === this.config.armTypes.wizard) {
|
||||||
return intelligence;
|
return intelligence;
|
||||||
@@ -279,7 +285,10 @@ export class WarUnitGeneral<
|
|||||||
return 0;
|
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 =
|
const coef =
|
||||||
armType === this.config.armTypes.wizard ||
|
armType === this.config.armTypes.wizard ||
|
||||||
armType === this.config.armTypes.siege ||
|
armType === this.config.armTypes.siege ||
|
||||||
|
|||||||
@@ -101,4 +101,13 @@ describe('GeneralItemInventory', () => {
|
|||||||
values: { source: 'shop' },
|
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', () => {
|
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 () => {
|
it('passes battle time and maximum tech level to year-scaling stat items', async () => {
|
||||||
const general = buildGeneral(80);
|
const general = buildGeneral(80);
|
||||||
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
|
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
|
||||||
@@ -604,8 +691,14 @@ describe('resolveWarBattle', () => {
|
|||||||
for (const expectedCharges of [1, null] as const) {
|
for (const expectedCharges of [1, null] as const) {
|
||||||
general.crew = 5000;
|
general.crew = 5000;
|
||||||
general.rice = 10000;
|
general.rice = 10000;
|
||||||
const defenderCity = { ...buildCity(), wall: 3000, wallMax: 3000 };
|
const defenderCity = {
|
||||||
resolveWarBattle({
|
...buildCity(),
|
||||||
|
defence: 100_000,
|
||||||
|
defenceMax: 100_000,
|
||||||
|
wall: 3000,
|
||||||
|
wallMax: 3000,
|
||||||
|
};
|
||||||
|
const outcome = resolveWarBattle({
|
||||||
rng: new RandUtil(new ConstantRNG(0)),
|
rng: new RandUtil(new ConstantRNG(0)),
|
||||||
unitSet: buildUnitSet(),
|
unitSet: buildUnitSet(),
|
||||||
config: buildConfig(),
|
config: buildConfig(),
|
||||||
@@ -625,13 +718,51 @@ describe('resolveWarBattle', () => {
|
|||||||
if (expectedCharges === null) {
|
if (expectedCharges === null) {
|
||||||
expect(equipped).toBeNull();
|
expect(equipped).toBeNull();
|
||||||
expect(general.role.items.item).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 {
|
} else {
|
||||||
expect(equipped?.state.charges).toBe(expectedCharges);
|
expect(equipped?.state.charges).toBe(expectedCharges);
|
||||||
expect(general.role.items.item).toBe('event_충차');
|
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 () => {
|
it('removes a one-use battle item through the canonical inventory', async () => {
|
||||||
const general = buildGeneral(100);
|
const general = buildGeneral(100);
|
||||||
equipNewItem(general, 'item', 'che_저격_수극');
|
equipNewItem(general, 'item', 'che_저격_수극');
|
||||||
@@ -639,7 +770,7 @@ describe('resolveWarBattle', () => {
|
|||||||
createItemModuleRegistry(await loadItemModules(['che_저격_수극']))
|
createItemModuleRegistry(await loadItemModules(['che_저격_수극']))
|
||||||
).war;
|
).war;
|
||||||
|
|
||||||
resolveWarBattle({
|
const outcome = resolveWarBattle({
|
||||||
rng: new RandUtil(new ConstantRNG(0)),
|
rng: new RandUtil(new ConstantRNG(0)),
|
||||||
unitSet: buildUnitSet(),
|
unitSet: buildUnitSet(),
|
||||||
config: buildConfig(),
|
config: buildConfig(),
|
||||||
@@ -657,6 +788,42 @@ describe('resolveWarBattle', () => {
|
|||||||
|
|
||||||
expect(getEquippedItemInstance(general, 'item')).toBeNull();
|
expect(getEquippedItemInstance(general, 'item')).toBeNull();
|
||||||
expect(general.role.items.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', () => {
|
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,
|
"repeatCnt": 1,
|
||||||
"action": "battle",
|
"action": "battle",
|
||||||
"attackerGeneral": {
|
"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,
|
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
|
||||||
"atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None",
|
"atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None",
|
||||||
"strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0,
|
"strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0,
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1
|
"name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1
|
||||||
},
|
},
|
||||||
"defenderGenerals": [{
|
"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,
|
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
|
||||||
"atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None",
|
"atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None",
|
||||||
"strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0,
|
"strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -16,8 +17,16 @@ import {
|
|||||||
loadNationTraitModules,
|
loadNationTraitModules,
|
||||||
loadPersonalityTraitModules,
|
loadPersonalityTraitModules,
|
||||||
loadWarTraitModules,
|
loadWarTraitModules,
|
||||||
|
ActionLogger,
|
||||||
|
formatLogText,
|
||||||
|
LogCategory,
|
||||||
|
LogFormat,
|
||||||
|
LogScope,
|
||||||
|
type LogEntryDraft,
|
||||||
type UnitSetDefinition,
|
type UnitSetDefinition,
|
||||||
|
type WarBattleOutcome,
|
||||||
type WarBattleTraceEvent,
|
type WarBattleTraceEvent,
|
||||||
|
type WarBattleTraceUnitSnapshot,
|
||||||
type WarEngineConfig,
|
type WarEngineConfig,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
@@ -32,13 +41,19 @@ import type {
|
|||||||
|
|
||||||
interface ReferenceTrace {
|
interface ReferenceTrace {
|
||||||
engine: 'ref';
|
engine: 'ref';
|
||||||
|
seed: string;
|
||||||
|
fixtureIdentity: FixtureIdentity;
|
||||||
conquered: boolean;
|
conquered: boolean;
|
||||||
|
attacker: WarBattleTraceEvent['attacker'];
|
||||||
|
city: WarBattleTraceEvent['city'];
|
||||||
|
finishedDefenders: WarBattleTraceEvent['attacker'][];
|
||||||
defenderOrder?: {
|
defenderOrder?: {
|
||||||
before: Array<{ id: number; order: number }>;
|
before: Array<{ id: number; order: number }>;
|
||||||
after: Array<{ id: number; order: number }>;
|
after: Array<{ id: number; order: number }>;
|
||||||
};
|
};
|
||||||
events: WarBattleTraceEvent[];
|
events: WarBattleTraceEvent[];
|
||||||
rng: RandomCall[];
|
rng: RandomCall[];
|
||||||
|
boolRng: BoolRandomCall[];
|
||||||
logs: {
|
logs: {
|
||||||
attacker: ReferenceLogBuckets;
|
attacker: ReferenceLogBuckets;
|
||||||
defenders: Record<string, 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 {
|
interface ReferenceLogBuckets {
|
||||||
generalHistoryLog: string[];
|
generalHistoryLog: string[];
|
||||||
generalActionLog: string[];
|
generalActionLog: string[];
|
||||||
@@ -56,6 +83,65 @@ interface ReferenceLogBuckets {
|
|||||||
globalActionLog: string[];
|
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 {
|
interface RandomCall {
|
||||||
seq: number;
|
seq: number;
|
||||||
operation: string;
|
operation: string;
|
||||||
@@ -80,6 +166,7 @@ type ReferenceTraitCatalog = Record<
|
|||||||
|
|
||||||
class TracingRng implements RNG {
|
class TracingRng implements RNG {
|
||||||
public readonly calls: RandomCall[] = [];
|
public readonly calls: RandomCall[] = [];
|
||||||
|
public readonly boolCalls: BoolRandomCall[] = [];
|
||||||
|
|
||||||
public constructor(private readonly inner: RNG) {}
|
public constructor(private readonly inner: RNG) {}
|
||||||
|
|
||||||
@@ -111,6 +198,19 @@ class TracingRng implements RNG {
|
|||||||
return result;
|
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 {
|
private record(operation: string, args: Record<string, unknown>, result: unknown): void {
|
||||||
this.calls.push({ seq: this.calls.length, operation, arguments: args, result });
|
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 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 => {
|
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
|
||||||
|
assertFixtureGeneralCityContract(fixtureJson);
|
||||||
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
||||||
if (compareContainer) {
|
if (compareContainer) {
|
||||||
const stdout = execFileSync(
|
const stdout = execFileSync(
|
||||||
@@ -156,61 +462,22 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
|
|||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
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;
|
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||||
if (compareSourceRoot) {
|
if (compareSourceRoot) {
|
||||||
const resolvedCompareRoot = path.resolve(compareSourceRoot);
|
const stdout = runReferenceSourceScript({
|
||||||
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-'));
|
workspaceRoot,
|
||||||
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
|
compareSourceRoot,
|
||||||
recursive: true,
|
script: 'hwe/compare/battle_trace.php',
|
||||||
filter: (source) => {
|
args: ['-'],
|
||||||
const relative = path.relative(resolvedCompareRoot, source);
|
input: fixtureJson,
|
||||||
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}`)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
|
const reference = JSON.parse(stdout) as ReferenceTrace;
|
||||||
try {
|
assertReferenceFixtureIdentity(reference, fixtureJson);
|
||||||
const stdout = execFileSync(
|
return reference;
|
||||||
'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 stdout = execFileSync(
|
const stdout = execFileSync(
|
||||||
'docker',
|
'docker',
|
||||||
@@ -222,21 +489,63 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
|
|||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
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[] => {
|
const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): ReferenceTrace[] => {
|
||||||
|
fixtureLines.forEach((line, index) => assertFixtureGeneralCityContract(line, `fixture[${index}]`));
|
||||||
const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH;
|
const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH;
|
||||||
if (precomputedTracePath) {
|
if (precomputedTracePath) {
|
||||||
const traces = fs
|
return readBoundPrecomputedTraces(precomputedTracePath, fixtureLines);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
||||||
if (compareContainer) {
|
if (compareContainer) {
|
||||||
@@ -259,84 +568,54 @@ const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]):
|
|||||||
maxBuffer: 512 * 1024 * 1024,
|
maxBuffer: 512 * 1024 * 1024,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return stdout
|
const traces = stdout
|
||||||
.split(/\r?\n/u)
|
.split(/\r?\n/u)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => JSON.parse(line) as ReferenceTrace);
|
.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;
|
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||||
if (!compareSourceRoot) {
|
if (!compareSourceRoot) {
|
||||||
throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.');
|
throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.');
|
||||||
}
|
}
|
||||||
const resolvedCompareRoot = path.resolve(compareSourceRoot);
|
const stdout = runReferenceSourceScript({
|
||||||
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-corpus-'));
|
workspaceRoot,
|
||||||
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
|
compareSourceRoot,
|
||||||
recursive: true,
|
script: 'hwe/compare/battle_trace.php',
|
||||||
filter: (source) => {
|
args: ['--jsonl'],
|
||||||
const relative = path.relative(resolvedCompareRoot, source);
|
input: normalizeJsonlForManifest(fixtureLines),
|
||||||
return !(
|
maxBuffer: 512 * 1024 * 1024,
|
||||||
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}`)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
|
const traces = stdout
|
||||||
try {
|
.split(/\r?\n/u)
|
||||||
const traces: ReferenceTrace[] = [];
|
.filter(Boolean)
|
||||||
const chunkSize = 200;
|
.map((line) => JSON.parse(line) as ReferenceTrace);
|
||||||
for (let offset = 0; offset < fixtureLines.length; offset += chunkSize) {
|
if (traces.length !== fixtureLines.length) {
|
||||||
const chunk = fixtureLines.slice(offset, offset + chunkSize);
|
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
traces.forEach((trace, index) =>
|
||||||
|
assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `source trace[${index}]`)
|
||||||
|
);
|
||||||
|
return traces;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record<string, ReferenceItemMetadata> => {
|
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(
|
const stdout = execFileSync(
|
||||||
'docker',
|
'docker',
|
||||||
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/item_catalog.php'],
|
['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 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(
|
const stdout = execFileSync(
|
||||||
'docker',
|
'docker',
|
||||||
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/trait_catalog.php'],
|
['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 => {
|
const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => {
|
||||||
expect(typeof actual, `${label}: actual type`).toBe('number');
|
expect(typeof actual, `${label}: actual type`).toBe('number');
|
||||||
expect(typeof expected, `${label}: reference type`).toBe('number');
|
expect(typeof expected, `${label}: reference type`).toBe('number');
|
||||||
if (process.env['STRICT_BATTLE_PARITY'] === '1') {
|
const actualNumber = actual as number;
|
||||||
expect(actual, `${label}: exact battle parity`).toBe(expected);
|
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;
|
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(
|
const tolerance = Math.max(
|
||||||
Number.EPSILON * Math.max(1, Math.abs(reference)) * 8,
|
Number.EPSILON * Math.max(1, Math.abs(expectedNumber)) * 16,
|
||||||
Math.abs(reference) * configuredRelativeTolerance
|
Math.abs(expectedNumber) * 1e-12,
|
||||||
|
1e-12
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
Math.abs((actual as number) - reference),
|
Math.abs(actualNumber - expectedNumber),
|
||||||
`${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}`
|
`${label}: core=${String(actualNumber)}, ref=${String(expectedNumber)}, tolerance=${tolerance}`
|
||||||
).toBeLessThanOrEqual(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> =>
|
const normalizeRandomArguments = (value: Record<string, unknown>): Record<string, unknown> =>
|
||||||
Array.isArray(value) && value.length === 0 ? {} : value;
|
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 describeTextDifference = (actual: string | undefined, expected: string): string => {
|
||||||
const actualText = actual ?? '';
|
const actualText = actual ?? '';
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -448,19 +844,23 @@ const assertRngParity = (reference: ReferenceTrace, coreRng: TracingRng | null):
|
|||||||
arguments: normalizeRandomArguments(args),
|
arguments: normalizeRandomArguments(args),
|
||||||
result,
|
result,
|
||||||
}));
|
}));
|
||||||
const rngDifference = describeSequenceDifference('rng', normalizedCoreRng, normalizedReferenceRng);
|
assertCanonicalValue(normalizedCoreRng, normalizedReferenceRng, 'rng');
|
||||||
if (rngDifference) {
|
assertCanonicalValue(coreRng?.boolCalls ?? [], reference.boolRng, 'boolRng');
|
||||||
throw new Error(rngDifference);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertTraceParity = (
|
const assertTraceParity = (
|
||||||
coreEvents: WarBattleTraceEvent[],
|
coreEvents: WarBattleTraceEvent[],
|
||||||
reference: ReferenceTrace,
|
reference: ReferenceTrace,
|
||||||
coreRng: TracingRng | null
|
coreRng: TracingRng | null,
|
||||||
|
coreOutcome: WarBattleOutcome | null
|
||||||
): void => {
|
): void => {
|
||||||
const defenderOrderEvent = coreEvents[0]?.event === 'defender_order' ? coreEvents[0] : null;
|
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) {
|
if (reference.defenderOrder) {
|
||||||
// Ref retains non-participating (order <= 0) defenders at the tail and
|
// Ref retains non-participating (order <= 0) defenders at the tail and
|
||||||
// stops when it reaches them. Core discards them before sorting. The
|
// 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),
|
after: reference.defenderOrder.after.filter(({ order }) => order > 0),
|
||||||
};
|
};
|
||||||
const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined;
|
const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined;
|
||||||
expect(coreOrder?.before.map(({ id }) => id), 'defender order before IDs').toEqual(
|
expect(
|
||||||
effectiveReferenceOrder.before.map(({ id }) => id)
|
coreOrder?.before.map(({ id }) => id),
|
||||||
);
|
'defender order before IDs'
|
||||||
expect(coreOrder?.after.map(({ id }) => id), 'defender order after IDs').toEqual(
|
).toEqual(effectiveReferenceOrder.before.map(({ id }) => id));
|
||||||
effectiveReferenceOrder.after.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 (const side of ['before', 'after'] as const) {
|
||||||
for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) {
|
for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) {
|
||||||
expectNearlyEqual(
|
expectNearlyEqual(
|
||||||
@@ -487,37 +889,145 @@ const assertTraceParity = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertRngParity(reference, coreRng);
|
assertRngParity(reference, coreRng);
|
||||||
const coreEventNames = comparableCoreEvents.map((event) => event.event);
|
assertCanonicalValue(comparableCoreEvents, reference.events, 'events');
|
||||||
const referenceEventNames = reference.events.map((event) => event.event);
|
assertFinalOutcomeParity(coreOutcome, coreEvents, reference);
|
||||||
expect(
|
};
|
||||||
coreEventNames,
|
|
||||||
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
|
|
||||||
).toEqual(referenceEventNames);
|
|
||||||
|
|
||||||
for (let index = 0; index < reference.events.length; index += 1) {
|
const outcomeMetaNumber = (general: WarBattleOutcome['attacker'], key: string): number => {
|
||||||
const core = comparableCoreEvents[index]!;
|
const value = general.meta[key];
|
||||||
const ref = reference.events[index]!;
|
return typeof value === 'number' ? value : 0;
|
||||||
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);
|
const buildOutcomeGeneralSnapshot = (
|
||||||
expect(core.attacker.realPhase, `event ${index} attacker.realPhase`).toBe(ref.attacker.realPhase);
|
transient: WarBattleTraceUnitSnapshot,
|
||||||
expect(core.attacker.maxPhase, `event ${index} attacker.maxPhase`).toBe(ref.attacker.maxPhase);
|
general: WarBattleOutcome['attacker'],
|
||||||
if (core.defender && ref.defender) {
|
report: WarBattleOutcome['reports'][number],
|
||||||
expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind);
|
activatedSkills: Record<string, number>
|
||||||
expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`);
|
): WarBattleTraceUnitSnapshot => ({
|
||||||
expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`);
|
...transient,
|
||||||
expect(core.defender.phase, `event ${index} defender.phase`).toBe(ref.defender.phase);
|
kind: 'general',
|
||||||
expect(core.defender.realPhase, `event ${index} defender.realPhase`).toBe(ref.defender.realPhase);
|
id: general.id,
|
||||||
expect(core.defender.maxPhase, `event ${index} defender.maxPhase`).toBe(ref.defender.maxPhase);
|
name: general.name,
|
||||||
} else {
|
isAttacker: report.isAttacker,
|
||||||
expect(core.defender, `event ${index} defender presence`).toBe(ref.defender);
|
crewTypeId: general.crewTypeId,
|
||||||
}
|
phase: report.phase ?? transient.phase,
|
||||||
if (core.event === 'phase_damage') {
|
hp: general.crew,
|
||||||
for (const key of ['rawDeadAttacker', 'rawDeadDefender', 'deadAttacker', 'deadDefender']) {
|
killed: report.killed,
|
||||||
expectNearlyEqual(core.details[key], ref.details[key], `event ${index} ${key}`);
|
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;
|
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;
|
const itWithBattleCorpus = battleCorpusPath ? it : it.skip;
|
||||||
|
|
||||||
describeWithReference('ref ↔ core2026 battle differential', () => {
|
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(
|
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 },
|
{ timeout: 600_000 },
|
||||||
() => {
|
() => {
|
||||||
const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10);
|
const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10);
|
||||||
@@ -560,7 +1089,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
const categoryCounts = new Map<string, number>();
|
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);
|
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
|
||||||
if (failures.length < 40) {
|
if (failures.length < 40) {
|
||||||
failures.push(
|
failures.push(
|
||||||
@@ -593,7 +1127,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const reference = referenceTraces[index]!;
|
const reference = referenceTraces[index]!;
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const coreResult = processBattleSimJob(
|
const coreResult = processBattleSimJob(
|
||||||
{
|
{
|
||||||
...fixture,
|
...fixture,
|
||||||
@@ -604,50 +1140,27 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error));
|
recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalReference = reference.events.at(-1);
|
try {
|
||||||
if (finalReference) {
|
assertAllLogBucketsParity(coreLogs, reference, fixture, `fixture[${index}]`);
|
||||||
const outcome = {
|
} catch (error) {
|
||||||
phase: coreResult.phase,
|
recordFailure('logs', index, fixture, error instanceof Error ? error.message : String(error));
|
||||||
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)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('<br>'));
|
const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('<br>'));
|
||||||
@@ -734,6 +1247,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
|
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const coreResult = processBattleSimJob(
|
const coreResult = processBattleSimJob(
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
@@ -744,16 +1258,19 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
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');
|
const opponentSwitches = coreEvents.filter((event) => event.event === 'opponent_switched');
|
||||||
if (entry.directCity) {
|
if (entry.directCity) {
|
||||||
expect(
|
expect(
|
||||||
@@ -792,7 +1309,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
'<C>●</>아군의 전멸에 상대의 <R>진격</>이 이어집니다!'
|
'<C>●</>아군의 전멸에 상대의 <R>진격</>이 이어집니다!'
|
||||||
);
|
);
|
||||||
expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain(
|
expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain(
|
||||||
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
|
'적군의 전멸에 <span style="color: cyan;">진격</span>이 이어집니다!'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -874,17 +1391,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
base.attackerGeneral.strength = 85;
|
base.attackerGeneral.strength = 85;
|
||||||
base.attackerGeneral.intel = 80;
|
base.attackerGeneral.intel = 80;
|
||||||
base.attackerGeneral.special =
|
base.attackerGeneral.special =
|
||||||
entry.kind === 'dualSlot'
|
entry.kind === 'dualSlot' ? entry.special : entry.kind === 'eventDomestic' ? entry.key : 'None';
|
||||||
? entry.special
|
|
||||||
: entry.kind === 'eventDomestic'
|
|
||||||
? entry.key
|
|
||||||
: 'None';
|
|
||||||
base.attackerGeneral.special2 =
|
base.attackerGeneral.special2 =
|
||||||
entry.kind === 'dualSlot'
|
entry.kind === 'dualSlot' ? entry.special2 : entry.kind === 'war' ? entry.key : 'None';
|
||||||
? entry.special2
|
|
||||||
: entry.kind === 'war'
|
|
||||||
? entry.key
|
|
||||||
: 'None';
|
|
||||||
base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None';
|
base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None';
|
||||||
if (entry.kind === 'nation') {
|
if (entry.kind === 'nation') {
|
||||||
base.attackerNation.type = entry.key;
|
base.attackerNation.type = entry.key;
|
||||||
@@ -892,6 +1401,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
|
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(
|
processBattleSimJob(
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
@@ -901,14 +1411,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
assertTraceParity(
|
||||||
|
coreEvents,
|
||||||
|
runReferenceTrace(workspaceRoot!, JSON.stringify(base)),
|
||||||
|
coreRng,
|
||||||
|
coreOutcome
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(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 },
|
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(
|
processBattleSimJob(
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
@@ -999,13 +1519,45 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(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', () => {
|
it('matches 척사 items against region-restricted troops', () => {
|
||||||
@@ -1033,7 +1585,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
base.attackerGeneral.crew = 5000;
|
base.attackerGeneral.crew = 5000;
|
||||||
base.defenderGenerals[0]!.crewtype = 1101;
|
base.defenderGenerals[0]!.crewtype = 1101;
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(
|
processBattleSimJob(
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
@@ -1043,17 +1597,39 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(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 fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json');
|
||||||
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
|
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
|
||||||
const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number };
|
const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number };
|
||||||
@@ -1086,18 +1662,271 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const coreResult = processBattleSimJob(payload, {
|
const coreResult = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(coreResult.result).toBe(true);
|
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', () => {
|
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
|
||||||
@@ -1142,11 +1971,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const result = processBattleSimJob(payload, {
|
const result = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
@@ -1157,12 +1990,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill))
|
Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill))
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
assertRngParity(reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
const finalReference = reference.events.at(-1)!;
|
|
||||||
expect({ phase: result.phase, killed: result.killed }).toEqual({
|
|
||||||
phase: finalReference.attacker.phase,
|
|
||||||
killed: finalReference.attacker.killed,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ref keeps the injury-adjusted intelligence fraction here:
|
// Ref keeps the injury-adjusted intelligence fraction here:
|
||||||
// (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2
|
// (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2
|
||||||
@@ -1185,7 +2013,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
{
|
{
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
fractionalCoreRng = new TracingRng(LiteHashDRBG.build(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[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const result = processBattleSimJob(payload, {
|
const result = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(result.result).toBe(true);
|
expect(result.result).toBe(true);
|
||||||
expect(reference.events.filter((event) => event.event === 'opponent_switched')).toHaveLength(2);
|
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', () => {
|
it('matches siege dexterity and castle damage handling', () => {
|
||||||
@@ -1315,18 +2147,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const result = processBattleSimJob(payload, {
|
const result = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(result.result).toBe(true);
|
expect(result.result).toBe(true);
|
||||||
expect(reference.events.some((event) => event.defender?.kind === 'city')).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', () => {
|
it('matches the no-defender supply-retreat branch without consuming RNG', () => {
|
||||||
@@ -1358,18 +2194,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
const result = processBattleSimJob(payload, {
|
const result = processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||||
|
|
||||||
expect(result.result).toBe(true);
|
expect(result.result).toBe(true);
|
||||||
expect(reference.events.map((event) => event.event)).toEqual(['battle_start', 'supply_retreat', 'battle_end']);
|
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 }, () => {
|
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 },
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(payload, {
|
processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
|
assertAllLogBucketsParity(coreLogs, reference, base, `item.attacker.${itemKey}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const debug =
|
const debug =
|
||||||
process.env['ITEM_PARITY_DEBUG'] === '1'
|
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 },
|
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||||
};
|
};
|
||||||
const coreEvents: WarBattleTraceEvent[] = [];
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
let coreRng: TracingRng | null = null;
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
processBattleSimJob(payload, {
|
processBattleSimJob(payload, {
|
||||||
trace: (event) => coreEvents.push(event),
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
rngFactory: (seed) => {
|
rngFactory: (seed) => {
|
||||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
return new RandUtil(coreRng);
|
return coreRng.createRandUtil();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, reference, coreRng);
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
|
assertAllLogBucketsParity(coreLogs, reference, base, `item.defender.${itemKey}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const debug =
|
const debug =
|
||||||
process.env['ITEM_PARITY_DEBUG'] === '1'
|
process.env['ITEM_PARITY_DEBUG'] === '1'
|
||||||
|
|||||||
@@ -210,7 +210,9 @@ integration('live sortie PostgreSQL persistence retry', () => {
|
|||||||
commandProfile: createCoreTurnCommandProfile(request),
|
commandProfile: createCoreTurnCommandProfile(request),
|
||||||
});
|
});
|
||||||
world = new InMemoryTurnWorld(state, snapshot, {
|
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,
|
generalTurnHandler: handler,
|
||||||
});
|
});
|
||||||
const actor = world.getGeneralById(request.actorGeneralId);
|
const actor = world.getGeneralById(request.actorGeneralId);
|
||||||
|
|||||||
Reference in New Issue
Block a user