← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error-0.00%
Changed7
Improved5
Regressed2
Added0
Retired1
Testcase Delta
288 / 288
Code / Config Changes
Commits (11747ac3 → 71303048)
71303048update testcase reportingpiddlyminx25/08/2026, 08:05:01
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Ahmose.json===================================================================--- simulator/config/hero_definitions/Ahmose.json prev run+++ simulator/config/hero_definitions/Ahmose.json this run@@ -39,13 +39,11 @@]},"duration": {"turns": {- "count": 2,- "delay": 1+ "count": 2}- },- "same_effect_stacking": "max"+ }},"ViperFormation/3": {"type": "active.hero.damageTaken.down","value": [@@ -61,13 +59,11 @@]},"duration": {"turns": {- "count": 2,- "delay": 1+ "count": 2}- },- "same_effect_stacking": "max"+ }}}},"PrayerOfFlame": {Index: simulator/config/hero_definitions/Blanchette.json===================================================================--- simulator/config/hero_definitions/Blanchette.json prev run+++ simulator/config/hero_definitions/Blanchette.json this run@@ -1,8 +1,7 @@{"name": "Blanchette","hero_generation": "S10",- "experimental": true,"troop_type": "marksman","skills": {"ArmedToTheTeeth": {"description": "Blanchette increases all Troops' Lethality by X%.",Index: simulator/config/hero_definitions/Fred.json===================================================================--- simulator/config/hero_definitions/Fred.json prev run+++ simulator/config/hero_definitions/Fred.json this run@@ -1,8 +1,7 @@{"name": "Fred","hero_generation": "S9",- "experimental": true,"troop_type": "lancer","skills": {"HydraulicSuppression": {"description": "Fred's water volleys destroy opponent momentum, reducing all enemy troops' Lethality by X%.",Index: simulator/config/hero_definitions/Freya.json===================================================================--- simulator/config/hero_definitions/Freya.json prev run+++ simulator/config/hero_definitions/Freya.json this run@@ -1,8 +1,7 @@{"name": "Freya","hero_generation": "S10",- "experimental": true,"troop_type": "lancer","skills": {"FogOfWar": {"description": "Freya reduces all enemy Troops' Attack by X%.",Index: simulator/config/hero_definitions/Gregory.json===================================================================--- simulator/config/hero_definitions/Gregory.json prev run+++ simulator/config/hero_definitions/Gregory.json this run@@ -1,8 +1,7 @@{"name": "Gregory","hero_generation": "S10",- "experimental": true,"troop_type": "infantry","skills": {"LegionOfTheSun": {"description": "Gregory increases Attack by X% and Defense by Y% for all troops.",Index: simulator/config/hero_definitions/Gwen.json===================================================================--- simulator/config/hero_definitions/Gwen.json prev run+++ simulator/config/hero_definitions/Gwen.json this run@@ -5,9 +5,10 @@"skills": {"EagleVision": {"description": "Increasing target's damage taken by X%","trigger": {- "type": "battle_start"+ "type": "attack",+ "source": "self.any"},"effects": {"EagleVision/1": {"type": "active.hero.damageTaken.up",@@ -19,8 +20,14 @@25],"units": {"applies_to": "enemy.any"+ },+ "duration": {+ "attacks": {+ "count": 1,+ "delay": 1+ }}}}},Index: simulator/config/hero_definitions/Magnus.json===================================================================--- simulator/config/hero_definitions/Magnus.json prev run+++ simulator/config/hero_definitions/Magnus.json this run@@ -1,8 +1,7 @@{"name": "Magnus","hero_generation": "S9",- "experimental": true,"troop_type": "infantry","skills": {"Rapacious": {"description": "Magnus rouses his troops with brutal intensity, boosting friendly Troop Attack by X%.",Index: simulator/config/hero_definitions/Norah.json===================================================================--- simulator/config/hero_definitions/Norah.json prev run+++ simulator/config/hero_definitions/Norah.json this run@@ -72,8 +72,13 @@"lancer"],"applies_vs": "any"},+ "duration": {+ "turns": {+ "count": 1+ }+ },"trigger_damage_jobs": [{"source": "use.source","target": "enemy.living"@@ -104,10 +109,9 @@"applies_vs": "trigger.target"},"duration": {"turns": {- "count": 2,- "delay": 1+ "count": 2}}},"Momentum/2": {@@ -120,10 +124,9 @@25],"duration": {"turns": {- "count": 2,- "delay": 1+ "count": 2}}}}Index: simulator/config/hero_definitions/README.md===================================================================--- simulator/config/hero_definitions/README.md prev run+++ simulator/config/hero_definitions/README.md this run@@ -68,10 +68,10 @@For callers constructing a raw `BattleInput`, generation stats are added only when they explicitly use `applyHeroGenerationStats(...)` or `BattleInputBuilder.addHeroGenerationStats()`. Tournament input builders do this. The dashboard also reads `hero_generation` directly when adding or subtracting hero base stats in its form state, so dashboard inputs can already contain those stats before reaching the core simulator.When enabled:-- the stat block from `hero_generation_stats.json` is added to every troop type's player stats;-- generation stats from all **main** heroes are summed;+- each main hero's generation stat block is added only to the player-stat block for that hero's troop type;+- generation stats from multiple main heroes of the same troop type are summed in that troop type's block;- joiner heroes do not contribute generation stats;- skill level does not change the generation stat block.A `hero_generation` value with no matching entry in `hero_generation_stats.json` is reported in `config.diagnostics.unsupportedEffects` as `missing_hero_generation`. Config loading still succeeds; helpers that look up the missing block add zeroes.Index: simulator/config/hero_definitions/Xura.json===================================================================--- simulator/config/hero_definitions/Xura.json prev run+++ simulator/config/hero_definitions/Xura.json this run@@ -1,8 +1,7 @@{"name": "Xura","hero_generation": "S9",- "experimental": true,"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%.",Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -61,8 +61,31 @@assert.throws(() => simulateBattles(input, config, { count: 0 }), /positive integer/i);assert.throws(() => simulateBattles(input, config, { count: 1.5 }), /positive integer/i);});+test("runPrepared terminates immediately when either or both armies start empty", () => {+ const config = loadSimulatorConfig();+ const empty: FighterInput = { troops: {}, heroes: {} };+ const occupied: FighterInput = { troops: { infantry_t6: 20 }, heroes: {} };++ const emptyAttacker = runOnce({ attacker: empty, defender: occupied }, config);+ assert.equal(emptyAttacker.winner, "defender");+ assert.equal(emptyAttacker.rounds, 0);+ assert.equal(emptyAttacker.attacks.length, 0);+ assert.equal(emptyAttacker.remaining.defender.infantry, 20);++ const emptyDefender = runOnce({ attacker: occupied, defender: empty }, config);+ assert.equal(emptyDefender.winner, "attacker");+ assert.equal(emptyDefender.rounds, 0);+ assert.equal(emptyDefender.attacks.length, 0);+ assert.equal(emptyDefender.remaining.attacker.infantry, 20);++ const bothEmpty = runOnce({ attacker: empty, defender: empty }, config);+ assert.equal(bothEmpty.winner, "draw");+ assert.equal(bothEmpty.rounds, 0);+ assert.equal(bothEmpty.attacks.length, 0);+});+test("runPrepared returns structured result for a no-hero battle", () => {const config = loadSimulatorConfig();const result = runOnce({@@ -499,12 +522,17 @@assert.equal(result.trace?.rounds[1]?.roundStartTroops.defender.lancer.toFixed(6), "198.013242");});test("runPrepared defaults to a 1500 round cap when no explicit maxRounds is provided", () => {+ const stalemate: FighterInput = {+ troops: { infantry_t1: 1 },+ stats: { inf: { attack: 0, defense: 1_000_000, lethality: 0, health: 1_000_000 } },+ heroes: {}+ };const result = runOnce({- attacker: { troops: {}, heroes: {} },- defender: { troops: {}, heroes: {} }+ attacker: stalemate,+ defender: stalemate},minimalConfig());Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -577,13 +577,14 @@if (!controlEffects.includes(winningControl)) used.push(winningControl);chargeUsedEffects(runtime);}-function winnerFor(troops: Record<SideId, Record<UnitType, number>>): SideId | undefined {+function winnerFor(troops: Record<SideId, Record<UnitType, number>>): SideId | "draw" | undefined {const attackerAlive = total(troops.attacker) > 0;const defenderAlive = total(troops.defender) > 0;if (attackerAlive && !defenderAlive) return "attacker";if (defenderAlive && !attackerAlive) return "defender";+ if (!attackerAlive && !defenderAlive) return "draw";return undefined;}// Signed battle outcome: positive = attacker survivors, negative = defender survivors, 0 = draw.Index: simulator/src/tooling/testcases.test.ts===================================================================--- simulator/src/tooling/testcases.test.ts prev run+++ simulator/src/tooling/testcases.test.ts this run@@ -6,9 +6,9 @@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";+import { adaptTestcaseEntry, applyComparisonQValues, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, deterministicRoundTolerancePct, discoverTestcaseFiles, runTestcases, testcaseArmiesFromEntry, type TestcaseSummaryEntry } from "./testcases";test("discoverTestcaseFiles follows simulator/testcases symlink and skips disabled or stale files by default", () => {const files = discoverTestcaseFiles();@@ -45,10 +45,45 @@assert.equal("result" in (summary as object), false);assert.ok(detail?.result);assert.equal(detail?.simulatorStats?.n, 5);assert.ok(detail?.visibility.attacker.troops.lancer);+ assert.deepEqual(summary?.armies?.attacker.troops, { lancer_t8: 200 });+ assert.deepEqual(summary?.armies?.defender.troops, { lancer_t9: 200 });+ assert.deepEqual(detail?.armies, summary?.armies);});+test("testcase artifacts retain exact troop tiers and hero skill levels", () => {+ const armies = testcaseArmiesFromEntry({+ attacker: {+ heroes: { Mia: { skill_1: 2, skill_2: 3, skill_3: 4 } },+ joiner_heroes: { Jessie: { skill_1: 5, skill_2: 5 } },+ troops: { infantry_t10_fc5: 12_345, lancer_t9: 678 },+ },+ defender: {+ heroes: {},+ joiner_heroes: {},+ troops: { marksman_t11_fc9: 9_876 },+ },+ });++ assert.deepEqual(armies.attacker.heroes.Mia, {+ skill_1: 2,+ skill_2: 3,+ skill_3: 4,+ });+ assert.deepEqual(armies.attacker.joinerHeroes.Jessie, {+ skill_1: 5,+ skill_2: 5,+ });+ assert.deepEqual(armies.attacker.troops, {+ infantry_t10_fc5: 12_345,+ lancer_t9: 678,+ });+ assert.deepEqual(armies.defender.troops, {+ marksman_t11_fc9: 9_876,+ });+});+test("runTestcases defaults stochastic cases to 100 samples", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001" }, config);const summary = Object.values(report.testcases)[0];@@ -255,17 +290,8 @@assert.equal(fourth?.game?.passes, true);assert.equal(fifth?.game?.passes, true);});-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.ok(summaries.some((summary) => summary.deterministic === false && summary.gameStatAdjustment));-});-test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "1-testcases_no-heroes_t6_single-type_nc.json", repeat: 1 }, config);const entry = report.details.find((item) => item.testcaseId === "daut_viper_9");Index: simulator/src/tooling/testcases.ts===================================================================--- simulator/src/tooling/testcases.ts prev run+++ simulator/src/tooling/testcases.ts this run@@ -29,14 +29,27 @@seed?: string | number;workers?: number;}+export interface TestcaseArmyDefinition {+ heroes: Record<string, Record<string, number>>;+ joinerHeroes: Record<string, Record<string, number>>;+ troops: Record<string, number>;+}++export interface TestcaseArmies {+ attacker: TestcaseArmyDefinition;+ defender: TestcaseArmyDefinition;+}+export interface TestcaseCaseReport {file: string;testcaseId: string;index: number;detailArtifact?: string;diagnostics: string[];+ armies?: TestcaseArmies;+ armiesSource?: "testcase" | "retained-result";gameResult?: unknown;calibration?: CalibrationCaseComparison;result?: BattleResult;simulatorScoreDelta?: number;@@ -67,8 +80,10 @@file: string;testcase_id: string;idx: number;detailArtifact?: string;+ armies?: TestcaseArmies;+ armiesSource?: "testcase" | "retained-result";deterministic: boolean;sampleCount: number;game: ParityComparisonMetrics | null;baseline: ParityComparisonMetrics | null;@@ -204,9 +219,9 @@entries.forEach((entry, index) => {const testcaseId = testcaseIdFor(entry, index);const diagnostics: string[] = [];- const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);+ const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics, entry);const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };try {preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed }, diagnostics);preparedCase.key = snapshotKey(reportFile, index);@@ -401,8 +416,10 @@report.testcases[preparedCase.key!] = {file: reportFile,testcase_id: testcaseId,idx: index,+ armies: detail.armies,+ armiesSource: detail.armiesSource,deterministic: execution.deterministic,sampleCount: execution.sampleCount,game,baseline: null,@@ -735,21 +752,70 @@files.push(path);}}-function emptyCaseReport(file: string, testcaseId: string, index: number, diagnostics: string[]): TestcaseCaseReport {+function emptyCaseReport(+ file: string,+ testcaseId: string,+ index: number,+ diagnostics: string[],+ entry: unknown,+): TestcaseCaseReport {return {file,testcaseId,index,diagnostics,+ armies: testcaseArmiesFromEntry(entry),+ armiesSource: "testcase",visibility: {attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }}};}+export function testcaseArmiesFromEntry(entry: unknown): TestcaseArmies {+ const testcase = asObject(entry);+ return {+ attacker: armyDefinition(testcase.attacker),+ defender: armyDefinition(testcase.defender),+ };+}++function armyDefinition(value: unknown): TestcaseArmyDefinition {+ const army = asObject(value);+ const heroes: Record<string, Record<string, number>> = {};+ const joinerHeroes: Record<string, Record<string, number>> = {};+ for (const [name, skills] of Object.entries(asObject(army.heroes))) {+ heroes[name] = numericRecord(skills);+ }+ for (const [name, skills] of Object.entries(asObject(army.joiner_heroes))) {+ joinerHeroes[name] = numericRecord(skills);+ }+ return {+ heroes,+ joinerHeroes,+ troops: numericRecord(army.troops),+ };+}++function numericRecord(value: unknown): Record<string, number> {+ const numbers: Record<string, number> = {};+ for (const [key, candidate] of Object.entries(asObject(value))) {+ if (typeof candidate === "number" && Number.isFinite(candidate)) {+ numbers[key] = candidate;+ }+ }+ return numbers;+}++function asObject(value: unknown): Record<string, unknown> {+ return value && typeof value === "object" && !Array.isArray(value)+ ? value as Record<string, unknown>+ : {};+}+function testcaseIdFor(entry: unknown, index: number): string {const id = (entry as { test_id?: unknown; id?: unknown }).test_id ?? (entry as { id?: unknown }).id;return id === undefined ? `case_${index}` : String(id);}Index: testcases/emulator_verified/greg_mia_combo.json===================================================================--- testcases/emulator_verified/greg_mia_combo.json prev run+++ testcases/emulator_verified/greg_mia_combo.json this run@@ -1,99 +0,0 @@-[- {- "test_id": "greg_mia_combo",- "description": "Greg (Infantry class) + Mia (Lancer class) combo defender \u2014 both have chance-based skills (Greg S1 20%, Mia S2 50%/S3 40%). Run 5+ battles for statistical confidence. Tests chance-skill stacking across classes. WIP attacks clean; minxxx defends.",- "attacker": {- "name": "[BBQ]XxWIPxX",- "heroes": {},- "troops": {- "infantry_t6": 1500,- "lancer_t6": 1069,- "marksman_t6": 1500- },- "stats": {- "inf": {- "attack": 140.7,- "defense": 148.2,- "lethality": 117.0,- "health": 131.9- },- "lanc": {- "attack": 149.2,- "defense": 148.2,- "lethality": 116.5,- "health": 105.1- },- "mark": {- "attack": 153.7,- "defense": 143.2,- "lethality": 121.9,- "health": 112.3- }- },- "joiner_heroes": {}- },- "defender": {- "name": "[ARK]Piddlyminxxx",- "heroes": {- "Mia": {- "skill_1": 2,- "skill_2": 2,- "skill_3": 2- },- "Greg": {- "skill_1": 1,- "skill_2": 1,- "skill_3": 0- }- },- "troops": {- "infantry_t6": 200,- "lancer_t6": 200,- "marksman_t6": 200- },- "stats": {- "inf": {- "attack": 245.9,- "defense": 240.7,- "lethality": 177.2,- "health": 183.3- },- "lanc": {- "attack": 324.9,- "defense": 325.7,- "lethality": 176.0,- "health": 176.5- },- "mark": {- "attack": 325.0,- "defense": 319.8,- "lethality": 181.7,- "health": 176.5- }- },- "joiner_heroes": {}- },- "game_report_result": [- {- "attacker": 3426,- "defender": 0- },- {- "attacker": 3404,- "defender": 0- },- {- "attacker": 3427,- "defender": 0- },- {- "attacker": 3450,- "defender": 0- },- {- "attacker": 3457,- "defender": 0- }- ]- }-]
Show raw per-run patches
Run B dirty state patch
diff --git a/simulator/config/hero_definitions/README.md b/simulator/config/hero_definitions/README.md--- a/simulator/config/hero_definitions/README.md+++ b/simulator/config/hero_definitions/README.md@@ -69,8 +69,8 @@When enabled:-- the stat block from `hero_generation_stats.json` is added to every troop type's player stats;-- generation stats from all **main** heroes are summed;+- each main hero's generation stat block is added only to the player-stat block for that hero's troop type;+- generation stats from multiple main heroes of the same troop type are summed in that troop type's block;- joiner heroes do not contribute generation stats;- skill level does not change the generation stat block.