← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.21%
Avg Error B0.21%
Δ Avg Error-0.00%
Improved0
Regressed0
Added0
Retired0
Testcase Delta
178 / 178
Code / Config Changes
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Gwen.json===================================================================--- simulator/config/hero_definitions/Gwen.json prev run+++ simulator/config/hero_definitions/Gwen.json this run@@ -9,9 +9,9 @@"type": "attack"},"effects": {"EagleVision/1": {- "type": "active.hero.defense.down",+ "type": "active.hero.damageTaken.up","value": [5,10,15,
Show raw per-run patches
Run A dirty state patch
diff --git a/shared/fighters_data/fighters_stats.json b/shared/fighters_data/fighters_stats.json--- a/shared/fighters_data/fighters_stats.json+++ b/shared/fighters_data/fighters_stats.json@@ -4,6 +4,11 @@"lanc": [1400.98, 1432.28, 1756.8, 1722.5],"mark": [1400.98, 1431.28, 1806.4, 1785.4]},+ "custom": {+ "inf": [1370.6, 1380.5, 1706.7, 1864.7],+ "lanc": [1370.6, 1380.5, 1820.4, 1669.3],+ "mark": [1370.6, 1380.5, 1910.9, 1732.9]+ },"daut": {"inf": [9.94,diff --git a/simulator/config/hero_definitions/Ahmose.json b/simulator/config/hero_definitions/Ahmose.json--- a/simulator/config/hero_definitions/Ahmose.json+++ b/simulator/config/hero_definitions/Ahmose.json@@ -24,7 +24,7 @@}},"ViperFormation/2": {- "type": "active.hero.defense.up",+ "type": "active.hero.damageTaken.down","value": [10,15,@@ -46,7 +46,7 @@"same_effect_stacking": "max"},"ViperFormation/3": {- "type": "active.hero.defense.up",+ "type": "active.hero.damageTaken.down","value": [10,25,@@ -75,7 +75,7 @@},"effects": {"PrayerOfFlame/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [20,40,@@ -99,7 +99,7 @@},"effects": {"BladeOfLight/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [12,24,@@ -110,6 +110,9 @@"units": {"applies_to": ["infantry"+ ],+ "applies_vs": [+ "target"]},"duration": {@@ -118,7 +121,7 @@}},"BladeOfLight/2": {- "type": "active.hero.defense.down",+ "type": "active.hero.damageTaken.up","value": [5,10,diff --git a/simulator/config/hero_definitions/Bradley.json b/simulator/config/hero_definitions/Bradley.json--- a/simulator/config/hero_definitions/Bradley.json+++ b/simulator/config/hero_definitions/Bradley.json@@ -28,7 +28,7 @@},"effects": {"PowerShot/1": {- "type": "active.hero.lethality.up",+ "type": "type.all.damage.up","value": [6,12,@@ -43,7 +43,7 @@}},"PowerShot/2": {- "type": "active.hero.lethality.up",+ "type": "type.all.damage.up","value": [5,10,@@ -67,7 +67,7 @@},"effects": {"TacticalAssistance/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [6,12,diff --git a/simulator/config/hero_definitions/Edith.json b/simulator/config/hero_definitions/Edith.json--- a/simulator/config/hero_definitions/Edith.json+++ b/simulator/config/hero_definitions/Edith.json@@ -25,7 +25,7 @@}},"StrategicBalance/2": {- "type": "active.hero.lethality.up",+ "type": "type.all.damage.up","value": [4,8,diff --git a/simulator/config/hero_definitions/Flint.json b/simulator/config/hero_definitions/Flint.json--- a/simulator/config/hero_definitions/Flint.json+++ b/simulator/config/hero_definitions/Flint.json@@ -10,7 +10,7 @@},"effects": {"Pyromaniac/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [20,40,diff --git a/simulator/config/hero_definitions/Greg.json b/simulator/config/hero_definitions/Greg.json--- a/simulator/config/hero_definitions/Greg.json+++ b/simulator/config/hero_definitions/Greg.json@@ -17,7 +17,7 @@},"effects": {"SwordOfJustice/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [8,16,diff --git a/simulator/config/hero_definitions/Hector.json b/simulator/config/hero_definitions/Hector.json--- a/simulator/config/hero_definitions/Hector.json+++ b/simulator/config/hero_definitions/Hector.json@@ -40,7 +40,7 @@},"effects": {"Rampant/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [100,125,@@ -65,7 +65,7 @@}},"Rampant/2": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [20,40,diff --git a/simulator/config/hero_definitions/Jeronimo.json b/simulator/config/hero_definitions/Jeronimo.json--- a/simulator/config/hero_definitions/Jeronimo.json+++ b/simulator/config/hero_definitions/Jeronimo.json@@ -47,7 +47,7 @@},"effects": {"EXpertSwordsmanship/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [6,12,diff --git a/simulator/config/hero_definitions/Norah.json b/simulator/config/hero_definitions/Norah.json--- a/simulator/config/hero_definitions/Norah.json+++ b/simulator/config/hero_definitions/Norah.json@@ -26,7 +26,7 @@}},"CombinedArms/2": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [3,6,@@ -96,7 +96,7 @@},"effects": {"Momentum/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [5,10,diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json--- a/simulator/config/hero_definitions/Renee.json+++ b/simulator/config/hero_definitions/Renee.json@@ -7,11 +7,13 @@"description": "Troops place Dream Marks on targets every two turns, dealing extra Lancer damage once next turn","trigger": {"type": "turn",- "every": 2+ "every": 2,+ "source": "self.lancer",+ "target": "enemy"},"effects": {"NightmareTrace/1": {- "type": "extra_skill_attack",+ "type": "type.normal.damage.up","value": [40,80,@@ -20,23 +22,15 @@200],"units": {- "applies_to": [- "lancer"- ],- "applies_vs": "any"+ "applies_to": "lancer",+ "applies_vs": "target"},"duration": {"type": "attack","value": 1,"delay": 1},- "same_effect_stacking": "max",- "trigger_damage_jobs": [- {- "source": "use.source",- "target": "use.target"- }- ]+ "same_effect_stacking": "max"}}},@@ -44,11 +38,13 @@"description": "Dream Marks highlight enemy vulnerabilities, increasing damage dealt by Lancers to marked targets","trigger": {"type": "turn",- "every": 2+ "every": 2,+ "source": "self.lancer",+ "target": "enemy"},"effects": {"Dreamcatcher/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [30,60,@@ -57,10 +53,8 @@150],"units": {- "applies_to": [- "lancer"- ],- "applies_vs": "any"+ "applies_to": "lancer",+ "applies_vs": "target"},"duration": {"type": "turn",@@ -76,11 +70,12 @@"trigger": {"type": "turn","every": 2,- "source": "self.any"+ "source": "self.lancer",+ "target": "enemy"},"effects": {"Dreamslice/1": {- "type": "active.hero.defense.down",+ "type": "type.all.damage.up","value": [15,30,@@ -89,8 +84,8 @@75],"units": {- "applies_to": "target",- "applies_vs": "any"+ "applies_to": "any",+ "applies_vs": "target"},"duration": {"type": "turn",diff --git a/simulator/config/hero_definitions/WuMing.json b/simulator/config/hero_definitions/WuMing.json--- a/simulator/config/hero_definitions/WuMing.json+++ b/simulator/config/hero_definitions/WuMing.json@@ -48,7 +48,7 @@},"effects": {"CrescentUplift/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [4,8,diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts--- a/simulator/src/classifierDamage.test.ts+++ b/simulator/src/classifierDamage.test.ts@@ -127,17 +127,24 @@["active.hero.attack.down", "active.hero.attack.down"],["active.hero.lethality.up", "active.hero.lethality.up"],["active.hero.lethality.down", "active.hero.lethality.down"],- ["active.hero.lethality.up", "active.hero.lethality.up"],+ ["active.hero.damage.up", "active.hero.damage.up"],+ ["active.hero.damage.down", "active.hero.damage.down"],+ ["active.troop.damage.up", "active.troop.damage.up"],+ ["active.troop.damage.down", "active.troop.damage.down"],["type.normal.damage.up", "type.normal.damage.up"],["type.normal.damage.down", "type.normal.damage.down"],["type.skill.damage.up", "type.skill.damage.up"],["type.skill.damage.down", "type.skill.damage.down"],+ ["type.all.damage.up", "type.all.damage.up"],+ ["type.all.damage.down", "type.all.damage.down"],["active.hero.defense.up", "active.hero.defense.up"],["active.hero.defense.down", "active.hero.defense.down"],["active.hero.health.up", "active.hero.health.up"],["active.hero.health.down", "active.hero.health.down"],- ["active.hero.defense.up", "active.hero.defense.up"],- ["active.hero.defense.down", "active.hero.defense.down"],+ ["active.hero.damageTaken.up", "active.hero.damageTaken.up"],+ ["active.hero.damageTaken.down", "active.hero.damageTaken.down"],+ ["active.troop.damageTaken.up", "active.troop.damageTaken.up"],+ ["active.troop.damageTaken.down", "active.troop.damageTaken.down"],["type.normal.defense.up", "type.normal.defense.up"],["type.normal.defense.down", "type.normal.defense.down"],["type.skill.defense.up", "type.skill.defense.up"],@@ -148,8 +155,10 @@"active.hero.defense.down","active.hero.health.up","active.hero.health.down",- "active.hero.defense.up",- "active.hero.defense.down",+ "active.hero.damageTaken.up",+ "active.hero.damageTaken.down",+ "active.troop.damageTaken.up",+ "active.troop.damageTaken.down","type.normal.defense.up","type.normal.defense.down","type.skill.defense.up",@@ -346,6 +355,34 @@assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.1) < 1e-12);});+test("multiplicative all-damage buckets compound instead of adding", () => {+ const fighters = simpleFighters();+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });+ const combined = calculateIndexedDamageJob(+ job,+ fighters,+ [effect("type.all.damage.up", "attacker", 20), effect("type.all.damage.up", "attacker", 25)],+ { trace: true }+ );++ assert.equal(combined.trace?.atomicBuckets["type.all.damage.up"].totalPct, 50);+ assert.equal(combined.trace?.atomicBuckets["type.all.damage.up"].contributors.length, 2);+ assert.equal(combined.trace?.aggregationGroups["type.all.damage.up"].factor, 1.5);+ assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.25) < 1e-12);+});++test("damage-taken buckets use the expected damage direction", () => {+ const fighters = simpleFighters();+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });+ const damageTakenUp = calculateIndexedDamageJob(job, fighters, [effect("active.hero.damageTaken.up", "defender", 25)], { trace: true });+ const damageTakenDown = calculateIndexedDamageJob(job, fighters, [effect("active.hero.damageTaken.down", "defender", 25)], { trace: true });++ assert.equal(damageTakenUp.trace?.aggregationGroups["active.hero.damageTaken.up"].placement, "numerator");+ assert.equal(damageTakenDown.trace?.aggregationGroups["active.hero.damageTaken.down"].placement, "denominator");+ assert.ok(Math.abs(damageTakenUp.kills - baseline.kills * 1.25) < 1e-12);+ assert.ok(Math.abs(damageTakenDown.kills - baseline.kills / 1.25) < 1e-12);+});+test("negative passive stat bonuses route to down buckets with positive factors", () => {const passiveHealthDown = {...effect("passive.health.down", "defender", 105),diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -350,6 +350,7 @@const index = BUCKET_IDS[bucketName];const definition = BUCKET_DEFINITIONS[bucketName];if (definition.update === "assign_factor") buckets.factors[index] = Math.max(0, value);+ else if (definition.update === "multiply_pct_factor") buckets.factors[index] *= 1 + value / 100;else buckets.factors[index] += value / 100;if (effectId) buckets.contributors?.[index].push({ effectId, source, sourceSide, valuePct: value, bucket: traceBucketName, stackingKey, sameEffectStacking });return value;diff --git a/simulator/src/damageBuckets.test.ts b/simulator/src/damageBuckets.test.ts--- a/simulator/src/damageBuckets.test.ts+++ b/simulator/src/damageBuckets.test.ts@@ -2,12 +2,35 @@import { test } from "node:test";import { createFastDamageScratch } from "./damage";-import { ATOMIC_BUCKETS } from "./damageBuckets";+import { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";-const REMOVED_ACTIVE_BUCKET_PATTERN = new RegExp(`^active\\.(${["hero", "troop"].join("|")})\\.(${["dam", "age"].join("")}|${["dam", "age", "Taken"].join("")})\\.`);+test("active hero and troop damage aliases are supported all-damage buckets", () => {+ for (const bucket of [+ "active.hero.damage.up",+ "active.hero.damage.down",+ "active.hero.damageTaken.up",+ "active.hero.damageTaken.down",+ "active.troop.damage.up",+ "active.troop.damage.down",+ "active.troop.damageTaken.up",+ "active.troop.damageTaken.down"+ ]) {+ assert.equal(ATOMIC_BUCKETS.includes(bucket), true, bucket);+ assert.equal(bucketDefinition(bucket)?.appliesTo, undefined, bucket);+ }+ assert.equal(bucketDefinition("active.hero.damage.up")?.placement, "numerator");+ assert.equal(bucketDefinition("active.hero.damage.down")?.placement, "denominator");+ assert.equal(bucketDefinition("active.hero.damageTaken.up")?.placement, "numerator");+ assert.equal(bucketDefinition("active.hero.damageTaken.down")?.placement, "denominator");+});-test("active hero and troop damage aliases are not supported buckets", () => {- assert.equal(ATOMIC_BUCKETS.some((bucket) => REMOVED_ACTIVE_BUCKET_PATTERN.test(bucket)), false);+test("type all damage buckets multiply percentage factors", () => {+ assert.equal(bucketDefinition("type.all.damage.up")?.update, "multiply_pct_factor");+ assert.equal(bucketDefinition("type.all.damage.up")?.placement, "numerator");+ assert.equal(bucketDefinition("type.all.damage.up")?.appliesTo, undefined);+ assert.equal(bucketDefinition("type.all.damage.down")?.update, "multiply_pct_factor");+ assert.equal(bucketDefinition("type.all.damage.down")?.placement, "denominator");+ assert.equal(bucketDefinition("type.all.damage.down")?.appliesTo, undefined);});test("damage scratch stores one factor value per atomic bucket", () => {diff --git a/simulator/src/damageBuckets.ts b/simulator/src/damageBuckets.ts--- a/simulator/src/damageBuckets.ts+++ b/simulator/src/damageBuckets.ts@@ -2,7 +2,7 @@export type BucketRole = "attacker" | "defender";export type BucketValueType = "raw" | "pct";-export type BucketUpdate = "assign_factor" | "add_pct_factor";+export type BucketUpdate = "assign_factor" | "add_pct_factor" | "multiply_pct_factor";export type BucketPlacement = "numerator" | "denominator";// "static" pools are closed at compile time (base/player/passive): every contributor is// known at battle start, so they are aggregated once by the static damage profile.@@ -71,6 +71,14 @@health: {up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ },+ damage: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ damageTaken: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }}},troop: {@@ -89,10 +97,24 @@health: {up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ },+ damage: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ damageTaken: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }}}},type: {+ all: {+ damage: {+ up: { role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" }+ }+ },normal: {damage: {up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },diff --git a/simulator/src/index.ts b/simulator/src/index.ts--- a/simulator/src/index.ts+++ b/simulator/src/index.ts@@ -1,6 +1,7 @@export { buildSimulatorConfig, loadSimulatorConfig } from "./config";export type { RawSimulatorConfig } from "./config";-export { simulateBattle, signedRemainingScore } from "./simulator";+export { simulateBattle, prepareBattle, runPrepared, signedRemainingScore } from "./simulator";+export type { CompiledBattle } from "./simulator";export { BattleInputBuilder } from "./battleInputBuilder";export { applyHeroGenerationStats } from "./resolve";export { discoverTestcaseFiles, runTestcases, adaptTestcaseEntry } from "./testcases";diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -84,7 +84,38 @@}export function simulateBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}): BattleResult {- const run = runBattle(input, config, options);+ return buildBattleResult(runBattle(input, config, options));+}++/**+ * A resolved battle ready to run many times with different seeds. When battle-start is deterministic,+ * the entire pre-loop runtime (`template`) is seed-independent and built once; each run clones its+ * mutable state and reuses the skills + static profile by reference. For stochastic battle-start the+ * template is absent and runs reuse only the resolved fighters.+ */+export interface CompiledBattle {+ input: BattleInput;+ config: SimulatorConfig;+ fighters: Record<SideId, ResolvedFighter>;+ template?: Runtime;+ deterministicBattleStart: boolean;+}++export function prepareBattle(input: BattleInput, config: SimulatorConfig): CompiledBattle {+ const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);+ const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);+ const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };+ const deterministicBattleStart = !buildRuntimeSkills([attacker, defender]).battleStart.some(hasChanceTrigger);+ // Deterministic battle-start => the entire pre-loop runtime is seed-independent; build it once.+ const template = deterministicBattleStart ? setupRuntime(fighters, input, "simulator-prepare") : undefined;+ return { input, config, fighters, template, deterministicBattleStart };+}++export function runPrepared(compiled: CompiledBattle, seed?: string | number, options: SimulationOptions = {}): BattleResult {+ return buildBattleResult(runBattle({ ...compiled.input, seed }, compiled.config, options, compiled));+}++function buildBattleResult(run: BattleRun): BattleResult {const { fighters, runtime } = run;return {winner: run.winner,@@ -104,20 +135,78 @@};}-function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions): BattleRun {- const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);- const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);- const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"));- const mode = options.mode ?? "standard";- const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(attacker, defender));- const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;+// Reuse a prepared fighter's immutable resolution; only the troop counts mutate during a run.+function cloneFighterForRun(fighter: ResolvedFighter): ResolvedFighter {+ return { ...fighter, troops: { ...fighter.initialTroops } };+}+// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage+// profile, and drop static-profile effects from the per-job index.+function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);addInputPassiveEffects(runtime, input.attacker.passive, "attacker");addInputPassiveEffects(runtime, input.defender.passive, "defender");runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);removeStaticProfileBucketEffects(runtime.effectIndex);+ return runtime;+}++// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)+// and the index rebuilt from the clones; skills and the static profile are shared by reference.+function cloneRuntime(template: Runtime, rng: Rng): Runtime {+ const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));+ const effectIndex = createEffectIndex();+ for (const effect of activeEffects) indexEffect(effectIndex, effect);+ removeStaticProfileBucketEffects(effectIndex);+ return {+ activeEffects,+ effectIndex,+ staticDamageProfile: template.staticDamageProfile,+ damageScratch: createFastDamageScratch(),+ rng,+ skills: template.skills,+ skillReports: cloneSkillReports(template.skillReports),+ effectActivationCounts: { ...template.effectActivationCounts },+ extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },+ attackControlCounts: { ...template.attackControlCounts },+ consumedEffectUseKeys: new Set(),+ counters: {+ attacks: { attacker: { ...template.counters.attacks.attacker }, defender: { ...template.counters.attacks.defender } },+ received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }+ }+ };+}++function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {+ const cloneSide = (side: Map<string, SkillReportEntry>): Map<string, SkillReportEntry> => {+ const out = new Map<string, SkillReportEntry>();+ for (const [key, entry] of side) out.set(key, { ...entry, unsupportedEffects: [...entry.unsupportedEffects] });+ return out;+ };+ return { attacker: cloneSide(reports.attacker), defender: cloneSide(reports.defender) };+}++function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions, prepared?: CompiledBattle): BattleRun {+ if (prepared?.template) {+ const fighters: Record<SideId, ResolvedFighter> = {+ attacker: cloneFighterForRun(prepared.fighters.attacker),+ defender: cloneFighterForRun(prepared.fighters.defender)+ };+ const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));+ return runLoop(input, fighters, runtime, options);+ }+ const attacker = prepared ? cloneFighterForRun(prepared.fighters.attacker) : resolveFighter(input.attacker, "attacker", config, input.engagement_type);+ const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);+ const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default");+ return runLoop(input, fighters, runtime, options);+}++function runLoop(input: BattleInput, fighters: Record<SideId, ResolvedFighter>, runtime: Runtime, options: SimulationOptions): BattleRun {+ const mode = options.mode ?? "standard";+ const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(fighters.attacker, fighters.defender));+ const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;let rounds = 0;for (let round = 1; round <= maxRounds; round += 1) {diff --git a/simulator/src/testcases.test.ts b/simulator/src/testcases.test.ts--- a/simulator/src/testcases.test.ts+++ b/simulator/src/testcases.test.ts@@ -237,13 +237,13 @@assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);});-test("runTestcases skips stat rounding correction for stochastic misses", () => {+test("runTestcases applies stat rounding correction for stochastic misses", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "greg_mia_combo", repeat: 5, calibrationReportPath: "/tmp/does-not-exist.json" }, config);const summaries = Object.values(report.testcases);assert.ok(summaries.some((summary) => summary.deterministic === false && summary.game?.passes === false));- assert.deepEqual(summaries.map((summary) => summary.gameStatAdjustment), summaries.map(() => undefined));+ assert.ok(summaries.some((summary) => summary.deterministic === false && summary.gameStatAdjustment));});test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {diff --git a/simulator/src/testcases.ts b/simulator/src/testcases.ts--- a/simulator/src/testcases.ts+++ b/simulator/src/testcases.ts@@ -10,7 +10,7 @@sampleStats} from "./calibration";import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";-import { simulateBattle } from "./simulator";+import { simulateBattle, prepareBattle, runPrepared } from "./simulator";import { DamageAggregationError } from "./damage";import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "./types";@@ -581,7 +581,11 @@const samples: number[] = [];const sampleOutcomes: TestcaseSampleOutcome[] = [];const sampleDeltas: number[] = [];- let result = simulateBattle(sampleInput(job.input, job.seed, job.file, job.testcaseId, job.index, 0), config);+ // Resolve the battle once and reuse it across every seeded sample of this case.+ const compiled = prepareBattle(job.input, config);+ const baseSeed = job.seed ?? job.input.seed;+ const sample = (iteration: number) => runPrepared(compiled, sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration));+ let result = sample(0);const firstScore = battleScoreDelta(result);if (firstScore !== undefined) {samples.push(firstScore);@@ -590,7 +594,7 @@}const sampleCount = result.randomness.deterministic ? 1 : job.repeat;for (let iteration = 1; iteration < sampleCount; iteration += 1) {- result = simulateBattle(sampleInput(job.input, job.seed, job.file, job.testcaseId, job.index, iteration), config);+ result = sample(iteration);const score = battleScoreDelta(result);if (score !== undefined) {samples.push(score);
Run B dirty state patch
diff --git a/shared/fighters_data/fighters_stats.json b/shared/fighters_data/fighters_stats.json--- a/shared/fighters_data/fighters_stats.json+++ b/shared/fighters_data/fighters_stats.json@@ -4,6 +4,11 @@"lanc": [1400.98, 1432.28, 1756.8, 1722.5],"mark": [1400.98, 1431.28, 1806.4, 1785.4]},+ "custom": {+ "inf": [1370.6, 1380.5, 1706.7, 1864.7],+ "lanc": [1370.6, 1380.5, 1820.4, 1669.3],+ "mark": [1370.6, 1380.5, 1910.9, 1732.9]+ },"daut": {"inf": [9.94,diff --git a/simulator/config/hero_definitions/Ahmose.json b/simulator/config/hero_definitions/Ahmose.json--- a/simulator/config/hero_definitions/Ahmose.json+++ b/simulator/config/hero_definitions/Ahmose.json@@ -24,7 +24,7 @@}},"ViperFormation/2": {- "type": "active.hero.defense.up",+ "type": "active.hero.damageTaken.down","value": [10,15,@@ -46,7 +46,7 @@"same_effect_stacking": "max"},"ViperFormation/3": {- "type": "active.hero.defense.up",+ "type": "active.hero.damageTaken.down","value": [10,25,@@ -75,7 +75,7 @@},"effects": {"PrayerOfFlame/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [20,40,@@ -99,7 +99,7 @@},"effects": {"BladeOfLight/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [12,24,@@ -110,6 +110,9 @@"units": {"applies_to": ["infantry"+ ],+ "applies_vs": [+ "target"]},"duration": {@@ -118,7 +121,7 @@}},"BladeOfLight/2": {- "type": "active.hero.defense.down",+ "type": "active.hero.damageTaken.up","value": [5,10,diff --git a/simulator/config/hero_definitions/Bradley.json b/simulator/config/hero_definitions/Bradley.json--- a/simulator/config/hero_definitions/Bradley.json+++ b/simulator/config/hero_definitions/Bradley.json@@ -28,7 +28,7 @@},"effects": {"PowerShot/1": {- "type": "active.hero.lethality.up",+ "type": "type.all.damage.up","value": [6,12,@@ -43,7 +43,7 @@}},"PowerShot/2": {- "type": "active.hero.lethality.up",+ "type": "type.all.damage.up","value": [5,10,@@ -67,7 +67,7 @@},"effects": {"TacticalAssistance/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [6,12,diff --git a/simulator/config/hero_definitions/Edith.json b/simulator/config/hero_definitions/Edith.json--- a/simulator/config/hero_definitions/Edith.json+++ b/simulator/config/hero_definitions/Edith.json@@ -25,7 +25,7 @@}},"StrategicBalance/2": {- "type": "active.hero.lethality.up",+ "type": "type.all.damage.up","value": [4,8,diff --git a/simulator/config/hero_definitions/Flint.json b/simulator/config/hero_definitions/Flint.json--- a/simulator/config/hero_definitions/Flint.json+++ b/simulator/config/hero_definitions/Flint.json@@ -10,7 +10,7 @@},"effects": {"Pyromaniac/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [20,40,diff --git a/simulator/config/hero_definitions/Greg.json b/simulator/config/hero_definitions/Greg.json--- a/simulator/config/hero_definitions/Greg.json+++ b/simulator/config/hero_definitions/Greg.json@@ -17,7 +17,7 @@},"effects": {"SwordOfJustice/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [8,16,diff --git a/simulator/config/hero_definitions/Gwen.json b/simulator/config/hero_definitions/Gwen.json--- a/simulator/config/hero_definitions/Gwen.json+++ b/simulator/config/hero_definitions/Gwen.json@@ -10,7 +10,7 @@},"effects": {"EagleVision/1": {- "type": "active.hero.defense.down",+ "type": "active.hero.damageTaken.up","value": [5,10,diff --git a/simulator/config/hero_definitions/Hector.json b/simulator/config/hero_definitions/Hector.json--- a/simulator/config/hero_definitions/Hector.json+++ b/simulator/config/hero_definitions/Hector.json@@ -40,7 +40,7 @@},"effects": {"Rampant/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [100,125,@@ -65,7 +65,7 @@}},"Rampant/2": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [20,40,diff --git a/simulator/config/hero_definitions/Jeronimo.json b/simulator/config/hero_definitions/Jeronimo.json--- a/simulator/config/hero_definitions/Jeronimo.json+++ b/simulator/config/hero_definitions/Jeronimo.json@@ -47,7 +47,7 @@},"effects": {"EXpertSwordsmanship/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [6,12,diff --git a/simulator/config/hero_definitions/Norah.json b/simulator/config/hero_definitions/Norah.json--- a/simulator/config/hero_definitions/Norah.json+++ b/simulator/config/hero_definitions/Norah.json@@ -26,7 +26,7 @@}},"CombinedArms/2": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [3,6,@@ -96,7 +96,7 @@},"effects": {"Momentum/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [5,10,diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json--- a/simulator/config/hero_definitions/Renee.json+++ b/simulator/config/hero_definitions/Renee.json@@ -7,11 +7,13 @@"description": "Troops place Dream Marks on targets every two turns, dealing extra Lancer damage once next turn","trigger": {"type": "turn",- "every": 2+ "every": 2,+ "source": "self.lancer",+ "target": "enemy"},"effects": {"NightmareTrace/1": {- "type": "extra_skill_attack",+ "type": "type.normal.damage.up","value": [40,80,@@ -20,23 +22,15 @@200],"units": {- "applies_to": [- "lancer"- ],- "applies_vs": "any"+ "applies_to": "lancer",+ "applies_vs": "target"},"duration": {"type": "attack","value": 1,"delay": 1},- "same_effect_stacking": "max",- "trigger_damage_jobs": [- {- "source": "use.source",- "target": "use.target"- }- ]+ "same_effect_stacking": "max"}}},@@ -44,11 +38,13 @@"description": "Dream Marks highlight enemy vulnerabilities, increasing damage dealt by Lancers to marked targets","trigger": {"type": "turn",- "every": 2+ "every": 2,+ "source": "self.lancer",+ "target": "enemy"},"effects": {"Dreamcatcher/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [30,60,@@ -57,10 +53,8 @@150],"units": {- "applies_to": [- "lancer"- ],- "applies_vs": "any"+ "applies_to": "lancer",+ "applies_vs": "target"},"duration": {"type": "turn",@@ -76,11 +70,12 @@"trigger": {"type": "turn","every": 2,- "source": "self.any"+ "source": "self.lancer",+ "target": "enemy"},"effects": {"Dreamslice/1": {- "type": "active.hero.defense.down",+ "type": "type.all.damage.up","value": [15,30,@@ -89,8 +84,8 @@75],"units": {- "applies_to": "target",- "applies_vs": "any"+ "applies_to": "any",+ "applies_vs": "target"},"duration": {"type": "turn",diff --git a/simulator/config/hero_definitions/WuMing.json b/simulator/config/hero_definitions/WuMing.json--- a/simulator/config/hero_definitions/WuMing.json+++ b/simulator/config/hero_definitions/WuMing.json@@ -48,7 +48,7 @@},"effects": {"CrescentUplift/1": {- "type": "active.hero.lethality.up",+ "type": "active.hero.damage.up","value": [4,8,diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts--- a/simulator/src/classifierDamage.test.ts+++ b/simulator/src/classifierDamage.test.ts@@ -127,17 +127,24 @@["active.hero.attack.down", "active.hero.attack.down"],["active.hero.lethality.up", "active.hero.lethality.up"],["active.hero.lethality.down", "active.hero.lethality.down"],- ["active.hero.lethality.up", "active.hero.lethality.up"],+ ["active.hero.damage.up", "active.hero.damage.up"],+ ["active.hero.damage.down", "active.hero.damage.down"],+ ["active.troop.damage.up", "active.troop.damage.up"],+ ["active.troop.damage.down", "active.troop.damage.down"],["type.normal.damage.up", "type.normal.damage.up"],["type.normal.damage.down", "type.normal.damage.down"],["type.skill.damage.up", "type.skill.damage.up"],["type.skill.damage.down", "type.skill.damage.down"],+ ["type.all.damage.up", "type.all.damage.up"],+ ["type.all.damage.down", "type.all.damage.down"],["active.hero.defense.up", "active.hero.defense.up"],["active.hero.defense.down", "active.hero.defense.down"],["active.hero.health.up", "active.hero.health.up"],["active.hero.health.down", "active.hero.health.down"],- ["active.hero.defense.up", "active.hero.defense.up"],- ["active.hero.defense.down", "active.hero.defense.down"],+ ["active.hero.damageTaken.up", "active.hero.damageTaken.up"],+ ["active.hero.damageTaken.down", "active.hero.damageTaken.down"],+ ["active.troop.damageTaken.up", "active.troop.damageTaken.up"],+ ["active.troop.damageTaken.down", "active.troop.damageTaken.down"],["type.normal.defense.up", "type.normal.defense.up"],["type.normal.defense.down", "type.normal.defense.down"],["type.skill.defense.up", "type.skill.defense.up"],@@ -148,8 +155,10 @@"active.hero.defense.down","active.hero.health.up","active.hero.health.down",- "active.hero.defense.up",- "active.hero.defense.down",+ "active.hero.damageTaken.up",+ "active.hero.damageTaken.down",+ "active.troop.damageTaken.up",+ "active.troop.damageTaken.down","type.normal.defense.up","type.normal.defense.down","type.skill.defense.up",@@ -346,6 +355,34 @@assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.1) < 1e-12);});+test("multiplicative all-damage buckets compound instead of adding", () => {+ const fighters = simpleFighters();+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });+ const combined = calculateIndexedDamageJob(+ job,+ fighters,+ [effect("type.all.damage.up", "attacker", 20), effect("type.all.damage.up", "attacker", 25)],+ { trace: true }+ );++ assert.equal(combined.trace?.atomicBuckets["type.all.damage.up"].totalPct, 50);+ assert.equal(combined.trace?.atomicBuckets["type.all.damage.up"].contributors.length, 2);+ assert.equal(combined.trace?.aggregationGroups["type.all.damage.up"].factor, 1.5);+ assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.25) < 1e-12);+});++test("damage-taken buckets use the expected damage direction", () => {+ const fighters = simpleFighters();+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });+ const damageTakenUp = calculateIndexedDamageJob(job, fighters, [effect("active.hero.damageTaken.up", "defender", 25)], { trace: true });+ const damageTakenDown = calculateIndexedDamageJob(job, fighters, [effect("active.hero.damageTaken.down", "defender", 25)], { trace: true });++ assert.equal(damageTakenUp.trace?.aggregationGroups["active.hero.damageTaken.up"].placement, "numerator");+ assert.equal(damageTakenDown.trace?.aggregationGroups["active.hero.damageTaken.down"].placement, "denominator");+ assert.ok(Math.abs(damageTakenUp.kills - baseline.kills * 1.25) < 1e-12);+ assert.ok(Math.abs(damageTakenDown.kills - baseline.kills / 1.25) < 1e-12);+});+test("negative passive stat bonuses route to down buckets with positive factors", () => {const passiveHealthDown = {...effect("passive.health.down", "defender", 105),diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -350,6 +350,7 @@const index = BUCKET_IDS[bucketName];const definition = BUCKET_DEFINITIONS[bucketName];if (definition.update === "assign_factor") buckets.factors[index] = Math.max(0, value);+ else if (definition.update === "multiply_pct_factor") buckets.factors[index] *= 1 + value / 100;else buckets.factors[index] += value / 100;if (effectId) buckets.contributors?.[index].push({ effectId, source, sourceSide, valuePct: value, bucket: traceBucketName, stackingKey, sameEffectStacking });return value;diff --git a/simulator/src/damageBuckets.test.ts b/simulator/src/damageBuckets.test.ts--- a/simulator/src/damageBuckets.test.ts+++ b/simulator/src/damageBuckets.test.ts@@ -2,12 +2,35 @@import { test } from "node:test";import { createFastDamageScratch } from "./damage";-import { ATOMIC_BUCKETS } from "./damageBuckets";+import { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";-const REMOVED_ACTIVE_BUCKET_PATTERN = new RegExp(`^active\\.(${["hero", "troop"].join("|")})\\.(${["dam", "age"].join("")}|${["dam", "age", "Taken"].join("")})\\.`);+test("active hero and troop damage aliases are supported all-damage buckets", () => {+ for (const bucket of [+ "active.hero.damage.up",+ "active.hero.damage.down",+ "active.hero.damageTaken.up",+ "active.hero.damageTaken.down",+ "active.troop.damage.up",+ "active.troop.damage.down",+ "active.troop.damageTaken.up",+ "active.troop.damageTaken.down"+ ]) {+ assert.equal(ATOMIC_BUCKETS.includes(bucket), true, bucket);+ assert.equal(bucketDefinition(bucket)?.appliesTo, undefined, bucket);+ }+ assert.equal(bucketDefinition("active.hero.damage.up")?.placement, "numerator");+ assert.equal(bucketDefinition("active.hero.damage.down")?.placement, "denominator");+ assert.equal(bucketDefinition("active.hero.damageTaken.up")?.placement, "numerator");+ assert.equal(bucketDefinition("active.hero.damageTaken.down")?.placement, "denominator");+});-test("active hero and troop damage aliases are not supported buckets", () => {- assert.equal(ATOMIC_BUCKETS.some((bucket) => REMOVED_ACTIVE_BUCKET_PATTERN.test(bucket)), false);+test("type all damage buckets multiply percentage factors", () => {+ assert.equal(bucketDefinition("type.all.damage.up")?.update, "multiply_pct_factor");+ assert.equal(bucketDefinition("type.all.damage.up")?.placement, "numerator");+ assert.equal(bucketDefinition("type.all.damage.up")?.appliesTo, undefined);+ assert.equal(bucketDefinition("type.all.damage.down")?.update, "multiply_pct_factor");+ assert.equal(bucketDefinition("type.all.damage.down")?.placement, "denominator");+ assert.equal(bucketDefinition("type.all.damage.down")?.appliesTo, undefined);});test("damage scratch stores one factor value per atomic bucket", () => {diff --git a/simulator/src/damageBuckets.ts b/simulator/src/damageBuckets.ts--- a/simulator/src/damageBuckets.ts+++ b/simulator/src/damageBuckets.ts@@ -2,7 +2,7 @@export type BucketRole = "attacker" | "defender";export type BucketValueType = "raw" | "pct";-export type BucketUpdate = "assign_factor" | "add_pct_factor";+export type BucketUpdate = "assign_factor" | "add_pct_factor" | "multiply_pct_factor";export type BucketPlacement = "numerator" | "denominator";// "static" pools are closed at compile time (base/player/passive): every contributor is// known at battle start, so they are aggregated once by the static damage profile.@@ -71,6 +71,14 @@health: {up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ },+ damage: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ damageTaken: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }}},troop: {@@ -89,10 +97,24 @@health: {up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ },+ damage: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ damageTaken: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }}}},type: {+ all: {+ damage: {+ up: { role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" }+ }+ },normal: {damage: {up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },diff --git a/simulator/src/index.ts b/simulator/src/index.ts--- a/simulator/src/index.ts+++ b/simulator/src/index.ts@@ -1,6 +1,7 @@export { buildSimulatorConfig, loadSimulatorConfig } from "./config";export type { RawSimulatorConfig } from "./config";-export { simulateBattle, signedRemainingScore } from "./simulator";+export { simulateBattle, prepareBattle, runPrepared, signedRemainingScore } from "./simulator";+export type { CompiledBattle } from "./simulator";export { BattleInputBuilder } from "./battleInputBuilder";export { applyHeroGenerationStats } from "./resolve";export { discoverTestcaseFiles, runTestcases, adaptTestcaseEntry } from "./testcases";diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -84,7 +84,38 @@}export function simulateBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}): BattleResult {- const run = runBattle(input, config, options);+ return buildBattleResult(runBattle(input, config, options));+}++/**+ * A resolved battle ready to run many times with different seeds. When battle-start is deterministic,+ * the entire pre-loop runtime (`template`) is seed-independent and built once; each run clones its+ * mutable state and reuses the skills + static profile by reference. For stochastic battle-start the+ * template is absent and runs reuse only the resolved fighters.+ */+export interface CompiledBattle {+ input: BattleInput;+ config: SimulatorConfig;+ fighters: Record<SideId, ResolvedFighter>;+ template?: Runtime;+ deterministicBattleStart: boolean;+}++export function prepareBattle(input: BattleInput, config: SimulatorConfig): CompiledBattle {+ const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);+ const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);+ const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };+ const deterministicBattleStart = !buildRuntimeSkills([attacker, defender]).battleStart.some(hasChanceTrigger);+ // Deterministic battle-start => the entire pre-loop runtime is seed-independent; build it once.+ const template = deterministicBattleStart ? setupRuntime(fighters, input, "simulator-prepare") : undefined;+ return { input, config, fighters, template, deterministicBattleStart };+}++export function runPrepared(compiled: CompiledBattle, seed?: string | number, options: SimulationOptions = {}): BattleResult {+ return buildBattleResult(runBattle({ ...compiled.input, seed }, compiled.config, options, compiled));+}++function buildBattleResult(run: BattleRun): BattleResult {const { fighters, runtime } = run;return {winner: run.winner,@@ -104,20 +135,78 @@};}-function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions): BattleRun {- const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);- const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);- const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"));- const mode = options.mode ?? "standard";- const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(attacker, defender));- const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;+// Reuse a prepared fighter's immutable resolution; only the troop counts mutate during a run.+function cloneFighterForRun(fighter: ResolvedFighter): ResolvedFighter {+ return { ...fighter, troops: { ...fighter.initialTroops } };+}+// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage+// profile, and drop static-profile effects from the per-job index.+function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);addInputPassiveEffects(runtime, input.attacker.passive, "attacker");addInputPassiveEffects(runtime, input.defender.passive, "defender");runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);removeStaticProfileBucketEffects(runtime.effectIndex);+ return runtime;+}++// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)+// and the index rebuilt from the clones; skills and the static profile are shared by reference.+function cloneRuntime(template: Runtime, rng: Rng): Runtime {+ const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));+ const effectIndex = createEffectIndex();+ for (const effect of activeEffects) indexEffect(effectIndex, effect);+ removeStaticProfileBucketEffects(effectIndex);+ return {+ activeEffects,+ effectIndex,+ staticDamageProfile: template.staticDamageProfile,+ damageScratch: createFastDamageScratch(),+ rng,+ skills: template.skills,+ skillReports: cloneSkillReports(template.skillReports),+ effectActivationCounts: { ...template.effectActivationCounts },+ extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },+ attackControlCounts: { ...template.attackControlCounts },+ consumedEffectUseKeys: new Set(),+ counters: {+ attacks: { attacker: { ...template.counters.attacks.attacker }, defender: { ...template.counters.attacks.defender } },+ received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }+ }+ };+}++function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {+ const cloneSide = (side: Map<string, SkillReportEntry>): Map<string, SkillReportEntry> => {+ const out = new Map<string, SkillReportEntry>();+ for (const [key, entry] of side) out.set(key, { ...entry, unsupportedEffects: [...entry.unsupportedEffects] });+ return out;+ };+ return { attacker: cloneSide(reports.attacker), defender: cloneSide(reports.defender) };+}++function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions, prepared?: CompiledBattle): BattleRun {+ if (prepared?.template) {+ const fighters: Record<SideId, ResolvedFighter> = {+ attacker: cloneFighterForRun(prepared.fighters.attacker),+ defender: cloneFighterForRun(prepared.fighters.defender)+ };+ const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));+ return runLoop(input, fighters, runtime, options);+ }+ const attacker = prepared ? cloneFighterForRun(prepared.fighters.attacker) : resolveFighter(input.attacker, "attacker", config, input.engagement_type);+ const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);+ const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default");+ return runLoop(input, fighters, runtime, options);+}++function runLoop(input: BattleInput, fighters: Record<SideId, ResolvedFighter>, runtime: Runtime, options: SimulationOptions): BattleRun {+ const mode = options.mode ?? "standard";+ const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(fighters.attacker, fighters.defender));+ const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;let rounds = 0;for (let round = 1; round <= maxRounds; round += 1) {diff --git a/simulator/src/testcases.test.ts b/simulator/src/testcases.test.ts--- a/simulator/src/testcases.test.ts+++ b/simulator/src/testcases.test.ts@@ -237,13 +237,13 @@assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);});-test("runTestcases skips stat rounding correction for stochastic misses", () => {+test("runTestcases applies stat rounding correction for stochastic misses", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "greg_mia_combo", repeat: 5, calibrationReportPath: "/tmp/does-not-exist.json" }, config);const summaries = Object.values(report.testcases);assert.ok(summaries.some((summary) => summary.deterministic === false && summary.game?.passes === false));- assert.deepEqual(summaries.map((summary) => summary.gameStatAdjustment), summaries.map(() => undefined));+ assert.ok(summaries.some((summary) => summary.deterministic === false && summary.gameStatAdjustment));});test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {diff --git a/simulator/src/testcases.ts b/simulator/src/testcases.ts--- a/simulator/src/testcases.ts+++ b/simulator/src/testcases.ts@@ -10,7 +10,7 @@sampleStats} from "./calibration";import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";-import { simulateBattle } from "./simulator";+import { simulateBattle, prepareBattle, runPrepared } from "./simulator";import { DamageAggregationError } from "./damage";import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "./types";@@ -581,7 +581,11 @@const samples: number[] = [];const sampleOutcomes: TestcaseSampleOutcome[] = [];const sampleDeltas: number[] = [];- let result = simulateBattle(sampleInput(job.input, job.seed, job.file, job.testcaseId, job.index, 0), config);+ // Resolve the battle once and reuse it across every seeded sample of this case.+ const compiled = prepareBattle(job.input, config);+ const baseSeed = job.seed ?? job.input.seed;+ const sample = (iteration: number) => runPrepared(compiled, sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration));+ let result = sample(0);const firstScore = battleScoreDelta(result);if (firstScore !== undefined) {samples.push(firstScore);@@ -590,7 +594,7 @@}const sampleCount = result.randomness.deterministic ? 1 : job.repeat;for (let iteration = 1; iteration < sampleCount; iteration += 1) {- result = simulateBattle(sampleInput(job.input, job.seed, job.file, job.testcaseId, job.index, iteration), config);+ result = sample(iteration);const score = battleScoreDelta(result);if (score !== undefined) {samples.push(score);