← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error0.00%
Changed0
Improved0
Regressed0
Added0
Retired0
Testcase Delta
267 / 267
Code / Config Changes
Commits (e1679b69 → 92a8e53d)
92a8e53dtidy uppiddlyminx06/08/2026, 16:47:07
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Blanchette.json===================================================================--- simulator/config/hero_definitions/Blanchette.json prev run+++ simulator/config/hero_definitions/Blanchette.json this run@@ -0,0 +1,86 @@+{+ "name": "Blanchette",+ "hero_generation": "S10",+ "troop_type": "marksman",+ "skills": {+ "ArmedToTheTeeth": {+ "description": "Blanchette increases all Troops' Lethality by X%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "ArmedToTheTeeth/1": {+ "type": "active.hero.lethality.up",+ "value": [5, 10, 15, 20, 25]+ }+ }+ },+ "BloodHunter": {+ "description": "Blanchette's Marksmen fire a crystal blade every 3 rounds, dealing X% extra damage to their target.",+ "trigger": {+ "type": "turn",+ "every": 3,+ "source": "marksman"+ },+ "effects": {+ "BloodHunter/1": {+ "type": "extra_skill_attack",+ "value": [15, 30, 45, 60, 75],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": { "attacks": { "count": 1 } },+ "trigger_damage_jobs": [+ { "source": "use.source", "target": "use.target" }+ ]+ }+ }+ },+ "CrimsonSniper": {+ "description": "Every 2 strikes, Blanchette's Marksmen deal X% extra damage to enemy Lancers and Y% extra damage to enemy Marksmen.",+ "trigger": {+ "type": "attack",+ "every": 2,+ "source": "marksman"+ },+ "effects": {+ "CrimsonSniper/1": {+ "type": "extra_skill_attack",+ "value": [8, 16, 24, 32, 40],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "any"+ },+ "duration": { "attacks": { "count": 1 } },+ "trigger_damage_jobs": [+ { "source": "use.source", "target": ["lancer"] }+ ]+ },+ "CrimsonSniper/2": {+ "type": "extra_skill_attack",+ "value": [4, 8, 12, 16, 20],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "any"+ },+ "duration": { "attacks": { "count": 1 } },+ "trigger_damage_jobs": [+ { "source": "use.source", "target": ["marksman"] }+ ]+ }+ }+ },+ "LightningStrike": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when rallying.",+ "trigger": { "type": "pre_battle" },+ "requirements": [+ { "level": 1, "type": "engagement_type", "value": "rally" }+ ],+ "effects": {+ "LightningStrike/1": {+ "type": "passive.lethality.up",+ "value": [5, 7.5, 10, 12.5, 15]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Fred.json===================================================================--- simulator/config/hero_definitions/Fred.json prev run+++ simulator/config/hero_definitions/Fred.json this run@@ -0,0 +1,71 @@+{+ "name": "Fred",+ "hero_generation": "S9",+ "troop_type": "lancer",+ "skills": {+ "HydraulicSuppression": {+ "description": "Fred's water volleys destroy opponent momentum, reducing all enemy troops' Lethality by X%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "HydraulicSuppression/1": {+ "type": "active.hero.lethality.down",+ "value": [4, 8, 12, 16, 20],+ "units": { "applies_to": "enemy.any" }+ }+ }+ },+ "Acidification": {+ "description": "Fred coats enemy Infantry shields with a special acidic blend, amplifying their damage taken by X%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "Acidification/1": {+ "type": "active.hero.damageTaken.up",+ "value": [4, 8, 12, 16, 20],+ "units": { "applies_to": "enemy.infantry" }+ }+ }+ },+ "Floodbringer": {+ "description": "Fred's Lancers deal X% additional damage every 4 strikes and reduce enemy troop damage dealt by Y% on the next turn.",+ "trigger": {+ "type": "attack",+ "every": 4,+ "source": "lancer"+ },+ "effects": {+ "Floodbringer/1": {+ "type": "extra_skill_attack",+ "value": [40, 80, 120, 160, 200],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": { "attacks": { "count": 1 } },+ "trigger_damage_jobs": [+ { "source": "use.source", "target": "use.target" }+ ]+ },+ "Floodbringer/2": {+ "type": "active.hero.damage.down",+ "value": [4, 8, 12, 16, 20],+ "units": { "applies_to": "enemy.any" },+ "duration": { "turns": { "delay": 1, "count": 1 } },+ "same_effect_stacking": "max"+ }+ }+ },+ "CallOfTheFirefighter": {+ "description": "Widget bonus: Increase Attack by X% for all troops when rallying.",+ "trigger": { "type": "pre_battle" },+ "requirements": [+ { "level": 1, "type": "engagement_type", "value": "rally" }+ ],+ "effects": {+ "CallOfTheFirefighter/1": {+ "type": "passive.attack.up",+ "value": [5, 7.5, 10, 12.5, 15]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Freya.json===================================================================--- simulator/config/hero_definitions/Freya.json prev run+++ simulator/config/hero_definitions/Freya.json this run@@ -0,0 +1,69 @@+{+ "name": "Freya",+ "hero_generation": "S10",+ "troop_type": "lancer",+ "skills": {+ "FogOfWar": {+ "description": "Freya reduces all enemy Troops' Attack by X%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "FogOfWar/1": {+ "type": "active.hero.attack.down",+ "value": [4, 8, 12, 16, 20],+ "units": { "applies_to": "enemy.any" }+ }+ }+ },+ "BloodMoonScythe": {+ "description": "After her Lancers launch a normal attack, Freya has a 50% chance of performing Reap for X% damage.",+ "trigger": {+ "type": "attack",+ "probability": [50, 50, 50, 50, 50],+ "source": "lancer"+ },+ "effects": {+ "BloodMoonScythe/1": {+ "type": "extra_skill_attack",+ "value": [20, 40, 60, 80, 100],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": { "attacks": { "count": 1 } },+ "trigger_damage_jobs": [+ { "source": "use.source", "target": "use.target" }+ ]+ }+ }+ },+ "NightsVengeance": {+ "description": "Freya reduces damage taken by X% and increases damage dealt by X% for friendly Infantry and Marksmen.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "NightsVengeance/1": {+ "type": "active.hero.damageTaken.down",+ "value": [3, 6, 9, 12, 15],+ "units": { "applies_to": ["infantry", "marksman"] }+ },+ "NightsVengeance/2": {+ "type": "active.hero.damage.up",+ "value": [3, 6, 9, 12, 15],+ "units": { "applies_to": ["infantry", "marksman"] }+ }+ }+ },+ "DefenderOfTheWatch": {+ "description": "Widget bonus: Increase Defense by X% for all troops when defending.",+ "trigger": { "type": "pre_battle" },+ "requirements": [+ { "level": 1, "type": "engagement_type", "value": "garrison" }+ ],+ "effects": {+ "DefenderOfTheWatch/1": {+ "type": "passive.defense.up",+ "value": [5, 7.5, 10, 12.5, 15]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Gregory.json===================================================================--- simulator/config/hero_definitions/Gregory.json prev run+++ simulator/config/hero_definitions/Gregory.json this run@@ -0,0 +1,66 @@+{+ "name": "Gregory",+ "hero_generation": "S10",+ "troop_type": "infantry",+ "skills": {+ "LegionOfTheSun": {+ "description": "Gregory increases Attack by X% and Defense by Y% for all troops.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "LegionOfTheSun/1": {+ "type": "active.hero.attack.up",+ "value": [3, 6, 9, 12, 15]+ },+ "LegionOfTheSun/2": {+ "type": "active.hero.defense.up",+ "value": [2, 4, 6, 8, 10]+ }+ }+ },+ "ChargedAssault": {+ "description": "Gregory grants all troops' normal attacks an X% chance of dealing critical damage.",+ "trigger": {+ "type": "attack",+ "probability": [5, 10, 15, 20, 25]+ },+ "effects": {+ "ChargedAssault/1": {+ "type": "extra_skill_attack",+ "value": [100, 100, 100, 100, 100],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": { "attacks": { "count": 1 } },+ "trigger_damage_jobs": [+ { "source": "use.source", "target": "use.target" }+ ]+ }+ }+ },+ "Unbroken": {+ "description": "Gregory reduces Infantry Damage Taken by X%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "Unbroken/1": {+ "type": "active.hero.damageTaken.down",+ "value": [4, 8, 12, 16, 20],+ "units": { "applies_to": ["infantry"] }+ }+ }+ },+ "DayOfTheGuard": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending.",+ "trigger": { "type": "pre_battle" },+ "requirements": [+ { "level": 1, "type": "engagement_type", "value": "garrison" }+ ],+ "effects": {+ "DayOfTheGuard/1": {+ "type": "passive.lethality.up",+ "value": [5, 7.5, 10, 12.5, 15]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Magnus.json===================================================================--- simulator/config/hero_definitions/Magnus.json prev run+++ simulator/config/hero_definitions/Magnus.json this run@@ -0,0 +1,63 @@+{+ "name": "Magnus",+ "hero_generation": "S9",+ "troop_type": "infantry",+ "skills": {+ "Rapacious": {+ "description": "Magnus rouses his troops with brutal intensity, boosting friendly Troop Attack by X%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "Rapacious/1": {+ "type": "active.hero.attack.up",+ "value": [5, 10, 15, 20, 25]+ }+ }+ },+ "IronPhalanx": {+ "description": "Infantry under Magnus' command have a 40% chance of gaining X% Defense when attacking for 1 turn.",+ "trigger": {+ "type": "attack",+ "probability": [40, 40, 40, 40, 40],+ "source": "infantry"+ },+ "effects": {+ "IronPhalanx/1": {+ "type": "active.hero.defense.up",+ "value": [10, 20, 30, 40, 50],+ "units": { "applies_to": "trigger.source" },+ "duration": { "turns": { "count": 1 } },+ "same_effect_stacking": "max"+ }+ }+ },+ "Iceman": {+ "description": "Magnus reduces damage taken by friendly Infantry by X% while boosting friendly Marksmen damage by X%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "Iceman/1": {+ "type": "active.hero.damageTaken.down",+ "value": [2, 4, 6, 8, 10],+ "units": { "applies_to": ["infantry"] }+ },+ "Iceman/2": {+ "type": "active.hero.damage.up",+ "value": [2, 4, 6, 8, 10],+ "units": { "applies_to": ["marksman"] }+ }+ }+ },+ "ValoricInspiration": {+ "description": "Widget bonus: Increase Health by X% for all troops when defending.",+ "trigger": { "type": "pre_battle" },+ "requirements": [+ { "level": 1, "type": "engagement_type", "value": "garrison" }+ ],+ "effects": {+ "ValoricInspiration/1": {+ "type": "passive.health.up",+ "value": [5, 7.5, 10, 12.5, 15]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/README.md===================================================================--- simulator/config/hero_definitions/README.md prev run+++ simulator/config/hero_definitions/README.md this run@@ -60,9 +60,13 @@### Filename, `name`, aliases, and registrationThe filename without `.json` is the definition's primary config key. The filename, `name`, and every entry in `aliases` can all resolve the hero after normalization. For example, `WuMing.json` can have `"name": "Wu Ming"`; both resolve to the same definition.-The Node directory loader discovers JSON files automatically. The browser-safe/default config in `simulator/src/config.ts` uses explicit JSON imports, so adding a new file also requires importing it and adding it to `DEFAULT_HERO_DEFINITIONS`.+This directory is the sole hero catalogue. Adding, changing, or removing a JSON+file requires no separate registration step. Standalone Node/TSX programs read+the directory from disk when they load the simulator config. The dashboard's+Webpack build discovers the same files and bundles them; `next dev` also watches+the directory, while production picks up changes on the next image build.### `hero_generation` is opt-in build-time data`hero_generation` is data, not an unconditional core-simulator effect. The core combat resolver copies the string into resolved output but does not add the associated stats by itself.@@ -512,9 +516,9 @@The engine accepts some values not currently used by a hero. This inventory distinguishes the data that exists in this directory from the wider native effect vocabulary documented above.| Field | Values currently present || --- | --- |-| `hero_generation` | `SR`, `S1`, `S1_natalia`, `S1_jeronimo`, `S2` through `S8` |+| `hero_generation` | `SR`, `S1`, `S1_natalia`, `S1_jeronimo`, `S2` through `S10` || `troop_type` | `infantry`, `lancer`, `marksman` || `requirements[].type/value` | `engagement_type` with `rally` or `garrison`, always beginning at level 1 || `trigger.type` | `pre_battle`, `battle_start`, `turn`, `attack` || `trigger.source` | omitted, `infantry`, `lancer`, `marksman`, `self.any`, `self.all`, `enemy.any` |Index: simulator/config/hero_definitions/Xura.json===================================================================--- simulator/config/hero_definitions/Xura.json prev run+++ simulator/config/hero_definitions/Xura.json this run@@ -0,0 +1,76 @@+{+ "name": "Xura",+ "hero_generation": "S9",+ "troop_type": "marksman",+ "skills": {+ "FungalFog": {+ "description": "Xura releases an underground fungi that quickly multiplies to block enemy vision, reducing damage dealt to friendly troops by X%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "FungalFog/1": {+ "type": "active.hero.damage.down",+ "value": [4, 8, 12, 16, 20],+ "units": { "applies_to": "enemy.any" }+ }+ }+ },+ "PiercingArrow": {+ "description": "Xura's Marksmen deal X% additional damage every 2 strikes and make their target take Y% more damage for 1 turn.",+ "trigger": {+ "type": "attack",+ "every": 2,+ "source": "marksman"+ },+ "effects": {+ "PiercingArrow/1": {+ "type": "extra_skill_attack",+ "value": [20, 40, 60, 80, 100],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": { "attacks": { "count": 1 } },+ "trigger_damage_jobs": [+ { "source": "use.source", "target": "use.target" }+ ]+ },+ "PiercingArrow/2": {+ "type": "active.hero.damageTaken.up",+ "value": [5, 10, 15, 20, 25],+ "units": { "applies_to": "trigger.target" },+ "duration": { "turns": { "count": 1 } },+ "same_effect_stacking": "max"+ }+ }+ },+ "Unorthodoxy": {+ "description": "Xura increases Marksmen damage dealt by X% while reducing their damage taken by Y%.",+ "trigger": { "type": "battle_start" },+ "effects": {+ "Unorthodoxy/1": {+ "type": "active.hero.damage.up",+ "value": [3, 6, 9, 12, 15],+ "units": { "applies_to": ["marksman"] }+ },+ "Unorthodoxy/2": {+ "type": "active.hero.damageTaken.down",+ "value": [2, 4, 6, 8, 10],+ "units": { "applies_to": ["marksman"] }+ }+ }+ },+ "GaiacHymn": {+ "description": "Widget bonus: Increase Attack by X% for all troops when defending.",+ "trigger": { "type": "pre_battle" },+ "requirements": [+ { "level": 1, "type": "engagement_type", "value": "garrison" }+ ],+ "effects": {+ "GaiacHymn/1": {+ "type": "passive.attack.up",+ "value": [5, 7.5, 10, 12.5, 15]+ }+ }+ }+ }+}Index: simulator/config/hero_generation_stats.json===================================================================--- simulator/config/hero_generation_stats.json prev run+++ simulator/config/hero_generation_stats.json this run@@ -61,6 +61,18 @@"attack": 780.62,"defense": 780.62,"lethality": 193,"health": 193+ },+ "S9": {+ "attack": 940.75,+ "defense": 940.75,+ "lethality": 232,+ "health": 232+ },+ "S10": {+ "attack": 1110.88,+ "defense": 1110.88,+ "lethality": 277.5,+ "health": 277.5}}Index: simulator/src/config-default.ts===================================================================--- simulator/src/config-default.ts prev run+++ simulator/src/config-default.ts this run@@ -0,0 +1,3 @@+// Standalone Node/TSX consumers resolve this module directly. Next's Webpack+// build replaces it with config-webpack.ts so browser bundles never import fs.+export { loadSimulatorConfig } from "./config-node";Index: simulator/src/config-node.ts===================================================================--- simulator/src/config-node.ts prev run+++ simulator/src/config-node.ts this run@@ -1,10 +1,19 @@import { readdirSync, readFileSync } from "node:fs";import { join, relative, resolve } from "node:path";+import { fileURLToPath } from "node:url";import { buildSimulatorConfig, type RawSimulatorConfig } from "./config";import type { SimulatorConfig, SkillFile } from "./types";+export const DEFAULT_CONFIG_DIR = fileURLToPath(+ new URL("../config", import.meta.url),+);++export function loadSimulatorConfig(): SimulatorConfig {+ return loadSimulatorConfigFromDir(DEFAULT_CONFIG_DIR);+}+export function loadSimulatorConfigFromDir(configDir: string): SimulatorConfig {const root = resolve(configDir);const heroDir = join(root, "hero_definitions");const heroDefinitions: Record<string, SkillFile> = {};Index: simulator/src/config-webpack.ts===================================================================--- simulator/src/config-webpack.ts prev run+++ simulator/src/config-webpack.ts this run@@ -0,0 +1,39 @@+import heroGenerationStatsJson from "../config/hero_generation_stats.json" with { type: "json" };+import troopSkillsJson from "../config/troop_skills.json" with { type: "json" };++import { buildSimulatorConfig } from "./config";+import type { SimulatorConfig, SkillFile } from "./types";++const heroDefinitionModules = import.meta.webpackContext(+ "../config/hero_definitions",+ {+ recursive: false,+ regExp: /\.json$/,+ mode: "sync",+ },+);++function moduleDefault(value: unknown): unknown {+ if (!value || typeof value !== "object" || !("default" in value)) {+ return value;+ }+ return (value as { default: unknown }).default;+}++function loadHeroDefinitions(): Record<string, SkillFile> {+ return Object.fromEntries(+ heroDefinitionModules.keys().map((modulePath) => {+ const name = modulePath.replace(/^\.\//, "").replace(/\.json$/, "");+ return [name, moduleDefault(heroDefinitionModules(modulePath)) as SkillFile];+ }),+ );+}++export function loadSimulatorConfig(): SimulatorConfig {+ return buildSimulatorConfig({+ heroGenerationStats:+ heroGenerationStatsJson as SimulatorConfig["heroGenerationStats"],+ troopSkills: troopSkillsJson as SkillFile,+ heroDefinitions: loadHeroDefinitions(),+ });+}Index: simulator/src/config.test.ts===================================================================--- simulator/src/config.test.ts prev run+++ simulator/src/config.test.ts this run@@ -4,10 +4,12 @@import { test } from "node:test";import { tmpdir } from "node:os";import { fileURLToPath } from "node:url";-import { loadSimulatorConfig } from "./config";-import { loadSimulatorConfigFromDir } from "./config-node";+import {+ loadSimulatorConfig,+ loadSimulatorConfigFromDir,+} from "./config-node";import type { SkillFile } from "./types";test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];Index: simulator/src/config.ts===================================================================--- simulator/src/config.ts prev run+++ simulator/src/config.ts this run@@ -1,40 +1,4 @@-import heroGenerationStatsJson from "../config/hero_generation_stats.json" with { type: "json" };-import troopSkillsJson from "../config/troop_skills.json" with { type: "json" };-import Ahmose from "../config/hero_definitions/Ahmose.json" with { type: "json" };-import Alonso from "../config/hero_definitions/Alonso.json" with { type: "json" };-import Bahiti from "../config/hero_definitions/Bahiti.json" with { type: "json" };-import Bradley from "../config/hero_definitions/Bradley.json" with { type: "json" };-import Edith from "../config/hero_definitions/Edith.json" with { type: "json" };-import Flint from "../config/hero_definitions/Flint.json" with { type: "json" };-import Gatot from "../config/hero_definitions/Gatot.json" with { type: "json" };-import Gordon from "../config/hero_definitions/Gordon.json" with { type: "json" };-import Greg from "../config/hero_definitions/Greg.json" with { type: "json" };-import Gwen from "../config/hero_definitions/Gwen.json" with { type: "json" };-import Hector from "../config/hero_definitions/Hector.json" with { type: "json" };-import Hendrik from "../config/hero_definitions/Hendrik.json" with { type: "json" };-import Jasser from "../config/hero_definitions/Jasser.json" with { type: "json" };-import Jeronimo from "../config/hero_definitions/Jeronimo.json" with { type: "json" };-import Jessie from "../config/hero_definitions/Jessie.json" with { type: "json" };-import Ling from "../config/hero_definitions/Ling.json" with { type: "json" };-import Logan from "../config/hero_definitions/Logan.json" with { type: "json" };-import Lumak from "../config/hero_definitions/Lumak.json" with { type: "json" };-import Lynn from "../config/hero_definitions/Lynn.json" with { type: "json" };-import Mia from "../config/hero_definitions/Mia.json" with { type: "json" };-import Molly from "../config/hero_definitions/Molly.json" with { type: "json" };-import Natalia from "../config/hero_definitions/Natalia.json" with { type: "json" };-import Norah from "../config/hero_definitions/Norah.json" with { type: "json" };-import Patrick from "../config/hero_definitions/Patrick.json" with { type: "json" };-import Philly from "../config/hero_definitions/Philly.json" with { type: "json" };-import Reina from "../config/hero_definitions/Reina.json" with { type: "json" };-import Renee from "../config/hero_definitions/Renee.json" with { type: "json" };-import SeoYoon from "../config/hero_definitions/Seo-yoon.json" with { type: "json" };-import Sergey from "../config/hero_definitions/Sergey.json" with { type: "json" };-import Sonya from "../config/hero_definitions/Sonya.json" with { type: "json" };-import Wayne from "../config/hero_definitions/Wayne.json" with { type: "json" };-import WuMing from "../config/hero_definitions/WuMing.json" with { type: "json" };-import Zinman from "../config/hero_definitions/Zinman.json" with { type: "json" };-import { UNIT_TYPES } from "./types";import type { ConfigDiagnostics, EffectIntentDefinition, SimulatorConfig, SkillFile, TriggerDamageJobDefinition } from "./types";import {DYNAMIC_EFFECT_BUCKETS,@@ -54,59 +18,15 @@"no_attack","attack_order"]);-const DEFAULT_HERO_DEFINITIONS = {- Ahmose,- Alonso,- Bahiti,- Bradley,- Edith,- Flint,- Gatot,- Gordon,- Greg,- Gwen,- Hector,- Hendrik,- Jasser,- Jeronimo,- Jessie,- Ling,- Logan,- Lumak,- Lynn,- Mia,- Molly,- Natalia,- Norah,- Patrick,- Philly,- Reina,- Renee,- "Seo-yoon": SeoYoon,- Sergey,- Sonya,- Wayne,- WuMing,- Zinman-} as unknown as Record<string, SkillFile>;-export interface RawSimulatorConfig {heroGenerationStats: SimulatorConfig["heroGenerationStats"];troopSkills: SkillFile;heroDefinitions: Record<string, SkillFile>;fileLabel?: (kind: "hero_generation_stats" | "troop_skills" | "hero_definition", key?: string) => string;}-export function loadSimulatorConfig(): SimulatorConfig {- return buildSimulatorConfig({- heroGenerationStats: heroGenerationStatsJson as SimulatorConfig["heroGenerationStats"],- troopSkills: troopSkillsJson as SkillFile,- heroDefinitions: DEFAULT_HERO_DEFINITIONS- });-}-export function buildSimulatorConfig(raw: RawSimulatorConfig): SimulatorConfig {const diagnostics: ConfigDiagnostics = {legacyFields: [],effectTypes: {},Index: simulator/src/gen8Heroes.test.ts===================================================================--- simulator/src/gen8Heroes.test.ts prev run+++ simulator/src/gen8Heroes.test.ts this run@@ -1,408 +0,0 @@-import assert from "node:assert/strict";-import { test } from "node:test";--import { loadSimulatorConfig } from "./config";-import { resolveFighter } from "./fighterResolution";-import { simulateBattles } from "./simulator";-import type { BattleInput } from "./types";--test("Gen8 heroes are registered with the shared S8 expedition stats", () => {- const config = loadSimulatorConfig();-- assert.deepEqual(config.heroGenerationStats.S8, {- attack: 780.62,- defense: 780.62,- lethality: 193,- health: 193- });- assert.deepEqual(- ["Gatot", "Sonya", "Hendrik"].map((name) => [name, config.heroDefinitions[name]?.hero_generation]),- [- ["Gatot", "S8"],- ["Sonya", "S8"],- ["Hendrik", "S8"]- ]- );-});--test("Gen8 widget skills are fourth, passive, and engagement-gated", () => {- const config = loadSimulatorConfig();- const expectations = [- ["Gatot", "IndestructibleCity", "garrison", "passive.defense.up"],- ["Sonya", "VortexTurret", "garrison", "passive.lethality.up"],- ["Hendrik", "AbyssalBlessing", "rally", "passive.attack.up"]- ] as const;-- for (const [heroName, skillName, engagement, effectType] of expectations) {- const definition = config.heroDefinitions[heroName];- const skillEntries = Object.entries(definition.skills);- assert.equal(skillEntries.length, 4);- assert.equal(skillEntries[3][0], skillName);- assert.equal(Object.values(skillEntries[3][1].effects)[0].type, effectType);- assert.deepEqual(skillEntries[3][1].requirements, [- { level: 1, type: "engagement_type", value: engagement }- ]);-- const matchingSide = engagement === "rally" ? "attacker" : "defender";- const gatedOutSide = engagement === "rally" ? "defender" : "attacker";- const matching = resolveFighter(- { troops: { infantry_t1: 1 }, heroes: { [heroName]: { skill_4: 5 } } },- matchingSide,- config,- "rally"- );- const gatedOut = resolveFighter(- { troops: { infantry_t1: 1 }, heroes: { [heroName]: { skill_4: 5 } } },- gatedOutSide,- config,- "rally"- );- assert.deepEqual(matching.heroes[0]?.skillIds, [skillName]);- assert.deepEqual(gatedOut.heroes[0]?.skillIds, []);- }-});--test("Gen8 combat skill values and active effect buckets match their definitions", () => {- const config = loadSimulatorConfig();- const gatot = config.heroDefinitions.Gatot.skills;- const sonya = config.heroDefinitions.Sonya.skills;- const hendrik = config.heroDefinitions.Hendrik.skills;-- assert.deepEqual(gatot.GoldenGuard.effects["GoldenGuard/1"].value, [6, 12, 18, 24, 30]);- assert.equal(gatot.GoldenGuard.effects["GoldenGuard/1"].type, "active.hero.defense.up");- assert.deepEqual(gatot.KingsBestowal.effects["KingsBestowal/1"].value, [6, 12, 18, 24, 30]);- assert.deepEqual(gatot.KingsBestowal.effects["KingsBestowal/1"], {- type: "active.hero.shield",- value: [6, 12, 18, 24, 30],- value_formula: { type: "percent_of", source: "trigger.source_attack" },- units: { applies_to: "trigger.source", applies_vs: "enemy.any" },- duration: { turns: { delay: 1, count: 1 } },- same_effect_stacking: "max"- });- assert.deepEqual(gatot.RoyalLegion.effects["RoyalLegion/1"].value, [5, 10, 15, 20, 25]);- assert.equal(gatot.RoyalLegion.effects["RoyalLegion/1"].type, "active.hero.attack.down");-- assert.deepEqual(sonya.TreasureHunter.effects["TreasureHunter/1"].value, [4, 8, 12, 16, 20]);- assert.equal(sonya.BountyTemptation.effects["BountyTemptation/1"].type, "extra_skill_attack");- assert.deepEqual(sonya.BountyTemptation.effects["BountyTemptation/1"].value, [15, 30, 45, 60, 75]);- assert.deepEqual(sonya.BountyTemptation.effects["BountyTemptation/1"].trigger_damage_jobs, [- { source: "use.source", target: "use.target" }- ]);- assert.equal(sonya.BountyTemptation.effects["BountyTemptation/2"].type, "active.hero.attack.up");- assert.deepEqual(sonya.TorrentialImpact.effects["TorrentialImpact/1"].value, [50, 100, 150, 200, 250]);- assert.equal(sonya.TorrentialImpact.effects["TorrentialImpact/1"].type, "extra_skill_attack");- assert.deepEqual(sonya.TorrentialImpact.effects["TorrentialImpact/1"].trigger_damage_jobs, [- { source: "use.source", target: "use.target" }- ]);- assert.equal(sonya.TorrentialImpact.effects["TorrentialImpact/2"].type, "no_attack");-- assert.deepEqual(hendrik.WormsRavage.effects["WormsRavage/1"].value, [5, 10, 15, 20, 25]);- assert.equal(hendrik.WormsRavage.effects["WormsRavage/1"].type, "active.hero.defense.down");- assert.deepEqual(hendrik.ArmorOfBarnacles.effects["ArmorOfBarnacles/1"].value, [6, 12, 18, 24, 30]);- assert.equal(hendrik.ArmorOfBarnacles.effects["ArmorOfBarnacles/1"].type, "active.hero.defense.up");- assert.deepEqual(hendrik.DragonsHeir.effects["DragonsHeir/1"].value, [8, 16, 24, 32, 40]);- assert.equal(hendrik.DragonsHeir.effects["DragonsHeir/1"].type, "extra_skill_attack");- assert.deepEqual(hendrik.DragonsHeir.effects["DragonsHeir/1"].trigger_damage_jobs, [- { source: "use.source", target: "enemy.living" }- ]);-});--test("Gatot converts source formation Attack into next-turn shield protection", () => {- const config = loadSimulatorConfig();- const input = gatotInfantryBattle(5000, 1000, 2);- const result = simulateBattles(input, config, { mode: "trace" })[0]!;- const roundTwoLeftAttack = result.attacks.find(- (attack) => attack.round === 2 && attack.kind === "normal" && attack.dealerSide === "attacker"- );- const rightShield = roundTwoLeftAttack?.appliedEffects?.find((effect) => "kind" in effect && effect.kind === "shield");- const expectedRightShield = Math.sqrt(1000) * (243 * (1 + 326.1 / 100)) / (730 * (1 + 18.2 / 100)) * 0.12;-- assert.ok(rightShield && "value" in rightShield);- assert.ok(Math.abs(rightShield.value - expectedRightShield) < 1e-12);- const unroundedDamage = Math.max(0, (roundTwoLeftAttack?.trace?.damageBeforeOffsets ?? 0) - expectedRightShield);- assert.equal(roundTwoLeftAttack?.kills, Math.ceil(unroundedDamage * 1000) / 1000);-});--test("Gatot caps the shield formation count at the smaller initial army", () => {- const config = loadSimulatorConfig();- const cases = [- { attacker: 450, defender: 200, attackerRemaining: 173, defenderRemaining: 200 },- { attacker: 2500, defender: 500, attackerRemaining: 974, defenderRemaining: 498 }- ] as const;-- for (const expected of cases) {- const result = simulateBattles(- gatotReciprocalMagnitudeBattle(expected.attacker, expected.defender),- config,- { mode: "trace" }- )[0]!;- const roundTwoDefenderAttack = result.attacks.find(- (attack) =>- attack.round === 2 &&- attack.kind === "normal" &&- attack.dealerSide === "defender"- );- const shield = roundTwoDefenderAttack?.appliedEffects?.find(- (effect) => "kind" in effect && effect.kind === "shield"- );- const expectedShield =- Math.sqrt(expected.defender) *- (243 * (1 + 472.7 / 100)) /- (730 * (1 + 164 / 100)) *- 0.06;-- assert.ok(shield && "value" in shield);- assert.ok(Math.abs(shield.value - expectedShield) < 1e-12);- assert.deepEqual(- {- rounds: result.rounds,- attackerRemaining: result.remaining.attacker.infantry,- defenderRemaining: result.remaining.defender.infantry- },- {- rounds: 1500,- attackerRemaining: expected.attackerRemaining,- defenderRemaining: expected.defenderRemaining- }- );- }-});--test("Gatot shields normal and genuine extra-skill attacks", () => {- const config = loadSimulatorConfig();- const input: BattleInput = {- maxRounds: 4,- seed: "gatot-all-attack-shield",- attacker: {- troops: { infantry_t6: 200 },- stats: {- infantry: { attack: 530.8, defense: 527.5, lethality: 218.4, health: 219.6 }- },- heroes: { Gatot: { skill_1: 1, skill_2: 3, skill_3: 1 } }- },- defender: {- troops: { infantry_t6: 450 },- stats: {- infantry: { attack: 472.6, defense: 471.9, lethality: 147.5, health: 160.9 }- },- heroes: {- Gatot: { skill_1: 1, skill_2: 1, skill_3: 1 },- Wayne: { skill_1: 3, skill_2: 3, skill_3: 3 }- }- }- };-- const result = simulateBattles(input, config, { mode: "trace" })[0]!;- const roundFourNormal = result.attacks.find(- (attack) =>- attack.round === 4 &&- attack.kind === "normal" &&- attack.dealerSide === "defender" &&- attack.dealerUnit === "infantry"- );- const thunderStrike = result.attacks.find(- (attack) =>- attack.round === 4 &&- attack.kind === "skill" &&- attack.dealerSide === "defender" &&- attack.sourceEffectId === "ThunderStrike/1"- );-- assert.ok(roundFourNormal?.appliedEffects?.some((effect) => "kind" in effect && effect.kind === "shield"));- assert.ok(thunderStrike?.appliedEffects?.some((effect) => "kind" in effect && effect.kind === "shield"));- assert.equal(roundFourNormal?.kills, 0);- assert.equal(thunderStrike?.kills, 0);-});--test("Gatot source-Attack shields reproduce the observed army-size threshold", () => {- const config = loadSimulatorConfig();- const expectations = [- { left: 4900, right: 1000, winner: "draw", rounds: 1500, leftLosses: 3, rightLosses: 4 },- { left: 5100, right: 1000, winner: "attacker", rounds: 1378, leftLosses: 3, rightLosses: 1000 },- { left: 10000, right: 2000, winner: "attacker", rounds: 452, leftLosses: 14, rightLosses: 2000 },- { left: 20000, right: 4000, winner: "attacker", rounds: 342, leftLosses: 541, rightLosses: 4000 }- ] as const;-- for (const expected of expectations) {- const result = simulateBattles(gatotInfantryBattle(expected.left, expected.right), config, { mode: "fast" })[0]!;- assert.deepEqual(- {- winner: result.winner,- rounds: result.rounds,- leftLosses: expected.left - result.remaining.attacker.infantry,- rightLosses: expected.right - result.remaining.defender.infantry- },- {- winner: expected.winner,- rounds: expected.rounds,- leftLosses: expected.leftLosses,- rightLosses: expected.rightLosses- }- );- }-});--test("Gatot conserves one complete turn shield across enemy formations", () => {- const input: BattleInput = {- maxRounds: 1500,- seed: "10024f8d-b3e3-423a-832c-5176cfbbbd7d",- attacker: {- troops: { infantry_t6: 5000 },- stats: { infantry: { attack: 295.3, defense: 293.3, lethality: 10, health: 10 } },- heroes: { Gatot: { skill_1: 1, skill_2: 3, skill_3: 3 } }- },- defender: {- troops: { infantry_t6: 1000, lancer_t6: 10 },- stats: {- infantry: { attack: 326.1, defense: 330.1, lethality: 18.2, health: 18.2 },- lancer: { attack: 284.8, defense: 288.8, lethality: 18.2, health: 18.2 }- },- heroes: {- Gatot: { skill_1: 2, skill_2: 2, skill_3: 2 },- Gordon: { skill_1: 3, skill_2: 3, skill_3: 3 }- }- }- };-- const result = simulateBattles(input, loadSimulatorConfig(), { mode: "fast" })[0]!;-- assert.deepEqual(- { winner: result.winner, rounds: result.rounds, leftInfantry: result.remaining.attacker.infantry },- { winner: "attacker", rounds: 1036, leftInfantry: 4996 }- );- assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });-});--test("Gatot turn-shield pooling reproduces the mixed-formation family", () => {- const config = loadSimulatorConfig();- const expectations = [- {- infantry: 1000,- lancer: 1000,- marksman: 0,- winner: "defender",- rounds: 266,- attackerSurvivors: 0,- defenderSurvivors: 4972- },- {- infantry: 1000,- lancer: 0,- marksman: 1000,- winner: "defender",- rounds: 278,- attackerSurvivors: 0,- defenderSurvivors: 4487- },- {- infantry: 2000,- lancer: 1000,- marksman: 0,- winner: "defender",- rounds: 546,- attackerSurvivors: 0,- defenderSurvivors: 4332- },- {- infantry: 1000,- lancer: 1000,- marksman: 1000,- winner: "defender",- rounds: 239,- attackerSurvivors: 0,- defenderSurvivors: 3052- },- {- infantry: 2000,- lancer: 0,- marksman: 1000,- winner: "attacker",- rounds: 895,- attackerSurvivors: 1337,- defenderSurvivors: 0- }- ] as const;-- for (const expected of expectations) {- const result = simulateBattles(- gatotMixedBattle(expected.infantry, expected.lancer, expected.marksman),- config,- { mode: "fast" }- )[0]!;- const attackerSurvivors = Object.values(result.remaining.attacker).reduce((sum, count) => sum + count, 0);- const defenderSurvivors = Object.values(result.remaining.defender).reduce((sum, count) => sum + count, 0);- assert.deepEqual(- {- winner: result.winner,- rounds: result.rounds,- attackerSurvivors,- defenderSurvivors- },- {- winner: expected.winner,- rounds: expected.rounds,- attackerSurvivors: expected.attackerSurvivors,- defenderSurvivors: expected.defenderSurvivors- }- );- }-});--function gatotInfantryBattle(left: number, right: number, maxRounds = 1500): BattleInput {- return {- maxRounds,- seed: "gatot-source-attack-shield",- attacker: {- troops: { infantry_t6: left },- stats: { infantry: { attack: 285.3, defense: 283.3, lethality: 0, health: 0 } },- heroes: { Gatot: { skill_1: 1, skill_2: 2, skill_3: 3 } }- },- defender: {- troops: { infantry_t6: right },- stats: { infantry: { attack: 326.1, defense: 330.1, lethality: 18.2, health: 18.2 } },- heroes: { Gatot: { skill_1: 2, skill_2: 2, skill_3: 2 } }- }- };-}--function gatotReciprocalMagnitudeBattle(attacker: number, defender: number): BattleInput {- return {- maxRounds: 1500,- seed: `gatot-reciprocal-magnitude-${attacker}-${defender}`,- attacker: {- troops: { infantry_t6: attacker },- stats: {- infantry: { attack: 472.7, defense: 472, lethality: 150.6, health: 164 }- },- heroes: { Gatot: { skill_1: 1, skill_2: 1, skill_3: 1 } }- },- defender: {- troops: { infantry_t6: defender },- stats: {- infantry: { attack: 530.8, defense: 527.5, lethality: 218.4, health: 219.6 }- },- heroes: { Gatot: { skill_1: 1, skill_2: 3, skill_3: 1 } }- }- };-}--function gatotMixedBattle(infantry: number, lancer: number, marksman: number): BattleInput {- return {- maxRounds: 1500,- seed: `gatot-mixed-${infantry}-${lancer}-${marksman}`,- attacker: {- troops: { infantry_t6: infantry, lancer_t6: lancer, marksman_t6: marksman },- stats: {- infantry: { attack: 326.1, defense: 330.1, lethality: 18.2, health: 18.2 },- lancer: { attack: 78.1, defense: 82.1, lethality: 18.2, health: 18.2 },- marksman: { attack: 78.1, defense: 82.1, lethality: 18.2, health: 18.2 }- },- heroes: { Gatot: { skill_1: 2, skill_2: 2, skill_3: 2 } }- },- defender: {- troops: { infantry_t6: 5000 },- stats: { infantry: { attack: 295.3, defense: 293.3, lethality: 10, health: 10 } },- heroes: { Gatot: { skill_1: 1, skill_2: 3, skill_3: 3 } }- }- };-}Index: simulator/src/index.ts===================================================================--- simulator/src/index.ts prev run+++ simulator/src/index.ts this run@@ -1,9 +0,0 @@-export { buildSimulatorConfig, loadSimulatorConfig } from "./config";-export type { RawSimulatorConfig } from "./config";-export { createTroopStatsRecord, fireCrystalMultiplier, generateTroopStats, generateTroopStatsCatalogue } from "./troopStats";-export { prepareBattle } from "./prepare";-export type { CompiledBattle } from "./prepare";-export { runPrepared, simulateBattles } from "./simulator";-export { BattleInputBuilder } from "./battleInputBuilder";-export { applyHeroGenerationStats } from "./fighterResolution";-export type * from "./types";Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -3,9 +3,9 @@import { test } from "node:test";import { fileURLToPath } from "node:url";import { BattleInputBuilder } from "./battleInputBuilder";-import { loadSimulatorConfig } from "./config";+import { loadSimulatorConfig } from "./config-node";import { createSeededRng, chancePasses, compiledTriggerForSkill } from "./effects";import { applyHeroGenerationStats, resolveFighter } from "./fighterResolution";import { prepareBattle, runPrepared, simulateBattles, simulateBearBattle, signedRemainingScore } from "./simulator";import { BEAR_TROOP_ID, createTroopStatsRecord, generateTroopStatsCatalogue } from "./troopStats";Index: simulator/src/tooling/exportGatotTestcases.ts===================================================================--- simulator/src/tooling/exportGatotTestcases.ts prev run+++ simulator/src/tooling/exportGatotTestcases.ts this run@@ -1,9 +1,9 @@import { mkdirSync, writeFileSync } from "node:fs";import { resolve } from "node:path";import { pathToFileURL } from "node:url";-import { loadSimulatorConfig } from "../config";+import { loadSimulatorConfig } from "../config-node";import type { BattleInput, SimulatorConfig } from "../types";import { GATOT_GAME_OBSERVATIONS, type GatotGameObservation } from "./gatotEvidence";export interface GatotTestcaseArtifact {Index: simulator/src/tooling/gatotEvidence.test.ts===================================================================--- simulator/src/tooling/gatotEvidence.test.ts prev run+++ simulator/src/tooling/gatotEvidence.test.ts this run@@ -1,8 +1,8 @@import assert from "node:assert/strict";import { test } from "node:test";-import { loadSimulatorConfig } from "../config";+import { loadSimulatorConfig } from "../config-node";import {GATOT_GAME_OBSERVATIONS,renderGatotEvidenceMarkdown,runGatotEvidence,Index: simulator/src/tooling/gatotEvidence.ts===================================================================--- simulator/src/tooling/gatotEvidence.ts prev run+++ simulator/src/tooling/gatotEvidence.ts this run@@ -1,8 +1,8 @@import { pathToFileURL } from "node:url";import { BattleInputBuilder } from "../battleInputBuilder";-import { loadSimulatorConfig } from "../config";+import { loadSimulatorConfig } from "../config-node";import { simulateBattles } from "../simulator";import type {BattleInput,BattleResult,Index: simulator/src/tooling/testcases.test.ts===================================================================--- simulator/src/tooling/testcases.test.ts prev run+++ simulator/src/tooling/testcases.test.ts this run@@ -3,9 +3,9 @@import { tmpdir } from "node:os";import { resolve } from "node:path";import { test } from "node:test";-import { loadSimulatorConfig } from "../config";+import { loadSimulatorConfig } from "../config-node";import { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";import { adaptTestcaseEntry, applyComparisonQValues, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, deterministicRoundTolerancePct, discoverTestcaseFiles, runTestcases, type TestcaseSummaryEntry } from "./testcases";Index: simulator/src/webpack-context.d.ts===================================================================--- simulator/src/webpack-context.d.ts prev run+++ simulator/src/webpack-context.d.ts this run@@ -0,0 +1,19 @@+interface WebpackContext {+ (request: string): unknown;+ keys(): string[];+ resolve(request: string): string | number;+ id: string | number;+}++interface ImportMeta {+ webpackContext(+ directory: string,+ options?: {+ recursive?: boolean;+ regExp?: RegExp;+ include?: RegExp;+ exclude?: RegExp;+ mode?: "sync" | "eager" | "weak" | "lazy" | "lazy-once";+ },+ ): WebpackContext;+}