feat: align battle simulator with legacy handlers
This commit is contained in:
@@ -99,6 +99,7 @@ export const buildBattleSimEnvironment = async (
|
||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand),
|
||||
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
|
||||
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
|
||||
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], 255),
|
||||
castleCrewTypeId,
|
||||
armTypes: {
|
||||
footman: 1,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import type { RandUtil } from '@sammo-ts/common';
|
||||
import {
|
||||
formatLogText,
|
||||
getTechCost,
|
||||
@@ -13,6 +14,16 @@ import {
|
||||
ITEM_KEYS,
|
||||
loadItemModules,
|
||||
createInheritBuffModules,
|
||||
createTraitModuleRegistry,
|
||||
DOMESTIC_TRAIT_KEYS,
|
||||
loadDomesticTraitModules,
|
||||
loadNationTraitModules,
|
||||
loadPersonalityTraitModules,
|
||||
loadWarTraitModules,
|
||||
NATION_TRAIT_KEYS,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
TraitWarActionRouter,
|
||||
WAR_TRAIT_KEYS,
|
||||
compileCrewTypeCatalog,
|
||||
createCrewTypeWarTriggerRegistry,
|
||||
type City,
|
||||
@@ -21,7 +32,9 @@ import {
|
||||
type UnitSetDefinition,
|
||||
type WarBattleOutcome,
|
||||
type WarActionModule,
|
||||
type WarActionContext,
|
||||
type WarUnitReport,
|
||||
type WarBattleTraceEvent,
|
||||
type CrewTypeDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
@@ -35,10 +48,50 @@ const itemWarModules: WarActionModule[] = createItemActionModules(
|
||||
createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]))
|
||||
).war;
|
||||
const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry();
|
||||
const traitRegistry = createTraitModuleRegistry({
|
||||
domestic: await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
|
||||
war: await loadWarTraitModules([...WAR_TRAIT_KEYS]),
|
||||
personality: await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
|
||||
nation: await loadNationTraitModules([...NATION_TRAIT_KEYS]),
|
||||
});
|
||||
const traitWarModules: WarActionModule[] = [
|
||||
new TraitWarActionRouter('nation', traitRegistry),
|
||||
{
|
||||
onCalcStat: (context: WarActionContext, statName, value) => {
|
||||
if (statName !== 'leadership' || typeof value !== 'number') {
|
||||
return value;
|
||||
}
|
||||
let officerLevel = context.general.officerLevel;
|
||||
// Legacy simulator payload omits general.city, so low city offices are
|
||||
// always downgraded by TriggerOfficerLevel's officer_city comparison.
|
||||
if (officerLevel >= 2 && officerLevel <= 4) {
|
||||
officerLevel = 1;
|
||||
}
|
||||
const nationLevel = context.nation?.level ?? 0;
|
||||
const leadershipBonus = officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0;
|
||||
return value + leadershipBonus;
|
||||
},
|
||||
getWarPowerMultiplier: (context: WarActionContext) => {
|
||||
let officerLevel = context.general.officerLevel;
|
||||
if (officerLevel >= 2 && officerLevel <= 4) {
|
||||
officerLevel = 1;
|
||||
}
|
||||
if (officerLevel === 12) return [1.07, 0.93];
|
||||
if (officerLevel === 11) return [1.05, 0.95];
|
||||
if ([10, 8, 6].includes(officerLevel)) return [1.1, 1];
|
||||
if ([9, 7, 5].includes(officerLevel)) return [1, 0.9];
|
||||
if ([4, 3, 2].includes(officerLevel)) return [1.05, 0.95];
|
||||
return [1, 1];
|
||||
},
|
||||
},
|
||||
new TraitWarActionRouter('domestic', traitRegistry),
|
||||
new TraitWarActionRouter('war', traitRegistry),
|
||||
new TraitWarActionRouter('personality', traitRegistry),
|
||||
];
|
||||
|
||||
const buildWarActionModules = (unitSet: UnitSetDefinition): WarActionModule[] => {
|
||||
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
|
||||
return [crewTypeCatalog.warActionModule, inheritBuffModules.war, ...itemWarModules];
|
||||
return [...traitWarModules, crewTypeCatalog.warActionModule, inheritBuffModules.war, ...itemWarModules];
|
||||
};
|
||||
|
||||
const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value);
|
||||
@@ -284,7 +337,15 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
|
||||
});
|
||||
};
|
||||
|
||||
export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResultPayload => {
|
||||
export interface BattleSimProcessorOptions {
|
||||
trace?: (event: WarBattleTraceEvent) => void;
|
||||
rngFactory?: (seed: string) => RandUtil;
|
||||
}
|
||||
|
||||
export const processBattleSimJob = (
|
||||
payload: BattleSimJobPayload,
|
||||
options: BattleSimProcessorOptions = {}
|
||||
): BattleSimResultPayload => {
|
||||
if (payload.action === 'reorder') {
|
||||
return {
|
||||
result: true,
|
||||
@@ -333,6 +394,7 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu
|
||||
|
||||
const outcome = resolveWarBattle({
|
||||
seed,
|
||||
rng: options.rngFactory?.(seed),
|
||||
unitSet: payload.unitSet,
|
||||
config: payload.config,
|
||||
time: payload.time,
|
||||
@@ -350,6 +412,7 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu
|
||||
})),
|
||||
defenderCity,
|
||||
defenderNation,
|
||||
trace: options.trace,
|
||||
});
|
||||
|
||||
lastBattle = outcome;
|
||||
|
||||
@@ -48,6 +48,7 @@ export type WarStatName =
|
||||
| 'killRice'
|
||||
| 'criticalDamageRange'
|
||||
| 'warMagicSuccessDamage'
|
||||
| 'warMagicFailDamage'
|
||||
| 'warMagicTrialProb'
|
||||
| 'warMagicSuccessProb'
|
||||
| 'addDex'
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { WarTriggerRegistry } from './triggers.js';
|
||||
import { WarTriggerCaller, type WarTriggerRegistry } from './triggers.js';
|
||||
import { che_기병병종전투 } from './triggers/che_기병병종전투.js';
|
||||
import { che_방어력증가5p } from './triggers/che_방어력증가5p.js';
|
||||
import { che_선제사격발동, che_선제사격시도 } from './triggers/che_선제사격.js';
|
||||
import { che_성벽부상무효 } from './triggers/che_성벽부상무효.js';
|
||||
import { che_저지, che_저지_시도 } from './triggers/che_저지.js';
|
||||
import { che_필살발동, che_필살시도 } from './triggers/che_필살.js';
|
||||
import { che_회피발동, che_회피시도 } from './triggers/che_회피.js';
|
||||
import { che_계략발동, che_계략실패, che_계략시도 } from './triggers/che_계략.js';
|
||||
|
||||
export const CREW_TYPE_WAR_TRIGGER_KEYS = [
|
||||
'che_성벽부상무효',
|
||||
@@ -13,6 +16,9 @@ export const CREW_TYPE_WAR_TRIGGER_KEYS = [
|
||||
'che_선제사격발동',
|
||||
'che_저지시도',
|
||||
'che_저지발동',
|
||||
'che_필살',
|
||||
'che_회피',
|
||||
'che_계략',
|
||||
] as const;
|
||||
|
||||
export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
|
||||
@@ -23,4 +29,8 @@ export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
|
||||
che_선제사격발동: (unit) => new che_선제사격발동(unit),
|
||||
che_저지시도: (unit) => new che_저지_시도(unit),
|
||||
che_저지발동: (unit) => new che_저지(unit),
|
||||
che_필살: (unit) => new WarTriggerCaller(new che_필살시도(unit), new che_필살발동(unit)),
|
||||
che_회피: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
|
||||
che_계략: (unit) =>
|
||||
new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
|
||||
});
|
||||
|
||||
@@ -9,7 +9,13 @@ import { WarActionPipeline, type WarActionModule } from './actions.js';
|
||||
import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js';
|
||||
import { WarCrewType } from './crewType.js';
|
||||
import { WarTriggerCaller, createWarTriggerEnv, type WarTriggerRegistry } from './triggers.js';
|
||||
import type { WarBattleInput, WarBattleOutcome, WarGeneralInput, WarUnitReport } from './types.js';
|
||||
import type {
|
||||
WarBattleInput,
|
||||
WarBattleOutcome,
|
||||
WarBattleTraceUnitSnapshot,
|
||||
WarGeneralInput,
|
||||
WarUnitReport,
|
||||
} from './types.js';
|
||||
import { getMetaNumber } from './utils.js';
|
||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js';
|
||||
|
||||
@@ -77,7 +83,7 @@ const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): W
|
||||
if (unit instanceof WarUnitGeneral) {
|
||||
const context = unit.getActionContext();
|
||||
caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context));
|
||||
} else {
|
||||
} else if (!(unit instanceof WarUnitCity && unit.isSiege())) {
|
||||
appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry);
|
||||
}
|
||||
return caller;
|
||||
@@ -86,12 +92,10 @@ const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): W
|
||||
const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => {
|
||||
const caller = new WarTriggerCaller();
|
||||
if (unit instanceof WarUnitGeneral) {
|
||||
if (registry['che_필살']) {
|
||||
appendCrewTypeTriggers(caller, unit, ['che_필살'], registry);
|
||||
}
|
||||
appendCrewTypeTriggers(caller, unit, ['che_필살', 'che_회피', 'che_계략'], registry);
|
||||
const context = unit.getActionContext();
|
||||
caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context));
|
||||
} else {
|
||||
} else if (!(unit instanceof WarUnitCity && unit.isSiege())) {
|
||||
appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry);
|
||||
}
|
||||
return caller;
|
||||
@@ -192,6 +196,57 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
|
||||
};
|
||||
};
|
||||
|
||||
const buildTraceUnitSnapshot = (
|
||||
unit: WarUnit,
|
||||
defenderCity: City
|
||||
): WarBattleTraceUnitSnapshot => {
|
||||
const common = {
|
||||
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
|
||||
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(),
|
||||
name: unit.getName(),
|
||||
isAttacker: unit.isAttacker(),
|
||||
crewTypeId: unit.getCrewType().id,
|
||||
phase: unit.getPhase(),
|
||||
realPhase: unit.getRealPhase(),
|
||||
maxPhase: unit.getMaxPhase(),
|
||||
hp: unit.getHP(),
|
||||
rawWarPower: unit.getRawWarPower(),
|
||||
warPower: unit.getWarPower(),
|
||||
warPowerMultiplier: unit.getWarPowerMultiply(),
|
||||
killed: unit.getKilled(),
|
||||
dead: unit.getDead(),
|
||||
activatedSkills: unit.getActivatedSkillLog(),
|
||||
};
|
||||
if (unit instanceof WarUnitGeneral) {
|
||||
const general = unit.getGeneral();
|
||||
return {
|
||||
...common,
|
||||
general: {
|
||||
crew: general.crew,
|
||||
rice: general.rice,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
injury: general.injury,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
dex1: getMetaNumber(general.meta, 'dex1'),
|
||||
dex2: getMetaNumber(general.meta, 'dex2'),
|
||||
dex3: getMetaNumber(general.meta, 'dex3'),
|
||||
dex4: getMetaNumber(general.meta, 'dex4'),
|
||||
dex5: getMetaNumber(general.meta, 'dex5'),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
cityState: {
|
||||
defence: defenderCity.defence,
|
||||
wall: defenderCity.wall,
|
||||
population: defenderCity.population,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const flushLoggers = (loggers: ActionLogger[]): Array<ReturnType<ActionLogger['flush']>[number]> => {
|
||||
const logs: Array<ReturnType<ActionLogger['flush']>[number]> = [];
|
||||
for (const logger of loggers) {
|
||||
@@ -307,6 +362,22 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
defender = getNextDefender(null, true);
|
||||
let conquerCity = false;
|
||||
let logWritten = false;
|
||||
let traceSeq = 0;
|
||||
const emitTrace = (
|
||||
event: string,
|
||||
currentDefender: WarUnit<TriggerState> | null,
|
||||
details: Record<string, unknown> = {}
|
||||
): void => {
|
||||
input.trace?.({
|
||||
seq: traceSeq++,
|
||||
event,
|
||||
attacker: buildTraceUnitSnapshot(attackerUnit, input.defenderCity),
|
||||
defender: currentDefender ? buildTraceUnitSnapshot(currentDefender, input.defenderCity) : null,
|
||||
city: buildTraceUnitSnapshot(cityUnit, input.defenderCity),
|
||||
details,
|
||||
});
|
||||
};
|
||||
emitTrace('battle_start', defender, { seed: input.seed ?? '' });
|
||||
|
||||
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
|
||||
const attackerName = attackerUnit.getName();
|
||||
@@ -349,6 +420,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
);
|
||||
|
||||
conquerCity = true;
|
||||
emitTrace('supply_retreat', defender);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -409,17 +481,31 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
const initCaller = buildBattleInitTriggers(attackerUnit, triggerRegistry);
|
||||
initCaller.merge(buildBattleInitTriggers(defender, triggerRegistry));
|
||||
initCaller.fire({ rng, attacker: attackerUnit, defender }, createWarTriggerEnv());
|
||||
emitTrace('opponent_initialized', defender);
|
||||
}
|
||||
|
||||
attackerUnit.beginPhase();
|
||||
defender.beginPhase();
|
||||
emitTrace('phase_power', defender, {
|
||||
attackerAttack: attackerUnit.getComputedAttack(),
|
||||
attackerDefence: attackerUnit.getComputedDefence(),
|
||||
attackerTrain: attackerUnit.getComputedTrain(),
|
||||
attackerAtmos: attackerUnit.getComputedAtmos(),
|
||||
defenderAttack: defender.getComputedAttack(),
|
||||
defenderDefence: defender.getComputedDefence(),
|
||||
defenderTrain: defender.getComputedTrain(),
|
||||
defenderAtmos: defender.getComputedAtmos(),
|
||||
});
|
||||
|
||||
const battleCaller = buildBattlePhaseTriggers(attackerUnit, triggerRegistry);
|
||||
battleCaller.merge(buildBattlePhaseTriggers(defender, triggerRegistry));
|
||||
battleCaller.fire({ rng, attacker: attackerUnit, defender }, createWarTriggerEnv());
|
||||
emitTrace('phase_triggered', defender);
|
||||
|
||||
let deadDefender = attackerUnit.calcDamage();
|
||||
let deadAttacker = defender.calcDamage();
|
||||
const rawDeadAttacker = deadAttacker;
|
||||
const rawDeadDefender = deadDefender;
|
||||
|
||||
const attackerHP = attackerUnit.getHP();
|
||||
const defenderHP = defender.getHP();
|
||||
@@ -445,6 +531,14 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
|
||||
attackerUnit.increaseKilled(deadDefender);
|
||||
defender.increaseKilled(deadAttacker);
|
||||
emitTrace('phase_damage', defender, {
|
||||
rawDeadAttacker,
|
||||
rawDeadDefender,
|
||||
deadAttacker,
|
||||
deadDefender,
|
||||
attackerHpBefore: attackerHP,
|
||||
defenderHpBefore: defenderHP,
|
||||
});
|
||||
|
||||
const phaseNickname = defender.getPhase() < 0 ? '先' : `${attackerUnit.getPhase() + 1} `;
|
||||
|
||||
@@ -464,9 +558,11 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
|
||||
attackerUnit.addPhase();
|
||||
defender.addPhase();
|
||||
emitTrace('phase_end', defender);
|
||||
|
||||
const attackerState = attackerUnit.continueWar();
|
||||
if (!attackerState.canContinue) {
|
||||
emitTrace('attacker_stopped', defender, { noRice: attackerState.noRice });
|
||||
logWritten = true;
|
||||
|
||||
attackerUnit.logBattleResult();
|
||||
@@ -500,6 +596,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
|
||||
const defenderState = defender.continueWar();
|
||||
if (!defenderState.canContinue) {
|
||||
emitTrace('defender_stopped', defender, { noRice: defenderState.noRice });
|
||||
logWritten = true;
|
||||
|
||||
attackerUnit.logBattleResult();
|
||||
@@ -555,6 +652,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
|
||||
defender.finishBattle();
|
||||
defender = getNextDefender(defender, true);
|
||||
emitTrace('opponent_switched', defender);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +684,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
);
|
||||
}
|
||||
}
|
||||
emitTrace('battle_end', defender, { conquered: conquerCity });
|
||||
|
||||
const logs = flushLoggers([attackerLogger, ...defenderGenerals.map((unit) => unit.getLogger())]);
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||
import type { WarTriggerModule } from './types.js';
|
||||
|
||||
const MAGIC_TO_GENERAL = {
|
||||
위보: [1.2, 1.1],
|
||||
매복: [1.4, 1.2],
|
||||
반목: [1.6, 1.3],
|
||||
화계: [1.8, 1.4],
|
||||
혼란: [2.0, 1.5],
|
||||
} as const;
|
||||
|
||||
const MAGIC_TO_CITY = {
|
||||
급습: [1.2, 1.1],
|
||||
위보: [1.4, 1.2],
|
||||
혼란: [1.6, 1.3],
|
||||
} as const;
|
||||
|
||||
type MagicState = [name: string, damage: number];
|
||||
|
||||
const applyMagicDamageModifiers = (
|
||||
self: WarUnitGeneral,
|
||||
oppose: WarUnit,
|
||||
statName: 'warMagicSuccessDamage' | 'warMagicFailDamage',
|
||||
damage: number,
|
||||
magic: string
|
||||
): number => {
|
||||
let result = self.getActionPipeline().onCalcStat(self.getActionContext(), statName, damage, magic);
|
||||
if (oppose instanceof WarUnitGeneral) {
|
||||
result = oppose.getActionPipeline().onCalcOpposeStat(oppose.getActionContext(), statName, result, magic);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export class che_계략시도 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Pre + 300);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
self: WarUnit,
|
||||
oppose: WarUnit,
|
||||
selfEnv: Record<string, unknown>,
|
||||
_opposeEnv: Record<string, unknown>
|
||||
): boolean {
|
||||
if (!(self instanceof WarUnitGeneral) || self.hasActivatedSkill('계략불가')) return true;
|
||||
|
||||
const general = self.getGeneral();
|
||||
let trialProbability =
|
||||
(self.getComputedStat('intelligence', general.stats.intelligence) / 100) *
|
||||
self.getCrewType().magicCoef;
|
||||
trialProbability = self
|
||||
.getActionPipeline()
|
||||
.onCalcStat(self.getActionContext(), 'warMagicTrialProb', trialProbability);
|
||||
if (oppose instanceof WarUnitGeneral) {
|
||||
trialProbability = oppose
|
||||
.getActionPipeline()
|
||||
.onCalcOpposeStat(oppose.getActionContext(), 'warMagicTrialProb', trialProbability);
|
||||
}
|
||||
if (trialProbability <= 0) return true;
|
||||
|
||||
const rawIntelligence = general.stats.intelligence;
|
||||
const allRawStats = general.stats.leadership + general.stats.strength + rawIntelligence;
|
||||
if (self.getPhase() === 0 && rawIntelligence * 3 >= allRawStats) {
|
||||
trialProbability *= 3;
|
||||
}
|
||||
if (!self.rng.nextBool(trialProbability)) return true;
|
||||
|
||||
let successProbability = 0.7;
|
||||
successProbability = self
|
||||
.getActionPipeline()
|
||||
.onCalcStat(self.getActionContext(), 'warMagicSuccessProb', successProbability);
|
||||
if (oppose instanceof WarUnitGeneral) {
|
||||
successProbability = oppose
|
||||
.getActionPipeline()
|
||||
.onCalcOpposeStat(oppose.getActionContext(), 'warMagicSuccessProb', successProbability);
|
||||
}
|
||||
|
||||
const table = oppose instanceof WarUnitCity ? MAGIC_TO_CITY : MAGIC_TO_GENERAL;
|
||||
const magic = self.rng.choice(Object.keys(table));
|
||||
const [rawSuccessDamage, failDamage] = table[magic as keyof typeof table];
|
||||
const successDamage = applyMagicDamageModifiers(
|
||||
self,
|
||||
oppose,
|
||||
'warMagicSuccessDamage',
|
||||
rawSuccessDamage,
|
||||
magic
|
||||
);
|
||||
|
||||
self.activateSkill('계략시도', magic);
|
||||
if (self.rng.nextBool(successProbability)) {
|
||||
self.activateSkill('계략');
|
||||
selfEnv['magic'] = [magic, successDamage] satisfies MagicState;
|
||||
} else {
|
||||
self.activateSkill('계략실패');
|
||||
selfEnv['magic'] = [magic, failDamage] satisfies MagicState;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const readMagic = (env: Record<string, unknown>): MagicState | null => {
|
||||
const value = env['magic'];
|
||||
if (!Array.isArray(value) || typeof value[0] !== 'string' || typeof value[1] !== 'number') return null;
|
||||
return [value[0], value[1]];
|
||||
};
|
||||
|
||||
export class che_계략발동 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Post + 300);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
self: WarUnit,
|
||||
oppose: WarUnit,
|
||||
selfEnv: Record<string, unknown>,
|
||||
_opposeEnv: Record<string, unknown>
|
||||
): boolean {
|
||||
if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략') || selfEnv['계략발동']) return true;
|
||||
const magicState = readMagic(selfEnv);
|
||||
if (!magicState) return true;
|
||||
selfEnv['계략발동'] = true;
|
||||
const [magic, rawDamage] = magicState;
|
||||
const damage = applyMagicDamageModifiers(self, oppose, 'warMagicFailDamage', rawDamage, magic);
|
||||
const particle = JosaUtil.pick(magic, '을');
|
||||
self.getLogger().pushGeneralBattleDetailLog(`<D>${magic}</>${particle} <C>성공</>했다!`, LogFormat.PLAIN);
|
||||
oppose.getLogger().pushGeneralBattleDetailLog(`<D>${magic}</>에 당했다!`, LogFormat.PLAIN);
|
||||
self.multiplyWarPowerMultiply(damage);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class che_계략실패 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Post + 300);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
self: WarUnit,
|
||||
oppose: WarUnit,
|
||||
selfEnv: Record<string, unknown>,
|
||||
_opposeEnv: Record<string, unknown>
|
||||
): boolean {
|
||||
if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패']) return true;
|
||||
const magicState = readMagic(selfEnv);
|
||||
if (!magicState) return true;
|
||||
selfEnv['계략실패'] = true;
|
||||
const [magic, rawDamage] = magicState;
|
||||
const damage = applyMagicDamageModifiers(self, oppose, 'warMagicFailDamage', rawDamage, magic);
|
||||
const particle = JosaUtil.pick(magic, '을');
|
||||
self.getLogger().pushGeneralBattleDetailLog(`<D>${magic}</>${particle} <R>실패</>했다!`, LogFormat.PLAIN);
|
||||
oppose.getLogger().pushGeneralBattleDetailLog(`<D>${magic}</>${particle} 간파했다!`, LogFormat.PLAIN);
|
||||
self.multiplyWarPowerMultiply(1 / damage);
|
||||
oppose.multiplyWarPowerMultiply(damage);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerModule: WarTriggerModule = {
|
||||
key: 'che_계략',
|
||||
name: '계략',
|
||||
info: '[전투] 귀병의 계략 시도/성공/실패',
|
||||
createTriggerList: (unit) =>
|
||||
new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||
import type { WarTriggerModule } from './types.js';
|
||||
|
||||
export class che_회피시도 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Pre + 200);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
self: WarUnit,
|
||||
_oppose: WarUnit,
|
||||
_selfEnv: Record<string, unknown>,
|
||||
_opposeEnv: Record<string, unknown>
|
||||
): boolean {
|
||||
if (!(self instanceof WarUnitGeneral)) return true;
|
||||
if (self.hasActivatedSkill('특수') || self.hasActivatedSkill('회피불가')) return true;
|
||||
if (!self.rng.nextBool(self.getComputedAvoidRatio())) return true;
|
||||
self.activateSkill('회피시도', '회피');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class che_회피발동 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Post + 500);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
self: WarUnit,
|
||||
oppose: WarUnit,
|
||||
_selfEnv: Record<string, unknown>,
|
||||
_opposeEnv: Record<string, unknown>
|
||||
): boolean {
|
||||
if (!self.hasActivatedSkill('회피')) return true;
|
||||
oppose.getLogger().pushGeneralBattleDetailLog('상대가 <R>회피</>했다!</>', LogFormat.PLAIN);
|
||||
self.getLogger().pushGeneralBattleDetailLog('<C>회피</>했다!</>', LogFormat.PLAIN);
|
||||
oppose.multiplyWarPowerMultiply(1 / 6);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerModule: WarTriggerModule = {
|
||||
key: 'che_회피',
|
||||
name: '회피',
|
||||
info: '[전투] 페이즈마다 확률로 회피 발동',
|
||||
createTriggerList: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export interface WarEngineConfig {
|
||||
maxAtmosByCommand: number;
|
||||
maxTrainByWar: number;
|
||||
maxAtmosByWar: number;
|
||||
maxGeneralStat?: number;
|
||||
castleCrewTypeId: number;
|
||||
armTypes: WarArmTypes;
|
||||
}
|
||||
@@ -53,6 +54,53 @@ export interface WarBattleInput<TriggerState extends GeneralTriggerState = Gener
|
||||
defenderNation: Nation | null;
|
||||
triggerRegistry?: WarTriggerRegistry;
|
||||
loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger;
|
||||
trace?: (event: WarBattleTraceEvent) => void;
|
||||
}
|
||||
|
||||
export interface WarBattleTraceUnitSnapshot {
|
||||
kind: 'general' | 'city';
|
||||
id: number;
|
||||
name: string;
|
||||
isAttacker: boolean;
|
||||
crewTypeId: number;
|
||||
phase: number;
|
||||
realPhase: number;
|
||||
maxPhase: number;
|
||||
hp: number;
|
||||
rawWarPower: number;
|
||||
warPower: number;
|
||||
warPowerMultiplier: number;
|
||||
killed: number;
|
||||
dead: number;
|
||||
activatedSkills: Record<string, number>;
|
||||
general?: {
|
||||
crew: number;
|
||||
rice: number;
|
||||
train: number;
|
||||
atmos: number;
|
||||
injury: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
dex1: number;
|
||||
dex2: number;
|
||||
dex3: number;
|
||||
dex4: number;
|
||||
dex5: number;
|
||||
};
|
||||
cityState?: {
|
||||
defence: number;
|
||||
wall: number;
|
||||
population: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WarBattleTraceEvent {
|
||||
seq: number;
|
||||
event: string;
|
||||
attacker: WarBattleTraceUnitSnapshot;
|
||||
defender: WarBattleTraceUnitSnapshot | null;
|
||||
city: WarBattleTraceUnitSnapshot;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WarUnitReport {
|
||||
|
||||
@@ -147,14 +147,33 @@ export class WarUnitGeneral<
|
||||
return phase + this.bonusPhase;
|
||||
}
|
||||
|
||||
private resolveStatValue(statName: keyof StatBlock, base: number): number {
|
||||
return this.actionPipeline.onCalcStat(this.getActionContext(), statName, base);
|
||||
public getComputedStat(
|
||||
statName: keyof StatBlock,
|
||||
base: number,
|
||||
options: { withInjury?: boolean; withStatAdjust?: boolean; withActions?: boolean } = {}
|
||||
): number {
|
||||
const withInjury = options.withInjury ?? true;
|
||||
const withStatAdjust = options.withStatAdjust ?? true;
|
||||
const withActions = options.withActions ?? true;
|
||||
const injuryRatio = withInjury ? (100 - this.general.injury) / 100 : 1;
|
||||
let value = base * injuryRatio;
|
||||
if (withStatAdjust && statName === 'strength') {
|
||||
value += round((this.general.stats.intelligence * injuryRatio) / 4);
|
||||
} else if (withStatAdjust && statName === 'intelligence') {
|
||||
value += round((this.general.stats.strength * injuryRatio) / 4);
|
||||
}
|
||||
const maxGeneralStat = this.config.maxGeneralStat ?? 255;
|
||||
value = clamp(value, 0, maxGeneralStat);
|
||||
if (withActions) {
|
||||
value = this.actionPipeline.onCalcStat(this.getActionContext(), statName, value);
|
||||
}
|
||||
return clamp(value, 0, maxGeneralStat);
|
||||
}
|
||||
|
||||
private resolveMainStat(armType: number): number {
|
||||
const leadership = this.resolveStatValue('leadership', this.general.stats.leadership);
|
||||
const strength = this.resolveStatValue('strength', this.general.stats.strength);
|
||||
const intelligence = this.resolveStatValue('intelligence', this.general.stats.intelligence);
|
||||
private resolveMainStat(armType: number, withInjury = true): number {
|
||||
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { withInjury });
|
||||
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury });
|
||||
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { withInjury });
|
||||
|
||||
if (armType === this.config.armTypes.wizard) {
|
||||
return intelligence;
|
||||
@@ -173,20 +192,22 @@ export class WarUnitGeneral<
|
||||
if (!(oppose instanceof WarUnitGeneral)) {
|
||||
return value;
|
||||
}
|
||||
return oppose.getActionPipeline().onCalcOpposeStat(this.getActionContext(), statName, value, aux);
|
||||
return oppose.getActionPipeline().onCalcOpposeStat(oppose.getActionContext(), statName, value, aux);
|
||||
}
|
||||
|
||||
public override getDex(crewType: WarCrewType): number {
|
||||
const armType =
|
||||
const storedArmType =
|
||||
crewType.armType === this.config.armTypes.castle
|
||||
? (this.config.armTypes.siege ?? crewType.armType)
|
||||
: crewType.armType;
|
||||
const base = getMetaNumber(this.general.meta, `${META_DEX_PREFIX}${armType}`);
|
||||
const base = getMetaNumber(this.general.meta, `${META_DEX_PREFIX}${storedArmType}`);
|
||||
const aux = {
|
||||
isAttacker: this.isAttacker(),
|
||||
opposeType: this.oppose?.getCrewType() ?? null,
|
||||
};
|
||||
const statName = toDexStatName(armType);
|
||||
// Legacy stores castle dexterity in the siege column, but still exposes
|
||||
// the requested castle arm type to trait handlers as `dex0`.
|
||||
const statName = toDexStatName(crewType.armType);
|
||||
let dex = this.actionPipeline.onCalcStat(this.getActionContext(), statName, base, aux);
|
||||
dex = this.resolveOpposeStatValue(statName, dex, aux);
|
||||
return dex;
|
||||
@@ -236,7 +257,7 @@ export class WarUnitGeneral<
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mainStat = this.resolveMainStat(armType);
|
||||
const mainStat = this.resolveMainStat(armType, false);
|
||||
const coef =
|
||||
armType === this.config.armTypes.wizard ||
|
||||
armType === this.config.armTypes.siege ||
|
||||
@@ -318,7 +339,10 @@ export class WarUnitGeneral<
|
||||
}
|
||||
|
||||
public addDex(crewType: WarCrewType, exp: number): void {
|
||||
const armType = crewType.armType;
|
||||
const armType =
|
||||
crewType.armType === this.config.armTypes.castle
|
||||
? (this.config.armTypes.siege ?? crewType.armType)
|
||||
: crewType.armType;
|
||||
const key = `${META_DEX_PREFIX}${armType}`;
|
||||
const base = getMetaNumber(this.general.meta, key);
|
||||
this.general.meta[key] = round(base + exp);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"seed": "battle-differential-basic-v1",
|
||||
"year": 200,
|
||||
"month": 1,
|
||||
"startYear": 180,
|
||||
"repeatCnt": 1,
|
||||
"action": "battle",
|
||||
"attackerGeneral": {
|
||||
"no": 1, "name": "공격자", "nation": 1, "turntime": "2026-01-01 00:00:00",
|
||||
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
|
||||
"atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None",
|
||||
"strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0,
|
||||
"leadership": 70, "leadership_exp": 0, "horse": "None", "item": "None",
|
||||
"explevel": 0, "experience": 100, "dedication": 100, "officer_level": 3,
|
||||
"officer_city": 1, "gold": 1000, "rice": 10000, "dex1": 0, "dex2": 0,
|
||||
"dex3": 0, "dex4": 0, "dex5": 0, "defence_train": 0, "recent_war": null,
|
||||
"warnum": 0, "killnum": 0, "killcrew": 0
|
||||
},
|
||||
"attackerCity": {
|
||||
"city": 1, "nation": 1, "supply": 1, "name": "공격도시", "pop": 10000,
|
||||
"agri": 1000, "comm": 1000, "secu": 1000, "def": 100, "wall": 100,
|
||||
"trust": 100, "level": 2, "pop_max": 10000, "agri_max": 1000,
|
||||
"comm_max": 1000, "secu_max": 1000, "def_max": 200, "wall_max": 200,
|
||||
"dead": 0, "state": 0, "conflict": "{}"
|
||||
},
|
||||
"attackerNation": {
|
||||
"type": "che_유가", "tech": 1000, "level": 1, "capital": 1, "nation": 1,
|
||||
"name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1
|
||||
},
|
||||
"defenderGenerals": [{
|
||||
"no": 2, "name": "수비자", "nation": 2, "turntime": "2026-01-01 00:00:00",
|
||||
"personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100,
|
||||
"atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None",
|
||||
"strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0,
|
||||
"leadership": 60, "leadership_exp": 0, "horse": "None", "item": "None",
|
||||
"explevel": 0, "experience": 100, "dedication": 100, "officer_level": 3,
|
||||
"officer_city": 2, "gold": 1000, "rice": 10000, "dex1": 0, "dex2": 0,
|
||||
"dex3": 0, "dex4": 0, "dex5": 0, "defence_train": 0, "recent_war": null,
|
||||
"warnum": 0, "killnum": 0, "killcrew": 0
|
||||
}],
|
||||
"defenderCity": {
|
||||
"city": 2, "nation": 2, "supply": 1, "name": "수비도시", "pop": 10000,
|
||||
"agri": 1000, "comm": 1000, "secu": 1000, "def": 100, "wall": 100,
|
||||
"trust": 100, "level": 2, "pop_max": 10000, "agri_max": 1000,
|
||||
"comm_max": 1000, "secu_max": 1000, "def_max": 200, "wall_max": 200,
|
||||
"dead": 0, "state": 0, "conflict": "{}"
|
||||
},
|
||||
"defenderNation": {
|
||||
"type": "che_유가", "tech": 1000, "level": 1, "capital": 2, "nation": 2,
|
||||
"name": "수비국", "gold": 1000, "rice": 10000, "gennum": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||
import type { UnitSetDefinition, WarBattleTraceEvent, WarEngineConfig } from '@sammo-ts/logic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { processBattleSimJob } from '../../../app/game-api/src/battleSim/processor.js';
|
||||
import type { BattleSimJobPayload, BattleSimRequestPayload } from '../../../app/game-api/src/battleSim/types.js';
|
||||
|
||||
interface ReferenceTrace {
|
||||
engine: 'ref';
|
||||
conquered: boolean;
|
||||
events: WarBattleTraceEvent[];
|
||||
rng: RandomCall[];
|
||||
}
|
||||
|
||||
interface RandomCall {
|
||||
seq: number;
|
||||
operation: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
class TracingRng implements RNG {
|
||||
public readonly calls: RandomCall[] = [];
|
||||
|
||||
public constructor(private readonly inner: RNG) {}
|
||||
|
||||
public getMaxInt(): number {
|
||||
return this.inner.getMaxInt();
|
||||
}
|
||||
|
||||
public nextBytes(bytes: number): Uint8Array<ArrayBuffer> {
|
||||
const result = this.inner.nextBytes(bytes);
|
||||
this.record('nextBytes', { bytes }, Buffer.from(result).toString('hex'));
|
||||
return result;
|
||||
}
|
||||
|
||||
public nextBits(bits: number): Uint8Array<ArrayBuffer> {
|
||||
const result = this.inner.nextBits(bits);
|
||||
this.record('nextBits', { bits }, Buffer.from(result).toString('hex'));
|
||||
return result;
|
||||
}
|
||||
|
||||
public nextInt(max?: number): number {
|
||||
const result = this.inner.nextInt(max);
|
||||
this.record('nextInt', { maxInclusive: max ?? null }, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public nextFloat1(): number {
|
||||
const result = this.inner.nextFloat1();
|
||||
this.record('nextFloat1', {}, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private record(operation: string, args: Record<string, unknown>, result: unknown): void {
|
||||
this.calls.push({ seq: this.calls.length, operation, arguments: args, result });
|
||||
}
|
||||
}
|
||||
|
||||
const findWorkspaceRoot = (start: string): string | null => {
|
||||
let current = path.resolve(start);
|
||||
while (true) {
|
||||
if (
|
||||
fs.existsSync(path.join(current, 'docker_compose_files/reference/compose.yml')) &&
|
||||
fs.existsSync(path.join(current, 'ref/sam/hwe/compare/battle_trace.php'))
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = <T>(filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
|
||||
|
||||
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'compose',
|
||||
'exec',
|
||||
'-T',
|
||||
'php',
|
||||
'php',
|
||||
'/var/www/html/hwe/compare/battle_trace.php',
|
||||
'-',
|
||||
],
|
||||
{
|
||||
cwd: path.join(workspaceRoot, 'docker_compose_files/reference'),
|
||||
input: fixtureJson,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
return JSON.parse(stdout) as ReferenceTrace;
|
||||
};
|
||||
|
||||
const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => {
|
||||
expect(typeof actual, `${label}: actual type`).toBe('number');
|
||||
expect(typeof expected, `${label}: reference type`).toBe('number');
|
||||
const reference = expected as number;
|
||||
const tolerance = Math.max(1, Math.abs(reference) * 0.01);
|
||||
expect(
|
||||
Math.abs((actual as number) - reference),
|
||||
`${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}`
|
||||
).toBeLessThanOrEqual(tolerance);
|
||||
};
|
||||
|
||||
const assertTraceParity = (
|
||||
coreEvents: WarBattleTraceEvent[],
|
||||
reference: ReferenceTrace,
|
||||
coreRng: TracingRng | null
|
||||
): void => {
|
||||
expect(coreEvents.map((event) => event.event)).toEqual(reference.events.map((event) => event.event));
|
||||
expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual(
|
||||
reference.rng.map(({ operation, result }) => ({ operation, result }))
|
||||
);
|
||||
|
||||
for (let index = 0; index < reference.events.length; index += 1) {
|
||||
const core = coreEvents[index]!;
|
||||
const ref = reference.events[index]!;
|
||||
expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`);
|
||||
expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`);
|
||||
if (core.defender && ref.defender) {
|
||||
expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind);
|
||||
expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`);
|
||||
expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`);
|
||||
} else {
|
||||
expect(core.defender, `event ${index} defender presence`).toBe(ref.defender);
|
||||
}
|
||||
if (core.event === 'phase_damage') {
|
||||
for (const key of ['rawDeadAttacker', 'rawDeadDefender', 'deadAttacker', 'deadDefender']) {
|
||||
expectNearlyEqual(core.details[key], ref.details[key], `event ${index} ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workspaceRoot = findWorkspaceRoot(process.cwd());
|
||||
const describeWithReference = workspaceRoot ? describe : describe.skip;
|
||||
|
||||
describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
it('keeps the detailed event sequence and phase values within 1%', () => {
|
||||
const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json');
|
||||
const fixtureJson = fs.readFileSync(fixturePath, 'utf8');
|
||||
const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number };
|
||||
const unitSet = readJson<UnitSetDefinition>(
|
||||
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||
);
|
||||
const castleCrewTypeId = 1000;
|
||||
const config: WarEngineConfig = {
|
||||
armPerPhase: 500,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
maxTrainByWar: 110,
|
||||
maxAtmosByWar: 150,
|
||||
castleCrewTypeId,
|
||||
armTypes: {
|
||||
footman: 1,
|
||||
archer: 2,
|
||||
cavalry: 3,
|
||||
wizard: 4,
|
||||
siege: 5,
|
||||
misc: 6,
|
||||
castle: unitSet.crewTypes?.find((crewType) => crewType.id === castleCrewTypeId)?.armType ?? 0,
|
||||
},
|
||||
};
|
||||
const payload: BattleSimJobPayload = {
|
||||
...request,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: request.year, month: request.month, startYear: request.startYear },
|
||||
};
|
||||
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
const coreResult = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(coreResult.result).toBe(true);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
});
|
||||
|
||||
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
|
||||
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||
);
|
||||
base.seed = 'battle-differential-magic-v1';
|
||||
base.attackerGeneral.crew = 4000;
|
||||
base.attackerGeneral.crewtype = 1400;
|
||||
base.attackerGeneral.leadership = 60;
|
||||
base.attackerGeneral.strength = 30;
|
||||
base.attackerGeneral.intel = 95;
|
||||
base.attackerGeneral.special2 = 'che_신산';
|
||||
base.attackerGeneral.personal = 'che_재간';
|
||||
base.attackerGeneral.dex4 = 12000;
|
||||
base.defenderGenerals[0]!.crew = 4000;
|
||||
base.defenderGenerals[0]!.crewtype = 1100;
|
||||
base.defenderGenerals[0]!.leadership = 65;
|
||||
base.defenderGenerals[0]!.strength = 75;
|
||||
base.defenderGenerals[0]!.intel = 35;
|
||||
base.defenderGenerals[0]!.special2 = 'che_견고';
|
||||
base.defenderGenerals[0]!.personal = 'che_유지';
|
||||
|
||||
const fixtureJson = JSON.stringify(base);
|
||||
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 payload: BattleSimJobPayload = {
|
||||
...base,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
const result = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(result.result).toBe(true);
|
||||
expect(
|
||||
reference.events.some((event) =>
|
||||
Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill))
|
||||
)
|
||||
).toBe(true);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
});
|
||||
|
||||
it('matches cavalry, item, inherit-buff, and multiple-defender handling', () => {
|
||||
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||
);
|
||||
base.seed = 'battle-differential-cavalry-items-v1';
|
||||
base.attackerGeneral.crew = 7000;
|
||||
base.attackerGeneral.crewtype = 1300;
|
||||
base.attackerGeneral.leadership = 88;
|
||||
base.attackerGeneral.strength = 92;
|
||||
base.attackerGeneral.intel = 55;
|
||||
base.attackerGeneral.special2 = 'che_기병';
|
||||
base.attackerGeneral.personal = 'che_패권';
|
||||
base.attackerGeneral.horse = 'che_명마_07_백상';
|
||||
base.attackerGeneral.dex3 = 18000;
|
||||
base.attackerGeneral.inheritBuff = {
|
||||
warAvoidRatio: 2,
|
||||
warCriticalRatio: 3,
|
||||
warCriticalRatioOppose: 2,
|
||||
};
|
||||
|
||||
const firstDefender = base.defenderGenerals[0]!;
|
||||
firstDefender.crew = 900;
|
||||
firstDefender.crewtype = 1200;
|
||||
firstDefender.leadership = 58;
|
||||
firstDefender.strength = 55;
|
||||
firstDefender.intel = 62;
|
||||
firstDefender.dex2 = 6000;
|
||||
firstDefender.inheritBuff = { warAvoidRatioOppose: 2 };
|
||||
base.defenderGenerals.push({
|
||||
...firstDefender,
|
||||
no: 3,
|
||||
name: '수비자2',
|
||||
crew: 1100,
|
||||
crewtype: 1300,
|
||||
leadership: 64,
|
||||
strength: 72,
|
||||
intel: 48,
|
||||
dex2: 0,
|
||||
dex3: 8000,
|
||||
special2: 'che_기병',
|
||||
personal: 'che_패권',
|
||||
horse: 'che_명마_07_백상',
|
||||
inheritBuff: {
|
||||
warAvoidRatio: 1,
|
||||
warCriticalRatioOppose: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const fixtureJson = JSON.stringify(base);
|
||||
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 payload: BattleSimJobPayload = {
|
||||
...base,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
const result = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(result.result).toBe(true);
|
||||
expect(reference.events.filter((event) => event.event === 'opponent_switched')).toHaveLength(2);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
});
|
||||
|
||||
it('matches siege dexterity and castle damage handling', () => {
|
||||
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||
);
|
||||
base.seed = 'battle-differential-siege-v1';
|
||||
base.attackerGeneral.crew = 5000;
|
||||
base.attackerGeneral.crewtype = 1501;
|
||||
base.attackerGeneral.leadership = 90;
|
||||
base.attackerGeneral.strength = 65;
|
||||
base.attackerGeneral.intel = 55;
|
||||
base.attackerGeneral.dex5 = 16000;
|
||||
base.defenderGenerals[0]!.crew = 300;
|
||||
base.defenderGenerals[0]!.crewtype = 1200;
|
||||
base.defenderGenerals[0]!.leadership = 65;
|
||||
base.defenderGenerals[0]!.strength = 72;
|
||||
base.defenderGenerals[0]!.intel = 50;
|
||||
base.defenderGenerals[0]!.dex2 = 10000;
|
||||
|
||||
const fixtureJson = JSON.stringify(base);
|
||||
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 payload: BattleSimJobPayload = {
|
||||
...base,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
const result = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(result.result).toBe(true);
|
||||
expect(reference.events.some((event) => event.defender?.kind === 'city')).toBe(true);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
});
|
||||
|
||||
it('matches the no-defender supply-retreat branch without consuming RNG', () => {
|
||||
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||
);
|
||||
base.seed = 'battle-differential-supply-v1';
|
||||
base.defenderGenerals = [];
|
||||
base.defenderNation.rice = 0;
|
||||
|
||||
const fixtureJson = JSON.stringify(base);
|
||||
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 payload: BattleSimJobPayload = {
|
||||
...base,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||
};
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
const result = processBattleSimJob(payload, {
|
||||
trace: (event) => coreEvents.push(event),
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
},
|
||||
});
|
||||
const reference = runReferenceTrace(workspaceRoot!, fixtureJson);
|
||||
|
||||
expect(result.result).toBe(true);
|
||||
expect(reference.events.map((event) => event.event)).toEqual([
|
||||
'battle_start',
|
||||
'supply_retreat',
|
||||
'battle_end',
|
||||
]);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user