← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.24%
Avg Error B0.20%
Δ Avg Error-0.04%
Improved8
Regressed19
Added8
Retired1
Testcase Delta
178 / 178
Code / Config Changes
Commits (3a1224f → 5c70816e)
5c70816eAdd testcase stat rounding correctionpiddlyminx09/06/2026, 04:45:23
Code Changes (Run A → Run B)
Index: shared/fighters_data/fighters_heroes.json===================================================================--- shared/fighters_data/fighters_heroes.json prev run+++ shared/fighters_data/fighters_heroes.json this run@@ -0,0 +1,586 @@+{+ "max": {+ "Jessie": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5+ }+ },+ "Jasser": {+ "skill_levels": {+ "skill_1": 5+ }+ },+ "Sergey": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5+ }+ },+ "Molly": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Zinman": {+ "skill_levels": {+ "skill_1": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Jeronimo": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Natalia": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Bahiti": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5+ }+ },+ "Seo-yoon": {+ "skill_levels": {+ "skill_1": 5+ }+ },+ "Lumak": {+ "skill_levels": {+ "skill_1": 5+ }+ },+ "Ling": {+ "skill_levels": {+ "skill_1": 5+ }+ },+ "Patrick": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5+ }+ },+ "Flint": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Philly": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Alonso": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Logan": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Mia": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Greg": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Ahmose": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Lynn": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Reina": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Hector": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Norah": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Gwen": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Wayne": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Renee": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Wu Ming": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Edith": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Gordon": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ },+ "Bradley": {+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5,+ "skill_4": 5+ }+ }+ },+ "daut": {+ "Jessie": {+ "stats": {+ "attack": 33.46,+ "defense": 33.46+ },+ "skill_levels": {+ "skill_1": 2,+ "skill_2": 2+ }+ },+ "Jasser": {+ "stats": {+ "attack": 50.96,+ "defense": 50.96,+ "lethality": 3.66,+ "health": 3.66+ },+ "skill_levels": {+ "skill_1": 2+ }+ },+ "Sergey": {+ "stats": {+ "attack": 33.46,+ "defense": 33.46,+ "lethality": 5.33,+ "health": 5.33+ },+ "skill_levels": {+ "skill_1": 2,+ "skill_2": 2+ }+ },+ "Molly": {+ "stats": {+ "attack": 47.81,+ "defense": 47.81+ },+ "skill_levels": {+ "skill_1": 2,+ "skill_2": 2+ }+ },+ "Logan": {+ "stats": {+ "attack": 59.78,+ "defense": 59.78+ },+ "skill_levels": {+ "skill_1": 2,+ "skill_2": 2+ }+ },+ "Seo-yoon": {+ "stats": {+ "attack": 47.74,+ "defense": 47.74+ },+ "skill_levels": {+ "skill_1": 3+ }+ },+ "Lumak": {+ "stats": {+ "attack": 44.52,+ "defense": 44.52+ },+ "skill_levels": {+ "skill_1": 2+ }+ },+ "Alonso": {+ "stats": {+ "attack": 76.32,+ "defense": 76.32+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ },+ "Bahiti": {+ "stats": {+ "attack": 40.37,+ "defense": 40.37+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 1+ }+ },+ "Mia": {+ "stats": {+ "attack": 36.61,+ "defense": 36.61+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 0,+ "skill_3": 0+ }+ },+ "Ling": {+ "stats": {+ "attack": 17.67,+ "defense": 17.67+ },+ "skill_levels": {+ "skill_1": 1+ }+ },+ "Patrick": {+ "stats": {+ "attack": 17.67,+ "defense": 17.67+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 0+ }+ },+ "Flint": {+ "stats": {+ "attack": 30.3,+ "defense": 30.3+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 0,+ "skill_3": 0+ }+ }+ },+ "viper": {+ "Hector": {+ "stats": {+ "attack": 253.65,+ "defense": 253.65,+ "lethality": 20.33,+ "health": 118.43+ },+ "skill_levels": {+ "skill_1": 4,+ "skill_2": 3,+ "skill_3": 3+ }+ },+ "Molly": {+ "stats": {+ "attack": 147.79,+ "defense": 147.79,+ "lethality": 74.32,+ "health": 18.17+ },+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 4,+ "skill_3": 5+ }+ },+ "Bahiti": {+ "stats": {+ "attack": 109.77,+ "defense": 109.77,+ "lethality": 45.65,+ "health": 13.17+ },+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 4+ }+ },+ "Sergey": {+ "stats": {+ "attack": 116.09,+ "defense": 116.09,+ "lethality": 5.33,+ "health": 18.36+ },+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 5+ }+ },+ "Flint": {+ "stats": {+ "attack": 129.37,+ "defense": 129.37+ },+ "skill_levels": {+ "skill_1": 4,+ "skill_2": 4,+ "skill_3": 4+ }+ },+ "Mia": {+ "stats": {+ "attack": 74.09,+ "defense": 74.09+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 0+ }+ },+ "Seo-yoon": {+ "stats": {+ "attack": 70.95,+ "defense": 70.95,+ "lethality": 2.66+ },+ "skill_levels": {+ "skill_1": 1+ }+ },+ "Jasser": {+ "stats": {+ "attack": 70.95,+ "defense": 70.95+ },+ "skill_levels": {+ "skill_1": 2+ }+ },+ "Ahmose": {+ "stats": {+ "attack": 55.4,+ "defense": 55.4+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 0,+ "skill_3": 0+ }+ },+ "Lumak": {+ "stats": {+ "attack": 66.44,+ "defense": 66.44+ },+ "skill_levels": {+ "skill_1": 1+ }+ },+ "Ling": {+ "stats": {+ "attack": 60.64,+ "defense": 60.64+ },+ "skill_levels": {+ "skill_1": 1+ }+ },+ "Zinman": {+ "stats": {+ "attack": 63.59,+ "defense": 63.59+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_3": 1+ }+ },+ "Greg": {+ "stats": {+ "attack": 69.32,+ "defense": 69.32+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 0+ }+ },+ "Logan": {+ "stats": {+ "attack": 69.32,+ "defense": 69.32+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 0+ }+ },+ "Philly": {+ "stats": {+ "attack": 53.42,+ "defense": 53.42+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 0+ }+ },+ "Alonso": {+ "stats": {+ "attack": 49.47,+ "defense": 49.47+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 2+ }+ },+ "Jessie": {+ "stats": {+ "attack": 103.45,+ "defense": 103.45+ },+ "skill_levels": {+ "skill_1": 5,+ "skill_2": 2+ }+ },+ "Patrick": {+ "stats": {+ "attack": 97.13,+ "defense": 97.13+ },+ "skill_levels": {+ "skill_1": 2,+ "skill_2": 1+ }+ },+ "Natalia": {+ "stats": {+ "attack": 41.23,+ "defense": 41.23+ },+ "skill_levels": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 0+ }+ },+ "Norah": {+ "stats": {+ "attack": 56.05,+ "defense": 56.05+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 0,+ "skill_3": 0+ }+ },+ "Reina": {+ "stats": {+ "attack": 76.27,+ "defense": 76.27+ },+ "skill_levels": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 0+ }+ }+ }+}Index: shared/fighters_data/fighters_stats.json===================================================================--- shared/fighters_data/fighters_stats.json prev run+++ shared/fighters_data/fighters_stats.json this run@@ -0,0 +1,227 @@+{+ "max": {+ "inf": [1400.98, 1432.28, 1806.6, 1791.5],+ "lanc": [1400.98, 1432.28, 1756.8, 1722.5],+ "mark": [1400.98, 1431.28, 1806.4, 1785.4]+ },+ "daut": {+ "inf": [+ 9.94,+ 9.44,+ 1.75,+ 1.75+ ],+ "lanc": [+ 9.94,+ 9.44,+ 1.75,+ 1.75+ ],+ "mark": [+ 10.43,+ 9.44,+ 1.75,+ 1.75+ ]+ },+ "viper": {+ "inf": [+ 184.5,+ 188.0,+ 107.24,+ 114.18+ ],+ "lanc": [+ 167.0,+ 163.0,+ 110.31,+ 100.21+ ],+ "mark": [+ 174.75,+ 168.25,+ 117.75,+ 104.74+ ]+ },+ "Nitro": {+ "inf": [+ 602.82,+ 607.82,+ 420.22,+ 403.0+ ],+ "lanc": [+ 557.32,+ 554.32,+ 368.26,+ 360.39+ ],+ "mark": [+ 557.32,+ 564.82,+ 388.3,+ 372.94+ ]+ },+ "Beast_22": {+ "infantry": [+ 144.0,+ 144.0,+ 144.0,+ 144.0+ ],+ "lancers": [+ 144.0,+ 144.0,+ 144.0,+ 144.0+ ],+ "marksmen": [+ 144.0,+ 144.0,+ 144.0,+ 144.0+ ]+ },+ "Beast_30": {+ "infantry": [+ 455.0,+ 455.0,+ 455.0,+ 455.0+ ],+ "lancers": [+ 455.0,+ 455.0,+ 455.0,+ 455.0+ ],+ "marksmen": [+ 455.0,+ 455.0,+ 455.0,+ 455.0+ ]+ },+ "Kill": {+ "inf": [+ 119.1,+ 123.6,+ 47.1,+ 54.3+ ],+ "lanc": [+ 110.1,+ 116.1,+ 31.4,+ 34.5+ ],+ "mark": [+ 111.1,+ 116.6,+ 85.4,+ 90.9+ ]+ },+ "Beast_15": {+ "infantry": [+ 26.5,+ 26.5,+ 26.5,+ 26.5+ ],+ "lancers": [+ 26.5,+ 26.5,+ 26.5,+ 26.5+ ],+ "marksmen": [+ 26.5,+ 26.5,+ 26.5,+ 26.5+ ]+ },+ "Rapi": {+ "inf": [+ 379.4,+ 409.9,+ 293.6,+ 325.4+ ],+ "lanc": [+ 55.0,+ 55.0,+ 35.0,+ 35.0+ ],+ "mark": [+ 55.0,+ 55.0,+ 35.0,+ 35.0+ ]+ },+ "Beast_18": {+ "infantry": [+ 57.0,+ 57.0,+ 57.0,+ 57.0+ ],+ "lancers": [+ 57.0,+ 57.0,+ 57.0,+ 57.0+ ],+ "marksmen": [+ 57.0,+ 57.0,+ 57.0,+ 57.0+ ]+ },+ "Attacker_neutral": {+ "inf": [+ 0.0,+ 0.0,+ 0.0,+ 0.0+ ],+ "lanc": [+ 0.0,+ 0.0,+ 0.0,+ 0.0+ ],+ "mark": [+ 0.0,+ 0.0,+ 0.0,+ 0.0+ ]+ },+ "Defender_neutral": {+ "inf": [+ 0.0,+ 0.0,+ 0.0,+ 0.0+ ],+ "lanc": [+ 0.0,+ 0.0,+ 0.0,+ 0.0+ ],+ "mark": [+ 0.0,+ 0.0,+ 0.0,+ 0.0+ ]+ }+}\ No newline at end of fileIndex: simulator/config/hero_definitions/Ahmose.json===================================================================--- simulator/config/hero_definitions/Ahmose.json prev run+++ simulator/config/hero_definitions/Ahmose.json this run@@ -0,0 +1,167 @@+{+ "name": "Ahmose",+ "hero_generation": "S4",+ "troop_type": "infantry",+ "skills": {+ "ViperFormation": {+ "description": "His infantry pauses the attack once every 4 times reducing damage taken by Lancers and Marksmen by X% and Infantry by X% for 2 turns",+ "trigger": {+ "type": "attack",+ "every": 4,+ "source": "infantry"+ },+ "effects": {+ "ViperFormation/1": {+ "type": "no_attack",+ "units": {+ "applies_to": [+ "infantry"+ ]+ },+ "duration": {+ "type": "turn",+ "value": 1+ }+ },+ "ViperFormation/2": {+ "type": "active.hero.defense.up",+ "value": [+ 10,+ 15,+ 20,+ 25,+ 30+ ],+ "units": {+ "applies_to": [+ "lancer",+ "marksman"+ ]+ },+ "duration": {+ "type": "turn",+ "value": 2,+ "delay": 1+ },+ "same_effect_stacking": "max"+ },+ "ViperFormation/3": {+ "type": "active.hero.defense.up",+ "value": [+ 10,+ 25,+ 40,+ 55,+ 70+ ],+ "units": {+ "applies_to": [+ "infantry"+ ]+ },+ "duration": {+ "type": "turn",+ "value": 2,+ "delay": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "PrayerOfFlame": {+ "description": "Ahmose amplifies the combat spirit of friendly infantry with the power of Fire Crystal, increasing their damage dealt by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "PrayerOfFlame/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": [+ "infantry"+ ]+ }+ }+ }+ },+ "BladeOfLight": {+ "description": "Increases his infantries' damage dealt per attack by X% and the target's damage taken by X% for 1 turn",+ "trigger": {+ "type": "attack",+ "source": "infantry"+ },+ "effects": {+ "BladeOfLight/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 12,+ 24,+ 36,+ 48,+ 60+ ],+ "units": {+ "applies_to": [+ "infantry"+ ]+ },+ "duration": {+ "type": "attack",+ "value": 1+ }+ },+ "BladeOfLight/2": {+ "type": "active.hero.defense.down",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "units": {+ "applies_to": "target"+ },+ "duration": {+ "type": "turn",+ "value": 1,+ "delay": 1+ },+ "same_effect_stacking": "add"+ }+ }+ },+ "GuardiansVitality": {+ "description": "Widget bonus: Increase Health by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "GuardiansVitality/1": {+ "type": "passive.health.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Alonso.json===================================================================--- simulator/config/hero_definitions/Alonso.json prev run+++ simulator/config/hero_definitions/Alonso.json this run@@ -0,0 +1,141 @@+{+ "name": "Alonso",+ "hero_generation": "S2",+ "troop_type": "marksman",+ "skills": {+ "Onslaught": {+ "description": "Granting a 40% chance of increasing all troop's Lethality by X%",+ "trigger": {+ "type": "turn",+ "probability": [+ 40,+ 40,+ 40,+ 40,+ 40+ ],+ "source": "self.any"+ },+ "effects": {+ "Onslaught/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "trigger",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "turn",+ "value": 1+ }+ }+ }+ },+ "IronStrength": {+ "description": "Grants all troops' attack a 20% chance of reducing damage dealt by X% for all enemy troops for 2 turns",+ "trigger": {+ "type": "attack",+ "probability": [+ 20,+ 20,+ 20,+ 20,+ 20+ ]+ },+ "effects": {+ "IronStrength/1": {+ "type": "active.hero.lethality.down",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "target",+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 2,+ "delay": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "PoisonHarpoon": {+ "description": "Grants all troops attack a 50% chance of dealing X% more damage",+ "trigger": {+ "type": "attack",+ "probability": [+ 50,+ 50,+ 50,+ 50,+ 50+ ]+ },+ "effects": {+ "PoisonHarpoon/1": {+ "type": "extra_skill_attack",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "same_effect_stacking": "max",+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ },+ "RazorVolley": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when attacking",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "RazorVolley/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Bahiti.json===================================================================--- simulator/config/hero_definitions/Bahiti.json prev run+++ simulator/config/hero_definitions/Bahiti.json this run@@ -0,0 +1,64 @@+{+ "name": "Bahiti",+ "hero_generation": "SR",+ "troop_type": "marksman",+ "skills": {+ "SixthSense": {+ "description": "Reducing damage taken by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "SixthSense/1": {+ "type": "active.hero.defense.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ]+ }+ }+ },+ "Fluorescence": {+ "description": "Grants all troops' attack a 50% chance of increasing damage dealt by X%",+ "trigger": {+ "type": "attack",+ "probability": [+ 50,+ 50,+ 50,+ 50,+ 50+ ]+ },+ "effects": {+ "Fluorescence/1": {+ "type": "extra_skill_attack",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Bradley.json===================================================================--- simulator/config/hero_definitions/Bradley.json prev run+++ simulator/config/hero_definitions/Bradley.json this run@@ -0,0 +1,112 @@+{+ "name": "Bradley",+ "hero_generation": "S7",+ "troop_type": "marksman",+ "skills": {+ "VeteransMight": {+ "description": "Bradley's years of combat experience enables him to destroy enemies efficiently, increasing Attack by X% for all troops.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "VeteransMight/1": {+ "type": "active.hero.attack.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "PowerShot": {+ "description": "Bradley uses his expertise in suppressive artillery against the enemy vanguard, increasing Damage Dealt to Lancers by X% and to Infantry by Y% for all troops.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "PowerShot/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "units": {+ "applies_vs": [+ "lancer"+ ]+ }+ },+ "PowerShot/2": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "units": {+ "applies_vs": [+ "infantry"+ ]+ }+ }+ }+ },+ "TacticalAssistance": {+ "description": "Bradley will press every advantage against a beleaguered enemy, increasing Damage Dealt by X% for all troops for 2 turns every 4 turns.",+ "trigger": {+ "type": "turn",+ "every": 4+ },+ "effects": {+ "TacticalAssistance/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "duration": {+ "type": "turn",+ "value": 2+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "SiegeInsight": {+ "description": "Widget bonus: Increase Attack by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "SiegeInsight/1": {+ "type": "passive.attack.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Edith.json===================================================================--- simulator/config/hero_definitions/Edith.json prev run+++ simulator/config/hero_definitions/Edith.json this run@@ -0,0 +1,112 @@+{+ "name": "Edith",+ "hero_generation": "S7",+ "troop_type": "infantry",+ "skills": {+ "StrategicBalance": {+ "description": "Mr. Tin's colossal presence automatically shields friendly ranged units, reducing damage taken by X% for Marksmen, and suppresses the enemy, increasing damage dealt by Y% for Lancers.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "StrategicBalance/1": {+ "type": "active.hero.defense.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": [+ "marksman"+ ]+ }+ },+ "StrategicBalance/2": {+ "type": "active.hero.lethality.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": "any"+ }+ }+ }+ },+ "Ironclad": {+ "description": "Mr. Tin's metallic body functions as a fortified wall on the field, reducing damage taken by X% for Infantry.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Ironclad/1": {+ "type": "active.hero.defense.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": [+ "infantry"+ ]+ }+ }+ }+ },+ "SteelSentinel": {+ "description": "Edith's mobile defense system is reliable, increasing Health by X% for all troops.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "SteelSentinel/1": {+ "type": "active.hero.health.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "Fortworks": {+ "description": "Widget bonus: Increase Health by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "Fortworks/1": {+ "type": "passive.health.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Flint.json===================================================================--- simulator/config/hero_definitions/Flint.json prev run+++ simulator/config/hero_definitions/Flint.json this run@@ -0,0 +1,92 @@+{+ "name": "Flint",+ "hero_generation": "S2",+ "troop_type": "infantry",+ "skills": {+ "Pyromaniac": {+ "description": "increases his Infantry's Damage Dealt by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Pyromaniac/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": [+ "infantry"+ ],+ "applies_vs": "any"+ }+ }+ }+ },+ "BurningResolve": {+ "description": "Increasing Attack by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "BurningResolve/1": {+ "type": "active.hero.attack.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "Immolation": {+ "description": "Increasing all troops' Lethality by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Immolation/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "FortressSpear": {+ "description": "Widget bonus: Increase Attack by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "FortressSpear/1": {+ "type": "passive.attack.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Gordon.json===================================================================--- simulator/config/hero_definitions/Gordon.json prev run+++ simulator/config/hero_definitions/Gordon.json this run@@ -0,0 +1,174 @@+{+ "name": "Gordon",+ "hero_generation": "S7",+ "troop_type": "lancer",+ "skills": {+ "VenomInfusion": {+ "description": "Every 2 attacks, Lancers deal X% extra damage and apply poison to the target for 1 turn. Poisoned enemies deal Y% less damage.",+ "trigger": {+ "type": "attack",+ "every": 2,+ "source": "lancer"+ },+ "effects": {+ "VenomInfusion/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ }+ },+ "VenomInfusion/2": {+ "type": "active.hero.lethality.down",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": "trigger.target",+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 1+ }+ }+ }+ },+ "ChemicalTerror": {+ "description": "Gordon's envenomed weapons terrorizes the field, increasing Lancers' Damage Dealt by X% and reducing Damage Dealt by enemy troops by Y% for 1 turn every 3 turns.",+ "trigger": {+ "type": "turn",+ "every": 3+ },+ "effects": {+ "ChemicalTerror/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 30,+ 60,+ 90,+ 120,+ 150+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max"+ },+ "ChemicalTerror/2": {+ "type": "active.hero.lethality.down",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "units": {+ "applies_to": "enemy.any",+ "applies_vs": "self.any"+ },+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "ToxicRelease": {+ "description": "Gordon generates a defensive bio-toxic fog, increasing Damage Taken by enemy Infantry by X% while reducing Damage Dealt by enemy Marksmen by Y% for 2 turns every 4 turns.",+ "trigger": {+ "type": "turn",+ "every": 4+ },+ "effects": {+ "ToxicRelease/1": {+ "type": "active.hero.defense.down",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "units": {+ "applies_to": "enemy.infantry"+ },+ "duration": {+ "type": "turn",+ "value": 2+ },+ "same_effect_stacking": "max"+ },+ "ToxicRelease/2": {+ "type": "active.hero.lethality.down",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "units": {+ "applies_to": "enemy.marksman"+ },+ "duration": {+ "type": "turn",+ "value": 2+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "BioAssault": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "BioAssault/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Greg.json===================================================================--- simulator/config/hero_definitions/Greg.json prev run+++ simulator/config/hero_definitions/Greg.json this run@@ -0,0 +1,118 @@+{+ "name": "Greg",+ "hero_generation": "S3",+ "troop_type": "marksman",+ "skills": {+ "SwordOfJustice": {+ "description": "Granting a 20% chance of increasing damage dealt by X% for all troops for 3 turns",+ "trigger": {+ "type": "turn",+ "probability": [+ 20,+ 20,+ 20,+ 20,+ 20+ ]+ },+ "effects": {+ "SwordOfJustice/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 8,+ 16,+ 24,+ 32,+ 40+ ],+ "same_effect_stacking": "add",+ "units": {+ "applies_to": "all",+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 3+ }+ }+ }+ },+ "DeterrenceOfLaw": {+ "description": "Granting all troops' attack a 20% chance of reducing enemy damage dealt by X% for 2 turns",+ "trigger": {+ "type": "attack",+ "probability": [+ 20,+ 20,+ 20,+ 20,+ 20+ ]+ },+ "effects": {+ "DeterrenceOfLaw/1": {+ "type": "active.hero.lethality.down",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "same_effect_stacking": "max",+ "units": {+ "applies_to": "trigger.target",+ "applies_vs": "self.any"+ },+ "duration": {+ "type": "turn",+ "value": 2+ }+ }+ }+ },+ "LawAndOrder": {+ "description": "Increasing Health by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "LawAndOrder/2": {+ "type": "active.hero.health.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "VitalSurge": {+ "description": "Widget bonus: Increase Health by X% for all troops when attacking",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "VitalSurge/1": {+ "type": "passive.health.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Gwen.json===================================================================--- simulator/config/hero_definitions/Gwen.json prev run+++ simulator/config/hero_definitions/Gwen.json this run@@ -0,0 +1,145 @@+{+ "name": "Gwen",+ "hero_generation": "S5",+ "troop_type": "marksman",+ "skills": {+ "EagleVision": {+ "description": "Increasing target's damage taken by X%",+ "trigger": {+ "type": "attack"+ },+ "effects": {+ "EagleVision/1": {+ "type": "active.hero.defense.down",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "units": {+ "applies_to": "target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ }+ }+ }+ },+ "AirDominance": {+ "description": "Grants all troops' attack X% extra damage after every 5 attacks and causes the target to receive X% extra damage for its next attack received",+ "trigger": {+ "type": "attack",+ "every": 5+ },+ "effects": {+ "AirDominance/1": {+ "type": "extra_skill_attack",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ },+ "AirDominance/2": {+ "type": "active.hero.defense.down",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ],+ "units": {+ "applies_to": "target"+ },+ "duration": {+ "type": "attack",+ "value": 1,+ "delay": 1+ }+ }+ }+ },+ "Blastmaster": {+ "description": "Gwen equips her Marksmen with grenades, dealing X% extra damage to all enemies on the next attack of every 4 attacks",+ "trigger": {+ "type": "attack",+ "every": 4,+ "source": "marksman"+ },+ "effects": {+ "Blastmaster/1": {+ "type": "extra_skill_attack",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": [+ "marksman"+ ],+ "applies_vs": "any"+ },+ "duration": {+ "type": "attack",+ "value": 1,+ "delay": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "enemy.living"+ }+ ]+ }+ }+ },+ "LethalPrecision": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when attacking",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "LethalPrecision/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Hector.json===================================================================--- simulator/config/hero_definitions/Hector.json prev run+++ simulator/config/hero_definitions/Hector.json this run@@ -0,0 +1,159 @@+{+ "name": "Hector",+ "hero_generation": "S5",+ "troop_type": "infantry",+ "skills": {+ "SurvivalInstincts": {+ "description": "Hector's presence grants a 40% chance of reducing damage taken by X% for all troops",+ "trigger": {+ "type": "turn",+ "probability": [+ 40,+ 40,+ 40,+ 40,+ 40+ ]+ },+ "effects": {+ "SurvivalInstincts/1": {+ "type": "active.hero.defense.up",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "Rampant": {+ "description": "Increases infantry's damage dealt by X% and marksmen's damage dealt by X%, it is effective for 10 attacks, with each attack's damage boost being 85% of the previous one.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Rampant/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 100,+ 125,+ 150,+ 175,+ 200+ ],+ "units": {+ "applies_to": [+ "infantry"+ ],+ "applies_vs": "any"+ },+ "duration": {+ "type": "attack",+ "value": 10+ },+ "value_evolution": {+ "type": "pct_decay",+ "step": "attack",+ "value": 15+ }+ },+ "Rampant/2": {+ "type": "active.hero.lethality.up",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": [+ "marksman"+ ],+ "applies_vs": "any"+ },+ "duration": {+ "type": "attack",+ "value": 10+ },+ "value_evolution": {+ "type": "pct_decay",+ "step": "attack",+ "value": 15+ }+ }+ }+ },+ "Blitz": {+ "description": "Grants all troops' attack a 25% chance of dealing X% damage",+ "trigger": {+ "type": "attack",+ "probability": [+ 25,+ 25,+ 25,+ 25,+ 25+ ]+ },+ "effects": {+ "Blitz/1": {+ "type": "extra_skill_attack",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ },+ "BulwarkCommander": {+ "description": "Widget bonus: Increase Attack by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "BulwarkCommander/1": {+ "type": "passive.attack.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Jasser.json===================================================================--- simulator/config/hero_definitions/Jasser.json prev run+++ simulator/config/hero_definitions/Jasser.json this run@@ -0,0 +1,32 @@+{+ "name": "Jasser",+ "hero_generation": "SR",+ "troop_type": "marksman",+ "skills": {+ "TacticalGenius": {+ "description": "Increasing damage dealt by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "TacticalGenius/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "NonCombatPlaceholder": {+ "description": "Placeholder skill to satisfy simulator config for non-combat hero data",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {}+ }+ }+}Index: simulator/config/hero_definitions/Jeronimo.json===================================================================--- simulator/config/hero_definitions/Jeronimo.json prev run+++ simulator/config/hero_definitions/Jeronimo.json this run@@ -0,0 +1,95 @@+{+ "name": "Jeronimo",+ "hero_generation": "S1_jeronimo",+ "troop_type": "infantry",+ "skills": {+ "BattleManifesto": {+ "description": "Increasing damage dealt by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "BattleManifesto/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "Swordmentor": {+ "description": "Increasing Attack by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Swordmentor/1": {+ "type": "active.hero.attack.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "EXpertSwordsmanship": {+ "description": "Increasing Damage Dealt by X% for all troops for 2 turns every 4 turns",+ "trigger": {+ "type": "turn",+ "every": 4+ },+ "effects": {+ "EXpertSwordsmanship/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "units": {+ "applies_to": "all",+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 2+ }+ }+ }+ },+ "BattleStance": {+ "description": "Widget bonus: Increase Attack by X% for all troops when attacking",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "BattleStance/1": {+ "type": "passive.attack.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Jessie.json===================================================================--- simulator/config/hero_definitions/Jessie.json prev run+++ simulator/config/hero_definitions/Jessie.json this run@@ -0,0 +1,43 @@+{+ "name": "Jessie",+ "hero_generation": "SR",+ "troop_type": "lancer",+ "skills": {+ "StandOfArms": {+ "description": "Increasing damage dealt by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "StandOfArms/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "Bulwarks": {+ "description": "Reducing damage taken by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Bulwarks/1": {+ "type": "active.hero.defense.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Ling.json===================================================================--- simulator/config/hero_definitions/Ling.json prev run+++ simulator/config/hero_definitions/Ling.json this run@@ -0,0 +1,35 @@+{+ "name": "Ling",+ "hero_generation": "SR",+ "troop_type": "lancer",+ "skills": {+ "FearsomeAura": {+ "description": "Reducing all enemy Troops' Attack by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "FearsomeAura/1": {+ "type": "active.hero.attack.down",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": "enemy.any"+ }+ }+ }+ },+ "NonCombatPlaceholder": {+ "description": "Placeholder skill to satisfy simulator config for non-combat hero data",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {}+ }+ }+}Index: simulator/config/hero_definitions/Logan.json===================================================================--- simulator/config/hero_definitions/Logan.json prev run+++ simulator/config/hero_definitions/Logan.json this run@@ -0,0 +1,89 @@+{+ "name": "Logan",+ "hero_generation": "S3",+ "troop_type": "infantry",+ "skills": {+ "LionStrike": {+ "description": "Reducing all enemy Troops' Attack by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "LionStrike/1": {+ "type": "active.hero.attack.down",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": "enemy.any"+ }+ }+ }+ },+ "LionIntimidation": {+ "description": "Reducing damage taken by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "LionIntimidation/1": {+ "type": "active.hero.defense.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ]+ }+ }+ },+ "LeaderInspiration": {+ "description": "Increasing Health by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "LeaderInspiration/1": {+ "type": "active.hero.health.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "IronDen": {+ "description": "Widget bonus: Increase Defense by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "IronDen/1": {+ "type": "passive.defense.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Lumak.json===================================================================--- simulator/config/hero_definitions/Lumak.json prev run+++ simulator/config/hero_definitions/Lumak.json this run@@ -0,0 +1,35 @@+{+ "name": "Lumak",+ "hero_generation": "SR",+ "troop_type": "lancer",+ "skills": {+ "TacticalDeception": {+ "description": "All enemy troops' damage dealt is reduced by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "TacticalDeception/1": {+ "type": "active.hero.lethality.down",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": "enemy.any"+ }+ }+ }+ },+ "NonCombatPlaceholder": {+ "description": "Placeholder skill to satisfy simulator config for non-combat hero data",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {}+ }+ }+}Index: simulator/config/hero_definitions/Lynn.json===================================================================--- simulator/config/hero_definitions/Lynn.json prev run+++ simulator/config/hero_definitions/Lynn.json this run@@ -0,0 +1,109 @@+{+ "name": "Lynn",+ "hero_generation": "S4",+ "troop_type": "marksman",+ "skills": {+ "SongOfLion": {+ "description": "Granting a 40% chance of increasing damage dealt by X% for all troops",+ "trigger": {+ "type": "turn",+ "probability": [+ 40,+ 40,+ 40,+ 40,+ 40+ ]+ },+ "effects": {+ "SongOfLion/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "MelancholicBallad": {+ "description": "Reducing damage dealt by X% for all enemy troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "MelancholicBallad/1": {+ "type": "active.hero.lethality.down",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": "enemy.any"+ }+ }+ }+ },+ "OonaiCadenza": {+ "description": "Increases her marksmen attack by X% for every 3 attacks. Stackable, and lasts until the end of battle",+ "trigger": {+ "type": "attack",+ "every": 3,+ "source": "marksman"+ },+ "effects": {+ "OonaiCadenza/1": {+ "type": "active.hero.attack.up",+ "value": [+ 1,+ 2,+ 3,+ 4,+ 5+ ],+ "units": {+ "applies_to": [+ "marksman"+ ]+ },+ "same_effect_stacking": "add"+ }+ }+ },+ "SilentThreat": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "SilentThreat/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Mia.json===================================================================--- simulator/config/hero_definitions/Mia.json prev run+++ simulator/config/hero_definitions/Mia.json this run@@ -0,0 +1,135 @@+{+ "name": "Mia",+ "hero_generation": "S3",+ "troop_type": "lancer",+ "skills": {+ "BadLuckStreak": {+ "description": "Grants all troops attack a 50% chance of cursing the target, increasing their damage taken by X%",+ "trigger": {+ "type": "attack",+ "probability": [+ 50,+ 50,+ 50,+ 50,+ 50+ ]+ },+ "effects": {+ "BadLuckStreak/1": {+ "type": "active.hero.defense.down",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "target"+ },+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "LuckyCharm": {+ "description": "Grants a 50% chance of boosting troops' Attack by X%",+ "trigger": {+ "type": "attack",+ "probability": [+ 50,+ 50,+ 50,+ 50,+ 50+ ]+ },+ "effects": {+ "LuckyCharm/1": {+ "type": "extra_skill_attack",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max",+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"+ }+ ]+ }+ }+ },+ "RitualDeciphering": {+ "description": "Grants a 40% chance of reducing damage taken by X% for all troops",+ "trigger": {+ "type": "turn",+ "probability": [+ 40,+ 40,+ 40,+ 40,+ 40+ ]+ },+ "effects": {+ "RitualDeciphering/1": {+ "type": "active.hero.defense.up",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "PrecisionDrive": {+ "description": "Widget bonus: Increase Attack by X% for all troops when attacking",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "PrecisionDrive/1": {+ "type": "passive.attack.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Molly.json===================================================================--- simulator/config/hero_definitions/Molly.json prev run+++ simulator/config/hero_definitions/Molly.json this run@@ -0,0 +1,121 @@+{+ "name": "Molly",+ "hero_generation": "S1",+ "troop_type": "lancer",+ "skills": {+ "CallingOfTheStorm": {+ "description": "granting a 40% chance of reducing all troops' Damage taken by X%",+ "trigger": {+ "type": "turn",+ "probability": [+ 40,+ 40,+ 40,+ 40,+ 40+ ]+ },+ "effects": {+ "CallingOfTheStorm/1": {+ "type": "active.hero.defense.up",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "all"+ },+ "duration": {+ "type": "turn",+ "value": 1+ }+ }+ }+ },+ "IceDominion": {+ "description": "Granting all troops' attack a 50% chance of increasing damage dealt by X%",+ "trigger": {+ "type": "attack",+ "probability": [+ 50,+ 50,+ 50,+ 50,+ 50+ ]+ },+ "effects": {+ "IceDominion/1": {+ "type": "extra_skill_attack",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ },+ "YouthfulRage": {+ "description": "Increasing damage dealt by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "YouthfulRage/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "WinterFang": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "WinterFang/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Natalia.json===================================================================--- simulator/config/hero_definitions/Natalia.json prev run+++ simulator/config/hero_definitions/Natalia.json this run@@ -0,0 +1,100 @@+{+ "name": "Natalia",+ "hero_generation": "S1_natalia",+ "troop_type": "infantry",+ "skills": {+ "WildlingRoar": {+ "description": "granting a 40% chance of reducing all troops' Damage taken by X%",+ "trigger": {+ "type": "turn",+ "probability": [+ 40,+ 40,+ 40,+ 40,+ 40+ ]+ },+ "effects": {+ "WildlingRoar/1": {+ "type": "active.hero.defense.up",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "all"+ },+ "duration": {+ "type": "turn",+ "value": 1+ }+ }+ }+ },+ "QueenOfTheWild": {+ "description": "Increasing Attack by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "QueenOfTheWild/1": {+ "type": "active.hero.attack.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "CallOfTheWild": {+ "description": "Increasing damage dealt by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "CallOfTheWild/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "GaleForce": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "GaleForce/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Norah.json===================================================================--- simulator/config/hero_definitions/Norah.json prev run+++ simulator/config/hero_definitions/Norah.json this run@@ -0,0 +1,159 @@+{+ "name": "Norah",+ "hero_generation": "S5",+ "troop_type": "lancer",+ "skills": {+ "CombinedArms": {+ "description": "Decreasing damage taken by X% and boosting damage dealt by X% for infantry and marksmen",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "CombinedArms/1": {+ "type": "active.hero.defense.up",+ "value": [+ 3,+ 6,+ 9,+ 12,+ 15+ ],+ "units": {+ "applies_to": [+ "infantry",+ "marksman"+ ]+ }+ },+ "CombinedArms/2": {+ "type": "active.hero.lethality.up",+ "value": [+ 3,+ 6,+ 9,+ 12,+ 15+ ],+ "units": {+ "applies_to": [+ "infantry",+ "marksman"+ ],+ "applies_vs": "any"+ }+ }+ }+ },+ "SneakStrike": {+ "description": "Granting her lancers a 20% chance of dealing X% extra damage to all enemies on attack",+ "trigger": {+ "type": "attack",+ "probability": [+ 20,+ 20,+ 20,+ 20,+ 20+ ],+ "source": "lancer"+ },+ "effects": {+ "SneakStrike/1": {+ "type": "extra_skill_attack",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max",+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "enemy.living"+ }+ ]+ }+ }+ },+ "Momentum": {+ "description": "Increasing all troops damage dealt by X% and reducing their damage taken by X%, every five attacks made by lancers for 2 turns",+ "trigger": {+ "type": "attack",+ "every": 5,+ "source": "lancer"+ },+ "effects": {+ "Momentum/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "units": {+ "applies_to": "all",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "turn",+ "value": 2+ }+ },+ "Momentum/2": {+ "type": "active.hero.defense.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "duration": {+ "type": "turn",+ "value": 2,+ "delay": 1+ }+ }+ }+ },+ "SteadfastGuard": {+ "description": "Widget bonus: Increase Defense by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "SteadfastGuard/1": {+ "type": "passive.defense.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Patrick.json===================================================================--- simulator/config/hero_definitions/Patrick.json prev run+++ simulator/config/hero_definitions/Patrick.json this run@@ -0,0 +1,43 @@+{+ "name": "Patrick",+ "hero_generation": "SR",+ "troop_type": "lancer",+ "skills": {+ "SuperNutrients": {+ "description": "Increasing Health by X% for all troop",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "SuperNutrients/1": {+ "type": "active.hero.health.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "CaloricBooster": {+ "description": "Increasing Attack by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "CaloricBooster/1": {+ "type": "active.hero.attack.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Philly.json===================================================================--- simulator/config/hero_definitions/Philly.json prev run+++ simulator/config/hero_definitions/Philly.json this run@@ -0,0 +1,131 @@+{+ "name": "Philly",+ "hero_generation": "S2",+ "troop_type": "lancer",+ "skills": {+ "VigorTactics": {+ "description": "Increasing Attack by X% and Defense by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "VigorTactics/1": {+ "type": "active.hero.attack.up",+ "value": [+ 3,+ 6,+ 9,+ 12,+ 15+ ]+ },+ "VigorTactics/2": {+ "type": "active.hero.defense.up",+ "value": [+ 2,+ 4,+ 6,+ 8,+ 10+ ]+ }+ }+ },+ "DosageBoost": {+ "description": "Granting all troops' attack a 25% chance of dealing X% damage",+ "trigger": {+ "type": "attack",+ "probability": [+ 25,+ 25,+ 25,+ 25,+ 25+ ]+ },+ "effects": {+ "DosageBoost/1": {+ "type": "extra_skill_attack",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ },+ "NumbingSpores": {+ "description": "granting a 40% chance of reducing all troops' Damage taken by X%",+ "trigger": {+ "type": "turn",+ "probability": [+ 40,+ 40,+ 40,+ 40,+ 40+ ]+ },+ "effects": {+ "NumbingSpores/1": {+ "type": "active.hero.defense.up",+ "value": [+ 10,+ 20,+ 30,+ 40,+ 50+ ],+ "units": {+ "applies_to": "all"+ },+ "duration": {+ "type": "turn",+ "value": 1+ }+ }+ }+ },+ "GuardianBloom": {+ "description": "Widget bonus: Increase Health by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "GuardianBloom/1": {+ "type": "passive.health.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Reina.json===================================================================--- simulator/config/hero_definitions/Reina.json prev run+++ simulator/config/hero_definitions/Reina.json this run@@ -0,0 +1,126 @@+{+ "name": "Reina",+ "hero_generation": "S4",+ "troop_type": "lancer",+ "skills": {+ "AssassinsInstinct": {+ "description": "Increasing normal attack damage by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "AssassinsInstinct/1": {+ "type": "type.normal.damage.up",+ "value": [+ 10,+ 15,+ 20,+ 25,+ 30+ ],+ "units": {+ "applies_to": "all",+ "applies_vs": "any"+ }+ }+ }+ },+ "SwiftJive": {+ "description": "TBD dodge semantics: Grants all troops a X% chance of dodging normal attacks",+ "status": "tbd",+ "notes": "Modeled as an enemy attack trigger with trigger-level probability. Exact dodge response timing remains TBD for implementation.",+ "trigger": {+ "type": "attack",+ "probability": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "source": "enemy.any",+ "target": "self.any"+ },+ "effects": {+ "SwiftJive/1": {+ "type": "dodge",+ "units": {+ "applies_to": "trigger"+ },+ "duration": {+ "type": "attack",+ "value": 1+ }+ }+ }+ },+ "ShadowBlade": {+ "description": "With Reina's clever tactics, her Lancers have a 25% chance of performing an extra attack, dealing X% damage",+ "trigger": {+ "type": "attack",+ "probability": [+ 25,+ 25,+ 25,+ 25,+ 25+ ],+ "source": "lancer"+ },+ "effects": {+ "ShadowBlade/1": {+ "type": "extra_skill_attack",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "turn",+ "value": 1+ },+ "same_effect_stacking": "max",+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ },+ "LancersEdge": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when attacking",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "LancersEdge/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Renee.json===================================================================--- simulator/config/hero_definitions/Renee.json prev run+++ simulator/config/hero_definitions/Renee.json this run@@ -0,0 +1,130 @@+{+ "name": "Renee",+ "hero_generation": "S6",+ "troop_type": "lancer",+ "skills": {+ "NightmareTrace": {+ "description": "Troops place Dream Marks on targets every two turns, dealing extra Lancer damage once next turn",+ "trigger": {+ "type": "turn",+ "every": 2+ },+ "effects": {+ "NightmareTrace/1": {+ "type": "extra_skill_attack",+ "value": [+ 40,+ 80,+ 120,+ 160,+ 200+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": "any"+ },+ "duration": {+ "type": "attack",+ "value": 1,+ "delay": 1+ },+ "same_effect_stacking": "max",+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"+ }+ ]+ }+ }+ },+ "Dreamcatcher": {+ "description": "Dream Marks highlight enemy vulnerabilities, increasing damage dealt by Lancers to marked targets",+ "trigger": {+ "type": "turn",+ "every": 2+ },+ "effects": {+ "Dreamcatcher/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 30,+ 60,+ 90,+ 120,+ 150+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 1,+ "delay": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "Dreamslice": {+ "description": "Dream Marks expose enemy weaknesses, increasing damage taken by marked targets for all troops",+ "trigger": {+ "type": "turn",+ "every": 2,+ "source": "self.any"+ },+ "effects": {+ "Dreamslice/1": {+ "type": "active.hero.defense.down",+ "value": [+ 15,+ 30,+ 45,+ 60,+ 75+ ],+ "units": {+ "applies_to": "target",+ "applies_vs": "any"+ },+ "duration": {+ "type": "turn",+ "value": 1,+ "delay": 1+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "DreamfangRally": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when rallying",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "DreamfangRally/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Seo-yoon.json===================================================================--- simulator/config/hero_definitions/Seo-yoon.json prev run+++ simulator/config/hero_definitions/Seo-yoon.json this run@@ -0,0 +1,32 @@+{+ "name": "Seo-yoon",+ "hero_generation": "SR",+ "troop_type": "marksman",+ "skills": {+ "RallyingBeat": {+ "description": "Increasing all Troops Attack by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "RallyingBeat/1": {+ "type": "active.hero.attack.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "NonCombatPlaceholder": {+ "description": "Placeholder skill to satisfy simulator config for non-combat hero data",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {}+ }+ }+}Index: simulator/config/hero_definitions/Sergey.json===================================================================--- simulator/config/hero_definitions/Sergey.json prev run+++ simulator/config/hero_definitions/Sergey.json this run@@ -0,0 +1,46 @@+{+ "name": "Sergey",+ "hero_generation": "SR",+ "troop_type": "infantry",+ "skills": {+ "DefendersEdge": {+ "description": "Reduces damage taken by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "DefendersEdge/1": {+ "type": "active.hero.defense.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ]+ }+ }+ },+ "Weaken": {+ "description": "Reduces Attack by X% for all enemy troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Weaken/1": {+ "type": "active.hero.attack.down",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": "enemy.any"+ }+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Wayne.json===================================================================--- simulator/config/hero_definitions/Wayne.json prev run+++ simulator/config/hero_definitions/Wayne.json this run@@ -0,0 +1,168 @@+{+ "name": "Wayne",+ "hero_generation": "S6",+ "troop_type": "marksman",+ "skills": {+ "ThunderStrike": {+ "description": "Wayne's brilliant battle planning allows all troops to launch an extra attack every 4 turns, dealing X% damage.",+ "trigger": {+ "type": "turn",+ "every": 4,+ "source": "self.any"+ },+ "effects": {+ "ThunderStrike/1": {+ "type": "extra_skill_attack",+ "value": [+ 20,+ 40,+ 60,+ 80,+ 100+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "any"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "same_effect_stacking": "max",+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"+ }+ ]+ }+ }+ },+ "RoundaboutHit": {+ "description": "Wayne's stratagems can pierce the thickest of defenses. On every other attack, his Marksmen deal X% extra damage to enemy Lancers and Y% extra damage to enemy Marksmen.",+ "trigger": {+ "type": "attack",+ "every": 2,+ "source": "marksman"+ },+ "effects": {+ "RoundaboutHit/1": {+ "type": "extra_skill_attack",+ "value": [+ 8,+ 16,+ 24,+ 32,+ 40+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "any"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": [+ "lancer"+ ]+ }+ ]+ },+ "RoundaboutHit/2": {+ "type": "extra_skill_attack",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "any"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": [+ "marksman"+ ]+ }+ ]+ }+ }+ },+ "Fleet": {+ "description": "Wayne ensures no misstep goes unpunished with an eagle's eye for weakness, granting all troops' attacks a X% Crit Rate.",+ "trigger": {+ "type": "attack",+ "probability": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ },+ "effects": {+ "Fleet/1": {+ "type": "extra_skill_attack",+ "value": [+ 100,+ 100,+ 100,+ 100,+ 100+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"+ }+ ]+ }+ }+ },+ "TacticalEdge": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "TacticalEdge/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/WuMing.json===================================================================--- simulator/config/hero_definitions/WuMing.json prev run+++ simulator/config/hero_definitions/WuMing.json this run@@ -0,0 +1,106 @@+{+ "name": "Wu Ming",+ "hero_generation": "S6",+ "troop_type": "infantry",+ "skills": {+ "ShadowsEvasion": {+ "description": "Reducing troops' damage taken from normal attacks by X% and from skill attacks by Y%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "ShadowsEvasion/1": {+ "type": "type.normal.defense.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "units": {+ "applies_to": [+ "infantry"+ ]+ }+ },+ "ShadowsEvasion/2": {+ "type": "type.skill.defense.up",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "units": {+ "applies_to": [+ "infantry"+ ]+ }+ }+ }+ },+ "CrescentUplift": {+ "description": "Increasing all troops' damage dealt by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "CrescentUplift/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ]+ }+ }+ },+ "ElementalResonance": {+ "description": "Increasing skill damage dealt by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "ElementalResonance/1": {+ "type": "type.skill.damage.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "IronBastion": {+ "description": "Widget bonus: Increase Defense by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "IronBastion/1": {+ "type": "passive.defense.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Zinman.json===================================================================--- simulator/config/hero_definitions/Zinman.json prev run+++ simulator/config/hero_definitions/Zinman.json this run@@ -0,0 +1,85 @@+{+ "name": "Zinman",+ "hero_generation": "S1",+ "troop_type": "marksman",+ "skills": {+ "Implacable": {+ "description": "Increasing all troops' Defense by X% and Health by X%",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Implacable/1": {+ "type": "active.hero.defense.up",+ "value": [+ 2,+ 4,+ 6,+ 8,+ 10+ ]+ },+ "Implacable/2": {+ "type": "active.hero.health.up",+ "value": [+ 2,+ 4,+ 6,+ 8,+ 10+ ]+ }+ }+ },+ "Bastionist": {+ "description": "Increase building speed (non combat placeholder skill)",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {}+ },+ "PositionalBattler": {+ "description": "Increasing damage dealt by X% for all troops",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "PositionalBattler/1": {+ "type": "active.hero.lethality.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ]+ }+ }+ },+ "Woodpecker": {+ "description": "Widget bonus: Increase Attack by X% for all troops when defending",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "Woodpecker/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@@ -0,0 +1,60 @@+{+ "SR": {+ "attack": 140.11,+ "defense": 140.11+ },+ "S1": {+ "attack": 200.16,+ "defense": 200.16,+ "lethality": 50,+ "health": 50+ },+ "S1_natalia": {+ "attack": 200.16,+ "defense": 200.16,+ "lethality": 55.5,+ "health": 55.5+ },+ "S1_jeronimo": {+ "attack": 260.2,+ "defense": 260.2,+ "lethality": 62.5,+ "health": 62.5+ },+ "S2": {+ "attack": 240.19,+ "defense": 240.19,+ "lethality": 60,+ "health": 60+ },+ "S3": {+ "attack": 290.23,+ "defense": 290.23,+ "lethality": 70,+ "health": 70+ },+ "S4": {+ "attack": 370.29,+ "defense": 370.29,+ "lethality": 92.5,+ "health": 92.5+ },+ "S5": {+ "attack": 444.35,+ "defense": 444.35,+ "lethality": 111,+ "health": 111+ },+ "S6": {+ "attack": 540.43,+ "defense": 540.43,+ "lethality": 133.5,+ "health": 133.5+ },+ "S7": {+ "attack": 650.52,+ "defense": 650.52,+ "lethality": 160.5,+ "health": 160.5+ }+}Index: simulator/config/troop_skills.json===================================================================--- simulator/config/troop_skills.json prev run+++ simulator/config/troop_skills.json this run@@ -0,0 +1,486 @@+{+ "name": "Troop Skills",+ "skills": {+ "MasterBrawler": {+ "description": "Increase Attack Damage to Lancers by 10%",+ "troop_type": "infantry",+ "requirements": [+ {+ "level": 1,+ "type": "tier",+ "value": 0+ }+ ],+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "MasterBrawler/1": {+ "type": "active.troop.lethality.up",+ "value": [+ 10+ ],+ "units": {+ "applies_to": [+ "infantry"+ ],+ "applies_vs": [+ "lancer"+ ]+ }+ }+ }+ },+ "BandsOfSteel": {+ "description": "Increase Defense against Lancers by 10%",+ "troop_type": "infantry",+ "requirements": [+ {+ "level": 1,+ "type": "tier",+ "value": 7+ }+ ],+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "BandsOfSteel/1": {+ "type": "active.troop.defense.up",+ "value": [+ 10+ ],+ "units": {+ "applies_to": [+ "infantry"+ ],+ "applies_vs": [+ "lancer"+ ]+ }+ }+ }+ },+ "CrystalShield": {+ "description": "Infantry has a chance to reduce damage when under attack.",+ "troop_type": "infantry",+ "requirements": [+ {+ "level": 1,+ "type": "fc",+ "value": 3+ },+ {+ "level": 2,+ "type": "fc",+ "value": 5+ }+ ],+ "trigger": {+ "type": "attack",+ "probability": [+ 25,+ 37.5+ ],+ "source": "enemy.any",+ "target": "self.infantry"+ },+ "effects": {+ "CrystalShield/1": {+ "type": "active.troop.defense.up",+ "value": [+ 36,+ 36+ ],+ "units": {+ "applies_to": "trigger.target",+ "applies_vs": "trigger.source"+ },+ "duration": {+ "type": "attack",+ "value": 1+ }+ }+ }+ },+ "BodyOfLight": {+ "description": "Increase Infantry Defense by 4%, and reduce extra damage while Crystal Shield is active.",+ "troop_type": "infantry",+ "requirements": [+ {+ "level": 1,+ "type": "fc",+ "value": 8+ }+ ],+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "BodyOfLight/1": {+ "type": "active.troop.defense.up",+ "value": [+ 4+ ],+ "units": {+ "applies_to": [+ "infantry"+ ],+ "applies_vs": "any"+ }+ },+ "BodyOfLight/2": {+ "type": "active.troop.defense.up",+ "value": [+ 10+ ],+ "units": {+ "applies_to": [+ "infantry"+ ],+ "applies_vs": "any"+ }+ }+ }+ },+ "Charge": {+ "description": "Increase Attack Damage to Marksmen by 10%",+ "troop_type": "lancer",+ "requirements": [+ {+ "level": 1,+ "type": "tier",+ "value": 0+ }+ ],+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "Charge/1": {+ "type": "active.troop.lethality.up",+ "value": [+ 10+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": [+ "marksman"+ ]+ }+ }+ }+ },+ "Ambusher": {+ "description": "Lancer attacks have a chance to strike Marksmen behind Infantry.",+ "troop_type": "lancer",+ "requirements": [+ {+ "level": 1,+ "type": "tier",+ "value": 7+ }+ ],+ "trigger": {+ "type": "turn",+ "probability": [+ 20+ ]+ },+ "effects": {+ "Ambusher/1": {+ "type": "attack_order",+ "value": [+ "marksman",+ "infantry",+ "lancer"+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": [+ "marksman"+ ]+ },+ "duration": {+ "type": "turn",+ "value": 1+ }+ }+ }+ },+ "CrystalLance": {+ "description": "Lancers have a chance to make an extra skill attack for 100% damage.",+ "troop_type": "lancer",+ "requirements": [+ {+ "level": 1,+ "type": "fc",+ "value": 3+ },+ {+ "level": 2,+ "type": "fc",+ "value": 5+ }+ ],+ "trigger": {+ "type": "attack",+ "probability": [+ 10,+ 15+ ],+ "source": "lancer"+ },+ "effects": {+ "CrystalLance/1": {+ "type": "extra_skill_attack",+ "value": [+ 100,+ 100+ ],+ "units": {+ "applies_to": [+ "lancer"+ ],+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ },+ "IncandescentField": {+ "description": "Lancers have a chance to take half damage when under attack.",+ "troop_type": "lancer",+ "requirements": [+ {+ "level": 1,+ "type": "fc",+ "value": 8+ }+ ],+ "trigger": {+ "type": "attack",+ "probability": [+ 10+ ],+ "source": "enemy.any",+ "target": "self.lancer"+ },+ "effects": {+ "IncandescentField/1": {+ "type": "active.troop.defense.up",+ "value": [+ 100+ ],+ "units": {+ "applies_to": "trigger.target",+ "applies_vs": "trigger.source"+ },+ "duration": {+ "type": "attack",+ "value": 1+ }+ }+ }+ },+ "RangedStrike": {+ "description": "Increase Attack Damage to Infantry by 10%",+ "troop_type": "marksman",+ "requirements": [+ {+ "level": 1,+ "type": "tier",+ "value": 0+ }+ ],+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "RangedStrike/1": {+ "type": "active.troop.lethality.up",+ "value": [+ 10+ ],+ "units": {+ "applies_to": [+ "marksman"+ ],+ "applies_vs": [+ "infantry"+ ]+ }+ }+ }+ },+ "Volley": {+ "description": "Marksman attacks have a chance to make an extra skill attack for 100% damage.",+ "troop_type": "marksman",+ "requirements": [+ {+ "level": 1,+ "type": "tier",+ "value": 7+ }+ ],+ "trigger": {+ "type": "attack",+ "probability": [+ 10+ ],+ "source": "marksman"+ },+ "effects": {+ "Volley/1": {+ "type": "extra_skill_attack",+ "value": [+ 100+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ },+ "CrystalGunpowder": {+ "description": "Marksmen have a chance to make an extra skill attack for 50% damage.",+ "troop_type": "marksman",+ "requirements": [+ {+ "level": 1,+ "type": "fc",+ "value": 3+ },+ {+ "level": 2,+ "type": "fc",+ "value": 5+ }+ ],+ "trigger": {+ "type": "attack",+ "probability": [+ 20,+ 30+ ],+ "source": "marksman"+ },+ "effects": {+ "CrystalGunpowder/1": {+ "type": "extra_skill_attack",+ "value": [+ 50,+ 50+ ],+ "units": {+ "applies_to": [+ "marksman"+ ],+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ },+ "FlameCharge": {+ "description": "Increase Marksmen's basic Attack by 4%.",+ "troop_type": "marksman",+ "requirements": [+ {+ "level": 1,+ "type": "fc",+ "value": 8+ }+ ],+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "FlameCharge/1": {+ "type": "active.troop.attack.up",+ "value": [+ 4+ ],+ "units": {+ "applies_to": [+ "marksman"+ ],+ "applies_vs": "any"+ }+ }+ }+ },+ "FlameChargeExtra": {+ "description": "Marksmen can make an additional extra skill attack while Crystal Gunpowder is active.",+ "troop_type": "marksman",+ "requirements": [+ {+ "level": 1,+ "type": "fc",+ "value": 8+ }+ ],+ "trigger": {+ "type": "attack",+ "source": "marksman"+ },+ "effects": {+ "FlameChargeExtra/1": {+ "type": "extra_skill_attack",+ "value": [+ 25+ ],+ "units": {+ "applies_to": [+ "marksman"+ ],+ "applies_vs": "trigger.target"+ },+ "duration": {+ "type": "attack",+ "value": 1+ },+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "effect.applies_vs"+ }+ ]+ }+ }+ }+ }+}Index: simulator/config/troop_stats.json===================================================================--- simulator/config/troop_stats.json prev run+++ simulator/config/troop_stats.json this run@@ -0,0 +1,2570 @@+{+ "infantry_t1": {+ "id": "infantry_t1",+ "type": "infantry",+ "tier": 1,+ "fc": 0,+ "stats": {+ "Attack": 63,+ "Defense": 10,+ "Lethality": 10,+ "Health": 189+ }+ },+ "alliance_boss_infantry_t1": {+ "id": "alliance_boss_infantry_t1",+ "type": "infantry",+ "tier": 1,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 60,+ "Lethality": 20,+ "Health": 27+ }+ },+ "infantry_t1_fc1": {+ "id": "infantry_t1_fc1",+ "type": "infantry",+ "tier": 1,+ "fc": 1,+ "stats": {+ "Attack": 66,+ "Defense": 10,+ "Lethality": 10,+ "Health": 197+ }+ },+ "infantry_t1_fc2": {+ "id": "infantry_t1_fc2",+ "type": "infantry",+ "tier": 1,+ "fc": 2,+ "stats": {+ "Attack": 69,+ "Defense": 10,+ "Lethality": 10,+ "Health": 206+ }+ },+ "infantry_t1_fc3": {+ "id": "infantry_t1_fc3",+ "type": "infantry",+ "tier": 1,+ "fc": 3,+ "stats": {+ "Attack": 72,+ "Defense": 10,+ "Lethality": 10,+ "Health": 217+ }+ },+ "infantry_t1_fc4": {+ "id": "infantry_t1_fc4",+ "type": "infantry",+ "tier": 1,+ "fc": 4,+ "stats": {+ "Attack": 76,+ "Defense": 10,+ "Lethality": 10,+ "Health": 228+ }+ },+ "infantry_t1_fc5": {+ "id": "infantry_t1_fc5",+ "type": "infantry",+ "tier": 1,+ "fc": 5,+ "stats": {+ "Attack": 80,+ "Defense": 10,+ "Lethality": 10,+ "Health": 239+ }+ },+ "infantry_t2": {+ "id": "infantry_t2",+ "type": "infantry",+ "tier": 2,+ "fc": 0,+ "stats": {+ "Attack": 94,+ "Defense": 10,+ "Lethality": 10,+ "Health": 283+ }+ },+ "alliance_boss_infantry_t2": {+ "id": "alliance_boss_infantry_t2",+ "type": "infantry",+ "tier": 2,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 75,+ "Lethality": 22,+ "Health": 28+ }+ },+ "infantry_t2_fc1": {+ "id": "infantry_t2_fc1",+ "type": "infantry",+ "tier": 2,+ "fc": 1,+ "stats": {+ "Attack": 98,+ "Defense": 10,+ "Lethality": 10,+ "Health": 294+ }+ },+ "infantry_t2_fc2": {+ "id": "infantry_t2_fc2",+ "type": "infantry",+ "tier": 2,+ "fc": 2,+ "stats": {+ "Attack": 103,+ "Defense": 10,+ "Lethality": 10,+ "Health": 309+ }+ },+ "infantry_t2_fc3": {+ "id": "infantry_t2_fc3",+ "type": "infantry",+ "tier": 2,+ "fc": 3,+ "stats": {+ "Attack": 108,+ "Defense": 10,+ "Lethality": 10,+ "Health": 324+ }+ },+ "infantry_t2_fc4": {+ "id": "infantry_t2_fc4",+ "type": "infantry",+ "tier": 2,+ "fc": 4,+ "stats": {+ "Attack": 113,+ "Defense": 10,+ "Lethality": 10,+ "Health": 341+ }+ },+ "infantry_t2_fc5": {+ "id": "infantry_t2_fc5",+ "type": "infantry",+ "tier": 2,+ "fc": 5,+ "stats": {+ "Attack": 119,+ "Defense": 10,+ "Lethality": 10,+ "Health": 358+ }+ },+ "infantry_t3": {+ "id": "infantry_t3",+ "type": "infantry",+ "tier": 3,+ "fc": 0,+ "stats": {+ "Attack": 132,+ "Defense": 10,+ "Lethality": 10,+ "Health": 397+ }+ },+ "alliance_boss_infantry_t3": {+ "id": "alliance_boss_infantry_t3",+ "type": "infantry",+ "tier": 3,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 95,+ "Lethality": 25,+ "Health": 30+ }+ },+ "infantry_t3_fc1": {+ "id": "infantry_t3_fc1",+ "type": "infantry",+ "tier": 3,+ "fc": 1,+ "stats": {+ "Attack": 137,+ "Defense": 10,+ "Lethality": 10,+ "Health": 413+ }+ },+ "infantry_t3_fc2": {+ "id": "infantry_t3_fc2",+ "type": "infantry",+ "tier": 3,+ "fc": 2,+ "stats": {+ "Attack": 144,+ "Defense": 10,+ "Lethality": 10,+ "Health": 434+ }+ },+ "infantry_t3_fc3": {+ "id": "infantry_t3_fc3",+ "type": "infantry",+ "tier": 3,+ "fc": 3,+ "stats": {+ "Attack": 151,+ "Defense": 10,+ "Lethality": 10,+ "Health": 455+ }+ },+ "infantry_t3_fc4": {+ "id": "infantry_t3_fc4",+ "type": "infantry",+ "tier": 3,+ "fc": 4,+ "stats": {+ "Attack": 159,+ "Defense": 10,+ "Lethality": 10,+ "Health": 478+ }+ },+ "infantry_t3_fc5": {+ "id": "infantry_t3_fc5",+ "type": "infantry",+ "tier": 3,+ "fc": 5,+ "stats": {+ "Attack": 167,+ "Defense": 10,+ "Lethality": 10,+ "Health": 502+ }+ },+ "infantry_t4": {+ "id": "infantry_t4",+ "type": "infantry",+ "tier": 4,+ "fc": 0,+ "stats": {+ "Attack": 172,+ "Defense": 10,+ "Lethality": 10,+ "Health": 516+ }+ },+ "alliance_boss_infantry_t4": {+ "id": "alliance_boss_infantry_t4",+ "type": "infantry",+ "tier": 4,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 129,+ "Lethality": 29,+ "Health": 29+ }+ },+ "infantry_t4_fc1": {+ "id": "infantry_t4_fc1",+ "type": "infantry",+ "tier": 4,+ "fc": 1,+ "stats": {+ "Attack": 179,+ "Defense": 10,+ "Lethality": 10,+ "Health": 537+ }+ },+ "infantry_t4_fc2": {+ "id": "infantry_t4_fc2",+ "type": "infantry",+ "tier": 4,+ "fc": 2,+ "stats": {+ "Attack": 188,+ "Defense": 10,+ "Lethality": 10,+ "Health": 563+ }+ },+ "infantry_t4_fc3": {+ "id": "infantry_t4_fc3",+ "type": "infantry",+ "tier": 4,+ "fc": 3,+ "stats": {+ "Attack": 197,+ "Defense": 10,+ "Lethality": 10,+ "Health": 592+ }+ },+ "infantry_t4_fc4": {+ "id": "infantry_t4_fc4",+ "type": "infantry",+ "tier": 4,+ "fc": 4,+ "stats": {+ "Attack": 207,+ "Defense": 10,+ "Lethality": 10,+ "Health": 621+ }+ },+ "infantry_t4_fc5": {+ "id": "infantry_t4_fc5",+ "type": "infantry",+ "tier": 4,+ "fc": 5,+ "stats": {+ "Attack": 217,+ "Defense": 10,+ "Lethality": 10,+ "Health": 652+ }+ },+ "infantry_t5": {+ "id": "infantry_t5",+ "type": "infantry",+ "tier": 5,+ "fc": 0,+ "stats": {+ "Attack": 206,+ "Defense": 10,+ "Lethality": 10,+ "Health": 619+ }+ },+ "alliance_boss_infantry_t5": {+ "id": "alliance_boss_infantry_t5",+ "type": "infantry",+ "tier": 5,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 175,+ "Lethality": 34,+ "Health": 28+ }+ },+ "infantry_t5_fc1": {+ "id": "infantry_t5_fc1",+ "type": "infantry",+ "tier": 5,+ "fc": 1,+ "stats": {+ "Attack": 214,+ "Defense": 10,+ "Lethality": 10,+ "Health": 644+ }+ },+ "infantry_t5_fc2": {+ "id": "infantry_t5_fc2",+ "type": "infantry",+ "tier": 5,+ "fc": 2,+ "stats": {+ "Attack": 225,+ "Defense": 10,+ "Lethality": 10,+ "Health": 676+ }+ },+ "infantry_t5_fc3": {+ "id": "infantry_t5_fc3",+ "type": "infantry",+ "tier": 5,+ "fc": 3,+ "stats": {+ "Attack": 236,+ "Defense": 10,+ "Lethality": 10,+ "Health": 710+ }+ },+ "infantry_t5_fc4": {+ "id": "infantry_t5_fc4",+ "type": "infantry",+ "tier": 5,+ "fc": 4,+ "stats": {+ "Attack": 248,+ "Defense": 10,+ "Lethality": 10,+ "Health": 745+ }+ },+ "infantry_t5_fc5": {+ "id": "infantry_t5_fc5",+ "type": "infantry",+ "tier": 5,+ "fc": 5,+ "stats": {+ "Attack": 260,+ "Defense": 10,+ "Lethality": 10,+ "Health": 782+ }+ },+ "infantry_t6": {+ "id": "infantry_t6",+ "type": "infantry",+ "tier": 6,+ "fc": 0,+ "stats": {+ "Attack": 243,+ "Defense": 10,+ "Lethality": 10,+ "Health": 730+ }+ },+ "alliance_boss_infantry_t6": {+ "id": "alliance_boss_infantry_t6",+ "type": "infantry",+ "tier": 6,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 190,+ "Lethality": 36,+ "Health": 35+ }+ },+ "infantry_t6_fc1": {+ "id": "infantry_t6_fc1",+ "type": "infantry",+ "tier": 6,+ "fc": 1,+ "stats": {+ "Attack": 253,+ "Defense": 10,+ "Lethality": 10,+ "Health": 759+ }+ },+ "infantry_t6_fc2": {+ "id": "infantry_t6_fc2",+ "type": "infantry",+ "tier": 6,+ "fc": 2,+ "stats": {+ "Attack": 265,+ "Defense": 10,+ "Lethality": 10,+ "Health": 797+ }+ },+ "infantry_t6_fc3": {+ "id": "infantry_t6_fc3",+ "type": "infantry",+ "tier": 6,+ "fc": 3,+ "stats": {+ "Attack": 279,+ "Defense": 10,+ "Lethality": 10,+ "Health": 837+ }+ },+ "infantry_t6_fc4": {+ "id": "infantry_t6_fc4",+ "type": "infantry",+ "tier": 6,+ "fc": 4,+ "stats": {+ "Attack": 293,+ "Defense": 10,+ "Lethality": 10,+ "Health": 879+ }+ },+ "infantry_t6_fc5": {+ "id": "infantry_t6_fc5",+ "type": "infantry",+ "tier": 6,+ "fc": 5,+ "stats": {+ "Attack": 307,+ "Defense": 10,+ "Lethality": 10,+ "Health": 923+ }+ },+ "infantry_t7": {+ "id": "infantry_t7",+ "type": "infantry",+ "tier": 7,+ "fc": 0,+ "stats": {+ "Attack": 287,+ "Defense": 10,+ "Lethality": 10,+ "Health": 862+ }+ },+ "alliance_boss_infantry_t7": {+ "id": "alliance_boss_infantry_t7",+ "type": "infantry",+ "tier": 7,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 230,+ "Lethality": 39,+ "Health": 39+ }+ },+ "infantry_t7_fc1": {+ "id": "infantry_t7_fc1",+ "type": "infantry",+ "tier": 7,+ "fc": 1,+ "stats": {+ "Attack": 298,+ "Defense": 10,+ "Lethality": 10,+ "Health": 896+ }+ },+ "infantry_t7_fc2": {+ "id": "infantry_t7_fc2",+ "type": "infantry",+ "tier": 7,+ "fc": 2,+ "stats": {+ "Attack": 313,+ "Defense": 10,+ "Lethality": 10,+ "Health": 941+ }+ },+ "infantry_t7_fc3": {+ "id": "infantry_t7_fc3",+ "type": "infantry",+ "tier": 7,+ "fc": 3,+ "stats": {+ "Attack": 329,+ "Defense": 10,+ "Lethality": 10,+ "Health": 988+ }+ },+ "infantry_t7_fc4": {+ "id": "infantry_t7_fc4",+ "type": "infantry",+ "tier": 7,+ "fc": 4,+ "stats": {+ "Attack": 346,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1038+ }+ },+ "infantry_t7_fc5": {+ "id": "infantry_t7_fc5",+ "type": "infantry",+ "tier": 7,+ "fc": 5,+ "stats": {+ "Attack": 363,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1090+ }+ },+ "infantry_t8": {+ "id": "infantry_t8",+ "type": "infantry",+ "tier": 8,+ "fc": 0,+ "stats": {+ "Attack": 339,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1017+ }+ },+ "alliance_boss_infantry_t8": {+ "id": "alliance_boss_infantry_t8",+ "type": "infantry",+ "tier": 8,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 262,+ "Lethality": 42,+ "Health": 47+ }+ },+ "infantry_t8_fc1": {+ "id": "infantry_t8_fc1",+ "type": "infantry",+ "tier": 8,+ "fc": 1,+ "stats": {+ "Attack": 353,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1058+ }+ },+ "infantry_t8_fc2": {+ "id": "infantry_t8_fc2",+ "type": "infantry",+ "tier": 8,+ "fc": 2,+ "stats": {+ "Attack": 370,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1111+ }+ },+ "infantry_t8_fc3": {+ "id": "infantry_t8_fc3",+ "type": "infantry",+ "tier": 8,+ "fc": 3,+ "stats": {+ "Attack": 389,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1166+ }+ },+ "infantry_t8_fc4": {+ "id": "infantry_t8_fc4",+ "type": "infantry",+ "tier": 8,+ "fc": 4,+ "stats": {+ "Attack": 408,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1224+ }+ },+ "infantry_t8_fc5": {+ "id": "infantry_t8_fc5",+ "type": "infantry",+ "tier": 8,+ "fc": 5,+ "stats": {+ "Attack": 429,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1286+ }+ },+ "infantry_t9": {+ "id": "infantry_t9",+ "type": "infantry",+ "tier": 9,+ "fc": 0,+ "stats": {+ "Attack": 400,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1200+ }+ },+ "alliance_boss_infantry_t9": {+ "id": "alliance_boss_infantry_t9",+ "type": "infantry",+ "tier": 9,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 292,+ "Lethality": 44,+ "Health": 57+ }+ },+ "infantry_t9_fc1": {+ "id": "infantry_t9_fc1",+ "type": "infantry",+ "tier": 9,+ "fc": 1,+ "stats": {+ "Attack": 416,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1248+ }+ },+ "infantry_t9_fc2": {+ "id": "infantry_t9_fc2",+ "type": "infantry",+ "tier": 9,+ "fc": 2,+ "stats": {+ "Attack": 437,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1310+ }+ },+ "infantry_t9_fc3": {+ "id": "infantry_t9_fc3",+ "type": "infantry",+ "tier": 9,+ "fc": 3,+ "stats": {+ "Attack": 459,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1376+ }+ },+ "infantry_t9_fc4": {+ "id": "infantry_t9_fc4",+ "type": "infantry",+ "tier": 9,+ "fc": 4,+ "stats": {+ "Attack": 482,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1445+ }+ },+ "infantry_t9_fc5": {+ "id": "infantry_t9_fc5",+ "type": "infantry",+ "tier": 9,+ "fc": 5,+ "stats": {+ "Attack": 506,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1517+ }+ },+ "infantry_t10": {+ "id": "infantry_t10",+ "type": "infantry",+ "tier": 10,+ "fc": 0,+ "stats": {+ "Attack": 472,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1416+ }+ },+ "alliance_boss_infantry_t10": {+ "id": "alliance_boss_infantry_t10",+ "type": "infantry",+ "tier": 10,+ "fc": 0,+ "stats": {+ "Attack": 0,+ "Defense": 320,+ "Lethality": 46,+ "Health": 70+ }+ },+ "infantry_t10_fc1": {+ "id": "infantry_t10_fc1",+ "type": "infantry",+ "tier": 10,+ "fc": 1,+ "stats": {+ "Attack": 491,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1473+ }+ },+ "infantry_t10_fc2": {+ "id": "infantry_t10_fc2",+ "type": "infantry",+ "tier": 10,+ "fc": 2,+ "stats": {+ "Attack": 515,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1546+ }+ },+ "infantry_t10_fc3": {+ "id": "infantry_t10_fc3",+ "type": "infantry",+ "tier": 10,+ "fc": 3,+ "stats": {+ "Attack": 541,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1624+ }+ },+ "infantry_t10_fc4": {+ "id": "infantry_t10_fc4",+ "type": "infantry",+ "tier": 10,+ "fc": 4,+ "stats": {+ "Attack": 568,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1705+ }+ },+ "infantry_t10_fc5": {+ "id": "infantry_t10_fc5",+ "type": "infantry",+ "tier": 10,+ "fc": 5,+ "stats": {+ "Attack": 597,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1790+ }+ },+ "infantry_t10_fc6": {+ "id": "infantry_t10_fc6",+ "type": "infantry",+ "tier": 10,+ "fc": 6,+ "stats": {+ "Attack": 627,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1880+ }+ },+ "infantry_t10_fc7": {+ "id": "infantry_t10_fc7",+ "type": "infantry",+ "tier": 10,+ "fc": 7,+ "stats": {+ "Attack": 658,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1974+ }+ },+ "infantry_t10_fc8": {+ "id": "infantry_t10_fc8",+ "type": "infantry",+ "tier": 10,+ "fc": 8,+ "stats": {+ "Attack": 691,+ "Defense": 10,+ "Lethality": 10,+ "Health": 2073+ }+ },+ "infantry_t11": {+ "id": "infantry_t11",+ "type": "infantry",+ "tier": 11,+ "fc": 0,+ "stats": {+ "Attack": 551,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1653+ }+ },+ "infantry_t11_fc5": {+ "id": "infantry_t11_fc5",+ "type": "infantry",+ "tier": 11,+ "fc": 5,+ "stats": {+ "Attack": 658,+ "Defense": 10,+ "Lethality": 10,+ "Health": 1973+ }+ },+ "infantry_t11_fc6": {+ "id": "infantry_t11_fc6",+ "type": "infantry",+ "tier": 11,+ "fc": 6,+ "stats": {+ "Attack": 691,+ "Defense": 10,+ "Lethality": 10,+ "Health": 2072+ }+ },+ "infantry_t11_fc7": {+ "id": "infantry_t11_fc7",+ "type": "infantry",+ "tier": 11,+ "fc": 7,+ "stats": {+ "Attack": 726,+ "Defense": 10,+ "Lethality": 10,+ "Health": 2176+ }+ },+ "infantry_t11_fc8": {+ "id": "infantry_t11_fc8",+ "type": "infantry",+ "tier": 11,+ "fc": 8,+ "stats": {+ "Attack": 762,+ "Defense": 10,+ "Lethality": 10,+ "Health": 2285+ }+ },+ "lancer_t10_fc6": {+ "id": "lancer_t10_fc6",+ "type": "lancer",+ "tier": 10,+ "fc": 6,+ "stats": {+ "Attack": 1880,+ "Defense": 10,+ "Lethality": 10,+ "Health": 627+ }+ },+ "lancer_t10_fc7": {+ "id": "lancer_t10_fc7",+ "type": "lancer",+ "tier": 10,+ "fc": 7,+ "stats": {+ "Attack": 1974,+ "Defense": 10,+ "Lethality": 10,+ "Health": 658+ }+ },+ "lancer_t10_fc8": {+ "id": "lancer_t10_fc8",+ "type": "lancer",+ "tier": 10,+ "fc": 8,+ "stats": {+ "Attack": 2073,+ "Defense": 10,+ "Lethality": 10,+ "Health": 691+ }+ },+ "lancer_t11_fc6": {+ "id": "lancer_t11_fc6",+ "type": "lancer",+ "tier": 11,+ "fc": 6,+ "stats": {+ "Attack": 2072,+ "Defense": 10,+ "Lethality": 10,+ "Health": 691+ }+ },+ "lancer_t11_fc7": {+ "id": "lancer_t11_fc7",+ "type": "lancer",+ "tier": 11,+ "fc": 7,+ "stats": {+ "Attack": 2176,+ "Defense": 10,+ "Lethality": 10,+ "Health": 726+ }+ },+ "lancer_t11_fc8": {+ "id": "lancer_t11_fc8",+ "type": "lancer",+ "tier": 11,+ "fc": 8,+ "stats": {+ "Attack": 2285,+ "Defense": 10,+ "Lethality": 10,+ "Health": 762+ }+ },+ "marksman_t10_fc6": {+ "id": "marksman_t10_fc6",+ "type": "marksman",+ "tier": 10,+ "fc": 6,+ "stats": {+ "Attack": 2506,+ "Defense": 10,+ "Lethality": 10,+ "Health": 470+ }+ },+ "marksman_t10_fc7": {+ "id": "marksman_t10_fc7",+ "type": "marksman",+ "tier": 10,+ "fc": 7,+ "stats": {+ "Attack": 2631,+ "Defense": 10,+ "Lethality": 10,+ "Health": 494+ }+ },+ "marksman_t10_fc8": {+ "id": "marksman_t10_fc8",+ "type": "marksman",+ "tier": 10,+ "fc": 8,+ "stats": {+ "Attack": 2763,+ "Defense": 10,+ "Lethality": 10,+ "Health": 519+ }+ },+ "marksman_t11_fc6": {+ "id": "marksman_t11_fc6",+ "type": "marksman",+ "tier": 11,+ "fc": 6,+ "stats": {+ "Attack": 2764,+ "Defense": 10,+ "Lethality": 10,+ "Health": 519+ }+ },+ "marksman_t11_fc7": {+ "id": "marksman_t11_fc7",+ "type": "marksman",+ "tier": 11,+ "fc": 7,+ "stats": {+ "Attack": 2902,+ "Defense": 10,+ "Lethality": 10,+ "Health": 545+ }+ },+ "marksman_t11_fc8": {+ "id": "marksman_t11_fc8",+ "type": "marksman",+ "tier": 11,+ "fc": 8,+ "stats": {+ "Attack": 3047,+ "Defense": 10,+ "Lethality": 10,+ "Health": 572+ }+ },+ "lancer_t1": {+ "id": "lancer_t1",+ "type": "lancers",+ "tier": 1,+ "fc": 0,+ "stats": {+ "Attack": 189,+ "Defense": 10,+ "Lethality": 10,+ "Health": 63+ }+ },+ "marksman_t1": {+ "id": "marksman_t1",+ "type": "marksmen",+ "tier": 1,+ "fc": 0,+ "stats": {+ "Attack": 252,+ "Defense": 10,+ "Lethality": 10,+ "Health": 47+ }+ },+ "lancer_t1_fc1": {+ "id": "lancer_t1_fc1",+ "type": "lancers",+ "tier": 1,+ "fc": 1,+ "stats": {+ "Attack": 197,+ "Defense": 10,+ "Lethality": 10,+ "Health": 66+ }+ },+ "marksman_t1_fc1": {+ "id": "marksman_t1_fc1",+ "type": "marksmen",+ "tier": 1,+ "fc": 1,+ "stats": {+ "Attack": 262,+ "Defense": 10,+ "Lethality": 10,+ "Health": 49+ }+ },+ "lancer_t1_fc2": {+ "id": "lancer_t1_fc2",+ "type": "lancers",+ "tier": 1,+ "fc": 2,+ "stats": {+ "Attack": 206,+ "Defense": 10,+ "Lethality": 10,+ "Health": 69+ }+ },+ "marksman_t1_fc2": {+ "id": "marksman_t1_fc2",+ "type": "marksmen",+ "tier": 1,+ "fc": 2,+ "stats": {+ "Attack": 275,+ "Defense": 10,+ "Lethality": 10,+ "Health": 51+ }+ },+ "lancer_t1_fc3": {+ "id": "lancer_t1_fc3",+ "type": "lancers",+ "tier": 1,+ "fc": 3,+ "stats": {+ "Attack": 217,+ "Defense": 10,+ "Lethality": 10,+ "Health": 72+ }+ },+ "marksman_t1_fc3": {+ "id": "marksman_t1_fc3",+ "type": "marksmen",+ "tier": 1,+ "fc": 3,+ "stats": {+ "Attack": 289,+ "Defense": 10,+ "Lethality": 10,+ "Health": 54+ }+ },+ "lancer_t1_fc4": {+ "id": "lancer_t1_fc4",+ "type": "lancers",+ "tier": 1,+ "fc": 4,+ "stats": {+ "Attack": 228,+ "Defense": 10,+ "Lethality": 10,+ "Health": 76+ }+ },+ "marksman_t1_fc4": {+ "id": "marksman_t1_fc4",+ "type": "marksmen",+ "tier": 1,+ "fc": 4,+ "stats": {+ "Attack": 303,+ "Defense": 10,+ "Lethality": 10,+ "Health": 57+ }+ },+ "lancer_t1_fc5": {+ "id": "lancer_t1_fc5",+ "type": "lancers",+ "tier": 1,+ "fc": 5,+ "stats": {+ "Attack": 239,+ "Defense": 10,+ "Lethality": 10,+ "Health": 80+ }+ },+ "marksman_t1_fc5": {+ "id": "marksman_t1_fc5",+ "type": "marksmen",+ "tier": 1,+ "fc": 5,+ "stats": {+ "Attack": 319,+ "Defense": 10,+ "Lethality": 10,+ "Health": 59+ }+ },+ "lancer_t2": {+ "id": "lancer_t2",+ "type": "lancers",+ "tier": 2,+ "fc": 0,+ "stats": {+ "Attack": 283,+ "Defense": 10,+ "Lethality": 10,+ "Health": 94+ }+ },+ "marksman_t2": {+ "id": "marksman_t2",+ "type": "marksmen",+ "tier": 2,+ "fc": 0,+ "stats": {+ "Attack": 378,+ "Defense": 10,+ "Lethality": 10,+ "Health": 71+ }+ },+ "lancer_t2_fc1": {+ "id": "lancer_t2_fc1",+ "type": "lancers",+ "tier": 2,+ "fc": 1,+ "stats": {+ "Attack": 294,+ "Defense": 10,+ "Lethality": 10,+ "Health": 98+ }+ },+ "marksman_t2_fc1": {+ "id": "marksman_t2_fc1",+ "type": "marksmen",+ "tier": 2,+ "fc": 1,+ "stats": {+ "Attack": 393,+ "Defense": 10,+ "Lethality": 10,+ "Health": 74+ }+ },+ "lancer_t2_fc2": {+ "id": "lancer_t2_fc2",+ "type": "lancers",+ "tier": 2,+ "fc": 2,+ "stats": {+ "Attack": 309,+ "Defense": 10,+ "Lethality": 10,+ "Health": 103+ }+ },+ "marksman_t2_fc2": {+ "id": "marksman_t2_fc2",+ "type": "marksmen",+ "tier": 2,+ "fc": 2,+ "stats": {+ "Attack": 413,+ "Defense": 10,+ "Lethality": 10,+ "Health": 78+ }+ },+ "lancer_t2_fc3": {+ "id": "lancer_t2_fc3",+ "type": "lancers",+ "tier": 2,+ "fc": 3,+ "stats": {+ "Attack": 324,+ "Defense": 10,+ "Lethality": 10,+ "Health": 108+ }+ },+ "marksman_t2_fc3": {+ "id": "marksman_t2_fc3",+ "type": "marksmen",+ "tier": 2,+ "fc": 3,+ "stats": {+ "Attack": 433,+ "Defense": 10,+ "Lethality": 10,+ "Health": 81+ }+ },+ "lancer_t2_fc4": {+ "id": "lancer_t2_fc4",+ "type": "lancers",+ "tier": 2,+ "fc": 4,+ "stats": {+ "Attack": 341,+ "Defense": 10,+ "Lethality": 10,+ "Health": 113+ }+ },+ "marksman_t2_fc4": {+ "id": "marksman_t2_fc4",+ "type": "marksmen",+ "tier": 2,+ "fc": 4,+ "stats": {+ "Attack": 455,+ "Defense": 10,+ "Lethality": 10,+ "Health": 85+ }+ },+ "lancer_t2_fc5": {+ "id": "lancer_t2_fc5",+ "type": "lancers",+ "tier": 2,+ "fc": 5,+ "stats": {+ "Attack": 358,+ "Defense": 10,+ "Lethality": 10,+ "Health": 119+ }+ },+ "marksman_t2_fc5": {+ "id": "marksman_t2_fc5",+ "type": "marksmen",+ "tier": 2,+ "fc": 5,+ "stats": {+ "Attack": 478,+ "Defense": 10,+ "Lethality": 10,+ "Health": 90+ }+ },+ "lancer_t3": {+ "id": "lancer_t3",+ "type": "lancers",+ "tier": 3,+ "fc": 0,+ "stats": {+ "Attack": 397,+ "Defense": 10,+ "Lethality": 10,+ "Health": 132+ }+ },+ "marksman_t3": {+ "id": "marksman_t3",+ "type": "marksmen",+ "tier": 3,+ "fc": 0,+ "stats": {+ "Attack": 529,+ "Defense": 10,+ "Lethality": 10,+ "Health": 99+ }+ },+ "lancer_t3_fc1": {+ "id": "lancer_t3_fc1",+ "type": "lancers",+ "tier": 3,+ "fc": 1,+ "stats": {+ "Attack": 413,+ "Defense": 10,+ "Lethality": 10,+ "Health": 137+ }+ },+ "marksman_t3_fc1": {+ "id": "marksman_t3_fc1",+ "type": "marksmen",+ "tier": 3,+ "fc": 1,+ "stats": {+ "Attack": 550,+ "Defense": 10,+ "Lethality": 10,+ "Health": 103+ }+ },+ "lancer_t3_fc2": {+ "id": "lancer_t3_fc2",+ "type": "lancers",+ "tier": 3,+ "fc": 2,+ "stats": {+ "Attack": 434,+ "Defense": 10,+ "Lethality": 10,+ "Health": 144+ }+ },+ "marksman_t3_fc2": {+ "id": "marksman_t3_fc2",+ "type": "marksmen",+ "tier": 3,+ "fc": 2,+ "stats": {+ "Attack": 578,+ "Defense": 10,+ "Lethality": 10,+ "Health": 108+ }+ },+ "lancer_t3_fc3": {+ "id": "lancer_t3_fc3",+ "type": "lancers",+ "tier": 3,+ "fc": 3,+ "stats": {+ "Attack": 455,+ "Defense": 10,+ "Lethality": 10,+ "Health": 151+ }+ },+ "marksman_t3_fc3": {+ "id": "marksman_t3_fc3",+ "type": "marksmen",+ "tier": 3,+ "fc": 3,+ "stats": {+ "Attack": 607,+ "Defense": 10,+ "Lethality": 10,+ "Health": 114+ }+ },+ "lancer_t3_fc4": {+ "id": "lancer_t3_fc4",+ "type": "lancers",+ "tier": 3,+ "fc": 4,+ "stats": {+ "Attack": 478,+ "Defense": 10,+ "Lethality": 10,+ "Health": 159+ }+ },+ "marksman_t3_fc4": {+ "id": "marksman_t3_fc4",+ "type": "marksmen",+ "tier": 3,+ "fc": 4,+ "stats": {+ "Attack": 637,+ "Defense": 10,+ "Lethality": 10,+ "Health": 119+ }+ },+ "lancer_t3_fc5": {+ "id": "lancer_t3_fc5",+ "type": "lancers",+ "tier": 3,+ "fc": 5,+ "stats": {+ "Attack": 502,+ "Defense": 10,+ "Lethality": 10,+ "Health": 167+ }+ },+ "marksman_t3_fc5": {+ "id": "marksman_t3_fc5",+ "type": "marksmen",+ "tier": 3,+ "fc": 5,+ "stats": {+ "Attack": 669,+ "Defense": 10,+ "Lethality": 10,+ "Health": 125+ }+ },+ "lancer_t4": {+ "id": "lancer_t4",+ "type": "lancers",+ "tier": 4,+ "fc": 0,+ "stats": {+ "Attack": 516,+ "Defense": 10,+ "Lethality": 10,+ "Health": 172+ }+ },+ "marksman_t4": {+ "id": "marksman_t4",+ "type": "marksmen",+ "tier": 4,+ "fc": 0,+ "stats": {+ "Attack": 688,+ "Defense": 10,+ "Lethality": 10,+ "Health": 129+ }+ },+ "lancer_t4_fc1": {+ "id": "lancer_t4_fc1",+ "type": "lancers",+ "tier": 4,+ "fc": 1,+ "stats": {+ "Attack": 537,+ "Defense": 10,+ "Lethality": 10,+ "Health": 179+ }+ },+ "marksman_t4_fc1": {+ "id": "marksman_t4_fc1",+ "type": "marksmen",+ "tier": 4,+ "fc": 1,+ "stats": {+ "Attack": 716,+ "Defense": 10,+ "Lethality": 10,+ "Health": 134+ }+ },+ "lancer_t4_fc2": {+ "id": "lancer_t4_fc2",+ "type": "lancers",+ "tier": 4,+ "fc": 2,+ "stats": {+ "Attack": 563,+ "Defense": 10,+ "Lethality": 10,+ "Health": 188+ }+ },+ "marksman_t4_fc2": {+ "id": "marksman_t4_fc2",+ "type": "marksmen",+ "tier": 4,+ "fc": 2,+ "stats": {+ "Attack": 751,+ "Defense": 10,+ "Lethality": 10,+ "Health": 141+ }+ },+ "lancer_t4_fc3": {+ "id": "lancer_t4_fc3",+ "type": "lancers",+ "tier": 4,+ "fc": 3,+ "stats": {+ "Attack": 592,+ "Defense": 10,+ "Lethality": 10,+ "Health": 197+ }+ },+ "marksman_t4_fc3": {+ "id": "marksman_t4_fc3",+ "type": "marksmen",+ "tier": 4,+ "fc": 3,+ "stats": {+ "Attack": 789,+ "Defense": 10,+ "Lethality": 10,+ "Health": 148+ }+ },+ "lancer_t4_fc4": {+ "id": "lancer_t4_fc4",+ "type": "lancers",+ "tier": 4,+ "fc": 4,+ "stats": {+ "Attack": 621,+ "Defense": 10,+ "Lethality": 10,+ "Health": 207+ }+ },+ "marksman_t4_fc4": {+ "id": "marksman_t4_fc4",+ "type": "marksmen",+ "tier": 4,+ "fc": 4,+ "stats": {+ "Attack": 828,+ "Defense": 10,+ "Lethality": 10,+ "Health": 155+ }+ },+ "lancer_t4_fc5": {+ "id": "lancer_t4_fc5",+ "type": "lancers",+ "tier": 4,+ "fc": 5,+ "stats": {+ "Attack": 652,+ "Defense": 10,+ "Lethality": 10,+ "Health": 217+ }+ },+ "marksman_t4_fc5": {+ "id": "marksman_t4_fc5",+ "type": "marksmen",+ "tier": 4,+ "fc": 5,+ "stats": {+ "Attack": 870,+ "Defense": 10,+ "Lethality": 10,+ "Health": 163+ }+ },+ "lancer_t5": {+ "id": "lancer_t5",+ "type": "lancers",+ "tier": 5,+ "fc": 0,+ "stats": {+ "Attack": 619,+ "Defense": 10,+ "Lethality": 10,+ "Health": 206+ }+ },+ "marksman_t5": {+ "id": "marksman_t5",+ "type": "marksmen",+ "tier": 5,+ "fc": 0,+ "stats": {+ "Attack": 825,+ "Defense": 10,+ "Lethality": 10,+ "Health": 155+ }+ },+ "lancer_t5_fc1": {+ "id": "lancer_t5_fc1",+ "type": "lancers",+ "tier": 5,+ "fc": 1,+ "stats": {+ "Attack": 644,+ "Defense": 10,+ "Lethality": 10,+ "Health": 214+ }+ },+ "marksman_t5_fc1": {+ "id": "marksman_t5_fc1",+ "type": "marksmen",+ "tier": 5,+ "fc": 1,+ "stats": {+ "Attack": 858,+ "Defense": 10,+ "Lethality": 10,+ "Health": 161+ }+ },+ "lancer_t5_fc2": {+ "id": "lancer_t5_fc2",+ "type": "lancers",+ "tier": 5,+ "fc": 2,+ "stats": {+ "Attack": 676,+ "Defense": 10,+ "Lethality": 10,+ "Health": 225+ }+ },+ "marksman_t5_fc2": {+ "id": "marksman_t5_fc2",+ "type": "marksmen",+ "tier": 5,+ "fc": 2,+ "stats": {+ "Attack": 901,+ "Defense": 10,+ "Lethality": 10,+ "Health": 169+ }+ },+ "lancer_t5_fc3": {+ "id": "lancer_t5_fc3",+ "type": "lancers",+ "tier": 5,+ "fc": 3,+ "stats": {+ "Attack": 710,+ "Defense": 10,+ "Lethality": 10,+ "Health": 236+ }+ },+ "marksman_t5_fc3": {+ "id": "marksman_t5_fc3",+ "type": "marksmen",+ "tier": 5,+ "fc": 3,+ "stats": {+ "Attack": 946,+ "Defense": 10,+ "Lethality": 10,+ "Health": 178+ }+ },+ "lancer_t5_fc4": {+ "id": "lancer_t5_fc4",+ "type": "lancers",+ "tier": 5,+ "fc": 4,+ "stats": {+ "Attack": 745,+ "Defense": 10,+ "Lethality": 10,+ "Health": 248+ }+ },+ "marksman_t5_fc4": {+ "id": "marksman_t5_fc4",+ "type": "marksmen",+ "tier": 5,+ "fc": 4,+ "stats": {+ "Attack": 993,+ "Defense": 10,+ "Lethality": 10,+ "Health": 187+ }+ },+ "lancer_t5_fc5": {+ "id": "lancer_t5_fc5",+ "type": "lancers",+ "tier": 5,+ "fc": 5,+ "stats": {+ "Attack": 782,+ "Defense": 10,+ "Lethality": 10,+ "Health": 260+ }+ },+ "marksman_t5_fc5": {+ "id": "marksman_t5_fc5",+ "type": "marksmen",+ "tier": 5,+ "fc": 5,+ "stats": {+ "Attack": 1043,+ "Defense": 10,+ "Lethality": 10,+ "Health": 196+ }+ },+ "lancer_t6": {+ "id": "lancer_t6",+ "type": "lancers",+ "tier": 6,+ "fc": 0,+ "stats": {+ "Attack": 730,+ "Defense": 10,+ "Lethality": 10,+ "Health": 243+ }+ },+ "marksman_t6": {+ "id": "marksman_t6",+ "type": "marksmen",+ "tier": 6,+ "fc": 0,+ "stats": {+ "Attack": 974,+ "Defense": 10,+ "Lethality": 10,+ "Health": 183+ }+ },+ "lancer_t6_fc1": {+ "id": "lancer_t6_fc1",+ "type": "lancers",+ "tier": 6,+ "fc": 1,+ "stats": {+ "Attack": 759,+ "Defense": 10,+ "Lethality": 10,+ "Health": 253+ }+ },+ "marksman_t6_fc1": {+ "id": "marksman_t6_fc1",+ "type": "marksmen",+ "tier": 6,+ "fc": 1,+ "stats": {+ "Attack": 1013,+ "Defense": 10,+ "Lethality": 10,+ "Health": 190+ }+ },+ "lancer_t6_fc2": {+ "id": "lancer_t6_fc2",+ "type": "lancers",+ "tier": 6,+ "fc": 2,+ "stats": {+ "Attack": 797,+ "Defense": 10,+ "Lethality": 10,+ "Health": 265+ }+ },+ "marksman_t6_fc2": {+ "id": "marksman_t6_fc2",+ "type": "marksmen",+ "tier": 6,+ "fc": 2,+ "stats": {+ "Attack": 1064,+ "Defense": 10,+ "Lethality": 10,+ "Health": 200+ }+ },+ "lancer_t6_fc3": {+ "id": "lancer_t6_fc3",+ "type": "lancers",+ "tier": 6,+ "fc": 3,+ "stats": {+ "Attack": 837,+ "Defense": 10,+ "Lethality": 10,+ "Health": 279+ }+ },+ "marksman_t6_fc3": {+ "id": "marksman_t6_fc3",+ "type": "marksmen",+ "tier": 6,+ "fc": 3,+ "stats": {+ "Attack": 1117,+ "Defense": 10,+ "Lethality": 10,+ "Health": 210+ }+ },+ "lancer_t6_fc4": {+ "id": "lancer_t6_fc4",+ "type": "lancers",+ "tier": 6,+ "fc": 4,+ "stats": {+ "Attack": 879,+ "Defense": 10,+ "Lethality": 10,+ "Health": 293+ }+ },+ "marksman_t6_fc4": {+ "id": "marksman_t6_fc4",+ "type": "marksmen",+ "tier": 6,+ "fc": 4,+ "stats": {+ "Attack": 1173,+ "Defense": 10,+ "Lethality": 10,+ "Health": 220+ }+ },+ "lancer_t6_fc5": {+ "id": "lancer_t6_fc5",+ "type": "lancers",+ "tier": 6,+ "fc": 5,+ "stats": {+ "Attack": 923,+ "Defense": 10,+ "Lethality": 10,+ "Health": 307+ }+ },+ "marksman_t6_fc5": {+ "id": "marksman_t6_fc5",+ "type": "marksmen",+ "tier": 6,+ "fc": 5,+ "stats": {+ "Attack": 1231,+ "Defense": 10,+ "Lethality": 10,+ "Health": 231+ }+ },+ "lancer_t7": {+ "id": "lancer_t7",+ "type": "lancers",+ "tier": 7,+ "fc": 0,+ "stats": {+ "Attack": 862,+ "Defense": 10,+ "Lethality": 10,+ "Health": 287+ }+ },+ "marksman_t7": {+ "id": "marksman_t7",+ "type": "marksmen",+ "tier": 7,+ "fc": 0,+ "stats": {+ "Attack": 1149,+ "Defense": 10,+ "Lethality": 10,+ "Health": 215+ }+ },+ "lancer_t7_fc1": {+ "id": "lancer_t7_fc1",+ "type": "lancers",+ "tier": 7,+ "fc": 1,+ "stats": {+ "Attack": 896,+ "Defense": 10,+ "Lethality": 10,+ "Health": 298+ }+ },+ "marksman_t7_fc1": {+ "id": "marksman_t7_fc1",+ "type": "marksmen",+ "tier": 7,+ "fc": 1,+ "stats": {+ "Attack": 1195,+ "Defense": 10,+ "Lethality": 10,+ "Health": 224+ }+ },+ "lancer_t7_fc2": {+ "id": "lancer_t7_fc2",+ "type": "lancers",+ "tier": 7,+ "fc": 2,+ "stats": {+ "Attack": 941,+ "Defense": 10,+ "Lethality": 10,+ "Health": 313+ }+ },+ "marksman_t7_fc2": {+ "id": "marksman_t7_fc2",+ "type": "marksmen",+ "tier": 7,+ "fc": 2,+ "stats": {+ "Attack": 1255,+ "Defense": 10,+ "Lethality": 10,+ "Health": 235+ }+ },+ "lancer_t7_fc3": {+ "id": "lancer_t7_fc3",+ "type": "lancers",+ "tier": 7,+ "fc": 3,+ "stats": {+ "Attack": 988,+ "Defense": 10,+ "Lethality": 10,+ "Health": 329+ }+ },+ "marksman_t7_fc3": {+ "id": "marksman_t7_fc3",+ "type": "marksmen",+ "tier": 7,+ "fc": 3,+ "stats": {+ "Attack": 1317,+ "Defense": 10,+ "Lethality": 10,+ "Health": 247+ }+ },+ "lancer_t7_fc4": {+ "id": "lancer_t7_fc4",+ "type": "lancers",+ "tier": 7,+ "fc": 4,+ "stats": {+ "Attack": 1038,+ "Defense": 10,+ "Lethality": 10,+ "Health": 346+ }+ },+ "marksman_t7_fc4": {+ "id": "marksman_t7_fc4",+ "type": "marksmen",+ "tier": 7,+ "fc": 4,+ "stats": {+ "Attack": 1383,+ "Defense": 10,+ "Lethality": 10,+ "Health": 259+ }+ },+ "lancer_t7_fc5": {+ "id": "lancer_t7_fc5",+ "type": "lancers",+ "tier": 7,+ "fc": 5,+ "stats": {+ "Attack": 1090,+ "Defense": 10,+ "Lethality": 10,+ "Health": 363+ }+ },+ "marksman_t7_fc5": {+ "id": "marksman_t7_fc5",+ "type": "marksmen",+ "tier": 7,+ "fc": 5,+ "stats": {+ "Attack": 1452,+ "Defense": 10,+ "Lethality": 10,+ "Health": 272+ }+ },+ "lancer_t8": {+ "id": "lancer_t8",+ "type": "lancers",+ "tier": 8,+ "fc": 0,+ "stats": {+ "Attack": 1017,+ "Defense": 10,+ "Lethality": 10,+ "Health": 339+ }+ },+ "marksman_t8": {+ "id": "marksman_t8",+ "type": "marksmen",+ "tier": 8,+ "fc": 0,+ "stats": {+ "Attack": 1356,+ "Defense": 10,+ "Lethality": 10,+ "Health": 254+ }+ },+ "lancer_t8_fc1": {+ "id": "lancer_t8_fc1",+ "type": "lancers",+ "tier": 8,+ "fc": 1,+ "stats": {+ "Attack": 1058,+ "Defense": 10,+ "Lethality": 10,+ "Health": 353+ }+ },+ "marksman_t8_fc1": {+ "id": "marksman_t8_fc1",+ "type": "marksmen",+ "tier": 8,+ "fc": 1,+ "stats": {+ "Attack": 1410,+ "Defense": 10,+ "Lethality": 10,+ "Health": 264+ }+ },+ "lancer_t8_fc2": {+ "id": "lancer_t8_fc2",+ "type": "lancers",+ "tier": 8,+ "fc": 2,+ "stats": {+ "Attack": 1111,+ "Defense": 10,+ "Lethality": 10,+ "Health": 370+ }+ },+ "marksman_t8_fc2": {+ "id": "marksman_t8_fc2",+ "type": "marksmen",+ "tier": 8,+ "fc": 2,+ "stats": {+ "Attack": 1481,+ "Defense": 10,+ "Lethality": 10,+ "Health": 277+ }+ },+ "lancer_t8_fc3": {+ "id": "lancer_t8_fc3",+ "type": "lancers",+ "tier": 8,+ "fc": 3,+ "stats": {+ "Attack": 1166,+ "Defense": 10,+ "Lethality": 10,+ "Health": 389+ }+ },+ "marksman_t8_fc3": {+ "id": "marksman_t8_fc3",+ "type": "marksmen",+ "tier": 8,+ "fc": 3,+ "stats": {+ "Attack": 1555,+ "Defense": 10,+ "Lethality": 10,+ "Health": 291+ }+ },+ "lancer_t8_fc4": {+ "id": "lancer_t8_fc4",+ "type": "lancers",+ "tier": 8,+ "fc": 4,+ "stats": {+ "Attack": 1224,+ "Defense": 10,+ "Lethality": 10,+ "Health": 408+ }+ },+ "marksman_t8_fc4": {+ "id": "marksman_t8_fc4",+ "type": "marksmen",+ "tier": 8,+ "fc": 4,+ "stats": {+ "Attack": 1633,+ "Defense": 10,+ "Lethality": 10,+ "Health": 306+ }+ },+ "lancer_t8_fc5": {+ "id": "lancer_t8_fc5",+ "type": "lancers",+ "tier": 8,+ "fc": 5,+ "stats": {+ "Attack": 1286,+ "Defense": 10,+ "Lethality": 10,+ "Health": 429+ }+ },+ "marksman_t8_fc5": {+ "id": "marksman_t8_fc5",+ "type": "marksmen",+ "tier": 8,+ "fc": 5,+ "stats": {+ "Attack": 1714,+ "Defense": 10,+ "Lethality": 10,+ "Health": 321+ }+ },+ "lancer_t9": {+ "id": "lancer_t9",+ "type": "lancers",+ "tier": 9,+ "fc": 0,+ "stats": {+ "Attack": 1200,+ "Defense": 10,+ "Lethality": 10,+ "Health": 400+ }+ },+ "marksman_t9": {+ "id": "marksman_t9",+ "type": "marksmen",+ "tier": 9,+ "fc": 0,+ "stats": {+ "Attack": 1600,+ "Defense": 10,+ "Lethality": 10,+ "Health": 300+ }+ },+ "lancer_t9_fc1": {+ "id": "lancer_t9_fc1",+ "type": "lancers",+ "tier": 9,+ "fc": 1,+ "stats": {+ "Attack": 1248,+ "Defense": 10,+ "Lethality": 10,+ "Health": 416+ }+ },+ "marksman_t9_fc1": {+ "id": "marksman_t9_fc1",+ "type": "marksmen",+ "tier": 9,+ "fc": 1,+ "stats": {+ "Attack": 1664,+ "Defense": 10,+ "Lethality": 10,+ "Health": 312+ }+ },+ "lancer_t9_fc2": {+ "id": "lancer_t9_fc2",+ "type": "lancers",+ "tier": 9,+ "fc": 2,+ "stats": {+ "Attack": 1310,+ "Defense": 10,+ "Lethality": 10,+ "Health": 437+ }+ },+ "marksman_t9_fc2": {+ "id": "marksman_t9_fc2",+ "type": "marksmen",+ "tier": 9,+ "fc": 2,+ "stats": {+ "Attack": 1747,+ "Defense": 10,+ "Lethality": 10,+ "Health": 328+ }+ },+ "lancer_t9_fc3": {+ "id": "lancer_t9_fc3",+ "type": "lancers",+ "tier": 9,+ "fc": 3,+ "stats": {+ "Attack": 1376,+ "Defense": 10,+ "Lethality": 10,+ "Health": 459+ }+ },+ "marksman_t9_fc3": {+ "id": "marksman_t9_fc3",+ "type": "marksmen",+ "tier": 9,+ "fc": 3,+ "stats": {+ "Attack": 1835,+ "Defense": 10,+ "Lethality": 10,+ "Health": 344+ }+ },+ "lancer_t9_fc4": {+ "id": "lancer_t9_fc4",+ "type": "lancers",+ "tier": 9,+ "fc": 4,+ "stats": {+ "Attack": 1445,+ "Defense": 10,+ "Lethality": 10,+ "Health": 482+ }+ },+ "marksman_t9_fc4": {+ "id": "marksman_t9_fc4",+ "type": "marksmen",+ "tier": 9,+ "fc": 4,+ "stats": {+ "Attack": 1926,+ "Defense": 10,+ "Lethality": 10,+ "Health": 361+ }+ },+ "lancer_t9_fc5": {+ "id": "lancer_t9_fc5",+ "type": "lancers",+ "tier": 9,+ "fc": 5,+ "stats": {+ "Attack": 1517,+ "Defense": 10,+ "Lethality": 10,+ "Health": 506+ }+ },+ "marksman_t9_fc5": {+ "id": "marksman_t9_fc5",+ "type": "marksmen",+ "tier": 9,+ "fc": 5,+ "stats": {+ "Attack": 2023,+ "Defense": 10,+ "Lethality": 10,+ "Health": 379+ }+ },+ "lancer_t10": {+ "id": "lancer_t10",+ "type": "lancers",+ "tier": 10,+ "fc": 0,+ "stats": {+ "Attack": 1416,+ "Defense": 10,+ "Lethality": 10,+ "Health": 472+ }+ },+ "marksman_t10": {+ "id": "marksman_t10",+ "type": "marksmen",+ "tier": 10,+ "fc": 0,+ "stats": {+ "Attack": 1888,+ "Defense": 10,+ "Lethality": 10,+ "Health": 354+ }+ },+ "lancer_t10_fc1": {+ "id": "lancer_t10_fc1",+ "type": "lancers",+ "tier": 10,+ "fc": 1,+ "stats": {+ "Attack": 1473,+ "Defense": 10,+ "Lethality": 10,+ "Health": 491+ }+ },+ "marksman_t10_fc1": {+ "id": "marksman_t10_fc1",+ "type": "marksmen",+ "tier": 10,+ "fc": 1,+ "stats": {+ "Attack": 1964,+ "Defense": 10,+ "Lethality": 10,+ "Health": 368+ }+ },+ "lancer_t10_fc2": {+ "id": "lancer_t10_fc2",+ "type": "lancers",+ "tier": 10,+ "fc": 2,+ "stats": {+ "Attack": 1546,+ "Defense": 10,+ "Lethality": 10,+ "Health": 515+ }+ },+ "marksman_t10_fc2": {+ "id": "marksman_t10_fc2",+ "type": "marksmen",+ "tier": 10,+ "fc": 2,+ "stats": {+ "Attack": 2062,+ "Defense": 10,+ "Lethality": 10,+ "Health": 387+ }+ },+ "lancer_t10_fc3": {+ "id": "lancer_t10_fc3",+ "type": "lancers",+ "tier": 10,+ "fc": 3,+ "stats": {+ "Attack": 1624,+ "Defense": 10,+ "Lethality": 10,+ "Health": 541+ }+ },+ "marksman_t10_fc3": {+ "id": "marksman_t10_fc3",+ "type": "marksmen",+ "tier": 10,+ "fc": 3,+ "stats": {+ "Attack": 2165,+ "Defense": 10,+ "Lethality": 10,+ "Health": 406+ }+ },+ "lancer_t10_fc4": {+ "id": "lancer_t10_fc4",+ "type": "lancers",+ "tier": 10,+ "fc": 4,+ "stats": {+ "Attack": 1705,+ "Defense": 10,+ "Lethality": 10,+ "Health": 568+ }+ },+ "marksman_t10_fc4": {+ "id": "marksman_t10_fc4",+ "type": "marksmen",+ "tier": 10,+ "fc": 4,+ "stats": {+ "Attack": 2273,+ "Defense": 10,+ "Lethality": 10,+ "Health": 426+ }+ },+ "lancer_t10_fc5": {+ "id": "lancer_t10_fc5",+ "type": "lancers",+ "tier": 10,+ "fc": 5,+ "stats": {+ "Attack": 1790,+ "Defense": 10,+ "Lethality": 10,+ "Health": 597+ }+ },+ "marksman_t10_fc5": {+ "id": "marksman_t10_fc5",+ "type": "marksmen",+ "tier": 10,+ "fc": 5,+ "stats": {+ "Attack": 2387,+ "Defense": 10,+ "Lethality": 10,+ "Health": 448+ }+ },+ "lancer_t11": {+ "id": "lancer_t11",+ "type": "lancers",+ "tier": 11,+ "fc": 0,+ "stats": {+ "Attack": 1653,+ "Defense": 10,+ "Lethality": 10,+ "Health": 551+ }+ },+ "marksman_t11": {+ "id": "marksman_t11",+ "type": "marksmen",+ "tier": 11,+ "fc": 0,+ "stats": {+ "Attack": 2204,+ "Defense": 10,+ "Lethality": 10,+ "Health": 413+ }+ },+ "lancer_t11_fc5": {+ "id": "lancer_t11_fc5",+ "type": "lancers",+ "tier": 11,+ "fc": 5,+ "stats": {+ "Attack": 1973,+ "Defense": 10,+ "Lethality": 10,+ "Health": 658+ }+ },+ "marksman_t11_fc5": {+ "id": "marksman_t11_fc5",+ "type": "marksmen",+ "tier": 11,+ "fc": 5,+ "stats": {+ "Attack": 2632,+ "Defense": 10,+ "Lethality": 10,+ "Health": 494+ }+ }+}Index: simulator/package-lock.json===================================================================--- simulator/package-lock.json prev run+++ simulator/package-lock.json this run@@ -0,0 +1,566 @@+{+ "name": "wos-simulator",+ "version": "0.1.0",+ "lockfileVersion": 3,+ "requires": true,+ "packages": {+ "": {+ "name": "wos-simulator",+ "version": "0.1.0",+ "devDependencies": {+ "@types/node": "^22.0.0",+ "tsx": "^4.0.0",+ "typescript": "^5.0.0"+ }+ },+ "node_modules/@esbuild/aix-ppc64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",+ "cpu": [+ "ppc64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "aix"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/android-arm": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",+ "cpu": [+ "arm"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "android"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/android-arm64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",+ "cpu": [+ "arm64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "android"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/android-x64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",+ "cpu": [+ "x64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "android"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/darwin-arm64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",+ "cpu": [+ "arm64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "darwin"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/darwin-x64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",+ "cpu": [+ "x64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "darwin"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/freebsd-arm64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",+ "cpu": [+ "arm64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "freebsd"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/freebsd-x64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",+ "cpu": [+ "x64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "freebsd"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-arm": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",+ "cpu": [+ "arm"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-arm64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",+ "cpu": [+ "arm64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-ia32": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",+ "cpu": [+ "ia32"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-loong64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",+ "cpu": [+ "loong64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-mips64el": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",+ "cpu": [+ "mips64el"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-ppc64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",+ "cpu": [+ "ppc64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-riscv64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",+ "cpu": [+ "riscv64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-s390x": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",+ "cpu": [+ "s390x"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/linux-x64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",+ "cpu": [+ "x64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "linux"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/netbsd-arm64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",+ "cpu": [+ "arm64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "netbsd"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/netbsd-x64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",+ "cpu": [+ "x64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "netbsd"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/openbsd-arm64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",+ "cpu": [+ "arm64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "openbsd"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/openbsd-x64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",+ "cpu": [+ "x64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "openbsd"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/openharmony-arm64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",+ "cpu": [+ "arm64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "openharmony"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/sunos-x64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",+ "cpu": [+ "x64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "sunos"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/win32-arm64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",+ "cpu": [+ "arm64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "win32"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/win32-ia32": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",+ "cpu": [+ "ia32"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "win32"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@esbuild/win32-x64": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",+ "cpu": [+ "x64"+ ],+ "dev": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "win32"+ ],+ "engines": {+ "node": ">=18"+ }+ },+ "node_modules/@types/node": {+ "version": "22.19.19",+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",+ "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",+ "dev": true,+ "license": "MIT",+ "dependencies": {+ "undici-types": "~6.21.0"+ }+ },+ "node_modules/esbuild": {+ "version": "0.28.0",+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",+ "dev": true,+ "hasInstallScript": true,+ "license": "MIT",+ "bin": {+ "esbuild": "bin/esbuild"+ },+ "engines": {+ "node": ">=18"+ },+ "optionalDependencies": {+ "@esbuild/aix-ppc64": "0.28.0",+ "@esbuild/android-arm": "0.28.0",+ "@esbuild/android-arm64": "0.28.0",+ "@esbuild/android-x64": "0.28.0",+ "@esbuild/darwin-arm64": "0.28.0",+ "@esbuild/darwin-x64": "0.28.0",+ "@esbuild/freebsd-arm64": "0.28.0",+ "@esbuild/freebsd-x64": "0.28.0",+ "@esbuild/linux-arm": "0.28.0",+ "@esbuild/linux-arm64": "0.28.0",+ "@esbuild/linux-ia32": "0.28.0",+ "@esbuild/linux-loong64": "0.28.0",+ "@esbuild/linux-mips64el": "0.28.0",+ "@esbuild/linux-ppc64": "0.28.0",+ "@esbuild/linux-riscv64": "0.28.0",+ "@esbuild/linux-s390x": "0.28.0",+ "@esbuild/linux-x64": "0.28.0",+ "@esbuild/netbsd-arm64": "0.28.0",+ "@esbuild/netbsd-x64": "0.28.0",+ "@esbuild/openbsd-arm64": "0.28.0",+ "@esbuild/openbsd-x64": "0.28.0",+ "@esbuild/openharmony-arm64": "0.28.0",+ "@esbuild/sunos-x64": "0.28.0",+ "@esbuild/win32-arm64": "0.28.0",+ "@esbuild/win32-ia32": "0.28.0",+ "@esbuild/win32-x64": "0.28.0"+ }+ },+ "node_modules/fsevents": {+ "version": "2.3.3",+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",+ "dev": true,+ "hasInstallScript": true,+ "license": "MIT",+ "optional": true,+ "os": [+ "darwin"+ ],+ "engines": {+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"+ }+ },+ "node_modules/tsx": {+ "version": "4.22.3",+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",+ "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",+ "dev": true,+ "license": "MIT",+ "dependencies": {+ "esbuild": "~0.28.0"+ },+ "bin": {+ "tsx": "dist/cli.mjs"+ },+ "engines": {+ "node": ">=18.0.0"+ },+ "optionalDependencies": {+ "fsevents": "~2.3.3"+ }+ },+ "node_modules/typescript": {+ "version": "5.9.3",+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",+ "dev": true,+ "license": "Apache-2.0",+ "bin": {+ "tsc": "bin/tsc",+ "tsserver": "bin/tsserver"+ },+ "engines": {+ "node": ">=14.17"+ }+ },+ "node_modules/undici-types": {+ "version": "6.21.0",+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",+ "dev": true,+ "license": "MIT"+ }+ }+}Index: simulator/package.json===================================================================--- simulator/package.json prev run+++ simulator/package.json this run@@ -0,0 +1,15 @@+{+ "name": "wos-simulator",+ "version": "0.1.0",+ "private": true,+ "type": "module",+ "scripts": {+ "test": "npx --yes tsx --test \"src/**/*.test.ts\"",+ "typecheck": "npx --yes --package typescript --package @types/node tsc --noEmit"+ },+ "devDependencies": {+ "@types/node": "^22.0.0",+ "tsx": "^4.0.0",+ "typescript": "^5.0.0"+ }+}Index: simulator/src/calibration.ts===================================================================--- simulator/src/calibration.ts prev run+++ simulator/src/calibration.ts this run@@ -0,0 +1,384 @@+import { readdirSync, readFileSync, statSync } from "node:fs";+import { dirname, relative, resolve } from "node:path";+import { fileURLToPath } from "node:url";++export interface CalibrationCaseComparison {+ file: string;+ testcaseId: string;+ idx: number;+ nSim?: number;+ muSim?: number;+ sigmaSim?: number;+ nGame?: number;+ muGame?: number;+ sigmaGame?: number;+ biasRaw?: number;+ biasPct?: number;+ sem?: number;+ p?: number | null;+ q?: number | null;+ statType?: string;+ passes?: boolean;+}++export interface SampleStats {+ n: number;+ mu: number;+ sigma: number;+ sem: number;+ samples?: number[];+}++export interface DistributionCompatibility {+ biasRaw?: number;+ biasPct?: number;+ z?: number;+ passes?: boolean;+ statType: "deterministic" | "distribution" | "unmatched";+}++export interface CalibrationComparison {+ calibrationAvailable: boolean;+ reportPath?: string;+ startedAt?: string;+ finishedAt?: string;+ gitSha?: string;+ totalReferenceCases: number;+ referencePassedCases: number;+ referencePassRate: number;+ thresholds?: Record<string, number>;+ cases: CalibrationCaseComparison[];+ table: CalibrationComparisonRow[];+}++export interface CalibrationComparisonRow extends CalibrationCaseComparison {+ matched: boolean;+ referencePasses?: boolean;+ referenceBiasPct?: number;+ simulatorScoreDelta?: number;+ simulator?: SampleStats;+ simulatorVsBaseline?: DistributionCompatibility;+ simulatorVsGame?: DistributionCompatibility;+ simulatorVsGameRaw?: number;+ simulatorVsGamePct?: number;+ simulatorPasses?: boolean;+ simulatorN?: number;+ simulatorMu?: number;+ simulatorSigma?: number;+ simulatorSem?: number;+ simulatorVsBaselineBiasRaw?: number;+ simulatorVsBaselineBiasPct?: number;+ simulatorVsBaselineZ?: number;+ simulatorVsBaselinePasses?: boolean;+ simulatorVsGameBiasRaw?: number;+ simulatorVsGameBiasPct?: number;+ simulatorVsGameZ?: number;+ simulatorVsGamePasses?: boolean;+}++export function defaultCalibrationDir(): string {+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "testcase_results");+}++export function loadCalibrationComparison(path?: string): CalibrationComparison {+ const reportPath = path ? resolve(path) : latestCalibrationReportPath(defaultCalibrationDir());+ if (!reportPath || !fileExists(reportPath)) return emptyCalibrationComparison();++ const parsed = JSON.parse(readFileSync(reportPath, "utf8")) as CalibrationReportJson;+ const cases = Object.values(parsed.testcases ?? {}).map(normalizeCalibrationCase).filter((entry): entry is CalibrationCaseComparison => !!entry);+ const referencePassedCases = cases.filter((entry) => entry.passes).length;+ return {+ calibrationAvailable: true,+ reportPath,+ startedAt: stringOrUndefined(parsed.started_at),+ finishedAt: stringOrUndefined(parsed.finished_at),+ gitSha: stringOrUndefined(parsed.git_sha),+ totalReferenceCases: cases.length,+ referencePassedCases,+ referencePassRate: cases.length > 0 ? referencePassedCases / cases.length : 0,+ thresholds: normalizeThresholds(parsed.thresholds),+ cases,+ table: []+ };+}++export function readCalibrationCase(+ comparison: CalibrationComparison,+ testcaseFile: string,+ testcaseId: string,+ options: { index?: number } = {}+): CalibrationCaseComparison | undefined {+ if (!comparison.calibrationAvailable) return undefined;+ const files = new Set(testcaseFileLookupVariants(testcaseFile));+ return comparison.cases.find((entry) => {+ if (entry.testcaseId !== testcaseId) return false;+ if (options.index !== undefined && entry.idx !== options.index) return false;+ return testcaseFileLookupVariants(entry.file).some((variant) => files.has(variant));+ });+}++export function testcaseFileLookupVariants(path: string): string[] {+ const normalized = normalizePath(path);+ const variants = new Set<string>([normalized]);+ // Current parity reports may embed "simulator/testcases/"; both that form and+ // the root "testcases/" form normalize to the canonical testcase id.+ for (const prefix of ["simulator/testcases/"]) {+ const idx = normalized.indexOf(prefix);+ if (idx >= 0) variants.add(`testcases/${normalized.slice(idx + prefix.length)}`);+ }+ const testcaseIndex = normalized.indexOf("testcases/");+ if (testcaseIndex >= 0) {+ const testcasePath = normalized.slice(testcaseIndex);+ variants.add(testcasePath);+ variants.add(`simulator/${testcasePath}`);+ }+ return [...variants];+}++export function addCalibrationTableRow(+ comparison: CalibrationComparison,+ caseReport: {+ file: string;+ testcaseId: string;+ index: number;+ simulatorScoreDelta?: number;+ simulatorStats?: SampleStats;+ calibration?: CalibrationCaseComparison;+ }+): CalibrationComparisonRow {+ const calibration = caseReport.calibration;+ const simulator = caseReport.simulatorStats;+ const simulatorVsBaseline = compareDistributions(simulator, calibration?.muSim, calibration?.sigmaSim, calibration?.nSim, comparison.thresholds, calibration?.statType);+ const simulatorVsGame = compareDistributions(simulator, calibration?.muGame, calibration?.sigmaGame, calibration?.nGame, comparison.thresholds, calibration?.statType);+ const simulatorVsGameRaw = simulatorVsGame.biasRaw;+ const simulatorVsGamePct = simulatorVsGame.biasPct;+ const row: CalibrationComparisonRow = {+ file: relativeDisplayPath(caseReport.file),+ testcaseId: caseReport.testcaseId,+ idx: caseReport.index,+ matched: !!calibration,+ nSim: calibration?.nSim,+ muSim: calibration?.muSim,+ sigmaSim: calibration?.sigmaSim,+ nGame: calibration?.nGame,+ muGame: calibration?.muGame,+ sigmaGame: calibration?.sigmaGame,+ statType: calibration?.statType,+ referenceBiasPct: calibration?.biasPct,+ referencePasses: calibration?.passes,+ passes: calibration?.passes,+ biasRaw: calibration?.biasRaw,+ biasPct: calibration?.biasPct,+ sem: calibration?.sem,+ p: calibration?.p,+ q: calibration?.q,+ simulatorScoreDelta: caseReport.simulatorScoreDelta,+ simulator,+ simulatorVsBaseline,+ simulatorVsGame,+ simulatorVsGameRaw,+ simulatorVsGamePct,+ simulatorPasses: simulatorVsGame.passes,+ simulatorN: simulator?.n,+ simulatorMu: simulator?.mu,+ simulatorSigma: simulator?.sigma,+ simulatorSem: simulator?.sem,+ simulatorVsBaselineBiasRaw: simulatorVsBaseline.biasRaw,+ simulatorVsBaselineBiasPct: simulatorVsBaseline.biasPct,+ simulatorVsBaselineZ: simulatorVsBaseline.z,+ simulatorVsBaselinePasses: simulatorVsBaseline.passes,+ simulatorVsGameBiasRaw: simulatorVsGame.biasRaw,+ simulatorVsGameBiasPct: simulatorVsGame.biasPct,+ simulatorVsGameZ: simulatorVsGame.z,+ simulatorVsGamePasses: simulatorVsGame.passes+ };+ comparison.table.push(row);+ return row;+}++export function sampleStats(samples: number[], options: { includeSamples?: boolean } = {}): SampleStats {+ const n = samples.length;+ const mu = n > 0 ? samples.reduce((sum, value) => sum + value, 0) / n : 0;+ const variance = n > 1 ? samples.reduce((sum, value) => sum + (value - mu) ** 2, 0) / (n - 1) : 0;+ const sigma = Math.sqrt(variance);+ const sem = n > 0 ? sigma / Math.sqrt(n) : 0;+ return {+ n,+ mu,+ sigma,+ sem,+ ...(options.includeSamples ? { samples: [...samples] } : {})+ };+}++function latestCalibrationReportPath(dir: string): string | undefined {+ if (!directoryExists(dir)) return undefined;+ const candidates = readdirSync(dir)+ .filter((name) => name.endsWith(".json"))+ .map((name) => resolve(dir, name))+ .filter((path) => statSync(path).isFile())+ .map((path) => ({ path, finishedAt: calibrationFinishedAt(path) }))+ .sort((a, b) => b.finishedAt - a.finishedAt || a.path.localeCompare(b.path));+ return candidates[0]?.path;+}++function fileExists(path: string): boolean {+ try {+ return statSync(path).isFile();+ } catch {+ return false;+ }+}++function directoryExists(path: string): boolean {+ try {+ return statSync(path).isDirectory();+ } catch {+ return false;+ }+}++function calibrationFinishedAt(path: string): number {+ try {+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { finished_at?: unknown };+ const time = Date.parse(String(parsed.finished_at ?? ""));+ return Number.isFinite(time) ? time : 0;+ } catch {+ return 0;+ }+}++function normalizeCalibrationCase(value: unknown): CalibrationCaseComparison | undefined {+ if (!value || typeof value !== "object") return undefined;+ const object = value as Record<string, unknown>;+ const file = stringOrUndefined(object.file);+ const testcaseId = stringOrUndefined(object.testcase_id ?? object.testcaseId);+ if (!file || !testcaseId) return undefined;+ return {+ file: normalizePath(file),+ testcaseId,+ idx: numberOrDefault(object.idx, 0),+ nSim: numberOrUndefined(object.n_sim),+ muSim: numberOrUndefined(object.mu_sim),+ sigmaSim: numberOrUndefined(object.sigma_sim),+ nGame: numberOrUndefined(object.n_game),+ muGame: numberOrUndefined(object.mu_game),+ sigmaGame: numberOrUndefined(object.sigma_game),+ biasRaw: numberOrUndefined(object.bias_raw),+ biasPct: numberOrUndefined(object.bias_pct),+ sem: numberOrUndefined(object.sem),+ p: nullableNumber(object.p),+ q: nullableNumber(object.q),+ statType: stringOrUndefined(object.stat_type),+ passes: booleanOrUndefined(object.passes)+ };+}++function emptyCalibrationComparison(): CalibrationComparison {+ return {+ calibrationAvailable: false,+ totalReferenceCases: 0,+ referencePassedCases: 0,+ referencePassRate: 0,+ cases: [],+ table: []+ };+}++function normalizeThresholds(value: unknown): Record<string, number> | undefined {+ if (!value || typeof value !== "object") return undefined;+ const entries = Object.entries(value as Record<string, unknown>)+ .map(([key, raw]) => [key, numberOrUndefined(raw)] as const)+ .filter((entry): entry is readonly [string, number] => entry[1] !== undefined);+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;+}++function compareDistributions(+ simulator: SampleStats | undefined,+ referenceMu: number | undefined,+ referenceSigma: number | undefined,+ referenceN: number | undefined,+ thresholds?: Record<string, number>,+ referenceStatType?: string+): DistributionCompatibility {+ if (!simulator || referenceMu === undefined || referenceSigma === undefined || referenceN === undefined) return { statType: "unmatched" };+ const biasRaw = simulator.mu - referenceMu;+ const biasPct = percentDelta(biasRaw, referenceMu);+ const combinedSem = Math.sqrt((simulator.sigma ** 2) / Math.max(1, simulator.n) + (referenceSigma ** 2) / Math.max(1, referenceN));+ const deterministic = referenceStatType === "deterministic" || (simulator.sigma === 0 && referenceSigma === 0);+ if (deterministic || combinedSem === 0) {+ return {+ biasRaw,+ biasPct,+ statType: "deterministic",+ passes: deterministicPasses(biasRaw, biasPct, thresholds)+ };+ }+ const z = biasRaw / combinedSem;+ return {+ biasRaw,+ biasPct,+ z,+ statType: "distribution",+ passes: distributionPasses(z, biasPct, thresholds)+ };+}++function deterministicPasses(raw: number, pct: number | undefined, thresholds?: Record<string, number>): boolean {+ if (pct === undefined) return raw === 0;+ const maxDiffRatio = thresholds?.max_diff_ratio_deterministic ?? thresholds?.max_diff_ratio ?? 0.01;+ return Math.abs(pct) <= maxDiffRatio * 100;+}++function distributionPasses(z: number, pct: number | undefined, thresholds?: Record<string, number>): boolean {+ const minBiasPct = thresholds?.min_bias_pct ?? 0.5;+ if (pct !== undefined && Math.abs(pct) < minBiasPct) return true;+ const zThreshold = thresholds?.z_threshold ?? 2;+ return Math.abs(z) <= zThreshold;+}++function percentDelta(rawDelta: number, expected: number): number | undefined {+ if (expected === 0) return rawDelta === 0 ? 0 : undefined;+ return (rawDelta / Math.abs(expected)) * 100;+}++function relativeDisplayPath(path: string): string {+ const normalized = normalizePath(path);+ const cwdRelative = normalizePath(relative(process.cwd(), path));+ if (!cwdRelative.startsWith("..")) return cwdRelative;+ return normalized;+}++function normalizePath(path: string): string {+ return path.replaceAll("\\", "/");+}++function stringOrUndefined(value: unknown): string | undefined {+ return typeof value === "string" && value.length > 0 ? value : undefined;+}++function numberOrUndefined(value: unknown): number | undefined {+ const number = Number(value);+ return Number.isFinite(number) ? number : undefined;+}++function nullableNumber(value: unknown): number | null | undefined {+ if (value === null) return null;+ return numberOrUndefined(value);+}++function numberOrDefault(value: unknown, fallback: number): number {+ return numberOrUndefined(value) ?? fallback;+}++function booleanOrUndefined(value: unknown): boolean | undefined {+ return typeof value === "boolean" ? value : undefined;+}++interface CalibrationReportJson {+ started_at?: unknown;+ finished_at?: unknown;+ git_sha?: unknown;+ thresholds?: unknown;+ testcases?: Record<string, unknown>;+}Index: simulator/src/classifier.ts===================================================================--- simulator/src/classifier.ts prev run+++ simulator/src/classifier.ts this run@@ -0,0 +1,54 @@+import type { ActiveEffect, DamageJob, SideId, UnitType } from "./types";+import { unitMaskHas } from "./types";+import { bucketDefinition, type BucketName } from "./damageBuckets";+import { staticPassiveBucketRole, type StaticPassiveBucket } from "./staticDamageProfile";++export interface Classification {+ kind: "bucket" | "control" | "extra_skill_attack" | "battle_order" | "report_only";+ bucket?: BucketName | StaticPassiveBucket;+ control?: "dodge" | "no_attack";+ reason?: string;+}++export function classifyEffectForJob(effect: ActiveEffect, job: DamageJob): Classification | undefined {+ if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };++ const type = effect.intent.type;+ if (type === "dodge" || type === "no_attack") return { kind: "control", control: type };+ if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };+ if (type === "attack_order") return { kind: "battle_order" };++ const definition = bucketDefinition(type);+ const staticPassiveRole = staticPassiveBucketRole(type);+ if ((!definition || definition.valueType !== "pct") && !staticPassiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };+ if (staticPassiveRole) {+ if (staticPassiveRole === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };+ if (staticPassiveRole === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };+ return { kind: "report_only", reason: unsupportedReason(effect, job) };+ }+ if (!definition) return { kind: "report_only", reason: unsupportedReason(effect, job) };+ if (definition.appliesTo !== undefined && definition.appliesTo !== job.kind) return { kind: "report_only", reason: "not_applicable_to_job_kind" };+ if (definition.role === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type };+ if (definition.role === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type };+ return { kind: "report_only", reason: unsupportedReason(effect, job) };+}++export function basicEffectApplies(effect: ActiveEffect, job: DamageJob): boolean {+ const affectedUnit = unitForSide(effect.appliesTo.side, job);+ if (!affectedUnit || !unitMaskHas(effect.appliesTo.units, affectedUnit)) return false;+ const opposingUnit = unitForSide(effect.appliesVs.side, job);+ if (!opposingUnit || !unitMaskHas(effect.appliesVs.units, opposingUnit)) return false;+ return true;+}++function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {+ if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";+ if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";+ return "wrong_side";+}++function unitForSide(side: SideId, job: DamageJob): UnitType | undefined {+ if (side === job.attackerSide) return job.attackerUnit;+ if (side === job.defenderSide) return job.defenderUnit;+ return undefined;+}Index: simulator/src/classifierDamage.test.ts===================================================================--- simulator/src/classifierDamage.test.ts prev run+++ simulator/src/classifierDamage.test.ts this run@@ -0,0 +1,649 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import { classifyEffectForJob } from "./classifier";+import { calculateDamageJob } from "./damage";+import { ATOMIC_BUCKETS } from "./damageBuckets";+import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";+import { activateEffect } from "./effects";+import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";+import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";+import { ALL_UNIT_MASK, unitMask } from "./types";++const job: DamageJob = {+ id: "job-1",+ round: 1,+ kind: "normal",+ sourceIntentId: "intent-1",+ roundStartTroops: {+ attacker: { infantry: 1000, lancer: 0, marksman: 0 },+ defender: { infantry: 0, lancer: 1000, marksman: 0 }+ },+ attackerSide: "attacker",+ attackerUnit: "infantry",+ defenderSide: "defender",+ defenderUnit: "lancer",+ sourceMultiplier: 1+};++test("resolved effect scope matches concrete side and unit masks", () => {+ const active: ActiveEffect = {+ id: "resolved-scope",+ source: { kind: "hero_skill", side: "attacker", heroName: "Example", skillId: "Scope", effectId: "scope/1" },+ intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },+ ownerSide: "attacker",+ kind: "modifier",+ valuePct: 25,+ appliesTo: { side: "attacker", units: unitMask(["infantry"]) },+ appliesVs: { side: "defender", units: unitMask(["lancer"]) },+ createdRound: 1,+ startRound: 1,+ duration: { type: "battle", value: 0 },+ uses: 0,+ sameEffectStacking: "add"+ };++ assert.equal(classifyEffectForJob(active, job)?.bucket, "active.hero.lethality.up");+ assert.equal(+ classifyEffectForJob(active, {+ ...job,+ id: "job-attacker-marksman",+ attackerUnit: "marksman"+ })?.reason,+ "not_applicable_to_job"+ );+ assert.equal(+ classifyEffectForJob(active, {+ ...job,+ id: "job-defender-marksman",+ defenderUnit: "marksman"+ })?.reason,+ "not_applicable_to_job"+ );+});++function effect(+ type: string,+ ownerSide: "attacker" | "defender",+ valuePct = 25,+ sourceKind: ActiveEffect["source"]["kind"] = "hero_skill"+): ActiveEffect {+ const source =+ sourceKind === "troop_skill"+ ? { kind: sourceKind, side: ownerSide, troopType: "infantry" as const, skillId: "Skill", effectId: `${type}/1` }+ : { kind: sourceKind, side: ownerSide, heroName: "Example", skillId: "Skill", effectId: `${type}/1` };+ return {+ id: `${type}-1`,+ source,+ intent: { id: `${type}/1`, type, value: [valuePct] },+ ownerSide,+ kind: "modifier",+ valuePct,+ appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },+ appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },+ createdRound: 1,+ startRound: 1,+ duration: { type: "battle", value: 0 },+ uses: 0,+ sameEffectStacking: "add"+ };+}++function calculateIndexedDamageJob(+ damageJob: DamageJob,+ fighters: Record<"attacker" | "defender", ResolvedFighter>,+ effects: ActiveEffect[],+ options: Partial<Parameters<typeof calculateDamageJob>[3]> = {}+) {+ const effectIndex = options.effectIndex ?? createEffectIndex();+ if (!options.effectIndex) {+ for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);+ }+ const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);+ removeStaticProfileBucketEffects(effectIndex);+ return calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile });+}++test("classifier routes up/down effects into neutral atomic buckets", () => {+ assert.equal(classifyEffectForJob(effect("active.hero.health.up", "defender"), job)?.bucket, "active.hero.health.up");+ assert.equal(classifyEffectForJob(effect("active.hero.health.down", "defender"), job)?.bucket, "active.hero.health.down");+ assert.equal(classifyEffectForJob(effect("active.hero.attack.up", "attacker"), job)?.bucket, "active.hero.attack.up");+ assert.equal(classifyEffectForJob(effect("active.hero.attack.down", "attacker"), job)?.bucket, "active.hero.attack.down");+});++test("classifier keeps hero and troop active effects in separate atomic buckets", () => {+ assert.equal(classifyEffectForJob(effect("active.hero.lethality.up", "attacker", 20, "hero_skill"), job)?.bucket, "active.hero.lethality.up");+ assert.equal(classifyEffectForJob(effect("active.troop.lethality.up", "attacker", 20, "troop_skill"), job)?.bucket, "active.troop.lethality.up");+ assert.equal(classifyEffectForJob(effect("active.hero.lethality.up", "attacker", 10, "hero_skill"), job)?.bucket, "active.hero.lethality.up");+ assert.equal(classifyEffectForJob(effect("active.troop.lethality.up", "attacker", 10, "troop_skill"), job)?.bucket, "active.troop.lethality.up");+});++test("classifier routes the complete native bucket policy into atomic buckets", () => {+ const atomicBuckets = new Set(ATOMIC_BUCKETS);+ const expected = new Map([+ ["active.hero.lethality.up", "active.hero.lethality.up"],+ ["active.hero.lethality.down", "active.hero.lethality.down"],+ ["active.hero.attack.up", "active.hero.attack.up"],+ ["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"],+ ["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"],+ ["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"],+ ["type.normal.defense.up", "type.normal.defense.up"],+ ["type.normal.defense.down", "type.normal.defense.down"],+ ["type.skill.defense.up", "type.skill.defense.up"],+ ["type.skill.defense.down", "type.skill.defense.down"]+ ]);+ const defenderEffectTypes = new Set([+ "active.hero.defense.up",+ "active.hero.defense.down",+ "active.hero.health.up",+ "active.hero.health.down",+ "active.hero.defense.up",+ "active.hero.defense.down",+ "type.normal.defense.up",+ "type.normal.defense.down",+ "type.skill.defense.up",+ "type.skill.defense.down"+ ]);++ for (const [type, expectedBucket] of expected) {+ const ownerSide = defenderEffectTypes.has(type) ? "defender" : "attacker";+ const classifierJob = type.startsWith("type.skill.") ? { ...job, id: "job-skill-route", kind: "skill" as const } : job;+ const bucket = classifyEffectForJob(effect(type, ownerSide), classifierJob)?.bucket;+ assert.equal(bucket, expectedBucket, type);+ assert.ok(bucket !== undefined && atomicBuckets.has(bucket), `${type} route ${bucket} is not an atomic bucket`);+ }++ const passiveExpected = new Map([+ ["passive.attack.up", "passive.attack.up"],+ ["passive.lethality.up", "passive.lethality.up"],+ ["passive.health.up", "passive.health.up"],+ ["passive.defense.up", "passive.defense.up"]+ ]);++ for (const [type, expectedBucket] of passiveExpected) {+ const ownerSide = type.includes(".attack.") || type.includes(".lethality.") ? "attacker" : "defender";+ const bucket = classifyEffectForJob(effect(type, ownerSide), job)?.bucket;+ assert.equal(bucket, expectedBucket, type);+ assert.ok(bucket !== undefined && (STATIC_PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a static passive bucket`);+ }++ assert.equal(classifyEffectForJob(effect("passive.attack.down", "attacker", 5), job)?.bucket, "passive.attack.down");+});++test("passive stat bonuses only apply in the damage role that consumes that stat", () => {+ const reversedJob: DamageJob = {+ ...job,+ id: "job-reversed",+ attackerSide: "defender",+ attackerUnit: "lancer",+ defenderSide: "attacker",+ defenderUnit: "infantry"+ };+ const passiveAttack = effect("passive.attack.up", "attacker", 10);+ const passiveHealth = effect("passive.health.up", "defender", 10);++ assert.equal(classifyEffectForJob(passiveAttack, job)?.bucket, "passive.attack.up");+ assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_defender_effect");+ assert.equal(classifyEffectForJob(passiveHealth, job)?.bucket, "passive.health.up");+ assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_attacker_effect");+});++test("damage calculator requires indexed effect candidates", () => {+ assert.throws(() => calculateDamageJob(job, simpleFighters(), [], { trace: true } as never), /effectIndex/i);+});++test("damage calculator uses centralized bucket definitions for player stat routing", () => {+ const attacker: ResolvedFighter = {+ side: "attacker",+ name: "A",+ troops: { infantry: 1000, lancer: 0, marksman: 0 },+ initialTroops: { infantry: 1000, lancer: 0, marksman: 0 },+ troopDetails: {+ infantry: { id: "infantry_t1", type: "infantry", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }+ },+ statBonuses: {+ infantry: { attack: 100, lethality: 0, defense: 0, health: 0 },+ lancer: { attack: 0, lethality: 0, defense: 0, health: 0 },+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }+ },+ heroes: [],+ troopSkills: [],+ diagnostics: []+ };+ const defender: ResolvedFighter = {+ ...attacker,+ side: "defender",+ name: "D",+ troops: { infantry: 0, lancer: 1000, marksman: 0 },+ initialTroops: { infantry: 0, lancer: 1000, marksman: 0 },+ troopDetails: {+ lancer: { id: "lancer_t1", type: "lancer", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }+ },+ statBonuses: {+ infantry: { attack: 0, lethality: 0, defense: 0, health: 0 },+ lancer: { attack: 0, lethality: 0, defense: 0, health: 100 },+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }+ }+ };++ const outcome = calculateIndexedDamageJob(job, { attacker, defender }, [], { trace: true });++ assert.equal(outcome.trace?.atomicBuckets["player.attack"].totalPct, 100);+ assert.equal(outcome.trace?.atomicBuckets["player.health"].totalPct, 100);+ assert.ok(outcome.kills > 0);+ assert.ok(outcome.kills < 1000);+});++test("active stat-up effects multiply separately from player stat bonuses", () => {+ const fighters = simpleFighters();+ fighters.attacker.statBonuses.infantry.attack = 100;++ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });+ const withRuntimeAttackUp = calculateIndexedDamageJob(job, fighters, [effect("active.hero.attack.up", "attacker", 100)], { trace: true });++ assert.equal(baseline.trace?.atomicBuckets["player.attack"].totalPct, 100);+ assert.equal(baseline.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 0);+ assert.equal(withRuntimeAttackUp.trace?.atomicBuckets["player.attack"].totalPct, 100);+ assert.equal(withRuntimeAttackUp.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);+ assert.equal(withRuntimeAttackUp.kills, baseline.kills * 2);+});++test("traced damage uses the effect index for applied bucket candidates", () => {+ const indexedEffect = effect("active.hero.attack.up", "attacker", 100);+ const index = createEffectIndex();+ indexEffect(index, indexedEffect);++ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true, effectIndex: index });++ assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);+ assert.equal(outcome.kills, baseline.kills * 2);+});++test("passive stat bonuses aggregate as up sum over down sum on top of player stats", () => {+ const fighters = simpleFighters();+ fighters.attacker.statBonuses.infantry.attack = 2015.2;+ const passiveAttack = (value: number): ActiveEffect => ({+ ...effect(value < 0 ? "passive.attack.down" : "passive.attack.up", "attacker", Math.abs(value)),+ id: `passive-attack-${value}`,+ intent: { id: `passive/attack/${value}`, type: value < 0 ? "passive.attack.down" : "passive.attack.up", value: Math.abs(value) }+ });++ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });+ const outcome = calculateIndexedDamageJob(job, fighters, [passiveAttack(20), passiveAttack(10), passiveAttack(-5)], { trace: true });++ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);+ assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.3);+ assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.down"].factor, 1.05);+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), Number((1.3 / 1.05).toFixed(6)));+});++test("static damage profile produces the same static bucket damage as per-job passive processing", () => {+ const fighters = simpleFighters();+ fighters.attacker.statBonuses.infantry.attack = 50;+ fighters.attacker.statBonuses.infantry.lethality = 25;+ fighters.defender.statBonuses.lancer.health = 40;+ fighters.defender.statBonuses.lancer.defense = 10;++ const passives = [+ effect("passive.attack.up", "attacker", 20),+ effect("passive.attack.down", "attacker", 5),+ effect("passive.lethality.up", "attacker", 10),+ effect("passive.health.up", "defender", 15),+ effect("passive.health.down", "defender", 30),+ effect("passive.defense.up", "defender", 5)+ ];+ const baseline = calculateIndexedDamageJob(job, fighters, passives, { trace: true });+ const profile = buildStaticDamageProfile(fighters, passives);+ const profiled = calculateIndexedDamageJob(job, fighters, passives, { trace: true, staticDamageProfile: profile });++ assert.ok(Math.abs(profiled.kills - baseline.kills) < 1e-12);+ assert.equal(profiled.trace?.atomicBuckets["player.attack"].totalPct, 50);+ assert.equal(profiled.trace?.atomicBuckets["passive.attack.up"].totalPct, 20);+ assert.equal(profiled.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);+ assert.equal(profiled.trace?.atomicBuckets["passive.health.down"].totalPct, 30);+ assert.equal(profiled.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.2);+});++test("static damage profile preserves max stacking for duplicate passive effects", () => {+ const fighters = simpleFighters();+ const weaker = { ...effect("passive.attack.up", "attacker", 20), id: "weak", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };+ const stronger = { ...effect("passive.attack.up", "attacker", 50), id: "strong", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };+ const profile = buildStaticDamageProfile(fighters, [weaker, stronger]);+ const outcome = calculateIndexedDamageJob(job, fighters, [weaker, stronger], { trace: true, staticDamageProfile: profile });++ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 50);+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].contributors.length, 1);+ assert.equal(outcome.kills, calculateIndexedDamageJob(job, fighters, [], { trace: true }).kills * 1.5);+});++test("default aggregation multiplies hero and troop active damage buckets", () => {+ const fighters = simpleFighters();+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });+ const combined = calculateIndexedDamageJob(+ job,+ fighters,+ [effect("active.hero.lethality.up", "attacker", 20, "hero_skill"), effect("active.troop.lethality.up", "attacker", 10, "troop_skill")],+ { trace: true }+ );++ assert.equal(combined.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 20);+ assert.equal(combined.trace?.atomicBuckets["active.troop.lethality.up"].totalPct, 10);+ assert.equal(combined.trace?.aggregationGroups["active.hero.lethality.up"].factor, 1.2);+ assert.equal(combined.trace?.aggregationGroups["active.troop.lethality.up"].factor, 1.1);+ assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.1) < 1e-12);+});++test("negative passive stat bonuses route to down buckets with positive factors", () => {+ const passiveHealthDown = {+ ...effect("passive.health.down", "defender", 105),+ intent: { id: "passive-health-down", type: "passive.health.down", value: 105 }+ };+ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [passiveHealthDown], { trace: true });++ assert.equal(outcome.trace?.atomicBuckets["passive.health.down"].totalPct, 105);+ assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].factor, 2.05);+ assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].placement, "numerator");+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);+});++test("pass-specific buckets only apply to matching damage job kind", () => {+ const normalEffect = effect("type.normal.damage.up", "attacker", 100);+ const skillEffect = effect("type.skill.damage.up", "attacker", 100);+ const fighters = simpleFighters();+ const normalOutcome = calculateIndexedDamageJob(job, fighters, [normalEffect, skillEffect], { trace: true });+ const skillOutcome = calculateIndexedDamageJob({ ...job, id: "job-skill", kind: "skill", sourceMultiplier: 1 }, fighters, [normalEffect, skillEffect], {+ trace: true+ });++ assert.equal(normalOutcome.trace?.atomicBuckets["type.normal.damage.up"].totalPct, 100);+ assert.equal(normalOutcome.trace?.aggregationGroups["type.skill.damage.up"], undefined);+ assert.equal(skillOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);+ assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);+});++test("attack-duration bucket effects are consumed by the applicable attack job", () => {+ const oneAttackEffect = {+ ...effect("active.hero.attack.up", "attacker", 100),+ id: "attack-up-active",+ duration: { type: "attack" as const, value: 1 }+ };++ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });++ assert.ok(outcome.consumedEffectIds.includes("attack-up-active"));+ assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);+});++test("turn-duration bucket effects are visible on the attack outcome without being consumed", () => {+ const turnEffect = {+ ...effect("active.hero.defense.down", "defender", 30),+ id: "bad-luck-like",+ source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },+ duration: { type: "round" as const, value: 1 }+ };++ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: false });++ assert.equal(outcome.consumedEffectIds.includes("bad-luck-like"), false);+ assert.ok(outcome.appliedEffectIds.includes("BadLuckStreak/1"));+ assert.deepEqual(outcome.appliedEffects, [+ {+ effectId: "BadLuckStreak/1",+ bucket: "active.hero.defense.down",+ valuePct: 30,+ source: "Example/Skill/BadLuckStreak/1",+ sourceSide: "defender",+ sameEffectStacking: "add"+ }+ ]);+});++test("attack-duration effects are only consumed when they participate in the calculation", () => {+ const defenderOutgoingBuff = {+ ...effect("active.hero.lethality.up", "defender", 100),+ id: "defender-outgoing-buff",+ duration: { type: "attack" as const, value: 1 }+ };++ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });++ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);+ assert.equal(outcome.consumedEffectIds.includes("defender-outgoing-buff"), false);+});++test("pct attack value evolution uses the effect use count for the current bucket value", () => {+ const decayedAttackUp = {+ ...effect("active.hero.attack.up", "attacker", 100),+ id: "decayed-attack-up",+ intent: {+ id: "active.hero.attack.up/decay",+ type: "active.hero.attack.up",+ value: 100,+ value_evolution: { type: "pct_decay", step: "attack", value: 15 }+ },+ duration: { type: "attack" as const, value: 10 },+ uses: 2+ };++ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [decayedAttackUp], { trace: true });++ assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);+ assert.ok(outcome.consumedEffectIds.includes("decayed-attack-up"));+});++test("pct turn value evolution starts decaying after the first active turn", () => {+ const turnDecayAttackUp = {+ ...effect("active.hero.attack.up", "attacker", 100),+ id: "turn-decay-attack-up",+ intent: {+ id: "active.hero.attack.up/turn_decay",+ type: "active.hero.attack.up",+ value: 100,+ value_evolution: { type: "pct_decay", step: "turn", value: 15 }+ },+ createdRound: 0,+ startRound: 0,+ duration: { type: "attack" as const, value: 10 }+ };++ const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });+ const secondTurn = calculateIndexedDamageJob({ ...job, round: 2 }, simpleFighters(), [turnDecayAttackUp], { trace: true });++ assert.equal(firstTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);+ assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);+});++test("max-stacked attack-duration effects consume the whole eligible group and output only the max current value", () => {+ const weaker = {+ ...effect("active.hero.lethality.up", "attacker", 50),+ id: "max-weaker",+ duration: { type: "attack" as const, value: 3 },+ stackingKey: "same-max-group",+ sameEffectStacking: "max" as const+ };+ const strongerButDecayed = {+ ...effect("active.hero.lethality.up", "attacker", 100),+ id: "max-stronger-decayed",+ intent: {+ id: "damage_up/decay",+ type: "active.hero.lethality.up",+ value: 100,+ value_evolution: { type: "pct_decay", step: "attack", value: 50 }+ },+ duration: { type: "attack" as const, value: 3 },+ uses: 1,+ stackingKey: "same-max-group",+ sameEffectStacking: "max" as const+ };++ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [weaker, strongerButDecayed], { trace: true });++ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);+ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);+ assert.deepEqual(new Set(outcome.consumedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));+});++test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {+ const active = activateEffect(+ {+ id: "TargetSkill",+ name: "TargetSkill",+ sourceKind: "hero_skill",+ side: "defender",+ heroName: "Targeter",+ level: 1,+ trigger: { type: "attack", source: "enemy.any", target: "self.any" },+ effects: []+ },+ {+ id: "targeted-defense",+ type: "active.hero.defense.up",+ value: 50,+ units: { applies_to: "trigger.target", applies_vs: "trigger.source" }+ },+ 1,+ {+ id: "intent",+ round: 1,+ source: "normal",+ attackerSide: "attacker",+ attackerUnit: "infantry",+ defenderSide: "defender",+ defenderUnit: "lancer",+ orderIndex: 0,+ previousAttackCount: 0,+ projectedAttackCount: 1,+ previousReceivedAttackCount: 0,+ projectedReceivedAttackCount: 1+ }+ );++ assert.deepEqual(active.appliesTo, { side: "defender", units: unitMask("lancer") });+ assert.deepEqual(active.appliesVs, { side: "attacker", units: unitMask("infantry") });+ assert.equal(classifyEffectForJob(active, job)?.bucket, "active.hero.defense.up");+});++test('applies_vs "target" resolves to the trigger target', () => {+ const active = activateEffect(+ {+ id: "TargetSkill",+ name: "TargetSkill",+ sourceKind: "hero_skill",+ side: "attacker",+ heroName: "Targeter",+ level: 1,+ trigger: { type: "attack", source: "self.any", target: "enemy.any" },+ effects: []+ },+ {+ id: "targeted-offense",+ type: "active.hero.lethality.up",+ value: 50,+ units: { applies_to: "trigger.source", applies_vs: "target" }+ },+ 1,+ {+ id: "intent",+ round: 1,+ source: "normal",+ attackerSide: "attacker",+ attackerUnit: "infantry",+ defenderSide: "defender",+ defenderUnit: "lancer",+ orderIndex: 0,+ previousAttackCount: 0,+ projectedAttackCount: 1,+ previousReceivedAttackCount: 0,+ projectedReceivedAttackCount: 1+ }+ );++ assert.deepEqual(active.appliesTo, { side: "attacker", units: unitMask("infantry") });+ assert.deepEqual(active.appliesVs, { side: "defender", units: unitMask("lancer") });+});++test('applies_vs "target" still resolves to the trigger target for defensive effects', () => {+ const active = activateEffect(+ {+ id: "TargetSkill",+ name: "TargetSkill",+ sourceKind: "hero_skill",+ side: "defender",+ heroName: "Targeter",+ level: 1,+ trigger: { type: "attack", source: "enemy.any", target: "self.any" },+ effects: []+ },+ {+ id: "targeted-defense",+ type: "active.hero.defense.up",+ value: 50,+ units: { applies_to: "trigger.target", applies_vs: "target" }+ },+ 1,+ {+ id: "intent",+ round: 1,+ source: "normal",+ attackerSide: "attacker",+ attackerUnit: "infantry",+ defenderSide: "defender",+ defenderUnit: "lancer",+ orderIndex: 0,+ previousAttackCount: 0,+ projectedAttackCount: 1,+ previousReceivedAttackCount: 0,+ projectedReceivedAttackCount: 1+ }+ );++ assert.deepEqual(active.appliesTo, { side: "defender", units: unitMask("lancer") });+ assert.deepEqual(active.appliesVs, { side: "defender", units: unitMask("lancer") });+});++function simpleFighters(): Record<"attacker" | "defender", ResolvedFighter> {+ const attacker: ResolvedFighter = {+ side: "attacker",+ name: "A",+ troops: { infantry: 1000, lancer: 0, marksman: 0 },+ initialTroops: { infantry: 1000, lancer: 0, marksman: 0 },+ troopDetails: {+ infantry: { id: "infantry_t1", type: "infantry", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }+ },+ statBonuses: {+ infantry: { attack: 0, lethality: 0, defense: 0, health: 0 },+ lancer: { attack: 0, lethality: 0, defense: 0, health: 0 },+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }+ },+ heroes: [],+ troopSkills: [],+ diagnostics: []+ };+ const defender: ResolvedFighter = {+ ...attacker,+ side: "defender",+ name: "D",+ troops: { infantry: 0, lancer: 1000, marksman: 0 },+ initialTroops: { infantry: 0, lancer: 1000, marksman: 0 },+ troopDetails: {+ lancer: { id: "lancer_t1", type: "lancer", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }+ }+ };+ return { attacker, defender };+}Index: simulator/src/config-node.ts===================================================================--- simulator/src/config-node.ts prev run+++ simulator/src/config-node.ts this run@@ -0,0 +1,31 @@+import { readdirSync, readFileSync } from "node:fs";+import { join, relative, resolve } from "node:path";++import { buildSimulatorConfig, type RawSimulatorConfig } from "./config";+import type { SimulatorConfig, SkillFile } from "./types";++export function loadSimulatorConfigFromDir(configDir: string): SimulatorConfig {+ const root = resolve(configDir);+ const heroDir = join(root, "hero_definitions");+ const heroDefinitions: Record<string, SkillFile> = {};+ for (const file of readdirSync(heroDir).filter((name) => name.endsWith(".json")).sort()) {+ heroDefinitions[file.slice(0, -".json".length)] = readJson(join(heroDir, file)) as SkillFile;+ }+ const raw: RawSimulatorConfig = {+ troopStats: readJson(join(root, "troop_stats.json")) as SimulatorConfig["troopStats"],+ heroGenerationStats: readJson(join(root, "hero_generation_stats.json")) as SimulatorConfig["heroGenerationStats"],+ troopSkills: readJson(join(root, "troop_skills.json")) as SkillFile,+ heroDefinitions,+ fileLabel(kind, key) {+ if (kind === "hero_definition") return relative(process.cwd(), join(heroDir, `${key}.json`));+ if (kind === "troop_stats") return relative(process.cwd(), join(root, "troop_stats.json"));+ if (kind === "hero_generation_stats") return relative(process.cwd(), join(root, "hero_generation_stats.json"));+ return relative(process.cwd(), join(root, "troop_skills.json"));+ }+ };+ return buildSimulatorConfig(raw);+}++function readJson(path: string): unknown {+ return JSON.parse(readFileSync(path, "utf8"));+}Index: simulator/src/config.test.ts===================================================================--- simulator/src/config.test.ts prev run+++ simulator/src/config.test.ts this run@@ -0,0 +1,299 @@+import assert from "node:assert/strict";+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";+import { join, relative } from "node:path";+import { test } from "node:test";+import { tmpdir } from "node:os";+import { fileURLToPath } from "node:url";++import { loadSimulatorConfigFromDir } from "./config-node";+import { UNIT_TYPES } from "./types";+import type { SkillFile, TriggerDamageJobDefinition } from "./types";++test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {+ const root = writeConfigWithTroopEffect({+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "trigger.source", applies_vs: "target" }+ });++ const config = loadSimulatorConfigFromDir(root);++ assert.deepEqual(config.diagnostics.ambiguousTurnTriggerSelectors, [+ {+ file: relative(process.cwd(), join(root, "troop_skills.json")),+ skillId: "ExampleSkill",+ effectId: "ExampleSkill/1",+ selector: "trigger.source",+ reason: "Turn trigger has no concrete attack intent; trigger-relative unit selectors fall back to all units"+ },+ {+ file: relative(process.cwd(), join(root, "troop_skills.json")),+ skillId: "ExampleSkill",+ effectId: "ExampleSkill/1",+ selector: "target",+ reason: "Turn trigger has no concrete attack intent; trigger-relative unit selectors fall back to all units"+ }+ ]);+});++test("loadSimulatorConfig rejects legacy fields in simulator config", () => {+ const root = join(tmpdir(), `wos-simulator-config-${Date.now()}`);+ mkdirSync(join(root, "hero_definitions"), { recursive: true });+ writeFileSync(+ join(root, "troop_stats.json"),+ JSON.stringify({+ infantry_t1: {+ id: "infantry_t1",+ type: "infantry",+ tier: 1,+ fc: 0,+ legacy: true,+ stats: { Attack: 1, Defense: 1, Lethality: 1, Health: 1 }+ }+ })+ );+ writeFileSync(join(root, "hero_generation_stats.json"), JSON.stringify({ S1: { attack: 1, defense: 1, lethality: 1, health: 1 } }));+ writeFileSync(join(root, "troop_skills.json"), JSON.stringify({ name: "Troop Skills", skills: {} }));+ writeFileSync(join(root, "hero_definitions", "Example.json"), JSON.stringify({ name: "Example", hero_generation: "S1", skills: {} }));++ assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);+});++test("loadSimulatorConfig rejects legacy effect metadata fields without naming them in source", () => {+ const legacyEffectMetadataKey = ["effect", "op"].join("_");+ const root = writeConfigWithTroopEffect({+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "trigger.source", applies_vs: "target" },+ [legacyEffectMetadataKey]: 101+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);+});++test("simulator config source does not reference legacy effect metadata names", () => {+ const legacyEffectMetadataKey = ["effect", "op"].join("_");+ const source = readFileSync(fileURLToPath(new URL("./config.ts", import.meta.url)), "utf8");++ assert.equal(source.includes(legacyEffectMetadataKey), false);+});++test("loadSimulatorConfig rejects duplicate normalized hero aliases", () => {+ const root = join(tmpdir(), `wos-simulator-config-alias-${Date.now()}`);+ mkdirSync(join(root, "hero_definitions"), { recursive: true });+ writeFileSync(+ join(root, "troop_stats.json"),+ JSON.stringify({+ infantry_t1: {+ id: "infantry_t1",+ type: "infantry",+ tier: 1,+ fc: 0,+ stats: { Attack: 1, Defense: 1, Lethality: 1, Health: 1 }+ }+ })+ );+ writeFileSync(join(root, "hero_generation_stats.json"), JSON.stringify({ S1: { attack: 1, defense: 1, lethality: 1, health: 1 } }));+ writeFileSync(join(root, "troop_skills.json"), JSON.stringify({ name: "Troop Skills", skills: {} }));+ writeFileSync(join(root, "hero_definitions", "Alpha.json"), JSON.stringify({ name: "Same Hero", hero_generation: "S1", skills: {} }));+ writeFileSync(join(root, "hero_definitions", "Beta.json"), JSON.stringify({ name: "Same-Hero", hero_generation: "S1", skills: {} }));++ assert.throws(() => loadSimulatorConfigFromDir(root), /duplicate hero alias.*samehero/i);+});++test("loadSimulatorConfig rejects legacy trigger units filters", () => {+ const root = writeConfigWithTroopEffect({+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "trigger.source", applies_vs: "target" }+ });+ const troopSkills = JSON.parse(readFileSync(join(root, "troop_skills.json"), "utf8")) as SkillFile;+ troopSkills.skills.ExampleSkill.trigger = { type: "attack", units: { side: "enemy", applies_vs: ["infantry"] } } as never;+ writeFileSync(join(root, "troop_skills.json"), JSON.stringify(troopSkills));++ assert.throws(() => loadSimulatorConfigFromDir(root), /legacy trigger units/i);+});++test('loadSimulatorConfig rejects native effect applies_vs "all"', () => {+ const root = writeConfigWithTroopEffect({+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "trigger.source", applies_vs: "all" }+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /applies_vs.*all/i);+});++test("loadSimulatorConfig rejects native effect units.side", () => {+ const root = writeConfigWithTroopEffect({+ type: "active.hero.lethality.up",+ value: 10,+ units: { side: "enemy", applies_to: "target" }+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /units\.side/i);+});++test("loadSimulatorConfig rejects trigger-relative effect selectors on battle_start triggers", () => {+ const root = writeConfigWithTroopEffect(+ {+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "trigger" }+ },+ { type: "battle_start" }+ );++ assert.throws(() => loadSimulatorConfigFromDir(root), /battle_start.*trigger-relative.*applies_to.*trigger/i);+});++test("loadSimulatorConfig rejects negative native bucket effect values", () => {+ const root = writeConfigWithTroopEffect({+ type: "active.hero.attack.down",+ value: -10,+ units: { applies_to: "trigger.source", applies_vs: "target" }+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /negative.*active\.hero\.attack\.down.*value/i);+});++test("loadSimulatorConfig rejects passive effects that are not battle-start static", () => {+ const turnRoot = writeConfigWithTroopEffect({+ type: "passive.attack.up",+ value: 10+ });+ const durationRoot = writeConfigWithTroopEffect(+ {+ type: "passive.attack.up",+ value: 10,+ duration: { type: "round", value: 1 }+ },+ { type: "battle_start" }+ );+ const evolvingRoot = writeConfigWithTroopEffect(+ {+ type: "passive.attack.up",+ value: 10,+ value_evolution: { type: "fixed_decay", step: "round", value: 1 }+ },+ { type: "battle_start" }+ );++ assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /passive.*battle_start/i);+ assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /passive.*battle.*duration/i);+ assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /passive.*value_evolution/i);+});++test("loadSimulatorConfig rejects invalid trigger_damage_jobs selector strings", () => {+ const root = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "effect.applies_v" }]+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /invalid trigger_damage_jobs target selector.*effect\.applies_v/i);+});++test("loadSimulatorConfig rejects trigger_damage_jobs with typoed keys", () => {+ const root = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ soruce: "use.source", target: "effect.applies_vs" }]+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /unknown trigger_damage_jobs key soruce/i);+});++test("loadSimulatorConfig requires trigger_damage_jobs source and target", () => {+ const missingSourceRoot = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ target: "effect.applies_vs" }]+ });+ const missingTargetRoot = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source" }]+ });++ assert.throws(() => loadSimulatorConfigFromDir(missingSourceRoot), /requires source/i);+ assert.throws(() => loadSimulatorConfigFromDir(missingTargetRoot), /requires target/i);+});++test('loadSimulatorConfig rejects effect.applies_vs jobs gated by applies_vs "any"', () => {+ const root = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "effect.applies_vs" }]+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /effect\.applies_vs.*applies_vs.*any/i);+});++test("loadSimulatorConfig rejects effect.applies_vs jobs without concrete applies_vs", () => {+ const omittedRoot = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source" },+ trigger_damage_jobs: [{ source: "use.source", target: "effect.applies_vs" }]+ });+ const allRoot = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "all" },+ trigger_damage_jobs: [{ source: "use.source", target: "effect.applies_vs" }]+ });++ assert.throws(() => loadSimulatorConfigFromDir(omittedRoot), /effect\.applies_vs.*concrete applies_vs/i);+ assert.throws(() => loadSimulatorConfigFromDir(allRoot), /applies_vs.*all/i);+});++function isAllowedTriggerDamageJobSelector(selector: TriggerDamageJobDefinition["source"]): boolean {+ const supported = new Set(["use.source", "use.target", "effect.applies_to", "effect.applies_vs", "enemy.living", "self.living"]);+ if (typeof selector === "string") return supported.has(selector) || (UNIT_TYPES as string[]).includes(selector);+ if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));+ return false;+}++function isEffectAppliesVsConcrete(selector: unknown): boolean {+ if (selector === "trigger.target" || selector === "target") return true;+ if (typeof selector === "string") return (UNIT_TYPES as string[]).includes(selector);+ if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));+ return false;+}++function writeConfigWithTroopEffect(effect: Record<string, unknown>, trigger: Record<string, unknown> = { type: "turn" }): string {+ const root = join(tmpdir(), `wos-simulator-config-trigger-jobs-${Date.now()}-${Math.random().toString(16).slice(2)}`);+ mkdirSync(join(root, "hero_definitions"), { recursive: true });+ writeFileSync(+ join(root, "troop_stats.json"),+ JSON.stringify({+ infantry_t1: {+ id: "infantry_t1",+ type: "infantry",+ tier: 1,+ fc: 0,+ stats: { Attack: 1, Defense: 1, Lethality: 1, Health: 1 }+ }+ })+ );+ writeFileSync(join(root, "hero_generation_stats.json"), JSON.stringify({ S1: { attack: 1, defense: 1, lethality: 1, health: 1 } }));+ writeFileSync(+ join(root, "troop_skills.json"),+ JSON.stringify({+ name: "Troop Skills",+ skills: {+ ExampleSkill: {+ trigger,+ effects: { "ExampleSkill/1": effect }+ }+ }+ })+ );+ return root;+}Index: simulator/src/config.ts===================================================================--- simulator/src/config.ts prev run+++ simulator/src/config.ts this run@@ -0,0 +1,350 @@+import troopStatsJson from "../config/troop_stats.json" with { type: "json" };+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 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 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 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 { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";+import { assertStaticPassiveEffectDefinition, isPassiveBucket, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";++const KNOWN_EFFECT_TYPES = new Set([+ ...ATOMIC_BUCKETS.filter((bucket) => bucket.startsWith("active.") || bucket.startsWith("type.")),+ ...STATIC_PASSIVE_BUCKETS,+ "extra_skill_attack",+ "dodge",+ "no_attack",+ "attack_order"+]);++const DEFAULT_HERO_DEFINITIONS = {+ Ahmose,+ Alonso,+ Bahiti,+ Bradley,+ Edith,+ Flint,+ Gordon,+ Greg,+ Gwen,+ Hector,+ Jasser,+ Jeronimo,+ Jessie,+ Ling,+ Logan,+ Lumak,+ Lynn,+ Mia,+ Molly,+ Natalia,+ Norah,+ Patrick,+ Philly,+ Reina,+ Renee,+ "Seo-yoon": SeoYoon,+ Sergey,+ Wayne,+ WuMing,+ Zinman+} as unknown as Record<string, SkillFile>;++export interface RawSimulatorConfig {+ troopStats: SimulatorConfig["troopStats"];+ heroGenerationStats: SimulatorConfig["heroGenerationStats"];+ troopSkills: SkillFile;+ heroDefinitions: Record<string, SkillFile>;+ fileLabel?: (kind: "troop_stats" | "hero_generation_stats" | "troop_skills" | "hero_definition", key?: string) => string;+}++export function loadSimulatorConfig(): SimulatorConfig {+ return buildSimulatorConfig({+ troopStats: troopStatsJson as SimulatorConfig["troopStats"],+ heroGenerationStats: heroGenerationStatsJson as SimulatorConfig["heroGenerationStats"],+ troopSkills: troopSkillsJson as SkillFile,+ heroDefinitions: DEFAULT_HERO_DEFINITIONS+ });+}++export function buildSimulatorConfig(raw: RawSimulatorConfig): SimulatorConfig {+ const diagnostics: ConfigDiagnostics = {+ legacyFields: [],+ effectTypes: {},+ unsupportedEffects: [],+ ambiguousTurnTriggerSelectors: []+ };++ scanLegacyFields(raw.troopStats, raw.fileLabel?.("troop_stats") ?? "config/troop_stats.json", "$", diagnostics);+ scanLegacyFields(raw.heroGenerationStats, raw.fileLabel?.("hero_generation_stats") ?? "config/hero_generation_stats.json", "$", diagnostics);+ scanLegacyFields(raw.troopSkills, raw.fileLabel?.("troop_skills") ?? "config/troop_skills.json", "$", diagnostics);++ for (const [name, hero] of Object.entries(raw.heroDefinitions)) {+ const file = raw.fileLabel?.("hero_definition", name) ?? `config/hero_definitions/${name}.json`;+ scanLegacyFields(hero, file, "$", diagnostics);+ if (hero.hero_generation && !raw.heroGenerationStats[hero.hero_generation]) {+ diagnostics.unsupportedEffects.push({+ file,+ skillId: "(hero_generation)",+ effectId: hero.hero_generation,+ type: "missing_hero_generation",+ reason: `Referenced hero_generation ${hero.hero_generation} is not defined`+ });+ }+ }++ collectEffectDiagnostics(raw.troopSkills, raw.fileLabel?.("troop_skills") ?? "config/troop_skills.json", diagnostics);+ for (const [name, hero] of Object.entries(raw.heroDefinitions)) {+ collectEffectDiagnostics(hero, raw.fileLabel?.("hero_definition", name) ?? `config/hero_definitions/${name}.json`, diagnostics);+ }++ const heroAliasIndex = buildHeroAliasIndex(raw.heroDefinitions);++ if (diagnostics.legacyFields.length > 0) {+ const first = diagnostics.legacyFields[0];+ throw new Error(`Legacy field found in simulator config: ${first.field} at ${first.file}:${first.path}`);+ }++ return {+ troopStats: raw.troopStats,+ heroGenerationStats: raw.heroGenerationStats,+ heroDefinitions: raw.heroDefinitions,+ heroAliasIndex,+ troopSkills: raw.troopSkills,+ diagnostics+ };+}++function scanLegacyFields(value: unknown, file: string, path: string, diagnostics: ConfigDiagnostics): void {+ if (!value || typeof value !== "object") return;+ if (Array.isArray(value)) {+ value.forEach((entry, index) => scanLegacyFields(entry, file, `${path}[${index}]`, diagnostics));+ return;+ }+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {+ if (isLegacyConfigField(key)) diagnostics.legacyFields.push({ file, path, field: key });+ scanLegacyFields(child, file, `${path}.${key}`, diagnostics);+ }+}++function isLegacyConfigField(key: string): boolean {+ if (key === "legacy") return true;+ if (!key.startsWith("effect_")) return false;+ const suffix = key.slice("effect_".length);+ return suffix === "op" || suffix === "type";+}++function collectEffectDiagnostics(skillFile: SkillFile, file: string, diagnostics: ConfigDiagnostics): void {+ for (const [skillId, skill] of Object.entries(skillFile.skills ?? {})) {+ validateTriggerDefinition(skill.trigger, file, skillId);+ for (const [effectId, effect] of Object.entries(skill.effects ?? {})) {+ const type = String((effect as { type?: unknown }).type ?? "");+ diagnostics.effectTypes[type] = (diagnostics.effectTypes[type] ?? 0) + 1;+ collectAmbiguousTurnTriggerSelectorDiagnostics(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId, diagnostics);+ validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);+ validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);+ validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);+ assertStaticPassiveEffectDefinition(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);+ if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);+ if (!KNOWN_EFFECT_TYPES.has(type)) {+ diagnostics.unsupportedEffects.push({+ file,+ skillId,+ effectId,+ type,+ reason: "Effect type is not in the initial simulator classifier policy"+ });+ }+ }+ }+}++function collectAmbiguousTurnTriggerSelectorDiagnostics(+ trigger: SkillFile["skills"][string]["trigger"],+ effect: EffectIntentDefinition,+ file: string,+ skillId: string,+ effectId: string,+ diagnostics: ConfigDiagnostics+): void {+ if (trigger.type !== "turn" || trigger.source !== undefined) return;+ for (const selector of [effect.units?.applies_to, effect.units?.applies_vs]) {+ if (!isTriggerRelativeUnitSelector(selector)) continue;+ diagnostics.ambiguousTurnTriggerSelectors.push({+ file,+ skillId,+ effectId,+ selector,+ reason: "Turn trigger has no concrete attack intent; trigger-relative unit selectors fall back to all units"+ });+ }+}++function validateTriggerDefinition(trigger: SkillFile["skills"][string]["trigger"], file: string, skillId: string): void {+ const legacyUnits = (trigger as unknown as Record<string, unknown>).units;+ if (legacyUnits !== undefined) {+ throw new Error(`legacy trigger units filters are not supported at ${file}:${skillId}.trigger.units; use trigger.source and trigger.target`);+ }+}++function isTriggerRelativeUnitSelector(selector: unknown): selector is string {+ return selector === "trigger" || selector === "trigger.source" || selector === "target" || selector === "trigger.target";+}++function validateBattleStartEffectSelectors(+ trigger: SkillFile["skills"][string]["trigger"],+ effect: EffectIntentDefinition,+ file: string,+ skillId: string,+ effectId: string+): void {+ if (trigger.type !== "battle_start") return;+ for (const field of ["applies_to", "applies_vs"] as const) {+ const selector = effect.units?.[field];+ if (!isTriggerRelativeUnitSelector(selector)) continue;+ throw new Error(+ `battle_start effect cannot use trigger-relative units.${field} selector ${JSON.stringify(selector)} at ${file}:${skillId}.${effectId}; use a concrete selector such as self.any`+ );+ }+}++function validateNativeEffectUnits(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {+ const path = `${file}:${skillId}.${effectId}`;+ if (effect.units && "side" in effect.units) {+ throw new Error(`native effect units.side is not supported at ${path}; use relation-qualified applies_to selectors such as enemy.any or trigger.target`);+ }+ if (effect.units?.applies_vs !== "all") return;+ throw new Error(+ `native effect units.applies_vs cannot be "all" at ${path}; use "any" for an unrestricted usage gate or trigger_damage_jobs target selectors for multi-target damage`+ );+}++function validateNativeEffectValue(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {+ const definition = bucketDefinition(effect.type);+ if ((!definition || definition.valueType !== "pct") && !isPassiveBucket(effect.type)) return;+ const values = Array.isArray(effect.value) ? effect.value : [effect.value];+ for (const value of values) {+ if (value === undefined) continue;+ if (typeof value !== "number" || !Number.isFinite(value)) {+ throw new Error(`native bucket effect ${effect.type} value must be a finite number at ${file}:${skillId}.${effectId}`);+ }+ if (value < 0) {+ throw new Error(`negative native bucket effect ${effect.type} value is not supported at ${file}:${skillId}.${effectId}; use positive magnitudes`);+ }+ }+}++function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {+ const path = `${file}:${skillId}.${effectId}`;+ if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {+ throw new Error(`extra_skill_attack requires non-empty trigger_damage_jobs at ${path}`);+ }+ effect.trigger_damage_jobs.forEach((job, index) => {+ validateTriggerDamageJobShape(job, path, index);+ validateTriggerDamageJobSelector(job.source, "source", path, index);+ validateTriggerDamageJobSelector(job.target, "target", path, index);+ if (job.target === "effect.applies_vs" && !isEffectAppliesVsConcrete(effect.units?.applies_vs)) {+ throw new Error(+ `trigger_damage_jobs target effect.applies_vs requires a concrete applies_vs, not ${JSON.stringify(effect.units?.applies_vs)}, at ${path}.trigger_damage_jobs[${index}]`+ );+ }+ if (job.multiplier !== undefined && typeof job.multiplier !== "number") {+ throw new Error(`trigger_damage_jobs multiplier must be a number at ${path}.trigger_damage_jobs[${index}]`);+ }+ });+}++function validateTriggerDamageJobShape(job: unknown, path: string, jobIndex: number): asserts job is TriggerDamageJobDefinition {+ if (!job || typeof job !== "object" || Array.isArray(job)) {+ throw new Error(`trigger_damage_jobs entry must be an object at ${path}.trigger_damage_jobs[${jobIndex}]`);+ }+ const allowedKeys = new Set(["source", "target", "multiplier"]);+ for (const key of Object.keys(job)) {+ if (!allowedKeys.has(key)) {+ throw new Error(`unknown trigger_damage_jobs key ${key} at ${path}.trigger_damage_jobs[${jobIndex}]`);+ }+ }+ const record = job as Record<string, unknown>;+ if (record.source === undefined) {+ throw new Error(`trigger_damage_jobs entry requires source at ${path}.trigger_damage_jobs[${jobIndex}]`);+ }+ if (record.target === undefined) {+ throw new Error(`trigger_damage_jobs entry requires target at ${path}.trigger_damage_jobs[${jobIndex}]`);+ }+}++function validateTriggerDamageJobSelector(+ selector: TriggerDamageJobDefinition["source"],+ role: "source" | "target",+ path: string,+ jobIndex: number+): void {+ if (isAllowedTriggerDamageJobSelector(selector)) return;+ throw new Error(`invalid trigger_damage_jobs ${role} selector ${JSON.stringify(selector)} at ${path}.trigger_damage_jobs[${jobIndex}]`);+}++function isAllowedTriggerDamageJobSelector(selector: TriggerDamageJobDefinition["source"]): boolean {+ const supported = new Set(["use.source", "use.target", "effect.applies_to", "effect.applies_vs", "enemy.living", "self.living"]);+ if (typeof selector === "string") return supported.has(selector) || (UNIT_TYPES as string[]).includes(selector);+ if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));+ return false;+}++function isEffectAppliesVsConcrete(selector: unknown): boolean {+ if (selector === "trigger.target" || selector === "target") return true;+ if (typeof selector === "string") return (UNIT_TYPES as string[]).includes(selector);+ if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));+ return false;+}++function buildHeroAliasIndex(heroDefinitions: Record<string, SkillFile>): Record<string, string> {+ const index: Record<string, string> = {};+ for (const [key, definition] of Object.entries(heroDefinitions)) {+ addHeroAlias(index, key, key);+ if (definition.name) addHeroAlias(index, definition.name, key);+ }+ return index;+}++function addHeroAlias(index: Record<string, string>, alias: string, heroKey: string): void {+ const normalized = normalizeHeroAlias(alias);+ if (!normalized) return;+ const existing = index[normalized];+ if (existing && existing !== heroKey) {+ throw new Error(`Duplicate hero alias ${normalized} resolves to both ${existing} and ${heroKey}`);+ }+ index[normalized] = heroKey;+}++function normalizeHeroAlias(name: string): string {+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");+}Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -0,0 +1,542 @@+import type {+ ActiveEffect,+ DamageAggregationGroupTrace,+ AttackOutcome,+ DamageBucketTrace,+ DamageEquationTrace,+ DamageJob,+ ResolvedFighter,+ SameEffectStacking,+ SideId,+ UnitType+} from "./types";+import { UNIT_TYPES } from "./types";+import { classifyEffectForJob } from "./classifier";+import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";+import { currentEffectValuePct, isEffectActive } from "./effects";+import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";+import {+ buildStaticDamageProfile,+ type StaticDamageBucket,+ type StaticDamageProfile,+ type StaticDamageProfileEntry+} from "./staticDamageProfile";++type AtomicBuckets = Record<string, DamageBucketTrace>;+type GroupPlacement = "numerator" | "denominator";+type DamageDetail = "full" | "fast";+type NumericBucketId = number;+interface DamageFactorTerm {+ id: string;+ bucket: NumericBucketId;+ bucketName: AtomicBucket;+ placement: GroupPlacement;+ appliesTo?: DamageJob["kind"];+}++interface BucketCandidate {+ effect: ActiveEffect;+ bucket: AtomicBucket;+ valuePct: number;+}++interface MaxBucketCandidateGroup {+ selected: BucketCandidate;+ candidates: BucketCandidate[];+}++interface DamageExpressionResult {+ rawDamage: number;+ aggregationGroups: Record<string, DamageAggregationGroupTrace>;+}++interface NumericDamageBuckets {+ factors: Float64Array;+ contributors?: DamageBucketTrace["contributors"][];+}++export type DamageScratch = NumericDamageBuckets;++const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;+const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};+const EMPTY_APPLIED_EFFECT_IDS: AttackOutcome["appliedEffectIds"] = [];+const EMPTY_COUNTER_DELTAS: AttackOutcome["counterDeltas"] = [];+const EMPTY_CONSUMED_EFFECT_IDS: AttackOutcome["consumedEffectIds"] = [];+const TROOPS_COUNT_TERM = factorTerm("troops.count");+const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");+const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));+const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");+const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");+const PROFILED_NUMERATOR_TERMS = DEFAULT_NUMERATOR_TERMS.filter((term) => term.bucketName !== "troops.count" && term.bucketName !== "source.extraSkill");+const PROFILED_DENOMINATOR_TERMS = DEFAULT_DENOMINATOR_TERMS;++export class DamageAggregationError extends Error {+ readonly groupId: string;+ readonly round: number;+ readonly jobId: string;+ readonly netPct: number | undefined;+ readonly factor: number;+ readonly contributors: DamageBucketTrace["contributors"];++ constructor(args: {+ groupId: string;+ round: number;+ jobId: string;+ netPct?: number;+ factor: number;+ contributors: DamageBucketTrace["contributors"];+ }) {+ super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor}${args.netPct === undefined ? "" : ` netPct=${args.netPct}`}`);+ this.name = "DamageAggregationError";+ this.groupId = args.groupId;+ this.round = args.round;+ this.jobId = args.jobId;+ this.netPct = args.netPct;+ this.factor = args.factor;+ this.contributors = args.contributors;+ }+}++export function calculateDamageJob(+ job: DamageJob,+ fighters: Record<SideId, ResolvedFighter>,+ activeEffects: ActiveEffect[],+ options: { trace?: boolean; effectIndex: EffectIndex; detail?: DamageDetail; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch }+): AttackOutcome {+ if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");+ const detail = options.detail ?? "full";+ const traceEnabled = detail === "full" && options.trace === true;+ const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);+ const attacker = fighters[job.attackerSide];+ const defender = fighters[job.defenderSide];+ const attackerTroops = job.roundStartTroops[job.attackerSide][job.attackerUnit] ?? 0;+ const defenderTroops = job.roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0;+ const minInitialArmy = Math.max(1, Math.min(totalTroops(attacker.initialTroops), totalTroops(defender.initialTroops)));+ const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));+ const needsTraceBuckets = traceEnabled;+ const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);+ applyBucketValue(buckets, "troops.count", armyTerm);+ applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);++ const appliedEffects: DamageEquationTrace["appliedEffects"] = detail === "full" ? [] : [];+ const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];+ const consumedEffectIds = new Set<string>();+ const candidates: BucketCandidate[] = [];+ const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;+ for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {+ if (!isEffectActive(candidate.effect, job.round)) continue;+ handledCandidateEffectIds?.add(candidate.effect.id);+ candidates.push({+ effect: candidate.effect,+ bucket: candidate.bucket,+ valuePct: currentEffectValuePct(candidate.effect, job.round)+ });+ }++ if (traceEnabled) {+ for (const effect of options.effectIndex.all) {+ if (handledCandidateEffectIds?.has(effect.id)) continue;+ if (!isEffectActive(effect, job.round)) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });+ continue;+ }+ const classification = classifyEffectForJob(effect, job);+ if (classification?.kind === "bucket" && classification.bucket) {+ throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);+ }+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });+ }+ }++ applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);+ if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);++ const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];+ const traceBuckets = needsTraceBuckets ? toTraceBuckets(buckets, staticTraceEntries) : undefined;+ const expressionDetail = traceEnabled ? "full" : "fast";+ const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, expressionDetail, staticProfile);+ const kills = Math.min(defenderTroops, Math.max(0, rawDamage));+ const trace = traceEnabled+ ? {+ roundStartTroops: {+ attacker: { ...job.roundStartTroops.attacker },+ defender: { ...job.roundStartTroops.defender }+ },+ armyTerm,+ atomicBuckets: traceBuckets ?? toTraceBuckets(buckets, staticTraceEntries),+ aggregationGroups,+ appliedEffects,+ rejectedEffects,+ rawDamage,+ finalKills: kills+ }+ : undefined;++ const fast = detail === "fast";+ const returnedConsumedEffectIds =+ fast && consumedEffectIds.size === 0 ? job.consumedEffectIds ?? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds, ...(job.consumedEffectIds ?? [])];++ return {+ jobId: job.id,+ kind: job.kind,+ sourceEffectId: job.sourceEffectId,+ sourceSkillReportKey: job.sourceSkillReportKey,+ attackerSide: job.attackerSide,+ attackerUnit: job.attackerUnit,+ defenderSide: job.defenderSide,+ defenderUnit: job.defenderUnit,+ kills,+ counterDeltas: fast+ ? EMPTY_COUNTER_DELTAS+ : [+ { side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause: job.kind === "skill" ? "extra_skill_attack" : "normal_attack" },+ { side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause: job.kind === "skill" ? "extra_skill_attack" : "normal_attack" }+ ],+ appliedEffectIds: fast ? EMPTY_APPLIED_EFFECT_IDS : appliedEffects.map((effect) => effect.effectId),+ appliedEffects,+ consumedEffectIds: returnedConsumedEffectIds,+ consumedEffectUseKey: job.consumedEffectUseKey,+ consumedEffectUseId: job.consumedEffectUseId,+ consumedEffectUseIds: job.consumedEffectUseIds,+ trace+ };+}++function applyBucketCandidates(+ candidates: BucketCandidate[],+ buckets: NumericDamageBuckets,+ detail: DamageDetail,+ appliedEffects: DamageEquationTrace["appliedEffects"],+ rejectedEffects: DamageEquationTrace["rejectedEffects"],+ consumedEffectIds: Set<string>+): void {+ let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;+ for (const candidate of candidates) {+ if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {+ maxGroups ??= new Map();+ const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;+ const group = maxGroups.get(key);+ if (group) {+ group.candidates.push(candidate);+ if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;+ } else {+ maxGroups.set(key, { selected: candidate, candidates: [candidate] });+ }+ } else {+ applyBucketCandidateGroup(candidate, [candidate], buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ }+ }+ if (!maxGroups) return;+ for (const group of maxGroups.values()) {+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ }+}++function applyBucketCandidateGroup(+ selected: BucketCandidate,+ candidates: BucketCandidate[],+ buckets: NumericDamageBuckets,+ detail: DamageDetail,+ appliedEffects: DamageEquationTrace["appliedEffects"],+ rejectedEffects: DamageEquationTrace["rejectedEffects"],+ consumedEffectIds: Set<string>+): void {+ const appliedValuePct = applyBucketValue(+ buckets,+ selected.bucket,+ selected.valuePct,+ detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",+ detail === "full" ? sourceLabel(selected.effect) : "",+ detail === "full" ? selected.effect.ownerSide : undefined,+ selected.bucket,+ selected.effect.stackingKey,+ selected.effect.sameEffectStacking+ );+ if (appliedValuePct !== 0) {+ if (detail === "full") {+ const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {+ effectId: selected.effect.source.effectId ?? selected.effect.id,+ bucket: selected.bucket,+ valuePct: appliedValuePct,+ source: sourceLabel(selected.effect),+ sourceSide: selected.effect.ownerSide,+ sameEffectStacking: selected.effect.sameEffectStacking+ };+ if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;+ appliedEffects.push(appliedEffect);+ }+ for (const candidate of candidates) {+ if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ if (detail === "full" && candidate !== selected) {+ rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });+ }+ }+ } else if (detail === "full") {+ for (const candidate of candidates) {+ rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ }+ }+}++function factorTerm(bucket: AtomicBucket): DamageFactorTerm {+ const definition = BUCKET_DEFINITIONS[bucket];+ return {+ id: bucket,+ bucket: BUCKET_IDS[bucket],+ bucketName: bucket,+ placement: definition.placement,+ appliesTo: definition.appliesTo+ };+}++function createNumericDamageBuckets(collectTrace: boolean): NumericDamageBuckets {+ const factors = new Float64Array(ATOMIC_BUCKETS.length);+ factors.fill(1);+ return {+ factors,+ contributors: collectTrace ? Array.from({ length: ATOMIC_BUCKETS.length }, () => []) : undefined+ };+}++export function createFastDamageScratch(): DamageScratch {+ return createNumericDamageBuckets(false);+}++function resetDamageScratch(buckets: DamageScratch): DamageScratch {+ buckets.factors.fill(1);+ return buckets;+}++function applyBucketValue(+ buckets: NumericDamageBuckets,+ bucketName: AtomicBucket,+ value: number,+ effectId = "",+ source = "",+ sourceSide: SideId | undefined = undefined,+ traceBucketName = bucketName,+ stackingKey?: string,+ sameEffectStacking: SameEffectStacking = "add"+): number {+ const index = BUCKET_IDS[bucketName];+ const definition = BUCKET_DEFINITIONS[bucketName];+ if (definition.update === "assign_factor") buckets.factors[index] = Math.max(0, value);+ else buckets.factors[index] += value / 100;+ if (effectId) buckets.contributors?.[index].push({ effectId, source, sourceSide, valuePct: value, bucket: traceBucketName, stackingKey, sameEffectStacking });+ return value;+}++function appendStaticProfileAppliedEffects(appliedEffects: DamageEquationTrace["appliedEffects"], entry: StaticDamageProfileEntry): void {+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {+ if (!bucket.startsWith("passive.") || !term) continue;+ for (const contributor of term.contributors) {+ appliedEffects.push({+ effectId: contributor.effectId,+ bucket,+ valuePct: contributor.valuePct,+ source: contributor.source,+ sourceSide: contributor.sourceSide,+ stackingKey: contributor.stackingKey,+ sameEffectStacking: contributor.sameEffectStacking+ });+ }+ }+}++function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, detail: DamageDetail, staticProfile?: StaticDamageProfile): DamageExpressionResult {+ if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, detail, staticProfile);+ let numerator = 1;+ for (const term of DEFAULT_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);+ let denominator = 1;+ for (const term of DEFAULT_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);+ return {+ rawDamage: numerator / denominator,+ aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets) : EMPTY_AGGREGATION_GROUPS+ };+}++function evaluateProfiledDamageExpression(+ job: DamageJob,+ buckets: NumericDamageBuckets,+ detail: DamageDetail,+ staticProfile: StaticDamageProfile+): DamageExpressionResult {+ validateProfiledStaticFactors(job, staticProfile);+ let numerator =+ valueForTerm(TROOPS_COUNT_TERM, job, buckets) *+ valueForTerm(SOURCE_EXTRA_SKILL_TERM, job, buckets) *+ staticProfile.offense[job.attackerSide][job.attackerUnit].factor *+ staticProfile.defense[job.defenderSide][job.defenderUnit].factor;+ for (const term of PROFILED_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);+ let denominator = 100;+ for (const term of PROFILED_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);+ return {+ rawDamage: numerator / denominator,+ aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets, staticProfile) : EMPTY_AGGREGATION_GROUPS+ };+}++function validateProfiledStaticFactors(job: DamageJob, staticProfile: StaticDamageProfile): void {+ validateProfiledPctFactor(job, "player.attacker.attack", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.attack");+ validateProfiledPctFactor(job, "player.attacker.lethality", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.lethality");+ validateProfiledPctFactor(job, "player.defender.health", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.health");+ validateProfiledPctFactor(job, "player.defender.defense", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.defense");+}++function validateProfiledPctFactor(job: DamageJob, groupId: string, entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): void {+ const term = entry.buckets[bucket];+ const totalPct = term?.totalPct ?? 0;+ const factor = 1 + totalPct / 100;+ if (factor > 0) return;+ throw new DamageAggregationError({+ groupId,+ round: job.round,+ jobId: job.id,+ netPct: totalPct,+ factor,+ contributors: term?.contributors ?? []+ });+}++function valueForTerm(term: DamageFactorTerm, job: DamageJob, buckets: NumericDamageBuckets): number {+ if (term.appliesTo && term.appliesTo !== job.kind) return 1;+ return buckets.factors[term.bucket];+}++function buildAggregationGroups(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): Record<string, DamageAggregationGroupTrace> {+ const aggregationGroups: Record<string, DamageAggregationGroupTrace> = {};+ if (staticProfile) addStaticAggregationGroups(aggregationGroups, job, staticProfile);+ for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS]) {+ if (term.appliesTo && term.appliesTo !== job.kind) continue;+ const definition = BUCKET_DEFINITIONS[term.bucketName];+ const factor = buckets.factors[term.bucket];+ if (definition.valueType === "raw") {+ aggregationGroups[term.id] = {+ id: term.id,+ mode: "raw",+ placement: term.placement,+ inputBuckets: [term.bucketName],+ factor,+ contributors: contributorsForTerm(term, buckets)+ };+ } else {+ const totalPct = pctFromFactor(factor);+ aggregationGroups[term.id] = {+ id: term.id,+ mode: "sum_pct",+ placement: term.placement,+ inputBuckets: [term.bucketName],+ totalPct,+ factor,+ contributors: contributorsForTerm(term, buckets)+ };+ }+ }+ return aggregationGroups;+}++function addStaticAggregationGroups(+ aggregationGroups: Record<string, DamageAggregationGroupTrace>,+ job: DamageJob,+ staticProfile: StaticDamageProfile+): void {+ const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];+ const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];+ addStaticRawGroup(aggregationGroups, "troops.baseAttack", "numerator", offense, "troops.baseAttack");+ addStaticRawGroup(aggregationGroups, "troops.baseLethality", "numerator", offense, "troops.baseLethality");+ addStaticPctGroup(aggregationGroups, "player.attacker.attack", "numerator", offense, "player.attack");+ addStaticPctGroup(aggregationGroups, "player.attacker.lethality", "numerator", offense, "player.lethality");+ addStaticPctGroup(aggregationGroups, "passive.attacker.attack.up", "numerator", offense, "passive.attack.up");+ addStaticPctGroup(aggregationGroups, "passive.attacker.lethality.up", "numerator", offense, "passive.lethality.up");+ addStaticPctGroup(aggregationGroups, "passive.defender.health.down", "numerator", defense, "passive.health.down");+ addStaticPctGroup(aggregationGroups, "passive.defender.defense.down", "numerator", defense, "passive.defense.down");++ addStaticRawGroup(aggregationGroups, "troops.baseHealth", "denominator", defense, "troops.baseHealth");+ addStaticRawGroup(aggregationGroups, "troops.baseDefense", "denominator", defense, "troops.baseDefense");+ addStaticPctGroup(aggregationGroups, "player.defender.health", "denominator", defense, "player.health");+ addStaticPctGroup(aggregationGroups, "player.defender.defense", "denominator", defense, "player.defense");+ addStaticPctGroup(aggregationGroups, "passive.attacker.attack.down", "denominator", offense, "passive.attack.down");+ addStaticPctGroup(aggregationGroups, "passive.attacker.lethality.down", "denominator", offense, "passive.lethality.down");+ addStaticPctGroup(aggregationGroups, "passive.defender.health.up", "denominator", defense, "passive.health.up");+ addStaticPctGroup(aggregationGroups, "passive.defender.defense.up", "denominator", defense, "passive.defense.up");+}++function addStaticRawGroup(+ aggregationGroups: Record<string, DamageAggregationGroupTrace>,+ id: string,+ placement: GroupPlacement,+ entry: StaticDamageProfileEntry,+ bucket: StaticDamageBucket+): void {+ const term = entry.buckets[bucket];+ aggregationGroups[id] = {+ id,+ mode: "raw",+ placement,+ inputBuckets: [bucket],+ factor: Math.max(0, term?.raw ?? 0),+ contributors: term?.contributors ?? []+ };+}++function addStaticPctGroup(+ aggregationGroups: Record<string, DamageAggregationGroupTrace>,+ id: string,+ placement: GroupPlacement,+ entry: StaticDamageProfileEntry,+ bucket: StaticDamageBucket+): void {+ const term = entry.buckets[bucket];+ const totalPct = term?.totalPct ?? 0;+ aggregationGroups[id] = {+ id,+ mode: "sum_pct",+ placement,+ inputBuckets: [bucket],+ totalPct,+ factor: 1 + totalPct / 100,+ contributors: term?.contributors ?? []+ };+}++function contributorsForTerm(term: DamageFactorTerm, buckets: NumericDamageBuckets): DamageBucketTrace["contributors"] {+ if (!buckets.contributors) return [];+ return buckets.contributors[term.bucket] ?? [];+}++function toTraceBuckets(buckets: NumericDamageBuckets, staticEntries: StaticDamageProfileEntry[] = []): AtomicBuckets {+ const traced = Object.fromEntries(+ ATOMIC_BUCKETS.map((bucket, index) => {+ const contributors = buckets.contributors?.[index] ?? [];+ const definition = BUCKET_DEFINITIONS[bucket];+ const factor = buckets.factors[index];+ if (definition.valueType === "raw") {+ return [bucket, { raw: factor, factor, contributors: [...contributors] }];+ }+ return [bucket, { totalPct: pctFromFactor(factor), factor, contributors: [...contributors] }];+ })+ ) as AtomicBuckets;+ for (const entry of staticEntries) {+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {+ if (!term) continue;+ traced[bucket] =+ term.raw !== undefined+ ? { raw: term.raw, factor: Math.max(0, term.raw), contributors: [...term.contributors] }+ : { totalPct: term.totalPct ?? 0, factor: 1 + (term.totalPct ?? 0) / 100, contributors: [...term.contributors] };+ }+ }+ return traced;+}++function totalTroops(troops: Record<UnitType, number>): number {+ return UNIT_TYPES.reduce((sum, unit) => sum + (troops[unit] ?? 0), 0);+}++function pctFromFactor(factor: number): number {+ return Number(((factor - 1) * 100).toFixed(12));+}++function sourceLabel(effect: ActiveEffect): string {+ return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");+}Index: simulator/src/damageBuckets.test.ts===================================================================--- simulator/src/damageBuckets.test.ts prev run+++ simulator/src/damageBuckets.test.ts this run@@ -0,0 +1,28 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import { createFastDamageScratch } from "./damage";+import { ATOMIC_BUCKETS } 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 not supported buckets", () => {+ assert.equal(ATOMIC_BUCKETS.some((bucket) => REMOVED_ACTIVE_BUCKET_PATTERN.test(bucket)), false);+});++test("damage scratch stores one factor value per atomic bucket", () => {+ const scratch = createFastDamageScratch() as unknown as {+ factors?: Float64Array;+ raw?: unknown;+ pct?: unknown;+ rawSet?: unknown;+ };++ assert.ok(scratch.factors instanceof Float64Array);+ const factors = scratch.factors as Float64Array;+ assert.equal(factors.length, ATOMIC_BUCKETS.length);+ assert.equal(factors.every((factor) => factor === 1), true);+ assert.equal("raw" in scratch, false);+ assert.equal("pct" in scratch, false);+ assert.equal("rawSet" in scratch, false);+});Index: simulator/src/damageBuckets.ts===================================================================--- simulator/src/damageBuckets.ts prev run+++ simulator/src/damageBuckets.ts this run@@ -0,0 +1,119 @@+import type { DamageKind } from "./types";++export type BucketRole = "attacker" | "defender";+export type BucketValueType = "raw" | "pct";+export type BucketUpdate = "assign_factor" | "add_pct_factor";+export type BucketPlacement = "numerator" | "denominator";++export interface BucketLeaf {+ role: BucketRole;+ valueType: BucketValueType;+ update: BucketUpdate;+ placement: BucketPlacement;+ appliesTo?: DamageKind;+}++type BucketTree = {+ [key: string]: BucketLeaf | BucketTree;+};++export const BUCKETS = {+ troops: {+ count: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" }+ },+ active: {+ hero: {+ attack: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ defense: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ },+ lethality: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ health: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ }+ },+ troop: {+ attack: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ defense: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ },+ lethality: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ health: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ }+ }+ },+ type: {+ normal: {+ damage: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" }+ },+ defense: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" }+ }+ },+ skill: {+ damage: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" }+ },+ defense: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" }+ }+ }+ },+ source: {+ extraSkill: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" }+ }+} as const satisfies BucketTree;++export interface BucketDefinition extends BucketLeaf {+ path: string;+}++export const BUCKET_DEFINITIONS = flattenBuckets(BUCKETS);+export const ATOMIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS).sort();++export type AtomicBucket = string;+export type BucketName = AtomicBucket;++export function bucketDefinition(path: string): BucketDefinition | undefined {+ return BUCKET_DEFINITIONS[path];+}++export function isAtomicBucket(path: string): path is AtomicBucket {+ return path in BUCKET_DEFINITIONS;+}++function flattenBuckets(tree: BucketTree, prefix = ""): Record<string, BucketDefinition> {+ const buckets: Record<string, BucketDefinition> = {};+ for (const [key, value] of Object.entries(tree)) {+ const path = prefix ? `${prefix}.${key}` : key;+ if (isBucketLeaf(value)) buckets[path] = { ...value, path };+ else Object.assign(buckets, flattenBuckets(value, path));+ }+ return buckets;+}++function isBucketLeaf(value: BucketLeaf | BucketTree): value is BucketLeaf {+ return "role" in value && "valueType" in value && "update" in value && "placement" in value;+}Index: simulator/src/effectIndex.test.ts===================================================================--- simulator/src/effectIndex.test.ts prev run+++ simulator/src/effectIndex.test.ts this run@@ -0,0 +1,93 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";+import { unitMask } from "./types";+import type { ActiveEffect, DamageJob } from "./types";++test("effect index returns bucket-tagged candidates from a direct job-shape lookup", () => {+ const index = createEffectIndex();+ const effect: ActiveEffect = {+ id: "effect-1",+ source: { kind: "hero_skill", side: "attacker", effectId: "boost" },+ intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },+ ownerSide: "attacker",+ kind: "modifier",+ valuePct: 25,+ appliesTo: { side: "attacker", units: unitMask("infantry") },+ appliesVs: { side: "defender", units: unitMask("lancer") },+ createdRound: 0,+ startRound: 0,+ duration: { type: "battle", value: 0 },+ uses: 0,+ stackingKey: "stack",+ sameEffectStacking: "add"+ };+ indexEffect(index, effect);++ const job: DamageJob = {+ id: "job-1",+ round: 1,+ kind: "normal",+ sourceIntentId: "intent-1",+ roundStartTroops: {+ attacker: { infantry: 100, lancer: 0, marksman: 0 },+ defender: { infantry: 0, lancer: 100, marksman: 0 }+ },+ attackerSide: "attacker",+ attackerUnit: "infantry",+ defenderSide: "defender",+ defenderUnit: "lancer"+ };++ assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);+});++test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {+ const index = createEffectIndex();+ const passive = effect("passive.attack.up");+ const active = effect("active.hero.lethality.up");+ indexEffect(index, passive);+ indexEffect(index, active);++ removeStaticProfileBucketEffects(index);++ assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);+ assert.deepEqual(index.all, [active]);+});++function effect(type: string): ActiveEffect {+ return {+ id: type,+ source: { kind: "hero_skill", side: "attacker", effectId: type },+ intent: { id: type, type, value: 25 },+ ownerSide: "attacker",+ kind: "modifier",+ valuePct: 25,+ appliesTo: { side: "attacker", units: unitMask("infantry") },+ appliesVs: { side: "defender", units: unitMask("lancer") },+ createdRound: 0,+ startRound: 0,+ duration: { type: "battle", value: 0 },+ uses: 0,+ stackingKey: "stack",+ sameEffectStacking: "add"+ };+}++function job(): DamageJob {+ return {+ id: "job-1",+ round: 1,+ kind: "normal",+ sourceIntentId: "intent-1",+ roundStartTroops: {+ attacker: { infantry: 100, lancer: 0, marksman: 0 },+ defender: { infantry: 0, lancer: 100, marksman: 0 }+ },+ attackerSide: "attacker",+ attackerUnit: "infantry",+ defenderSide: "defender",+ defenderUnit: "lancer"+ };+}Index: simulator/src/effectIndex.ts===================================================================--- simulator/src/effectIndex.ts prev run+++ simulator/src/effectIndex.ts this run@@ -0,0 +1,172 @@+import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";+import { unitsFromMask } from "./types";+import { bucketDefinition, type AtomicBucket } from "./damageBuckets";+import { isStaticProfileBucket } from "./staticDamageProfile";++export type DamageEffectKey = `${DamageKind}:${SideId}:${UnitType}:${SideId}:${UnitType}:${AtomicBucket}`;+export type DamageJobShapeKey = `${DamageKind}:${SideId}:${UnitType}:${SideId}:${UnitType}`;++export interface IndexedBucketEffect {+ effect: ActiveEffect;+ bucket: AtomicBucket;+}++export interface EffectIndex {+ all: ActiveEffect[];+ damageByJobShape: Array<IndexedBucketEffect[] | undefined>;+ controls: ActiveEffect[];+ extraAttacks: ActiveEffect[];+ battleOrder: ActiveEffect[];+}++const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;++export function createEffectIndex(): EffectIndex {+ return {+ all: [],+ damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),+ controls: [],+ extraAttacks: [],+ battleOrder: []+ };+}++export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {+ index.all.push(effect);+ if (effect.kind === "control") {+ index.controls.push(effect);+ return;+ }+ if (effect.kind === "extra_attack") {+ index.extraAttacks.push(effect);+ return;+ }+ if (effect.kind === "battle_order") {+ index.battleOrder.push(effect);+ return;+ }++ const definition = bucketDefinition(effect.intent.type);+ if (!definition || definition.valueType !== "pct") return;++ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];+ const appliesToUnits = unitsFromMask(effect.appliesTo.units);+ const appliesVsUnits = unitsFromMask(effect.appliesVs.units);+ for (const jobKind of jobKinds) {+ for (const appliesToUnit of appliesToUnits) {+ for (const appliesVsUnit of appliesVsUnits) {+ const slot =+ definition.role === "attacker"+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);+ const effects = index.damageByJobShape[slot];+ const candidate = { effect, bucket: definition.path };+ if (effects) effects.push(candidate);+ else index.damageByJobShape[slot] = [candidate];+ }+ }+ }+}++export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {+ compactEffects(index.all, isActive);+ compactEffects(index.controls, isActive);+ compactEffects(index.extraAttacks, isActive);+ compactEffects(index.battleOrder, isActive);+ for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {+ const candidates = index.damageByJobShape[slot];+ if (!candidates) continue;+ compactCandidates(candidates, isActive);+ if (candidates.length === 0) index.damageByJobShape[slot] = undefined;+ }+}++export function removeStaticProfileBucketEffects(index: EffectIndex): void {+ compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));+ for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {+ const candidates = index.damageByJobShape[slot];+ if (!candidates) continue;+ compactCandidates(candidates, (effect, candidate) => !isStaticProfileBucket(candidate.bucket));+ if (candidates.length === 0) index.damageByJobShape[slot] = undefined;+ }+}++export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {+ return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];+}++export function bucketEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {+ return bucketCandidatesForJob(index, job).map((candidate) => candidate.effect);+}++export function damageJobShapeKey(+ jobKind: DamageKind,+ attackerSide: SideId,+ attackerUnit: UnitType,+ defenderSide: SideId,+ defenderUnit: UnitType+): DamageJobShapeKey {+ return `${jobKind}:${attackerSide}:${attackerUnit}:${defenderSide}:${defenderUnit}`;+}++export function damageEffectKey(+ jobKind: DamageKind,+ attackerSide: SideId,+ attackerUnit: UnitType,+ defenderSide: SideId,+ defenderUnit: UnitType,+ bucket: AtomicBucket+): DamageEffectKey {+ return `${jobKind}:${attackerSide}:${attackerUnit}:${defenderSide}:${defenderUnit}:${bucket}`;+}++function damageJobShapeSlot(+ jobKind: DamageKind,+ attackerSide: SideId,+ attackerUnit: UnitType,+ defenderSide: SideId,+ defenderUnit: UnitType+): number {+ return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);+}++function kindIndex(kind: DamageKind): number {+ return kind === "normal" ? 0 : 1;+}++function sideIndex(side: SideId): number {+ return side === "attacker" ? 0 : 1;+}++function unitIndex(unit: UnitType): number {+ if (unit === "infantry") return 0;+ if (unit === "lancer") return 1;+ return 2;+}++function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {+ let write = 0;+ for (let read = 0; read < effects.length; read += 1) {+ const effect = effects[read];+ if (!isActive(effect)) continue;+ effects[write] = effect;+ write += 1;+ }+ effects.length = write;+}++function isStaticProfileEffect(effect: ActiveEffect): boolean {+ const definition = bucketDefinition(effect.intent.type);+ return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));+}++function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect, candidate: IndexedBucketEffect) => boolean): void {+ let write = 0;+ for (let read = 0; read < candidates.length; read += 1) {+ const candidate = candidates[read];+ if (!isActive(candidate.effect, candidate)) continue;+ candidates[write] = candidate;+ write += 1;+ }+ candidates.length = write;+}Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -0,0 +1,250 @@+import type {+ ActiveEffect,+ ActiveEffectKind,+ AttackIntent,+ EffectDuration,+ EffectIntentDefinition,+ ResolvedSkill,+ ResolvedUnitScope,+ SameEffectStacking,+ SideId,+ UnitType+} from "./types";+import { ALL_UNIT_MASK, UNIT_TYPES, unitMask } from "./types";+import { normalizeUnitType } from "./normalize";++export type Rng = () => number;++interface CompiledTriggerSelectors {+ source: ParsedTriggerSelector;+ target: ParsedTriggerSelector;+}++const TRIGGER_SELECTOR_CACHE = new WeakMap<ResolvedSkill, CompiledTriggerSelectors>();++export function oppositeSide(side: SideId): SideId {+ return side === "attacker" ? "defender" : "attacker";+}++export function skillMatchesTrigger(+ skill: ResolvedSkill,+ triggerType: "battle_start" | "round_start" | "attack_declared",+ round: number,+ intent?: AttackIntent+): boolean {+ const trigger = skill.trigger;+ if (triggerType === "battle_start" && trigger.type !== "battle_start") return false;+ if (triggerType === "round_start" && trigger.type !== "turn") return false;+ if (triggerType === "attack_declared" && trigger.type !== "attack") return false;+ if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every)) return false;+ if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every)) return false;+ if (!intent) return true;+ const selectors = compiledTriggerSelectors(skill);+ return (+ triggerSelectorMatches(selectors.source, skill.side, intent.attackerSide, intent.attackerUnit) &&+ triggerSelectorMatches(selectors.target, skill.side, intent.defenderSide, intent.defenderUnit)+ );+}++function compiledTriggerSelectors(skill: ResolvedSkill): CompiledTriggerSelectors {+ const cached = TRIGGER_SELECTOR_CACHE.get(skill);+ if (cached) return cached;+ const compiled = {+ source: parseTriggerSelector(skill.trigger.source, "self"),+ target: parseTriggerSelector(skill.trigger.target, "enemy")+ };+ TRIGGER_SELECTOR_CACHE.set(skill, compiled);+ return compiled;+}++export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {+ const probability = skill.trigger.probability;+ if (probability === undefined) return true;+ const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);+ if (!Number.isFinite(value) || value <= 0) return false;+ if (value >= 100) return true;+ const threshold = value / 100;+ return rng() < threshold;+}++export function createSeededRng(seed: string | number = "simulator-default"): Rng {+ let state = hashSeed(String(seed));+ return () => {+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;+ return state / 0x100000000;+ };+}++export function crossedFrequency(previous: number, current: number, frequency: number): boolean {+ return Math.floor(previous / frequency) < Math.floor(current / frequency);+}++export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {+ const units = intent.units ?? {};+ const ownerSide = skill.side;+ const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);+ const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);+ const duration = normalizeDuration(intent.duration);+ const delay = duration.delay ?? 0;+ const effectKind = kindForIntent(intent);+ if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {+ throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);+ }+ const sourceKey = skillActivationSourceKey(skill);+ return {+ id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,+ source: {+ kind: skill.sourceKind,+ side: skill.side,+ heroName: skill.heroName,+ heroInstanceId: skill.heroInstanceId,+ troopType: skill.troopType,+ skillId: skill.id,+ skillName: skill.name,+ effectId: intent.id+ },+ intent,+ ownerSide,+ kind: effectKind,+ valuePct: typeof intent.value === "number" ? intent.value : undefined,+ appliesTo,+ appliesVs,+ triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,+ createdRound: round,+ startRound: round + delay,+ duration,+ uses: 0,+ stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,+ sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)+ };+}++function skillActivationSourceKey(skill: ResolvedSkill): string {+ return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";+}++function skillStackingSourceKey(skill: ResolvedSkill): string {+ return skill.heroName ?? skill.troopType ?? "global";+}++export function isEffectActive(effect: ActiveEffect, round: number): boolean {+ if (round < effect.startRound) return false;+ if (effect.duration.type === "battle") return true;+ if (effect.duration.type === "round") return round < effect.startRound + Math.max(1, effect.duration.value);+ return effect.uses < Math.max(1, effect.duration.value);+}++export function currentEffectValuePct(effect: ActiveEffect, round: number): number {+ const baseValue = Number(effect.valuePct ?? 0);+ if (!Number.isFinite(baseValue)) return 0;+ const evolution = effect.intent.value_evolution;+ if (!evolution) return baseValue;+ const firstActiveRound = Math.max(1, effect.startRound);+ const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;+ const amount = Number(evolution.value ?? 0);+ if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;+ if (evolution.type === "pct_decay") {+ const factor = Math.max(0, 1 - amount / 100);+ return baseValue * factor ** stepCount;+ }+ if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);+ return baseValue;+}++export type TriggerSelectorRelation = "self" | "enemy";++export interface ParsedTriggerSelector {+ relation: TriggerSelectorRelation;+ units?: UnitType[];+}++export function parseTriggerSelector(value: unknown, defaultRelation: TriggerSelectorRelation): ParsedTriggerSelector {+ if (typeof value === "string") {+ const [maybeRelation, maybeUnit, ...extra] = value.split(".");+ if (extra.length === 0 && (maybeRelation === "self" || maybeRelation === "enemy")) {+ if (maybeUnit === undefined || maybeUnit === "any" || maybeUnit === "all") return { relation: maybeRelation };+ return { relation: maybeRelation, units: [normalizeUnitType(maybeUnit)] };+ }+ if (value === "any" || value === "all") return { relation: defaultRelation };+ }+ return { relation: defaultRelation, units: normalizeUnitList(value) };+}++export function sideForTriggerRelation(skillSide: SideId, relation: TriggerSelectorRelation): SideId {+ return relation === "self" ? skillSide : oppositeSide(skillSide);+}++function triggerSelectorMatches(selector: ParsedTriggerSelector, skillSide: SideId, actualSide: SideId, actualUnit: UnitType): boolean {+ if (sideForTriggerRelation(skillSide, selector.relation) !== actualSide) return false;+ return selector.units === undefined || selector.units.includes(actualUnit);+}++export function normalizeEngagementType(value: unknown): string | undefined {+ if (typeof value !== "string") return undefined;+ const normalized = value.trim().toLowerCase();+ return normalized ? normalized : undefined;+}++function resolveUnitScope(+ value: unknown,+ defaultSide: SideId,+ role: "applies_to" | "applies_vs",+ attackIntent?: AttackIntent,+ ownerSide?: SideId+): ResolvedUnitScope {+ if (role === "applies_vs" && value === "all") {+ throw new Error('effect units.applies_vs cannot be "all"; use "any" for an unrestricted usage gate');+ }+ if ((value === "trigger.source" || value === "trigger") && attackIntent) {+ return { side: attackIntent.attackerSide, units: unitMask(attackIntent.attackerUnit) };+ }+ if ((value === "trigger.target" || value === "target") && attackIntent) {+ return { side: attackIntent.defenderSide, units: unitMask(attackIntent.defenderUnit) };+ }+ if (ownerSide && isRelationQualifiedSelector(value)) {+ const selector = parseTriggerSelector(value, "self");+ const side = sideForTriggerRelation(ownerSide, selector.relation);+ return { side, units: selector.units ? unitMask(selector.units) : ALL_UNIT_MASK };+ }+ const list = normalizeUnitList(value);+ return { side: defaultSide, units: list ? unitMask(list) : ALL_UNIT_MASK };+}++function normalizeUnitList(value: unknown): UnitType[] | undefined {+ if (Array.isArray(value)) return value.map((entry) => normalizeUnitType(String(entry)));+ if (typeof value === "string" && !["any", "target", "all", "trigger", "trigger.source", "trigger.target", "friendly"].includes(value)) return [normalizeUnitType(value)];+ return undefined;+}++function isRelationQualifiedSelector(value: unknown): value is string {+ return typeof value === "string" && (value === "self" || value === "enemy" || value.startsWith("self.") || value.startsWith("enemy."));+}++function kindForIntent(intent: EffectIntentDefinition): ActiveEffectKind {+ if (intent.type === "extra_skill_attack") return "extra_attack";+ if (intent.type === "dodge" || intent.type === "no_attack") return "control";+ if (intent.type === "attack_order") return "battle_order";+ return "modifier";+}++function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {+ if (!duration) return { type: "battle", value: 0 };+ const rawType = duration.type === "turn" ? "round" : duration.type;+ if (rawType === "round" || rawType === "attack" || rawType === "battle") {+ return { type: rawType, value: Number(duration.value ?? (rawType === "battle" ? 0 : 1)), delay: Number(duration.delay ?? 0) };+ }+ return { type: "battle", value: 0 };+}++function normalizeSameEffectStacking(value: EffectIntentDefinition["same_effect_stacking"]): SameEffectStacking {+ return value === "max" ? "max" : "add";+}++function hashSeed(seed: string): number {+ let hash = 2166136261;+ for (let index = 0; index < seed.length; index += 1) {+ hash ^= seed.charCodeAt(index);+ hash = Math.imul(hash, 16777619);+ }+ return hash >>> 0;+}Index: simulator/src/index.ts===================================================================--- simulator/src/index.ts prev run+++ simulator/src/index.ts this run@@ -0,0 +1,9 @@+export { buildSimulatorConfig, loadSimulatorConfig } from "./config";+export type { RawSimulatorConfig } from "./config";+export { simulateBattle } from "./simulator";+export { discoverTestcaseFiles, runTestcases, adaptTestcaseEntry } from "./testcases";+export { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";+export { classifyEffectForJob } from "./classifier";+export { calculateDamageJob } from "./damage";+export type { CalibrationCaseComparison, CalibrationComparison, CalibrationComparisonRow } from "./calibration";+export type * from "./types";Index: simulator/src/node-shims.d.ts===================================================================--- simulator/src/node-shims.d.ts prev run+++ simulator/src/node-shims.d.ts this run@@ -0,0 +1,42 @@+declare const process: {+ cwd(): string;+};++declare module "node:assert/strict" {+ const assert: any;+ export default assert;+}++declare module "node:fs" {+ export function copyFileSync(source: string, destination: string): void;+ export function existsSync(path: string): boolean;+ export function mkdtempSync(prefix: string): string;+ export function mkdirSync(path: string, options?: Record<string, unknown>): unknown;+ export function readdirSync(path: string): string[];+ export function readFileSync(path: string, encoding: string): string;+ export function rmSync(path: string, options?: Record<string, unknown>): void;+ export function statSync(path: string): { isDirectory(): boolean; isFile(): boolean };+ export function writeFileSync(path: string, data: string): void;+}++declare module "node:os" {+ export function cpus(): unknown[];+ export function tmpdir(): string;+}++declare module "node:path" {+ export function basename(path: string): string;+ export function dirname(path: string): string;+ export function join(...parts: string[]): string;+ export function relative(from: string, to: string): string;+ export function resolve(...parts: string[]): string;+}++declare module "node:test" {+ export function test(name: string, fn: () => void): void;+}++declare module "node:url" {+ export function fileURLToPath(url: string | URL): string;+ export function pathToFileURL(path: string): URL;+}Index: simulator/src/normalize.test.ts===================================================================--- simulator/src/normalize.test.ts prev run+++ simulator/src/normalize.test.ts this run@@ -0,0 +1,13 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import { normalizeStatBlock } from "./normalize";++test("normalizeStatBlock accepts stat tuples in attack defense lethality health order", () => {+ assert.deepEqual(normalizeStatBlock([1, 2, 3, 4]), {+ attack: 1,+ defense: 2,+ lethality: 3,+ health: 4+ });+});Index: simulator/src/normalize.ts===================================================================--- simulator/src/normalize.ts prev run+++ simulator/src/normalize.ts this run@@ -0,0 +1,59 @@+import type { StatBlock, UnitType } from "./types";++export type StatBlockInput = Record<string, unknown> | readonly unknown[];++export function normalizeUnitType(value: string): UnitType {+ const normalized = value.toLowerCase().replace(/[\s_-]/g, "");+ if (["inf", "infantry"].includes(normalized)) return "infantry";+ if (["lanc", "lancer", "lancers"].includes(normalized)) return "lancer";+ if (["mark", "marksman", "marksmen", "marks"].includes(normalized)) return "marksman";+ throw new Error(`Unsupported unit type: ${value}`);+}++export function normalizeStatBlock(input: StatBlockInput | undefined): StatBlock {+ return {+ attack: numberField(input, "attack", "Attack", 0),+ defense: numberField(input, "defense", "Defense", 1),+ lethality: numberField(input, "lethality", "Lethality", 2),+ health: numberField(input, "health", "Health", 3)+ };+}++export function zeroStats(): StatBlock {+ return { attack: 0, defense: 0, lethality: 0, health: 0 };+}++export function addStats(a: StatBlock, b: Partial<StatBlock>): StatBlock {+ return {+ attack: a.attack + (b.attack ?? 0),+ defense: a.defense + (b.defense ?? 0),+ lethality: a.lethality + (b.lethality ?? 0),+ health: a.health + (b.health ?? 0)+ };+}++export function numberField(input: StatBlockInput | undefined, ...keys: Array<string | number>): number {+ if (!input) return 0;+ for (const key of keys) {+ const value = Array.isArray(input) ? (typeof key === "number" ? input[key] : undefined) : valueForObjectKey(input as Record<string, unknown>, key);+ if (typeof value === "number" && Number.isFinite(value)) return value;+ }+ return 0;+}++function valueForObjectKey(input: Record<string, unknown>, key: string | number): unknown {+ return typeof key === "string" ? input[key] : undefined;+}++export function valueAtLevel(value: unknown, level: number): number | string[] {+ if (Array.isArray(value)) {+ const index = Math.max(0, Math.min(value.length - 1, level - 1));+ const selected = value[index];+ if (typeof selected === "number") return selected;+ if (Array.isArray(selected)) return selected.map(String);+ return Number(selected) || 0;+ }+ if (typeof value === "number") return value;+ if (Array.isArray(value)) return value.map(String);+ return Number(value) || 0;+}Index: simulator/src/parityMetrics.ts===================================================================--- simulator/src/parityMetrics.ts prev run+++ simulator/src/parityMetrics.ts this run@@ -0,0 +1,117 @@+export interface OutcomeDistribution {+ n: number;+ mu: number;+ sigma: number;+}++export interface ParityThresholds {+ z_threshold?: number;+ min_bias_pct?: number;+ max_diff_ratio?: number;+ max_diff_ratio_deterministic?: number;+}++export interface ParityComparisonMetrics {+ n_candidate: number;+ mu_candidate: number;+ sigma_candidate: number;+ n_reference: number;+ mu_reference: number;+ sigma_reference: number;+ bias_raw: number;+ bias_pct: number;+ sem: number;+ stat_type: "deterministic" | "zero_var" | "single_obs" | "t";+ stat: number | null;+ p: number | null;+ q: number | null;+ passes: boolean;+}++export function compareOutcomeDistribution(options: {+ candidate: OutcomeDistribution;+ reference: OutcomeDistribution;+ initialTroops: number;+ deterministic: boolean;+ thresholds?: ParityThresholds;+}): ParityComparisonMetrics {+ const thresholds = options.thresholds ?? {};+ const initialTroops = options.initialTroops || 1;+ const biasRaw = round(options.candidate.mu - options.reference.mu, 2);+ const biasPct = round((biasRaw / initialTroops) * 100, 2);+ const zThreshold = thresholds.z_threshold ?? 2;+ const minBiasPct = thresholds.min_bias_pct ?? 0.5;+ const deterministicLimit = (thresholds.max_diff_ratio_deterministic ?? thresholds.max_diff_ratio ?? 0.01) * 100;++ let sem = 0;+ let stat: number | null = null;+ let p: number | null = null;+ let statType: ParityComparisonMetrics["stat_type"];+ let passes: boolean;++ if (options.deterministic) {+ statType = "deterministic";+ passes = Math.abs(biasPct) <= deterministicLimit;+ } else if (options.candidate.sigma === 0) {+ statType = "zero_var";+ passes = Math.abs(biasPct) <= deterministicLimit;+ } else if (options.reference.n <= 1) {+ statType = "single_obs";+ sem = options.candidate.sigma;+ stat = sem === 0 ? null : round(biasRaw / sem, 4);+ p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);+ passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;+ } else {+ statType = "t";+ sem = options.candidate.sigma * Math.sqrt(1 / Math.max(options.candidate.n, 1) + 1 / options.reference.n);+ stat = sem === 0 ? null : round(biasRaw / sem, 4);+ p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);+ passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;+ }++ return {+ n_candidate: options.candidate.n,+ mu_candidate: round(options.candidate.mu, 2),+ sigma_candidate: round(options.candidate.sigma, 2),+ n_reference: options.reference.n,+ mu_reference: round(options.reference.mu, 2),+ sigma_reference: round(options.reference.sigma, 2),+ bias_raw: biasRaw,+ bias_pct: biasPct,+ sem: round(sem, 2),+ stat_type: statType,+ stat,+ p,+ q: null,+ passes+ };+}++export function applyBenjaminiHochberg(rows: Array<{ p: number | null; q: number | null }>): void {+ const ranked = rows+ .filter((row): row is { p: number; q: number | null } => row.p !== null)+ .sort((a, b) => a.p - b.p);+ const m = ranked.length;+ let runningMin = 1;+ for (let index = m - 1; index >= 0; index -= 1) {+ const rawQ = (ranked[index].p * m) / (index + 1);+ runningMin = Math.min(runningMin, rawQ);+ ranked[index].q = round(Math.min(1, runningMin), 6);+ }+}++function normalCdf(x: number): number {+ return 0.5 * (1 + erf(x / Math.SQRT2));+}++function erf(x: number): number {+ const sign = x < 0 ? -1 : 1;+ const ax = Math.abs(x);+ const t = 1 / (1 + 0.3275911 * ax);+ const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);+ return sign * y;+}++function round(value: number, digits: number): number {+ return Number(value.toFixed(digits));+}Index: simulator/src/resolve.ts===================================================================--- simulator/src/resolve.ts prev run+++ simulator/src/resolve.ts this run@@ -0,0 +1,348 @@+import type {+ EffectIntentDefinition,+ FighterInput,+ HeroInputCollection,+ BattleInput,+ ResolvedFighter,+ ResolvedHero,+ ResolvedSkill,+ ResolvedTroopLine,+ SideId,+ SimulatorConfig,+ SkillDefinition,+ SkillFile,+ SkillRequirement,+ StatBlock,+ UnitType+} from "./types";+import { UNIT_TYPES } from "./types";+import { normalizeEngagementType } from "./effects";+import { addStats, normalizeStatBlock, normalizeUnitType, valueAtLevel, zeroStats } from "./normalize";++export function resolveFighter(input: FighterInput, side: SideId, config: SimulatorConfig, mechanics?: BattleInput["mechanics"]): ResolvedFighter {+ const diagnostics: string[] = [];+ const troops = emptyTroops();+ const initialTroops = emptyTroops();+ const troopDetails: Partial<Record<UnitType, ResolvedTroopLine>> = {};+ const weightedStats: Partial<Record<UnitType, { count: number; stats: StatBlock; tier: number; fc: number; ids: string[] }>> = {};++ for (const [troopId, rawCount] of Object.entries(input.troops ?? {})) {+ const count = Number(rawCount) || 0;+ if (count <= 0) continue;+ const record = config.troopStats[troopId];+ if (!record) {+ diagnostics.push(`Unsupported troop id ${troopId}`);+ continue;+ }+ let type: UnitType;+ try {+ type = normalizeUnitType(String(record.type));+ } catch {+ diagnostics.push(`Unsupported troop type ${record.type} for ${troopId}`);+ continue;+ }+ const stats = normalizeStatBlock(record.stats);+ troops[type] += count;+ initialTroops[type] += count;+ const existing = weightedStats[type] ?? { count: 0, stats: zeroStats(), tier: 0, fc: 0, ids: [] };+ existing.count += count;+ existing.stats = addStats(existing.stats, {+ attack: stats.attack * count,+ defense: stats.defense * count,+ lethality: stats.lethality * count,+ health: stats.health * count+ });+ existing.tier = Math.max(existing.tier, Number(record.tier) || 0);+ existing.fc = Math.max(existing.fc, Number(record.fc) || 0);+ existing.ids.push(troopId);+ weightedStats[type] = existing;+ }++ for (const [type, aggregate] of Object.entries(weightedStats) as Array<[UnitType, NonNullable<typeof weightedStats[UnitType]>]>) {+ troopDetails[type] = {+ id: aggregate.ids.join("+"),+ type,+ tier: aggregate.tier,+ fc: aggregate.fc,+ count: aggregate.count,+ stats: {+ attack: aggregate.stats.attack / aggregate.count,+ defense: aggregate.stats.defense / aggregate.count,+ lethality: aggregate.stats.lethality / aggregate.count,+ health: aggregate.stats.health / aggregate.count+ }+ };+ }++ const statBonuses = resolveInputStatBonuses(input.stats);+ const heroes = resolveHeroes(input, side, config, statBonuses, diagnostics, mechanics);+ const heroSkills = resolveHeroSkills(input, side, config, diagnostics, mechanics);+ const troopSkills = resolveTroopSkills(side, troopDetails, config, mechanics);++ return {+ side,+ name: input.name ?? side,+ troops,+ initialTroops,+ troopDetails,+ statBonuses,+ heroes,+ heroSkills,+ troopSkills,+ diagnostics+ };+}++export function emptyTroops(): Record<UnitType, number> {+ return { infantry: 0, lancer: 0, marksman: 0 };+}++function resolveInputStatBonuses(stats: FighterInput["stats"]): Record<UnitType, StatBlock> {+ const byUnit: Record<UnitType, StatBlock> = {+ infantry: zeroStats(),+ lancer: zeroStats(),+ marksman: zeroStats()+ };+ for (const [key, block] of Object.entries(stats ?? {})) {+ try {+ byUnit[normalizeUnitType(key)] = normalizeStatBlock(block as Record<string, unknown>);+ } catch {+ // Unknown testcase stat aliases are non-fatal because troop ids still determine active units.+ }+ }+ return byUnit;+}++interface HeroInputInstance {+ name: string;+ levels: Record<string, number>;+ role: "main" | "joiner";+ instanceId: string;+}++function heroInputInstances(input: FighterInput): HeroInputInstance[] {+ return [...heroCollectionInstances(input.heroes, "main"), ...heroCollectionInstances(input.joiner_heroes, "joiner")];+}++function heroCollectionInstances(collection: HeroInputCollection | undefined, role: "main" | "joiner"): HeroInputInstance[] {+ if (!collection) return [];+ const entries = Array.isArray(collection)+ ? collection.map((entry) => ({ name: entry.name, levels: entry.levels ?? {} }))+ : Object.entries(collection).map(([name, levels]) => ({ name, levels }));+ const counts = new Map<string, number>();+ return entries.map((entry) => {+ const key = `${role}:${normalizeHeroName(entry.name)}`;+ const index = counts.get(key) ?? 0;+ counts.set(key, index + 1);+ return {+ name: entry.name,+ levels: entry.levels,+ role,+ instanceId: `${role}:${normalizeHeroName(entry.name)}:${index}`+ };+ });+}++function resolveHeroes(+ input: FighterInput,+ side: SideId,+ config: SimulatorConfig,+ statBonuses: Record<UnitType, StatBlock>,+ diagnostics: string[],+ mechanics?: BattleInput["mechanics"]+): ResolvedHero[] {+ const heroes: ResolvedHero[] = [];+ for (const instance of heroInputInstances(input)) {+ const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);+ const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;+ if (!definition) {+ diagnostics.push(`Missing hero definition for ${instance.name}`);+ heroes.push({+ name: instance.name,+ generationStats: zeroStats(),+ skillIds: [],+ instanceId: instance.instanceId,+ role: instance.role,+ missing: true+ });+ continue;+ }+ const generationStats = normalizeStatBlock(config.heroGenerationStats[definition.hero_generation ?? ""] as Record<string, unknown>);+ if (instance.role === "main" && shouldApplyHeroGenerationStats(mechanics)) {+ for (const unit of UNIT_TYPES) {+ statBonuses[unit] = addStats(statBonuses[unit], generationStats);+ }+ }+ heroes.push({+ name: definition.name ?? instance.name,+ heroGeneration: definition.hero_generation,+ generationStats,+ skillIds: resolveHeroSkillIds(definition, instance.levels, side, mechanics),+ instanceId: instance.instanceId,+ role: instance.role+ });+ }+ void side;+ return heroes;+}++function resolveHeroSkills(+ input: FighterInput,+ side: SideId,+ config: SimulatorConfig,+ diagnostics: string[],+ mechanics?: BattleInput["mechanics"]+): ResolvedSkill[] {+ const skills: ResolvedSkill[] = [];+ for (const instance of heroInputInstances(input)) {+ const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);+ const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;+ if (!definition) continue;+ let index = 0;+ for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {+ index += 1;+ const level = Number(instance.levels[`skill_${index}`] ?? instance.levels[skillId] ?? 0);+ if (level <= 0) continue;+ if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, mechanics)) continue;+ skills.push(hydrateSkill(skillId, rawSkill, side, level, "hero_skill", definition.name ?? resolvedHeroName, undefined, instance.instanceId, instance.role));+ }+ }+ void diagnostics;+ return skills;+}++function resolveHeroSkillIds(definition: SkillFile, levelMap: Record<string, number>, side: SideId, mechanics?: BattleInput["mechanics"]): string[] {+ const ids: string[] = [];+ let index = 0;+ for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {+ index += 1;+ const level = Number(levelMap[`skill_${index}`] ?? levelMap[skillId] ?? 0);+ if (level > 0 && heroRequirementsSatisfied(rawSkill.requirements, level, side, mechanics)) ids.push(skillId);+ }+ return ids;+}++function resolveTroopSkills(+ side: SideId,+ troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>,+ config: SimulatorConfig,+ mechanics?: BattleInput["mechanics"]+): ResolvedSkill[] {+ const skills: ResolvedSkill[] = [];+ for (const [skillId, rawSkill] of Object.entries(config.troopSkills.skills ?? {})) {+ let troopType: UnitType;+ try {+ troopType = normalizeUnitType(String((rawSkill as { troop_type?: unknown }).troop_type));+ } catch {+ continue;+ }+ const troop = troopDetails[troopType];+ if (!troop) continue;+ const requirements = (((rawSkill as { requirements?: unknown }).requirements ?? []) as SkillRequirement[]);+ const troopRequirements = requirements.filter((req) => req.type === "tier" || req.type === "fc");+ const satisfied = troopRequirements+ .filter((req) => (req.type === "tier" ? troop.tier >= Number(req.value) : req.type === "fc" ? troop.fc >= Number(req.value) : false))+ .sort((a, b) => b.level - a.level)[0];+ if (!satisfied) continue;+ if (!battleRequirementsSatisfied(requirements, satisfied.level, mechanics)) continue;+ skills.push(hydrateSkill(skillId, rawSkill, side, satisfied.level, "troop_skill", undefined, troopType));+ }+ return skills;+}++function heroRequirementsSatisfied(requirements: SkillRequirement[] | undefined, level: number, side: SideId, mechanics?: BattleInput["mechanics"]): boolean {+ return (requirements ?? []).every((requirement) => {+ if (requirement.type !== "engagement_type") return battleRequirementsSatisfied([requirement], level, mechanics);+ if (level < Number(requirement.level ?? 1)) return true;+ return heroEngagementRequirementSatisfied(requirement, side, mechanics);+ });+}++function battleRequirementsSatisfied(requirements: SkillRequirement[], level: number, mechanics?: BattleInput["mechanics"]): boolean {+ return requirements.every((requirement) => {+ if ((requirement.type === "tier" || requirement.type === "fc") && level < Number(requirement.level ?? 1)) return true;+ if (requirement.type !== "engagement_type") return true;+ if (level < Number(requirement.level ?? 1)) return true;+ return normalizeEngagementType(requirement.value) === currentEngagementType(mechanics);+ });+}++function heroEngagementRequirementSatisfied(requirement: SkillRequirement, side: SideId, mechanics?: BattleInput["mechanics"]): boolean {+ const required = normalizeEngagementType(requirement.value);+ const current = currentEngagementType(mechanics);+ if (required === current) {+ if (current !== "rally") return true;+ return side === "attacker";+ }+ if (required === "garrison" && current === "rally") return side === "defender";+ return false;+}++function currentEngagementType(mechanics?: BattleInput["mechanics"]): string | undefined {+ return normalizeEngagementType(mechanics?.engagement_type ?? mechanics?.engagementType);+}++function shouldApplyHeroGenerationStats(mechanics?: BattleInput["mechanics"]): boolean {+ return mechanics?.hero_generation_stats === true || mechanics?.heroGenerationStats === true;+}++function hydrateSkill(+ skillId: string,+ rawSkill: Omit<SkillDefinition, "id" | "name">,+ side: SideId,+ level: number,+ sourceKind: "hero_skill" | "troop_skill",+ heroName?: string,+ troopType?: UnitType,+ heroInstanceId?: string,+ heroRole?: "main" | "joiner"+): ResolvedSkill {+ const effects: EffectIntentDefinition[] = [];+ for (const [effectId, effect] of Object.entries(rawSkill.effects ?? {})) {+ effects.push({+ id: effectId,+ ...effect,+ value: levelSelectPreservingOrder(effect.value, level)+ });+ }+ return {+ id: skillId,+ name: skillId,+ sourceKind,+ side,+ heroName,+ heroInstanceId,+ heroRole,+ troopType,+ level,+ trigger: rawSkill.trigger,+ effects+ };+}++function levelSelectPreservingOrder(value: unknown, level: number): unknown {+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value;+ return valueAtLevel(value, level);+}++function resolveHeroDefinitionKey(heroName: string, config: SimulatorConfig): string | undefined {+ if (config.heroDefinitions[heroName]) return heroName;+ const normalized = normalizeHeroName(heroName);+ const indexed = config.heroAliasIndex?.[normalized];+ if (indexed && config.heroDefinitions[indexed]) return indexed;+ const manualAliases: Record<string, string> = {+ lingxue: "Ling",+ lumakbokan: "Lumak",+ wuming: "WuMing"+ };+ const manual = manualAliases[normalized];+ if (manual && config.heroDefinitions[manual]) return manual;+ for (const [key, definition] of Object.entries(config.heroDefinitions)) {+ if (normalizeHeroName(key) === normalized || normalizeHeroName(definition.name ?? "") === normalized) return key;+ }+ return undefined;+}++function normalizeHeroName(name: string): string {+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");+}Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -0,0 +1,1803 @@+import assert from "node:assert/strict";+import { readFileSync } from "node:fs";+import { test } from "node:test";+import { fileURLToPath } from "node:url";++import { loadSimulatorConfig } from "./config";+import { createSeededRng, chancePasses } from "./effects";+import { resolveFighter } from "./resolve";+import { simulateBattle, simulateBattleScore } from "./simulator";+import type { BattleInput, EffectIntentDefinition, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";++test("simulateBattle returns structured result for a no-hero battle", () => {+ const config = loadSimulatorConfig();+ const result = simulateBattle(+ {+ maxRounds: 3,+ trace: true,+ attacker: {+ name: "A",+ troops: { infantry_t6: 8000 },+ stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },+ heroes: {}+ },+ defender: {+ name: "D",+ troops: { infantry_t6: 500 },+ stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },+ heroes: {}+ }+ },+ config+ );++ assert.match(result.winner, /attacker|defender|draw/);+ assert.ok(result.rounds >= 1);+ assert.ok(result.attacks.some((attack) => attack.kind === "normal"));+ assert.ok(result.trace?.resolved.attacker.troops.infantry);+ assert.deepEqual(result.resolved.attacker.heroes, []);+ assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));+});++test("simulateBattle calculates all same-round damage from the round-start troop snapshot", () => {+ const config = loadSimulatorConfig();+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ name: "A",+ troops: { infantry_t1: 1 },+ stats: { inf: { attack: 100000, lethality: 100000 } },+ heroes: {}+ },+ defender: {+ name: "D",+ troops: { infantry_t1: 1 },+ stats: { inf: { attack: 100000, lethality: 100000 } },+ heroes: {}+ }+ },+ config+ );++ const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");+ assert.equal(normalAttacks.length, 2);+ assert.deepEqual(+ normalAttacks.map((attack) => attack.kills),+ [1, 1]+ );+ assert.equal(result.remaining.attacker.infantry, 0);+ assert.equal(result.remaining.defender.infantry, 0);+ assert.equal(result.trace?.rounds[0]?.roundStartTroops.attacker.infantry, 1);+ assert.equal(result.trace?.rounds[0]?.roundStartTroops.defender.infantry, 1);+ assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);+});++test("simulateBattle carries fractional casualties between rounds and ceils final survivors", () => {+ const config = loadSimulatorConfig();+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/simple_001_nc.json", import.meta.url));+ const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;+ const input = testcases.find((testcase) => testcase.test_id === "simple_001");+ assert.notEqual(input, undefined);++ const result = simulateBattle({ ...input!, trace: true }, config);++ assert.equal(totalRemaining(result.remaining.attacker) - totalRemaining(result.remaining.defender), -186);+ assert.equal(result.remaining.defender.lancer, 186);+ assert.equal(result.trace?.rounds[1]?.roundStartTroops.defender.lancer.toFixed(6), "198.003308");+});++test("simulateBattle defaults to a 1500 round cap when no explicit maxRounds is provided", () => {+ const result = simulateBattle(+ {+ attacker: { troops: {}, heroes: {} },+ defender: { troops: {}, heroes: {} }+ },+ minimalConfig()+ );++ assert.equal(result.winner, "draw");+ assert.equal(result.rounds, 1500);+});++test("round-start trigger source self.any rolls once then creates one target-locked effect per live unit type", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 100, lancer_t1: 100 },+ heroes: { PerUnit: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ PerUnit: {+ name: "PerUnit",+ skills: {+ RoundFanout: {+ trigger: { type: "turn", source: "self.any" },+ effects: {+ buff: {+ type: "active.hero.lethality.up",+ value: 100,+ units: { applies_to: "trigger", applies_vs: "target" },+ duration: { type: "turn", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const report = result.skillReport.attacker.find((entry) => entry.skillId === "RoundFanout");+ assert.equal(report?.triggersSeen, 1);+ assert.equal(report?.skillActivations, 1);+ assert.equal(report?.effectActivations, 2);+});++test("attack trigger source and target selectors match relative to the skill owner", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 100, lancer_t1: 100 },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: { Shield: { skill_1: 1 } }+ }+ },+ minimalConfig({+ Shield: {+ name: "Shield",+ skills: {+ EnemyHitsInfantry: {+ trigger: { type: "attack", source: "enemy.any", target: "self.infantry" },+ effects: {+ guard: {+ type: "active.hero.defense.up",+ value: 100,+ units: { applies_to: "target", applies_vs: "target" },+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const report = result.skillReport.defender.find((entry) => entry.skillId === "EnemyHitsInfantry");+ assert.equal(report?.triggersSeen, 2);+ assert.equal(report?.skillActivations, 2);+ assert.equal(report?.effectActivations, 2);+});++test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {+ const config = loadSimulatorConfig();+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ name: "A",+ troops: { marksman_t8_fc5: 900, infantry_t8_fc5: 100 },+ stats: {+ mark: { attack: 100, defense: 100, lethality: 100, health: 100 },+ inf: { attack: 100, defense: 100, lethality: 100, health: 100 }+ },+ heroes: { Alonso: { skill_1: 5, skill_2: 5, skill_3: 5 } }+ },+ defender: {+ name: "D",+ troops: { infantry_t8_fc5: 1000 },+ stats: { inf: { attack: 100, defense: 100, lethality: 100, health: 100 } },+ heroes: { Flint: { skill_1: 5, skill_2: 5, skill_3: 5 } }+ }+ },+ config+ );++ assert.ok(result.resolved.attacker.heroes.some((hero) => hero.name === "Alonso"));+ assert.ok(result.resolved.attacker.troopSkillIds.length > 0);+ assert.ok(result.skillReport.attacker.some((entry) => entry.effectActivations > 0));+ assert.ok(Object.keys(result.extraSkillAttackJobsByEffect).length > 0);+ assert.ok(result.attackControlCounts.dodge >= 0);+ assert.ok(result.attackControlCounts.no_attack >= 0);+});++test("display-name hero aliases resolve to simulator hero definitions", () => {+ const config = loadSimulatorConfig();+ const fighter = resolveFighter(+ {+ troops: { infantry_t1: 1 },+ heroes: {+ "Ling Xue": { skill_1: 1 },+ "Lumak Bokan": { skill_1: 1 },+ "Wu Ming": { skill_1: 1 }+ }+ },+ "attacker",+ config+ );++ assert.deepEqual(+ fighter.heroes.map((hero) => hero.name),+ ["Ling", "Lumak", "Wu Ming"]+ );+ assert.equal(fighter.heroes.some((hero) => hero.missing), false);+ assert.equal(fighter.diagnostics.some((line) => line.includes("Missing hero definition")), false);+});++test("hero generation stats are opt-in because testcase stats are authoritative", () => {+ const config = minimalConfig({+ Example: {+ name: "Example",+ hero_generation: "S1",+ skills: {}+ }+ });+ config.heroGenerationStats.S1 = { attack: 50, defense: 40, lethality: 30, health: 20 };+ const input = {+ troops: { infantry_t1: 10 },+ stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },+ heroes: { Example: { skill_1: 1 } }+ };++ const defaultFighter = resolveFighter(input, "attacker", config);+ const optInFighter = resolveFighter(input, "attacker", config, { hero_generation_stats: true });++ assert.deepEqual(defaultFighter.statBonuses.infantry, { attack: 1, defense: 2, lethality: 3, health: 4 });+ assert.deepEqual(optInFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });+});++test("array joiner heroes preserve duplicate skill instances", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 100 },+ heroes: [],+ joiner_heroes: [+ { name: "Repeat", levels: { skill_1: 1 } },+ { name: "Repeat", levels: { skill_1: 1 } }+ ]+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ Repeat: {+ name: "Repeat",+ skills: {+ Buff: {+ trigger: { type: "battle_start" },+ effects: {+ boost: {+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },+ duration: { type: "battle", value: 1 },+ same_effect_stacking: "add"+ }+ }+ }+ }+ }+ })+ );++ const reports = result.skillReport.attacker.filter((entry) => entry.heroName === "Repeat" && entry.skillId === "Buff");+ assert.equal(reports.length, 2);+ assert.deepEqual(+ reports.map((entry) => entry.effectActivations),+ [1, 1]+ );+});++test("joiner hero generation stats are not applied when main hero stats are enabled", () => {+ const config = minimalConfig({+ Main: {+ name: "Main",+ hero_generation: "S1",+ skills: {}+ },+ Joiner: {+ name: "Joiner",+ hero_generation: "S2",+ skills: {+ JoinerBuff: {+ trigger: { type: "battle_start" },+ effects: {}+ }+ }+ }+ });+ config.heroGenerationStats.S1 = { attack: 10, defense: 20, lethality: 30, health: 40 };+ config.heroGenerationStats.S2 = { attack: 100, defense: 200, lethality: 300, health: 400 };++ const fighter = resolveFighter(+ {+ troops: { infantry_t1: 10 },+ stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },+ heroes: [{ name: "Main", levels: {} }],+ joiner_heroes: [{ name: "Joiner", levels: { skill_1: 1 } }]+ },+ "attacker",+ config,+ { hero_generation_stats: true }+ );++ assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });+});++test("attack-duration effects are consumed even when a normal attack is cancelled", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 10 },+ heroes: { Canceller: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10 },+ heroes: {}+ }+ },+ minimalConfig({+ Canceller: {+ name: "Canceller",+ skills: {+ CancelAndDebuff: {+ trigger: { type: "attack", source: "infantry" },+ effects: {+ cancel: {+ type: "no_attack",+ value: 100,+ units: { applies_to: "trigger", applies_vs: "target" },+ duration: { type: "attack", value: 1 }+ },+ debuff: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "trigger", applies_vs: "target" },+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");+ assert.notEqual(cancelled, undefined);+ const cancelledAttack = cancelled!;+ assert.equal(cancelledAttack.consumedEffectIds.length, 2);+ assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":cancel:")));+ assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":debuff:")));+ assert.deepEqual(cancelledAttack.counterDeltas, [+ { side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },+ { side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }+ ]);+});++test("cancelled normal attacks advance attack counters for later frequency checks", () => {+ const result = simulateBattle(+ {+ maxRounds: 4,+ attacker: {+ troops: { infantry_t1: 100 },+ heroes: { EveryOtherCancel: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ EveryOtherCancel: {+ name: "EveryOtherCancel",+ skills: {+ Pause: {+ trigger: { type: "attack", every: 2, source: "infantry" },+ effects: {+ cancel: {+ type: "no_attack",+ units: { applies_to: "trigger", applies_vs: "target" },+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),+ ["r2:attacker:infantry:0:cancelled", "r4:attacker:infantry:0:cancelled"]+ );+});++test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {+ const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"));+ const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"));++ const maxRoundTwo = maxResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");+ const addRoundTwo = addResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");++ assert.equal(maxRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 100);+ assert.equal(maxRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);+ assert.equal(addRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 200);+ assert.equal(addRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 2);+});++test("same_effect_stacking max caps duplicate hero instances of the same skill effect", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { infantry_t1: 100 },+ heroes: [{ name: "Repeat", levels: { skill_1: 1 } }],+ joiner_heroes: [{ name: "Repeat", levels: { skill_1: 1 } }]+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ },+ sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up")+ );++ const attack = result.attacks.find((entry) => entry.jobId.startsWith("r1:attacker:infantry") && entry.kind === "normal");+ assert.equal(result.skillReport.attacker.filter((entry) => entry.heroName === "Repeat" && entry.skillId === "Overlap").length, 2);+ assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 100);+ assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);+});++test("same_effect_stacking max caps overlapping extra skill attacks while add keeps all activations", () => {+ const maxResult = simulateBattle(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"));+ const addResult = simulateBattle(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"));++ const maxRoundTwoSkillJobs = maxResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];+ const addRoundTwoSkillJobs = addResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];++ assert.equal(maxRoundTwoSkillJobs.length, 1);+ assert.equal(addRoundTwoSkillJobs.length, 2);+ assert.deepEqual(maxResult.extraSkillAttackJobsByEffect, { stacking: 2 });+ assert.deepEqual(addResult.extraSkillAttackJobsByEffect, { stacking: 3 });+});++test("same_effect_stacking max consumes overlapping attack-duration extra skill effects as one group", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ trace: true,+ attacker: {+ troops: { infantry_t1: 10000 },+ heroes: { MaxExtraAttackDuration: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10000 },+ heroes: {}+ }+ },+ minimalConfig({+ MaxExtraAttackDuration: {+ name: "MaxExtraAttackDuration",+ skills: {+ Overlap: {+ trigger: { type: "turn" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ same_effect_stacking: "max",+ units: { applies_to: ["infantry"], applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 2 }+ }+ }+ }+ }+ }+ })+ );++ const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));++ assert.notEqual(roundTwoSkillOutcome, undefined);+ assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 2);+});++test("requires_effect is ignored by native simulator effect activation", () => {+ const result = simulateBattle(+ {+ maxRounds: 0,+ attacker: {+ troops: { infantry_t1: 10 },+ heroes: { LegacyDependency: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10 },+ heroes: {}+ }+ },+ minimalConfig({+ LegacyDependency: {+ name: "LegacyDependency",+ skills: {+ BothEffectsActivate: {+ trigger: { type: "battle_start" },+ effects: {+ base: {+ type: "passive.attack.up",+ value: 10+ },+ legacyDependent: {+ ...({ requires_effect: "missing-effect" } as Record<string, unknown>),+ type: "passive.defense.up",+ value: 10+ }+ }+ }+ }+ }+ })+ );++ const report = result.skillReport.attacker.find((entry) => entry.skillId === "BothEffectsActivate");+ assert.equal(report?.effectActivations, 2);+});++test("fighter passive effects are added to the static profile after battle_start effects", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { infantry_t1: 100 },+ stats: { infantry: { attack: 0, lethality: 0, defense: 0, health: 0 } },+ passive: {+ attack: { up: 20, down: 5 }+ },+ heroes: { BattleStart: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ stats: { infantry: { attack: 0, lethality: 0, defense: 0, health: 0 } },+ heroes: {}+ }+ },+ minimalConfig({+ BattleStart: {+ name: "BattleStart",+ skills: {+ StartsFirst: {+ trigger: { type: "battle_start" },+ effects: {+ skillBuff: {+ type: "passive.attack.up",+ value: 10+ }+ }+ }+ }+ }+ })+ );++ const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");+ assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);+ assert.equal(attack?.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);+ assert.deepEqual(+ attack?.trace?.atomicBuckets["passive.attack.up"].contributors.map((contributor) => contributor.effectId).sort(),+ ["input:passive.attack.up", "skillBuff"]+ );+});++test("extra skill attacks with array trigger damage targets hit those defender unit types", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { Router: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ Router: {+ name: "Router",+ skills: {+ SplitHit: {+ trigger: { type: "attack", source: "marksman" },+ effects: {+ hitLancer: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: ["lancer"] }],+ duration: { type: "attack", value: 1 }+ },+ hitMarksman: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: ["marksman"] }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];+ assert.deepEqual(+ skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),+ [+ ["hitLancer", "lancer"],+ ["hitMarksman", "marksman"]+ ]+ );+});++test('extra skill attacks with applies_vs "any" keep current-target compatibility', () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { Router: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ Router: {+ name: "Router",+ skills: {+ SingleHit: {+ trigger: { type: "attack", source: "marksman" },+ effects: {+ hitAny: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];+ assert.deepEqual(+ skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),+ [["hitAny", "lancer"]]+ );+});++test("skill report attributes kills only to the skill damage source", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { Shooter: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: { Guard: { skill_1: 1 } }+ }+ },+ minimalConfig({+ Shooter: {+ name: "Shooter",+ skills: {+ PowerShot: {+ trigger: { type: "attack", source: "marksman" },+ effects: {+ shot: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ },+ Guard: {+ name: "Guard",+ skills: {+ CrystalShield: {+ trigger: { type: "attack", source: "enemy.any", target: "self.infantry" },+ effects: {+ shield: {+ type: "active.hero.defense.up",+ value: 50,+ units: { applies_to: "trigger.target", applies_vs: "trigger.source" },+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const powerShot = result.skillReport.attacker.find((entry) => entry.skillId === "PowerShot");+ const crystalShield = result.skillReport.defender.find((entry) => entry.skillId === "CrystalShield");++ assert.ok((powerShot?.skillKills ?? 0) > 0);+ assert.equal(crystalShield?.skillKills, 0);+});++test("same-round outcomes are capped to available target troops before tracing skill kills", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 1000 },+ heroes: { Blaster: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10 },+ heroes: {}+ }+ },+ minimalConfig({+ Blaster: {+ name: "Blaster",+ skills: {+ DoubleBlast: {+ trigger: { type: "attack", source: "marksman" },+ effects: {+ first: {+ type: "extra_skill_attack",+ value: 500,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ },+ second: {+ type: "extra_skill_attack",+ value: 500,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const defenderLosses = result.attacks+ .filter((attack) => attack.defenderSide === "defender" && attack.defenderUnit === "infantry")+ .reduce((sum, attack) => sum + attack.kills, 0);+ const doubleBlast = result.skillReport.attacker.find((entry) => entry.skillId === "DoubleBlast");++ assert.equal(Number(defenderLosses.toFixed(6)), 10);+ assert.ok((doubleBlast?.skillKills ?? 0) <= 10);+ assert.equal(result.remaining.defender.infantry, 0);+});++test("same-round cap does not leave exhausted units targetable through floating point residue", () => {+ const config = loadSimulatorConfig();+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/sergey_solo_nc.json", import.meta.url));+ const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;+ const input = testcases.find((testcase) => testcase.test_id === "sergey_solo");+ if (!input) throw new Error("missing sergey_solo fixture");++ const result = simulateBattle(input, config);++ assert.equal(result.winner, "attacker");+ assert.equal(result.remaining.attacker.marksman, 1349);+ assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });+});++test("extra skill trigger damage jobs reject missing runtime selectors", () => {+ assert.throws(+ () =>+ simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { Malformed: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ Malformed: {+ name: "Malformed",+ skills: {+ MissingSelector: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source" } as never],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ ),+ /target selector is required/i+ );+});++test("extra skill attacks reject missing trigger damage jobs at activation", () => {+ assert.throws(+ () =>+ simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { Malformed: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ Malformed: {+ name: "Malformed",+ skills: {+ MissingJobs: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ ),+ /extra_skill_attack.*trigger_damage_jobs/i+ );+});++test("attack-triggered extra skill attacks activate then resolve trigger damage jobs on normal attack use", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ FollowUpShot: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const jobs = result.trace?.rounds[0]?.jobs ?? [];+ const normalJobs = jobs.filter((job) => job.kind === "normal");+ const skillJobs = jobs.filter((job) => job.kind === "skill");++ assert.equal(normalJobs.length, 2);+ assert.equal(skillJobs.length, 1);+ assert.equal(skillJobs[0]?.attackerSide, normalJobs[0]?.attackerSide);+ assert.equal(skillJobs[0]?.attackerUnit, normalJobs[0]?.attackerUnit);+ assert.equal(skillJobs[0]?.defenderSide, normalJobs[0]?.defenderSide);+ assert.equal(skillJobs[0]?.defenderUnit, normalJobs[0]?.defenderUnit);+});++test("cancelled normal attacks do not consume extra skill attack uses", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: { Canceller: { skill_1: 1 } }+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ OneUseFollowUp: {+ trigger: { type: "battle_start" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: ["marksman"], applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ },+ Canceller: {+ name: "Canceller",+ skills: {+ CancelFirstMarksman: {+ trigger: { type: "battle_start" },+ effects: {+ cancel: {+ type: "no_attack",+ value: 100,+ units: { applies_to: "enemy.marksman", applies_vs: "any" },+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");+ assert.notEqual(cancelled, undefined);+ assert.equal(cancelled!.consumedEffectIds.some((id) => id.includes(":hitAgain:")), false);++ const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];+ assert.deepEqual(skillJobsByRound, [0, 1]);+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });+});++test("extra skill attack effects cannot be used by later enemy normal attacks", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ FollowUpShot: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 2 }+ }+ }+ }+ }+ }+ })+ );++ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];+ assert.deepEqual(+ skillJobs.map((job) => `${job.attackerSide}.${job.attackerUnit}->${job.defenderSide}.${job.defenderUnit}`),+ ["attacker.marksman->defender.infantry"]+ );+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });+});++test("extra skill trigger damage jobs can resolve to multiple living enemy targets without recursive attack triggers", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { MultiTarget: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ MultiTarget: {+ name: "MultiTarget",+ skills: {+ MultiTargetShot: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitLiving: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],+ duration: { type: "attack", value: 1 }+ }+ }+ },+ RecursiveGuard: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ recursive: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];+ assert.deepEqual(+ skillJobs.map((job) => job.defenderUnit).sort(),+ ["infantry", "infantry", "lancer", "marksman"]+ );+ assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "MultiTargetShot")?.triggersSeen, 1);+ assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "RecursiveGuard")?.triggersSeen, 1);+});++test("extra skill attack consumes one use regardless of multiple generated target jobs", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { MultiTarget: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ MultiTarget: {+ name: "MultiTarget",+ skills: {+ MultiTargetShot: {+ trigger: { type: "battle_start" },+ effects: {+ hitLiving: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: ["marksman"], applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],+ duration: { type: "attack", value: 2 }+ }+ }+ }+ }+ }+ })+ );++ const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];+ assert.deepEqual(skillJobsByRound, [3, 3]);+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 6 });+});++test('direct simulator configs reject applies_vs "all" usage gates', () => {+ assert.throws(+ () =>+ simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { MultiTarget: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ MultiTarget: {+ name: "MultiTarget",+ skills: {+ MultiTargetShot: {+ trigger: { type: "battle_start" },+ effects: {+ hitLiving: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: ["marksman"], applies_vs: "all" },+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }]+ }+ }+ }+ }+ }+ })+ ),+ /applies_vs.*all/i+ );+});++test("extra skill attack consumes one use when multiple same-round normal attacks match the effect", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { infantry_t1: 100, marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ OneUseFollowUp: {+ trigger: { type: "battle_start" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: ["infantry", "marksman"], applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];+ assert.equal(skillJobs.length, 1);+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });+});++test("extra skill attack applies_vs must match the current normal attack target", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ LancerOnlyFollowUp: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitLancer: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: ["lancer"] },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const jobs = result.trace?.rounds[0]?.jobs ?? [];+ assert.deepEqual(+ jobs.map((job) => `${job.kind}:${job.attackerUnit}->${job.defenderUnit}`),+ ["normal:marksman->infantry", "normal:infantry->marksman", "normal:lancer->marksman"]+ );+ assert.deepEqual(result.extraSkillAttackJobsByEffect, {});+});++test("extra skill de-dupe does not suppress unrelated attack-duration effect consumption", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ MultiTargetFollowUp: {+ trigger: { type: "battle_start" },+ effects: {+ hitLiving: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: ["marksman"], applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],+ duration: { type: "attack", value: 2 }+ }+ }+ },+ SkillDamageBoost: {+ trigger: { type: "battle_start" },+ effects: {+ boost: {+ type: "type.skill.damage.up",+ value: 100,+ units: { applies_to: ["marksman"], applies_vs: "any" },+ duration: { type: "attack", value: 2 }+ }+ }+ }+ }+ }+ })+ );++ const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];+ const skillBoosts = result.attacks+ .filter((attack) => attack.kind === "skill")+ .map((attack) => attack.trace?.atomicBuckets["type.skill.damage.up"].totalPct ?? 0);+ assert.deepEqual(skillJobsByRound, [2, 2]);+ assert.deepEqual(skillBoosts, [100, 100, 0, 0]);+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });+});++test("attack-triggered source and target selectors resolve to concrete active scopes", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { infantry_t1: 100 },+ heroes: { Debuffer: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ Debuffer: {+ name: "Debuffer",+ skills: {+ TargetedDebuff: {+ trigger: { type: "attack", source: "infantry" },+ effects: {+ down: {+ type: "active.hero.lethality.down",+ value: 50,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ duration: { type: "turn", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");+ const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");++ assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);+ assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);+ assert.equal(defenderMarksmanAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);+});++test('attack-triggered target selector with applies_vs "any" gates later opposing attacks', () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { infantry_t1: 100 },+ heroes: { Debuffer: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ minimalConfig({+ Debuffer: {+ name: "Debuffer",+ skills: {+ TargetAnyDebuff: {+ trigger: { type: "attack", source: "infantry" },+ effects: {+ down: {+ type: "active.hero.lethality.down",+ value: 50,+ units: { applies_to: "trigger.target", applies_vs: "any" },+ duration: { type: "turn", value: 1 }+ }+ }+ }+ }+ }+ })+ );++ const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");+ const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");++ assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);+ assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);+ assert.equal(defenderMarksmanAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);+});++test("engagement_type requirements decide whether hero skills resolve", () => {+ const config = minimalConfig({+ Gated: {+ name: "Gated",+ skills: {+ RallyOnly: {+ requirements: [{ level: 1, type: "engagement_type", value: "rally" }],+ trigger: { type: "battle_start" },+ effects: { rally: { type: "passive.attack.up", value: 10 } }+ },+ GarrisonOnly: {+ requirements: [{ level: 1, type: "engagement_type", value: "garrison" }],+ trigger: { type: "battle_start" },+ effects: { garrison: { type: "passive.defense.up", value: 10 } }+ }+ }+ }+ });+ const input = {+ maxRounds: 0,+ attacker: {+ troops: { infantry_t1: 10 },+ heroes: { Gated: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10 },+ heroes: {}+ }+ };++ const defaultResult = simulateBattle(input, config);+ const rallyResult = simulateBattle({ ...input, mechanics: { engagement_type: "rally" } }, config);+ const garrisonResult = simulateBattle({ ...input, mechanics: { engagement_type: "garrison" } }, config);++ assert.equal(skillActivations(defaultResult, "RallyOnly"), 0);+ assert.equal(skillActivations(defaultResult, "GarrisonOnly"), 0);+ assert.equal(defaultResult.skillReport.attacker.some((entry) => entry.skillId === "RallyOnly"), false);+ assert.equal(defaultResult.skillReport.attacker.some((entry) => entry.skillId === "GarrisonOnly"), false);+ assert.equal(skillActivations(rallyResult, "RallyOnly"), 1);+ assert.equal(skillActivations(rallyResult, "GarrisonOnly"), 0);+ assert.equal(rallyResult.skillReport.attacker.some((entry) => entry.skillId === "GarrisonOnly"), false);+ assert.equal(skillActivations(garrisonResult, "RallyOnly"), 0);+ assert.equal(skillActivations(garrisonResult, "GarrisonOnly"), 1);+ assert.equal(garrisonResult.skillReport.attacker.some((entry) => entry.skillId === "RallyOnly"), false);+});++test("rally engagement resolves hero widget roles by owning side", () => {+ const config = minimalConfig({+ Gated: {+ name: "Gated",+ skills: {+ RallyOnly: {+ requirements: [{ level: 1, type: "engagement_type", value: "rally" }],+ trigger: { type: "battle_start" },+ effects: { rally: { type: "passive.attack.up", value: 10 } }+ },+ GarrisonOnly: {+ requirements: [{ level: 1, type: "engagement_type", value: "garrison" }],+ trigger: { type: "battle_start" },+ effects: { garrison: { type: "passive.defense.up", value: 10 } }+ }+ }+ }+ });+ const input = {+ maxRounds: 0,+ mechanics: { engagement_type: "rally" },+ attacker: {+ troops: { infantry_t1: 10 },+ heroes: { Gated: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10 },+ heroes: { Gated: { skill_1: 1, skill_2: 1 } }+ }+ };++ const result = simulateBattle(input, config);++ const attackerRally = result.skillReport.attacker.find((entry) => entry.skillId === "RallyOnly");+ const defenderGarrison = result.skillReport.defender.find((entry) => entry.skillId === "GarrisonOnly");++ assert.equal(attackerRally?.skillActivations, 1);+ assert.equal(defenderGarrison?.skillActivations, 1);+ assert.equal(Boolean(attackerRally), true);+ assert.equal(result.skillReport.attacker.some((entry) => entry.skillId === "GarrisonOnly"), false);+ assert.equal(result.skillReport.defender.some((entry) => entry.skillId === "RallyOnly"), false);+ assert.equal(Boolean(defenderGarrison), true);+});++test("simulateBattle identifies stochastic battles from resolved chance triggers", () => {+ const config = minimalConfig({+ Chance: {+ name: "Chance",+ skills: {+ CoinFlip: {+ trigger: { type: "battle_start", probability: [50] },+ effects: { buff: { type: "passive.attack.up", value: 10 } }+ },+ RallyCoinFlip: {+ requirements: [{ level: 1, type: "engagement_type", value: "rally" }],+ trigger: { type: "battle_start", probability: [50] },+ effects: { rally: { type: "passive.attack.up", value: 10 } }+ }+ }+ }+ });++ const input = {+ maxRounds: 0,+ attacker: {+ troops: { infantry_t1: 10 },+ heroes: { Chance: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10 },+ heroes: {}+ }+ };++ const chanceResult = simulateBattle(input, config);+ const gatedOnlyResult = simulateBattle(+ {+ ...input,+ attacker: { ...input.attacker, heroes: { Chance: { skill_2: 1 } } }+ },+ config+ );++ assert.equal(chanceResult.randomness.deterministic, false);+ assert.deepEqual(chanceResult.randomness.chanceSkillIds.attacker, ["CoinFlip"]);+ assert.equal(gatedOnlyResult.randomness.deterministic, true);+ assert.deepEqual(gatedOnlyResult.randomness.chanceSkillIds.attacker, []);+});++test("fast simulation matches full semantic output without detailed attacks", () => {+ const cases: Array<{ name: string; input: BattleInput; config: SimulatorConfig }> = [+ {+ name: "no-hero baseline",+ input: {+ maxRounds: 3,+ seed: "fast-baseline",+ attacker: {+ troops: { infantry_t1: 250, lancer_t1: 150 },+ stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 300, marksman_t1: 80 },+ stats: { inf: { attack: 8, defense: 12, lethality: 2, health: 3 } },+ heroes: {}+ }+ },+ config: minimalConfig()+ },+ {+ name: "duplicate max-stacked hero instances",+ input: {+ maxRounds: 2,+ seed: "fast-duplicate-max",+ attacker: {+ troops: { infantry_t1: 10000 },+ heroes: [{ name: "Repeat", levels: { skill_1: 1 } }],+ joiner_heroes: [{ name: "Repeat", levels: { skill_1: 1 } }]+ },+ defender: {+ troops: { infantry_t1: 10000 },+ heroes: {}+ }+ },+ config: sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up")+ },+ {+ name: "controls and extra skill attacks",+ input: {+ maxRounds: 3,+ seed: "fast-controls-extra",+ attacker: {+ troops: { marksman_t1: 400, infantry_t1: 100 },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 450, lancer_t1: 100 },+ heroes: { Canceller: { skill_1: 1 } }+ }+ },+ config: minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ FollowUpShot: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "attack", value: 2 }+ }+ }+ }+ }+ },+ Canceller: {+ name: "Canceller",+ skills: {+ PauseFirstHit: {+ trigger: { type: "battle_start" },+ effects: {+ pause: {+ type: "no_attack",+ units: { applies_to: "enemy.marksman", applies_vs: "any" },+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ })+ },+ {+ name: "stochastic real hero battle",+ input: {+ maxRounds: 8,+ seed: "fast-real-heroes",+ mechanics: { hero_generation_stats: true, engagement_type: "rally" },+ attacker: {+ troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },+ heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } },+ joiner_heroes: [{ name: "Jessie", levels: { skill_1: 5 } }]+ },+ defender: {+ troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },+ heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Bradley: { skill_1: 5, skill_2: 5, skill_3: 5 } },+ joiner_heroes: [{ name: "Norah", levels: { skill_1: 5 } }]+ }+ },+ config: loadSimulatorConfig()+ }+ ];++ for (const { name, input, config } of cases) {+ const full = simulateBattle({ ...input, trace: true }, config);+ const fast = simulateBattle({ ...input, trace: true }, config, { detail: "fast" });++ assert.deepEqual(semanticBattleSummary(fast), semanticBattleSummary(full), name);+ assert.deepEqual(fast.attacks, [], name);+ assert.equal(fast.trace, undefined, name);+ }+});++test("simulateBattleScore returns signed remaining troops from the same battle semantics", () => {+ const config = loadSimulatorConfig();+ const input: BattleInput = {+ maxRounds: 8,+ seed: "score-real-heroes",+ mechanics: { hero_generation_stats: true, engagement_type: "rally" },+ attacker: {+ troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },+ heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } },+ joiner_heroes: [{ name: "Jessie", levels: { skill_1: 5 } }]+ },+ defender: {+ troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },+ heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Bradley: { skill_1: 5, skill_2: 5, skill_3: 5 } },+ joiner_heroes: [{ name: "Norah", levels: { skill_1: 5 } }]+ }+ };++ const result = simulateBattle(input, config, { detail: "fast" });+ const score = simulateBattleScore(input, config);++ assert.equal(score, signedRemainingScore(result));+});++test("seeded probability rolls are deterministic and compare percentage thresholds", () => {+ const skill: ResolvedSkill = {+ id: "Chance",+ name: "Chance",+ sourceKind: "hero_skill",+ side: "attacker",+ heroName: "ChanceHero",+ level: 1,+ trigger: { type: "battle_start", probability: [50] },+ effects: []+ };+ const first = createSeededRng("same-seed");+ const second = createSeededRng("same-seed");+ const different = createSeededRng("different-seed");++ assert.deepEqual(+ [chancePasses(skill, first), chancePasses(skill, first), chancePasses(skill, first)],+ [chancePasses(skill, second), chancePasses(skill, second), chancePasses(skill, second)]+ );+ assert.deepEqual([createSeededRng("numeric")(), createSeededRng("numeric")()], [createSeededRng("numeric")(), createSeededRng("numeric")()]);+ assert.ok(different() >= 0 && different() < 1);+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [0] } }, createSeededRng("x")), false);+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [1] } }, () => 0.009), true);+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [1] } }, () => 0.01), false);+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [0.5] } }, () => 0.004), true);+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [0.5] } }, () => 0.005), false);+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [100] } }, createSeededRng("x")), true);+});++function skillActivations(result: ReturnType<typeof simulateBattle>, skillId: string): number {+ return result.skillReport.attacker.find((entry) => entry.skillId === skillId)?.skillActivations ?? 0;+}++function totalRemaining(troops: Record<UnitType, number>): number {+ return Object.values(troops).reduce((sum, count) => sum + count, 0);+}++function signedRemainingScore(result: ReturnType<typeof simulateBattle>): number {+ if (result.winner === "attacker") return totalRemaining(result.remaining.attacker);+ if (result.winner === "defender") return -totalRemaining(result.remaining.defender);+ return 0;+}++function semanticBattleSummary(result: ReturnType<typeof simulateBattle>): Pick<+ ReturnType<typeof simulateBattle>,+ "winner" | "rounds" | "remaining" | "effectActivationCounts" | "extraSkillAttackJobsByEffect" | "attackControlCounts"+> {+ return {+ winner: result.winner,+ rounds: result.rounds,+ remaining: result.remaining,+ effectActivationCounts: result.effectActivationCounts,+ extraSkillAttackJobsByEffect: result.extraSkillAttackJobsByEffect,+ attackControlCounts: result.attackControlCounts+ };+}++function sameEffectStackingInput(heroName: string): BattleInput {+ return {+ maxRounds: 2,+ trace: true,+ attacker: {+ troops: { infantry_t1: 10000 },+ heroes: { [heroName]: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10000 },+ heroes: {}+ }+ };+}++function sameEffectStackingConfig(heroName: string, same_effect_stacking: "add" | "max", type: "active.hero.lethality.up" | "extra_skill_attack"): SimulatorConfig {+ const effect: Omit<EffectIntentDefinition, "id"> =+ type === "extra_skill_attack"+ ? {+ type,+ value: 100,+ same_effect_stacking,+ units: { applies_to: ["infantry"], applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { type: "turn", value: 2 }+ }+ : {+ type,+ value: 100,+ same_effect_stacking,+ units: { applies_to: ["infantry"], applies_vs: "any" },+ duration: { type: "turn", value: 2 }+ };+ return minimalConfig({+ [heroName]: {+ name: heroName,+ skills: {+ Overlap: {+ trigger: { type: "turn" },+ effects: { stacking: effect }+ }+ }+ }+ });+}++function minimalConfig(heroDefinitions: Record<string, SkillFile> = {}): SimulatorConfig {+ return {+ troopStats: {+ infantry_t1: { id: "infantry_t1", type: "infantry", tier: 1, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } },+ lancer_t1: { id: "lancer_t1", type: "lancer", tier: 1, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } },+ marksman_t1: { id: "marksman_t1", type: "marksman", tier: 1, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }+ },+ heroGenerationStats: {},+ heroDefinitions,+ troopSkills: { name: "troop skills", skills: {} },+ diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }+ };+}Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -0,0 +1,838 @@+import type {+ ActiveEffect,+ AttackIntent,+ AttackOutcome,+ BattleInput,+ BattleRandomness,+ BattleResult,+ BattleTrace,+ DamageJob,+ EffectSource,+ FighterInput,+ ResolvedFighter,+ ResolvedSkill,+ SideId,+ SimulationOptions,+ SimulatorConfig,+ SkillReportEntry,+ TriggerDamageJobSelector,+ UnitType+} from "./types";+import { ALL_UNIT_MASK, UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";+import { calculateDamageJob, createFastDamageScratch, type DamageScratch } from "./damage";+import {+ activateEffect,+ chancePasses,+ createSeededRng,+ currentEffectValuePct,+ isEffectActive,+ oppositeSide,+ parseTriggerSelector,+ sideForTriggerRelation,+ skillMatchesTrigger,+ type Rng+} from "./effects";+import { classifyEffectForJob } from "./classifier";+import { createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";+import { normalizeUnitType } from "./normalize";+import { emptyTroops, resolveFighter } from "./resolve";+import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";++const DEFAULT_MAX_ROUNDS = 1500;+const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();++interface Runtime {+ activeEffects: ActiveEffect[];+ effectIndex: EffectIndex;+ staticDamageProfile?: StaticDamageProfile;+ damageScratch: DamageScratch;+ rng: Rng;+ skills: RuntimeSkills;+ skillReports: Record<SideId, Map<string, SkillReportEntry>>;+ effectActivationCounts: Record<SideId, number>;+ extraSkillAttackJobsByEffect: Record<string, number>;+ attackControlCounts: { dodge: number; no_attack: number };+ consumedEffectUseKeys: Set<string>;+ counters: {+ attacks: Record<SideId, Record<UnitType, number>>;+ received: Record<SideId, Record<UnitType, number>>;+ };+}++interface RuntimeSkills {+ all: ResolvedSkill[];+ battleStart: ResolvedSkill[];+ roundStart: ResolvedSkill[];+ roundStartGlobal: ResolvedSkill[];+ roundStartPerUnit: ResolvedSkill[];+ attackDeclared: ResolvedSkill[];+}++interface ExtraAttackEffectGroup {+ selected: ActiveEffect;+ effects: ActiveEffect[];+}++interface BattleRun {+ fighters: Record<SideId, ResolvedFighter>;+ runtime: Runtime;+ winner: SideId | "draw";+ rounds: number;+ attacks: AttackOutcome[];+ trace?: BattleTrace;+}++export function simulateBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}): BattleResult {+ const run = runBattle(input, config, options);+ const { fighters, runtime } = run;+ return {+ winner: run.winner,+ rounds: run.rounds,+ remaining: { attacker: ceilTroops(fighters.attacker.troops), defender: ceilTroops(fighters.defender.troops) },+ attacks: run.attacks,+ skillReport: {+ attacker: [...runtime.skillReports.attacker.values()],+ defender: [...runtime.skillReports.defender.values()]+ },+ resolved: buildResolved(fighters.attacker, fighters.defender),+ effectActivationCounts: runtime.effectActivationCounts,+ extraSkillAttackJobsByEffect: runtime.extraSkillAttackJobsByEffect,+ attackControlCounts: runtime.attackControlCounts,+ randomness: classifyRandomness(runtime.skills.all),+ trace: run.trace+ };+}++export function simulateBattleScore(input: BattleInput, config: SimulatorConfig): number {+ const run = runBattle(input, config, { detail: "fast" });+ return signedRemainingScore(run.winner, run.fighters);+}++function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions): BattleRun {+ const attacker = resolveFighter(input.attacker, "attacker", config, input.mechanics);+ const defender = resolveFighter(input.defender, "defender", config, input.mechanics);+ const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };+ const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"));+ const detail = options.detail ?? "full";+ const traceEnabled = detail === "full" && input.trace;+ const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;+ const attacks: AttackOutcome[] = [];+ const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;++ 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);++ let rounds = 0;+ for (let round = 1; round <= maxRounds; round += 1) {+ if (winnerFor(fighters)) break;+ rounds = round;+ const roundStartTroops = snapshotTroops(fighters);+ expireInactive(runtime, round);+ triggerRoundStartSkills(round, fighters, runtime, roundStartTroops);++ const intents = resolveAttackIntents(round, fighters, runtime, roundStartTroops);+ const jobs: DamageJob[] = [];+ const cancelled: AttackOutcome[] = [];++ for (const intent of intents) {+ triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);+ const job = normalJob(intent, roundStartTroops);+ const controls = applicableControls(job, round, runtime);+ if (controls.no_attack || controls.dodge) {+ const control = controls.no_attack ?? controls.dodge!;+ runtime.attackControlCounts[control.reason] += 1;+ const outcome = cancelledOutcome(intent, control.effect.id, control.reason, attackDurationEffectIdsForJob(job, round, runtime.activeEffects));+ cancelled.push(outcome);+ consumeEffects(runtime, outcome.consumedEffectIds);+ } else {+ jobs.push(job);+ jobs.push(...extraSkillJobs(job, round, runtime, roundStartTroops));+ }+ }++ const roundOutcomes: AttackOutcome[] = [];+ for (const job of jobs) {+ const outcome =+ detail === "fast"+ ? calculateDamageJob(job, fighters, runtime.activeEffects, {+ detail: "fast",+ effectIndex: runtime.effectIndex,+ staticDamageProfile: runtime.staticDamageProfile,+ scratch: runtime.damageScratch+ })+ : calculateDamageJob(job, fighters, runtime.activeEffects, {+ trace: traceEnabled,+ effectIndex: runtime.effectIndex,+ staticDamageProfile: runtime.staticDamageProfile+ });+ roundOutcomes.push(outcome);+ consumeEffects(runtime, outcome.consumedEffectIds, outcome.consumedEffectUseKey, outcome.consumedEffectUseId, outcome.consumedEffectUseIds);+ }+ finalizeRoundOutcomes(roundOutcomes, roundStartTroops, runtime);+ if (detail === "full") attacks.push(...cancelled, ...roundOutcomes);+ commitOutcomes(cancelled, fighters, runtime);+ commitOutcomes(roundOutcomes, fighters, runtime);+ trace?.rounds.push({ round, roundStartTroops, intents, jobs });+ }++ const winner = winnerFor(fighters) ?? "draw";+ return {+ fighters,+ runtime,+ winner,+ rounds,+ attacks,+ trace+ };+}++function triggerRoundStartSkills(+ round: number,+ fighters: Record<SideId, ResolvedFighter>,+ runtime: Runtime,+ roundStartTroops: DamageJob["roundStartTroops"]+): ActiveEffect[] {+ const activated: ActiveEffect[] = [];+ activated.push(...triggerSkills("round_start", round, runtime.skills.roundStartGlobal, runtime));+ for (const skill of runtime.skills.roundStartPerUnit) {+ const side = roundTriggerSourceSide(skill);+ const defenderSide = roundTriggerTargetSide(skill, side);+ if (!skillMatchesTrigger(skill, "round_start", round)) continue;+ const report = runtime.skillReports[skill.side].get(reportKey(skill));+ if (report) report.triggersSeen += 1;+ if (!chancePasses(skill, runtime.rng)) continue;+ if (report) report.skillActivations += 1;+ let orderIndex = 0;+ for (const attackerUnit of roundTriggerUnits(skill, roundStartTroops)) {+ const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);+ if (!defenderUnit) continue;+ const intent = syntheticRoundIntent(round, side, attackerUnit, defenderSide, defenderUnit, orderIndex, runtime);+ if (!skillMatchesTrigger(skill, "round_start", round, intent)) continue;+ for (const effectIntent of skill.effects) {+ const effect = activateEffect(skill, effectIntent, round, intent);+ addActiveEffect(runtime, effect);+ activated.push(effect);+ runtime.effectActivationCounts[skill.side] += 1;+ if (report) report.effectActivations += 1;+ }+ orderIndex += 1;+ }+ }+ return activated;+}++function hasPerUnitRoundTrigger(skill: ResolvedSkill): boolean {+ return skill.trigger.type === "turn" && skill.trigger.source !== undefined;+}++function roundTriggerUnits(skill: ResolvedSkill, roundStartTroops: DamageJob["roundStartTroops"]): UnitType[] {+ const selector = parseTriggerSelector(skill.trigger.source, "self");+ const side = sideForTriggerRelation(skill.side, selector.relation);+ const living = UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0);+ return selector.units === undefined ? living : living.filter((unit) => selector.units?.includes(unit));+}++function roundTriggerSourceSide(skill: ResolvedSkill): SideId {+ const selector = parseTriggerSelector(skill.trigger.source, "self");+ return sideForTriggerRelation(skill.side, selector.relation);+}++function roundTriggerTargetSide(skill: ResolvedSkill, sourceSide: SideId): SideId {+ const selector = parseTriggerSelector(skill.trigger.target, "enemy");+ const targetSide = sideForTriggerRelation(skill.side, selector.relation);+ return targetSide === sourceSide ? oppositeSide(sourceSide) : targetSide;+}++function syntheticRoundIntent(+ round: number,+ attackerSide: SideId,+ attackerUnit: UnitType,+ defenderSide: SideId,+ defenderUnit: UnitType,+ orderIndex: number,+ runtime: Runtime+): AttackIntent {+ return {+ id: `r${round}:${attackerSide}:${attackerUnit}:turn:${orderIndex}`,+ round,+ source: "normal",+ attackerSide,+ attackerUnit,+ defenderSide,+ defenderUnit,+ orderIndex,+ previousAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],+ projectedAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],+ previousReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit],+ projectedReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit]+ };+}++function classifyRandomness(skills: ResolvedSkill[]): BattleRandomness {+ const chanceSkillIds: Record<SideId, string[]> = { attacker: [], defender: [] };+ for (const skill of skills) {+ if (hasChanceTrigger(skill)) chanceSkillIds[skill.side].push(skill.id);+ }+ return {+ deterministic: chanceSkillIds.attacker.length === 0 && chanceSkillIds.defender.length === 0,+ chanceSkillIds+ };+}++function hasChanceTrigger(skill: ResolvedSkill): boolean {+ const probability = skill.trigger.probability;+ if (probability === undefined) return false;+ const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);+ return Number.isFinite(value) && value > 0 && value < 100;+}++function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {+ const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };+ const skills = buildRuntimeSkills(fighters);+ for (const fighter of fighters) {+ for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {+ reports[fighter.side].set(reportKey(skill), {+ sourceKind: skill.sourceKind,+ heroName: skill.heroName,+ troopType: skill.troopType,+ skillId: skill.id,+ skillName: skill.name,+ level: skill.level,+ triggersSeen: 0,+ skillActivations: 0,+ effectActivations: 0,+ skillKills: 0,+ unsupportedEffects: []+ });+ }+ }+ return {+ activeEffects: [],+ effectIndex: createEffectIndex(),+ staticDamageProfile: undefined,+ damageScratch: createFastDamageScratch(),+ rng,+ skills,+ skillReports: reports,+ effectActivationCounts: { attacker: 0, defender: 0 },+ extraSkillAttackJobsByEffect: {},+ attackControlCounts: { dodge: 0, no_attack: 0 },+ consumedEffectUseKeys: new Set(),+ counters: {+ attacks: { attacker: emptyTroops(), defender: emptyTroops() },+ received: { attacker: emptyTroops(), defender: emptyTroops() }+ }+ };+}++function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {+ const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);+ const battleStart = all.filter((skill) => skill.trigger.type === "battle_start");+ const roundStart = all.filter((skill) => skill.trigger.type === "turn");+ const attackDeclared = all.filter((skill) => skill.trigger.type === "attack");+ return {+ all,+ battleStart,+ roundStart,+ roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),+ roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),+ attackDeclared+ };+}++function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {+ runtime.activeEffects.push(effect);+ indexEffect(runtime.effectIndex, effect);+}++function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {+ if (!passive) return;+ for (const stat of ["attack", "defense", "lethality", "health"] as const) {+ for (const direction of ["up", "down"] as const) {+ const valuePct = Number(passive[stat]?.[direction] ?? 0);+ if (!Number.isFinite(valuePct) || valuePct <= 0) continue;+ const bucket = `passive.${stat}.${direction}`;+ addActiveEffect(runtime, {+ id: `${side}:input_stat:${bucket}`,+ source: {+ kind: "input_stat",+ side,+ effectId: `input:${bucket}`+ },+ intent: {+ id: `input:${bucket}`,+ type: bucket,+ value: valuePct+ },+ ownerSide: side,+ kind: "modifier",+ valuePct,+ appliesTo: { side, units: ALL_UNIT_MASK },+ appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },+ createdRound: 0,+ startRound: 0,+ duration: { type: "battle", value: 0 },+ uses: 0,+ sameEffectStacking: "add"+ });+ }+ }+}++function triggerSkills(+ triggerType: "battle_start" | "round_start" | "attack_declared",+ round: number,+ skills: ResolvedSkill[],+ runtime: Runtime,+ intent?: AttackIntent+): ActiveEffect[] {+ const activated: ActiveEffect[] = [];+ for (const skill of skills) {+ if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;+ const report = runtime.skillReports[skill.side].get(reportKey(skill));+ if (report) report.triggersSeen += 1;+ if (!chancePasses(skill, runtime.rng)) continue;+ if (report) report.skillActivations += 1;+ for (const effectIntent of skill.effects) {+ const effect = activateEffect(skill, effectIntent, round, intent);+ addActiveEffect(runtime, effect);+ activated.push(effect);+ runtime.effectActivationCounts[skill.side] += 1;+ if (report) report.effectActivations += 1;+ }+ }+ return activated;+}++function resolveAttackIntents(+ round: number,+ fighters: Record<SideId, ResolvedFighter>,+ runtime: Runtime,+ roundStartTroops: DamageJob["roundStartTroops"]+): AttackIntent[] {+ const intents: AttackIntent[] = [];+ for (const side of ["attacker", "defender"] as SideId[]) {+ const defenderSide = oppositeSide(side);+ let orderIndex = 0;+ for (const attackerUnit of UNIT_TYPES) {+ if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;+ const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);+ if (!defenderUnit) continue;+ const previousAttackCount = runtime.counters.attacks[side][attackerUnit];+ const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];+ intents.push({+ id: `r${round}:${side}:${attackerUnit}:${orderIndex}`,+ round,+ source: "normal",+ attackerSide: side,+ attackerUnit,+ defenderSide,+ defenderUnit,+ orderIndex,+ previousAttackCount,+ projectedAttackCount: previousAttackCount + 1,+ previousReceivedAttackCount,+ projectedReceivedAttackCount: previousReceivedAttackCount + 1+ });+ orderIndex += 1;+ }+ }+ return intents;+}++function chooseDefenderUnit(+ attackerUnit: UnitType,+ attackerSide: SideId,+ defenderSide: SideId,+ roundStartTroops: DamageJob["roundStartTroops"],+ effectIndex: EffectIndex,+ round: number+): UnitType | undefined {+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round) ?? UNIT_TYPES;+ return order.find((unit) => (roundStartTroops[defenderSide][unit] ?? 0) > 0);+}++function orderFromEffects(attackerUnit: UnitType, attackerSide: SideId, index: EffectIndex, round: number): UnitType[] | undefined {+ for (const effect of index.battleOrder) {+ if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;+ if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;+ if (Array.isArray(effect.intent.value)) {+ try {+ return effect.intent.value.map((value) => normalizeUnitType(String(value)));+ } catch {+ return undefined;+ }+ }+ }+ return undefined;+}++function applicableControls(+ job: DamageJob,+ round: number,+ runtime: Runtime+): { dodge?: Control; no_attack?: Control } {+ const controls: { dodge?: Control; no_attack?: Control } = {};+ for (const effect of runtime.effectIndex.controls) {+ if (!isEffectActive(effect, round)) continue;+ const classification = classifyEffectForJob(effect, job);+ if (classification?.kind === "control" && classification.control) {+ controls[classification.control] = { effect, reason: classification.control };+ }+ }+ return controls;+}++interface Control {+ effect: ActiveEffect;+ reason: "dodge" | "no_attack";+}++function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {+ return {+ id: `${intent.id}:normal`,+ round: intent.round,+ kind: "normal",+ sourceIntentId: intent.id,+ roundStartTroops,+ attackerSide: intent.attackerSide,+ attackerUnit: intent.attackerUnit,+ defenderSide: intent.defenderSide,+ defenderUnit: intent.defenderUnit,+ sourceMultiplier: 1+ };+}++function extraSkillJobs(+ normalAttack: DamageJob,+ round: number,+ runtime: Runtime,+ roundStartTroops: DamageJob["roundStartTroops"]+): DamageJob[] {+ const jobs: DamageJob[] = [];+ const effectGroups = selectStackedExtraAttackEffectGroups(+ runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),+ round+ );+ for (const effectGroup of effectGroups) {+ const effect = effectGroup.selected;+ const definitions = effect.triggerDamageJobs ?? [];+ const consumedEffectIds = effectGroup.effects.map((groupEffect) => groupEffect.id);+ const consumedEffectUseKey = `${normalAttack.sourceIntentId}:${effect.stackingKey ?? effect.id}`;+ const firstJobIndex = jobs.length;+ let definitionIndex = 0;+ for (const definition of definitions) {+ const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);+ const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);+ const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);+ if (multiplier <= 0) continue;+ const sourceEffectId = effect.source.effectId ?? effect.intent.id;+ const sourceSkillReportKey = reportKeyForEffectSource(effect.source);+ for (const source of sources) {+ if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;+ for (const target of targets) {+ if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;+ jobs.push({+ id: `${normalAttack.sourceIntentId}:skill:${sourceEffectId}:${definitionIndex}:${jobs.length}`,+ round,+ kind: "skill",+ sourceIntentId: normalAttack.sourceIntentId,+ roundStartTroops,+ attackerSide: source.side,+ attackerUnit: source.unit,+ defenderSide: target.side,+ defenderUnit: target.unit,+ sourceEffectId,+ sourceSkillReportKey,+ sourceMultiplier: multiplier,+ consumedEffectIds,+ consumedEffectUseKey,+ consumedEffectUseId: effect.id,+ consumedEffectUseIds: consumedEffectIds+ });+ runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;+ }+ }+ definitionIndex += 1;+ }+ if (jobs.length > firstJobIndex) {+ for (const consumedEffectId of consumedEffectIds) reserveConsumedEffectUse(runtime, consumedEffectUseKey, consumedEffectId);+ }+ }+ return jobs;+}++function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {+ const selected: ExtraAttackEffectGroup[] = [];+ const maxByKey = new Map<string, number>();+ for (const effect of effects) {+ if (effect.sameEffectStacking !== "max" || !effect.stackingKey) {+ selected.push({ selected: effect, effects: [effect] });+ continue;+ }+ const existingIndex = maxByKey.get(effect.stackingKey);+ if (existingIndex === undefined) {+ maxByKey.set(effect.stackingKey, selected.length);+ selected.push({ selected: effect, effects: [effect] });+ continue;+ }+ const existing = selected[existingIndex];+ existing.effects.push(effect);+ if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;+ }+ return selected;+}++function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {+ return (+ effect.appliesTo.side === normalAttack.attackerSide &&+ unitMaskHas(effect.appliesTo.units, normalAttack.attackerUnit) &&+ effect.appliesVs.side === normalAttack.defenderSide &&+ unitMaskHas(effect.appliesVs.units, normalAttack.defenderUnit)+ );+}++interface TriggerJobUnit {+ side: SideId;+ unit: UnitType;+}++function resolveTriggerJobSelector(+ selector: TriggerDamageJobSelector,+ role: "source" | "target",+ effect: ActiveEffect,+ normalAttack: DamageJob,+ roundStartTroops: DamageJob["roundStartTroops"]+): TriggerJobUnit[] {+ if (selector === "use.source") return [{ side: normalAttack.attackerSide, unit: normalAttack.attackerUnit }];+ if (selector === "use.target") return [{ side: normalAttack.defenderSide, unit: normalAttack.defenderUnit }];+ if (selector === "effect.applies_to") return unitsFromMask(effect.appliesTo.units).map((unit) => ({ side: effect.appliesTo.side, unit }));+ if (selector === "effect.applies_vs") return unitsFromMask(effect.appliesVs.units).map((unit) => ({ side: effect.appliesVs.side, unit }));+ if (selector === "enemy.living") return livingUnits(normalAttack.defenderSide, roundStartTroops);+ if (selector === "self.living") return livingUnits(normalAttack.attackerSide, roundStartTroops);+ const units = unitListFromSelector(selector);+ if (!units) {+ throw new Error(`trigger_damage_jobs ${role} selector is required and must be a supported selector, got ${JSON.stringify(selector)}`);+ }+ const fallbackSide = role === "source" ? normalAttack.attackerSide : normalAttack.defenderSide;+ return units.map((unit) => ({ side: fallbackSide, unit }));+}++function livingUnits(side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): TriggerJobUnit[] {+ return UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0).map((unit) => ({ side, unit }));+}++function unitListFromSelector(selector: TriggerDamageJobSelector): UnitType[] | undefined {+ if (Array.isArray(selector)) {+ try {+ return selector.map((entry) => normalizeUnitType(String(entry)));+ } catch {+ return undefined;+ }+ }+ if (typeof selector === "string") {+ try {+ return [normalizeUnitType(selector)];+ } catch {+ return undefined;+ }+ }+ return undefined;+}++function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {+ const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;+ const pct = Number(raw ?? 0);+ return Number.isFinite(pct) ? pct / 100 : 0;+}++function cancelledOutcome(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[] = [effectId]): AttackOutcome {+ return {+ jobId: `${intent.id}:cancelled`,+ kind: "normal",+ attackerSide: intent.attackerSide,+ attackerUnit: intent.attackerUnit,+ defenderSide: intent.defenderSide,+ defenderUnit: intent.defenderUnit,+ kills: 0,+ counterDeltas: [+ { side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },+ { side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }+ ],+ appliedEffectIds: [],+ appliedEffects: [],+ consumedEffectIds,+ cancelledBy: effectId,+ cancelReason: reason+ };+}++function finalizeRoundOutcomes(outcomes: AttackOutcome[], roundStartTroops: DamageJob["roundStartTroops"], runtime: Runtime): void {+ capRoundOutcomeKills(outcomes, roundStartTroops);+ attributeSkillKills(outcomes, runtime);+}++function capRoundOutcomeKills(outcomes: AttackOutcome[], roundStartTroops: DamageJob["roundStartTroops"]): void {+ for (const side of ["attacker", "defender"] as SideId[]) {+ for (const unit of UNIT_TYPES) {+ const matching = outcomes.filter((outcome) => outcome.defenderSide === side && outcome.defenderUnit === unit && outcome.kills > 0);+ if (matching.length === 0) continue;+ const available = Math.max(0, roundStartTroops[side][unit] ?? 0);+ const totalKills = matching.reduce((sum, outcome) => sum + outcome.kills, 0);+ if (totalKills <= available) continue;+ let remaining = available;+ for (const outcome of matching) {+ outcome.kills = Math.min(outcome.kills, remaining);+ remaining -= outcome.kills;+ if (outcome.trace) outcome.trace.finalKills = outcome.kills;+ }+ }+ }+}++function attributeSkillKills(outcomes: AttackOutcome[], runtime: Runtime): void {+ for (const outcome of outcomes) {+ if (outcome.kind !== "skill" || !outcome.sourceSkillReportKey || outcome.kills <= 0) continue;+ const report = runtime.skillReports[outcome.attackerSide].get(outcome.sourceSkillReportKey);+ if (report) report.skillKills += outcome.kills;+ }+}++function commitOutcomes(outcomes: AttackOutcome[], fighters: Record<SideId, ResolvedFighter>, runtime: Runtime): void {+ const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };+ for (const outcome of outcomes) {+ losses[outcome.defenderSide][outcome.defenderUnit] += outcome.kills;+ if (outcome.counterDeltas.length === 0) {+ runtime.counters.attacks[outcome.attackerSide][outcome.attackerUnit] += 1;+ runtime.counters.received[outcome.defenderSide][outcome.defenderUnit] += 1;+ } else {+ for (const delta of outcome.counterDeltas) {+ if (delta.counter === "attacks") runtime.counters.attacks[delta.side][delta.unit] += delta.by;+ else runtime.counters.received[delta.side][delta.unit] += delta.by;+ }+ }+ }+ for (const side of ["attacker", "defender"] as SideId[]) {+ for (const unit of UNIT_TYPES) {+ fighters[side].troops[unit] = Math.max(0, fighters[side].troops[unit] - losses[side][unit]);+ }+ }+}++function expireInactive(runtime: Runtime, round: number): void {+ let write = 0;+ for (let read = 0; read < runtime.activeEffects.length; read += 1) {+ const effect = runtime.activeEffects[read];+ if (isEffectActive(effect, round)) {+ runtime.activeEffects[write] = effect;+ write += 1;+ }+ }+ runtime.activeEffects.length = write;+ pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));+}++function attackDurationEffectIdsForJob(job: DamageJob, round: number, effects: ActiveEffect[]): string[] {+ return effects+ .filter((effect) => {+ if (effect.kind === "extra_attack" || effect.duration.type !== "attack" || !isEffectActive(effect, round)) return false;+ const classification = classifyEffectForJob(effect, job);+ return classification?.kind === "bucket" || classification?.kind === "control";+ })+ .map((effect) => effect.id);+}++function consumeEffects(+ runtime: Runtime,+ consumedEffectIds: string[],+ consumedEffectUseKey?: string,+ consumedEffectUseId?: string,+ consumedEffectUseIds?: string[]+): void {+ const dedupeIds = consumedEffectUseIds ?? (consumedEffectUseId ? [consumedEffectUseId] : undefined);+ for (const consumed of consumedEffectIds) {+ if (consumedEffectUseKey && dedupeIds?.includes(consumed)) {+ const useKey = `${consumedEffectUseKey}:${consumed}`;+ if (runtime.consumedEffectUseKeys.has(useKey)) continue;+ runtime.consumedEffectUseKeys.add(useKey);+ }+ for (const effect of runtime.activeEffects) {+ if (effect.id === consumed) effect.uses += 1;+ }+ }+}++function reserveConsumedEffectUse(runtime: Runtime, consumedEffectUseKey: string, consumedEffectId: string): void {+ const useKey = `${consumedEffectUseKey}:${consumedEffectId}`;+ if (runtime.consumedEffectUseKeys.has(useKey)) return;+ runtime.consumedEffectUseKeys.add(useKey);+ for (const effect of runtime.activeEffects) {+ if (effect.id === consumedEffectId) effect.uses += 1;+ }+}++function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {+ const attackerAlive = total(fighters.attacker.troops) > 0;+ const defenderAlive = total(fighters.defender.troops) > 0;+ if (attackerAlive && !defenderAlive) return "attacker";+ if (defenderAlive && !attackerAlive) return "defender";+ return undefined;+}++function signedRemainingScore(winner: SideId | "draw", fighters: Record<SideId, ResolvedFighter>): number {+ if (winner === "attacker") return total(ceilTroops(fighters.attacker.troops));+ if (winner === "defender") return -total(ceilTroops(fighters.defender.troops));+ return 0;+}++function total(troops: Record<UnitType, number>): number {+ return UNIT_TYPES.reduce((sum, unit) => sum + troops[unit], 0);+}++function snapshotTroops(fighters: Record<SideId, ResolvedFighter>): DamageJob["roundStartTroops"] {+ return {+ attacker: { ...fighters.attacker.troops },+ defender: { ...fighters.defender.troops }+ };+}++function ceilTroops(troops: Record<UnitType, number>): Record<UnitType, number> {+ return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, Math.ceil(troops[unit] ?? 0)])) as Record<UnitType, number>;+}++function buildResolved(attacker: ResolvedFighter, defender: ResolvedFighter): BattleResult["resolved"] {+ return {+ attacker: {+ troops: { ...attacker.initialTroops },+ heroes: attacker.heroes,+ troopSkillIds: attacker.troopSkills.map((skill) => skill.id),+ diagnostics: attacker.diagnostics+ },+ defender: {+ troops: { ...defender.initialTroops },+ heroes: defender.heroes,+ troopSkillIds: defender.troopSkills.map((skill) => skill.id),+ diagnostics: defender.diagnostics+ }+ };+}++function reportKey(skill: ResolvedSkill): string {+ const cached = REPORT_KEY_CACHE.get(skill);+ if (cached) return cached;+ const key = reportKeyFromParts(skill.sourceKind, skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "", skill.id);+ REPORT_KEY_CACHE.set(skill, key);+ return key;+}++function reportKeyForEffectSource(source: EffectSource): string | undefined {+ if ((source.kind !== "hero_skill" && source.kind !== "troop_skill") || !source.skillId) return undefined;+ return reportKeyFromParts(source.kind, source.heroInstanceId ?? source.heroName ?? source.troopType ?? "", source.skillId);+}++function reportKeyFromParts(sourceKind: SkillReportEntry["sourceKind"], sourceKey: string, skillId: string): string {+ return `${sourceKind}:${sourceKey}:${skillId}`;+}Index: simulator/src/staticDamageProfile.ts===================================================================--- simulator/src/staticDamageProfile.ts prev run+++ simulator/src/staticDamageProfile.ts this run@@ -0,0 +1,264 @@+import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";+import { UNIT_TYPES, unitMaskHas } from "./types";+import type { BucketRole } from "./damageBuckets";+import { currentEffectValuePct } from "./effects";++export interface StaticDamageProfileTerm {+ raw?: number;+ totalPct?: number;+ contributors: DamageBucketTrace["contributors"];+}++export interface StaticDamageProfileEntry {+ factor: number;+ buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;+}++export interface StaticDamageProfile {+ offense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;+ defense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;+}++interface PassiveCandidate {+ effect: ActiveEffect;+ bucket: StaticPassiveBucket;+ valuePct: number;+}++interface PassiveCandidateGroup {+ selected: PassiveCandidate;+ candidates: PassiveCandidate[];+ entry: StaticDamageProfileEntry;+}++export const STATIC_RAW_BUCKETS = ["troops.baseAttack", "troops.baseLethality", "troops.baseHealth", "troops.baseDefense"] as const;+export const STATIC_PLAYER_BUCKETS = ["player.attack", "player.lethality", "player.health", "player.defense"] as const;+export const STATIC_PASSIVE_BUCKETS = [+ "passive.attack.up",+ "passive.attack.down",+ "passive.lethality.up",+ "passive.lethality.down",+ "passive.health.up",+ "passive.health.down",+ "passive.defense.up",+ "passive.defense.down"+] as const;++export type StaticRawBucket = (typeof STATIC_RAW_BUCKETS)[number];+export type StaticPlayerBucket = (typeof STATIC_PLAYER_BUCKETS)[number];+export type StaticPassiveBucket = (typeof STATIC_PASSIVE_BUCKETS)[number];+export type StaticDamageBucket = StaticRawBucket | StaticPlayerBucket | StaticPassiveBucket;++const STATIC_PASSIVE_BUCKET_ROLES: Record<StaticPassiveBucket, BucketRole> = {+ "passive.attack.up": "attacker",+ "passive.attack.down": "attacker",+ "passive.lethality.up": "attacker",+ "passive.lethality.down": "attacker",+ "passive.health.up": "defender",+ "passive.health.down": "defender",+ "passive.defense.up": "defender",+ "passive.defense.down": "defender"+};++export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {+ const profile: StaticDamageProfile = {+ offense: {+ attacker: buildSideEntries(fighters.attacker, "offense"),+ defender: buildSideEntries(fighters.defender, "offense")+ },+ defense: {+ attacker: buildSideEntries(fighters.attacker, "defense"),+ defender: buildSideEntries(fighters.defender, "defense")+ }+ };++ applyStaticPassives(profile, activeEffects);+ recomputeFactors(profile);+ return profile;+}++export function assertStaticPassiveEffectDefinition(+ trigger: SkillFile["skills"][string]["trigger"],+ effect: EffectIntentDefinition,+ file: string,+ skillId: string,+ effectId: string+): void {+ if (!isPassiveBucket(effect.type)) return;++ const path = `${file}:${skillId}.${effectId}`;+ if (trigger.type !== "battle_start") {+ throw new Error(`passive effect ${effect.type} must use battle_start trigger at ${path}`);+ }+ if (effect.value_evolution !== undefined) {+ throw new Error(`passive effect ${effect.type} cannot define value_evolution at ${path}; passive effects must be static`);+ }++ const duration = effect.duration;+ if (duration === undefined) return;+ const durationType = duration.type ?? "battle";+ if (durationType !== "battle") {+ throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);+ }+ if (duration.delay !== undefined && duration.delay !== 0) {+ throw new Error(`passive effect ${effect.type} must use battle duration with no delay at ${path}`);+ }+}++export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {+ return (STATIC_BUCKET_SET as Set<string>).has(bucket);+}++export function isPassiveBucket(bucket: string): bucket is StaticPassiveBucket {+ return (STATIC_PASSIVE_BUCKET_SET as Set<string>).has(bucket);+}++export function staticPassiveBucketRole(bucket: string): BucketRole | undefined {+ return isPassiveBucket(bucket) ? STATIC_PASSIVE_BUCKET_ROLES[bucket] : undefined;+}++function buildSideEntries(fighter: ResolvedFighter, role: "offense" | "defense"): Record<UnitType, StaticDamageProfileEntry> {+ return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildEntry(fighter, unit, role)])) as Record<UnitType, StaticDamageProfileEntry>;+}++function buildEntry(fighter: ResolvedFighter, unit: UnitType, role: "offense" | "defense"): StaticDamageProfileEntry {+ const stats = fighter.troopDetails[unit]?.stats ?? fallbackStats();+ const bonuses = fighter.statBonuses[unit] ?? emptyStats();+ const buckets: StaticDamageProfileEntry["buckets"] = {};+ if (role === "offense") {+ setRaw(buckets, "troops.baseAttack", stats.attack);+ setRaw(buckets, "troops.baseLethality", stats.lethality);+ setPct(buckets, "player.attack", bonuses.attack, [{ effectId: "input:attack", source: "input_stats", valuePct: bonuses.attack, bucket: "player.attack" }]);+ setPct(buckets, "player.lethality", bonuses.lethality, [+ { effectId: "input:lethality", source: "input_stats", valuePct: bonuses.lethality, bucket: "player.lethality" }+ ]);+ } else {+ setRaw(buckets, "troops.baseHealth", stats.health);+ setRaw(buckets, "troops.baseDefense", stats.defense);+ setPct(buckets, "player.health", bonuses.health, [{ effectId: "input:health", source: "input_stats", valuePct: bonuses.health, bucket: "player.health" }]);+ setPct(buckets, "player.defense", bonuses.defense, [+ { effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }+ ]);+ }+ return { factor: 1, buckets };+}++function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {+ const groups = new Map<string, PassiveCandidateGroup>();+ for (const effect of activeEffects) {+ const bucket = effect.intent.type;+ if (!isPassiveBucket(bucket)) continue;+ const role = staticPassiveBucketRole(bucket)!;+ const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];+ for (const unit of UNIT_TYPES) {+ if (!unitMaskHas(effect.appliesTo.units, unit)) continue;+ const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {+ const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;+ const group = groups.get(key);+ if (group) {+ group.candidates.push(candidate);+ if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;+ } else {+ groups.set(key, { selected: candidate, candidates: [candidate], entry: targetEntries[unit] });+ }+ } else {+ addPassiveCandidate(targetEntries[unit], candidate);+ }+ }+ }+ for (const group of groups.values()) {+ addPassiveCandidate(group.entry, group.selected);+ }+}++function addPassiveCandidate(entry: StaticDamageProfileEntry, candidate: PassiveCandidate): void {+ addPct(entry.buckets, candidate.bucket, candidate.valuePct, {+ effectId: candidate.effect.source.effectId ?? candidate.effect.id,+ source: sourceLabel(candidate.effect),+ sourceSide: candidate.effect.ownerSide,+ valuePct: candidate.valuePct,+ bucket: candidate.bucket,+ stackingKey: candidate.effect.stackingKey,+ sameEffectStacking: candidate.effect.sameEffectStacking+ });+}++function recomputeFactors(profile: StaticDamageProfile): void {+ for (const side of ["attacker", "defender"] as SideId[]) {+ for (const unit of UNIT_TYPES) {+ profile.offense[side][unit].factor = offenseFactor(profile.offense[side][unit]);+ profile.defense[side][unit].factor = defenseFactor(profile.defense[side][unit]);+ }+ }+}++function offenseFactor(entry: StaticDamageProfileEntry): number {+ return (+ raw(entry, "troops.baseAttack") *+ raw(entry, "troops.baseLethality") *+ pct(entry, "player.attack") *+ pct(entry, "player.lethality") *+ pct(entry, "passive.attack.up") *+ pct(entry, "passive.lethality.up") /+ (pct(entry, "passive.attack.down") * pct(entry, "passive.lethality.down"))+ );+}++function defenseFactor(entry: StaticDamageProfileEntry): number {+ return (+ (pct(entry, "passive.health.down") * pct(entry, "passive.defense.down")) /+ (raw(entry, "troops.baseHealth") *+ raw(entry, "troops.baseDefense") *+ pct(entry, "player.health") *+ pct(entry, "player.defense") *+ pct(entry, "passive.health.up") *+ pct(entry, "passive.defense.up"))+ );+}++function raw(entry: StaticDamageProfileEntry, bucket: StaticRawBucket): number {+ return Math.max(0, entry.buckets[bucket]?.raw ?? 0);+}++function pct(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket | StaticPassiveBucket): number {+ return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100;+}++function setRaw(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {+ buckets[bucket] = { raw, contributors: [] };+}++function setPct(+ buckets: StaticDamageProfileEntry["buckets"],+ bucket: StaticPlayerBucket,+ valuePct: number,+ contributors: DamageBucketTrace["contributors"] = []+): void {+ buckets[bucket] = { totalPct: valuePct, contributors };+}++function addPct(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticPassiveBucket, valuePct: number, contributor: DamageBucketTrace["contributors"][number]): void {+ const existing = buckets[bucket];+ if (existing) {+ existing.totalPct = (existing.totalPct ?? 0) + valuePct;+ existing.contributors.push(contributor);+ } else {+ buckets[bucket] = { totalPct: valuePct, contributors: [contributor] };+ }+}++function fallbackStats(): StatBlock {+ return { attack: 1, defense: 1, lethality: 1, health: 1 };+}++function emptyStats(): StatBlock {+ return { attack: 0, defense: 0, lethality: 0, health: 0 };+}++function sourceLabel(effect: ActiveEffect): string {+ return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");+}++const STATIC_BUCKET_SET = new Set<string>([...STATIC_RAW_BUCKETS, ...STATIC_PLAYER_BUCKETS, ...STATIC_PASSIVE_BUCKETS]);+const STATIC_PASSIVE_BUCKET_SET = new Set<string>(STATIC_PASSIVE_BUCKETS);Index: simulator/src/testcases.test.ts===================================================================--- simulator/src/testcases.test.ts prev run+++ simulator/src/testcases.test.ts this run@@ -0,0 +1,408 @@+import assert from "node:assert/strict";+import { mkdirSync, writeFileSync } from "node:fs";+import { tmpdir } from "node:os";+import { resolve } from "node:path";+import { test } from "node:test";++import { loadSimulatorConfig } from "./config";+import { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";+import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";+import { adaptTestcaseEntry, applyComparisonQValues, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, discoverTestcaseFiles, runTestcases, type TestcaseSummaryEntry } from "./testcases";++test("discoverTestcaseFiles follows simulator/testcases symlink and skips disabled or stale files by default", () => {+ const files = discoverTestcaseFiles();++ assert.ok(files.some((file) => file.endsWith("emulator_verified/simple_001_nc.json")));+ assert.ok(!files.some((file) => file.endsWith(".disabled")));+ assert.ok(!files.some((file) => file.endsWith(".stale_troops")));+});++test("discoverTestcaseFiles includes disabled and stale testcase files when requested", () => {+ const files = discoverTestcaseFiles({ includeDisabled: true });++ assert.ok(files.some((file) => file.endsWith("emulator_verified/jasser_solo.json.disabled")));+ assert.ok(files.some((file) => file.endsWith("emulator_verified/reina_logan_combo_v2.json.stale_troops")));+});++test("runTestcases returns compact summary entries and full detail entries separately", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "simple_001", repeat: 5 }, config);+ const key = Object.keys(report.testcases)[0];+ const summary = report.testcases[key];+ const detail = report.details[0];++ assert.equal(report.reportKind, "simulator-parity-summary");+ assert.equal(report.counts.filesFound, 1);+ assert.equal(report.counts.testcasesFound, 1);+ assert.equal(report.counts.executed, 1);+ assert.equal(report.counts.errors, 0);+ assert.equal(summary?.testcase_id, "simple_001");+ assert.equal(summary?.deterministic, false);+ assert.equal(summary?.sampleCount, 5);+ assert.equal(typeof summary?.game?.mu_candidate, "number");+ assert.equal(typeof summary?.baseline?.mu_candidate, "number");+ assert.deepEqual(Object.keys(summary?.game ?? {}), Object.keys(summary?.baseline ?? {}));+ 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);+});++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];+ const detail = report.details[0];++ assert.equal(report.options.repeat, 100);+ assert.equal(summary?.deterministic, false);+ assert.equal(summary?.sampleCount, 100);+ assert.equal(detail?.simulatorStats?.n, 100);+});++test("assignDetailArtifactPaths assigns deterministic compact detail paths", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);++ assignDetailArtifactPaths(report, "simulator_parity_test");++ assert.equal(report.artifactRoot, "simulator_parity_test");+ const keys = Object.keys(report.testcases);+ assert.deepEqual(keys, [...keys].sort());+ assert.equal(report.testcases[keys[0]!]?.detailArtifact, "simulator_parity_test/cases/000001.json");+});++test("buildSummaryForOutput excludes full detail artifacts from compact output", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);+ assignDetailArtifactPaths(report, "simulator_parity_test");++ const summary = buildSummaryForOutput(report);+ const json = JSON.stringify(summary);++ assert.equal("details" in summary, false);+ assert.equal(json.includes("\"result\""), false);+ assert.equal(json.includes("\"attacks\""), false);+ assert.equal(Object.values(summary.testcases)[0]?.detailArtifact, "simulator_parity_test/cases/000001.json");+});++test("assignDetailArtifactPaths exposes failed testcase diagnostics through errors", () => {+ const testcaseRoot = tempDir("simulator-invalid-testcases");+ writeFileSync(+ resolve(testcaseRoot, "invalid.json"),+ JSON.stringify([{ test_id: "bad_case", attacker: { troops: { infantry_t1: 1 } } }]),+ );+ const config = loadSimulatorConfig();+ const report = runTestcases({ testcaseRoot, calibrationReportPath: "/tmp/does-not-exist.json" }, config);++ assignDetailArtifactPaths(report, "simulator_parity_failed");++ assert.equal(report.counts.errors, 1);+ assert.equal(report.details[0]?.detailArtifact, "simulator_parity_failed/cases/000001.json");+ assert.equal(report.errors[0]?.detailArtifact, "simulator_parity_failed/cases/000001.json");+ assert.equal(report.errors[0]?.stage, "adapt");+ assert.equal(Object.keys(report.testcases).length, 0);+});++test("runTestcases logs structured damage aggregation errors and continues", () => {+ const testcaseRoot = tempDir("simulator-aggregation-error-testcases");+ writeFileSync(+ resolve(testcaseRoot, "bad-aggregation.json"),+ JSON.stringify([+ {+ test_id: "bad_aggregation",+ attacker: {+ troops: { infantry_t1: 1000 },+ stats: { infantry: { attack: 0, defense: 0, lethality: 0, health: 0 } }+ },+ defender: {+ troops: { lancer_t1: 1000 },+ stats: { lancer: { attack: 0, defense: 0, lethality: 0, health: -105 } }+ }+ },+ {+ test_id: "next_case_runs",+ attacker: {+ troops: { infantry_t1: 1000 },+ stats: { infantry: { attack: 0, defense: 0, lethality: 0, health: 0 } }+ },+ defender: {+ troops: { lancer_t1: 1000 },+ stats: { lancer: { attack: 0, defense: 0, lethality: 0, health: 0 } }+ }+ }+ ])+ );+ const config = loadSimulatorConfig();+ const report = runTestcases({ testcaseRoot, calibrationReportPath: "/tmp/does-not-exist.json" }, config);++ assert.equal(report.counts.errors, 1);+ assert.equal(report.counts.executed, 1);+ assert.equal(report.errors[0]?.testcase_id, "bad_aggregation");+ assert.equal(report.details[0]?.errorDetails?.type, "DamageAggregationError");+ assert.equal(report.details[0]?.errorDetails?.groupId, "player.defender.health");+ assert.equal(report.details[0]?.errorDetails?.factor, -0.050000000000000044);+ assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");+});++test("runTestcases keeps executed testcase and warns when baseline snapshot row is missing", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "simple_001", repeat: 1, calibrationReportPath: "/tmp/does-not-exist.json" }, config);+ const summary = Object.values(report.testcases)[0];++ assert.equal(report.counts.executed, 1);+ assert.equal(summary?.game?.n_candidate, 1);+ assert.equal(summary?.baseline, null);+ assert.equal(report.warnings[0]?.stage, "baseline_comparison");+});++test("applyComparisonQValues keeps game and baseline correction families separate", () => {+ const firstGame = comparisonMetric(0.01);+ const secondGame = comparisonMetric(0.02);+ const onlyBaseline = comparisonMetric(0.04);+ const testcases: Record<string, TestcaseSummaryEntry> = {+ "testcases/a.json#0": summaryEntry("a", 0, firstGame, onlyBaseline),+ "testcases/b.json#0": summaryEntry("b", 0, secondGame, null)+ };++ applyComparisonQValues({ testcases });++ assert.equal(firstGame.q, 0.02);+ assert.equal(secondGame.q, 0.02);+ assert.equal(onlyBaseline.q, 0.04);+});++test("calibration lookup supports simulator symlink and source testcase path variants", () => {+ assert.deepEqual(testcaseFileLookupVariants("simulator/testcases/emulator_verified/simple_001_nc.json"), [+ "simulator/testcases/emulator_verified/simple_001_nc.json",+ "testcases/emulator_verified/simple_001_nc.json"+ ]);++ const comparison = loadCalibrationComparison();+ const sourceRow = readCalibrationCase(comparison, "testcases/emulator_verified/simple_001_nc.json", "simple_001");+ if (sourceRow) {+ assert.deepEqual(readCalibrationCase(comparison, "simulator/testcases/emulator_verified/simple_001_nc.json", "simple_001"), sourceRow);+ }+});++test("duplicate no-hero testcase ids align calibration rows by file and case index", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "greg_mia_nohero_control_current", repeat: 1 }, config);++ assert.equal(report.counts.testcasesFound, 2);+ assert.equal(report.counts.executed, 2);++ const first = report.details.find((entry) => entry.index === 0);+ const second = report.details.find((entry) => entry.index === 1);+ assert.equal(first?.testcaseId, "greg_mia_nohero_control_current");+ assert.equal(second?.testcaseId, "greg_mia_nohero_control_current");+ assert.equal(first?.visibility.attacker.heroes.length, 0);+ assert.equal(second?.visibility.attacker.heroes.length, 0);+ assert.equal(first?.calibration?.idx, 0);+ assert.equal(second?.calibration?.idx, 1);+ assert.equal(first?.calibration?.muGame, 3752);+ assert.equal(second?.calibration?.muGame, 3652);+ assert.equal(battleScoreDelta(first?.gameResult), first?.calibration?.muGame);+ assert.equal(battleScoreDelta(second?.gameResult), second?.calibration?.muGame);+});++test("no-hero simple testcase loads, runs, compares to calibration, and exposes aligned core fields", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);+ const entry = report.details[0];+ const summary = Object.values(report.testcases)[0];++ assert.equal(report.counts.testcasesFound, 1);+ assert.equal(summary?.testcase_id, "simple_001");+ assert.equal(summary?.game?.n_reference, 1);+ assert.equal(summary?.baseline?.n_reference, 100);+ assert.equal(entry?.visibility.attacker.heroes.length, 0);+ assert.equal(entry?.visibility.defender.heroes.length, 0);+ assert.equal(entry?.calibration?.muGame, -186);+ assert.equal(battleScoreDelta(entry?.gameResult), -186);+ assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);+});++test("runTestcases applies possible stat rounding correction to exact deterministic misses", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "norah_s2_inf_only_B" }, config);+ const summary = Object.values(report.testcases)[0];++ assert.equal(summary?.testcase_id, "norah_s2_inf_only_B");+ assert.equal(summary?.deterministic, true);+ assert.equal(summary?.game?.passes, true);+ assert.equal(summary?.game?.bias_raw, 0);+ assert.equal(summary?.gameStatAdjustment?.mode, "deterministic_exact");+ assert.equal(summary?.gameStatAdjustment?.value, 0.05);+ assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);+});++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");++ assert.equal(entry?.result?.winner, "attacker");+ assert.ok((entry?.result?.rounds ?? 0) > 100);+});++test("runTestcases reports a parity summary from calibration JSON", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);+ const row = Object.values(report.testcases)[0];+ const detail = report.details[0];++ assert.ok(report.calibrationReportPath?.endsWith("baseline_result_2026-05-21T04-46-47Z.json"));+ assert.equal(row?.testcase_id, "simple_001");+ assert.equal(row?.idx, 0);+ assert.equal(row?.game?.mu_reference, -186);+ assert.equal(row?.baseline?.mu_reference, -186);+ assert.equal(row?.baseline?.bias_raw, 0);+ assert.equal(row?.baseline?.sem, 0);+ assert.equal(row?.baseline?.p, null);+ assert.equal(row?.baseline?.q, null);+ assert.equal(typeof detail?.simulatorScoreDelta, "number");+ assert.equal(typeof row?.game?.bias_raw, "number");+ assert.equal(row?.game?.n_candidate, 1);+ assert.equal(typeof row?.game?.mu_candidate, "number");+ assert.equal(typeof row?.baseline?.bias_raw, "number");+ assert.equal(typeof row?.game?.bias_raw, "number");+ assert.equal(typeof row?.baseline?.passes, "boolean");+ assert.equal(typeof row?.game?.passes, "boolean");+ assert.equal(report.counts.comparedToGame, 1);+ assert.equal(report.counts.comparedToBaseline, 1);+});++test("adaptTestcaseEntry passes testcase mechanics and engagement aliases into BattleInput", () => {+ const input = adaptTestcaseEntry({+ test_id: "mechanics_case",+ engagement_type: "rally",+ mechanics: { weather: "clear" },+ attacker: { troops: { infantry_t1: 1 } },+ defender: { troops: { infantry_t1: 1 } }+ });++ assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });+});++test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {+ const metrics = compareOutcomeDistribution({+ candidate: { n: 1, mu: -186, sigma: 0 },+ reference: { n: 1, mu: -186, sigma: 0 },+ initialTroops: 1200,+ deterministic: true,+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }+ });++ assert.deepEqual(metrics, {+ n_candidate: 1,+ mu_candidate: -186,+ sigma_candidate: 0,+ n_reference: 1,+ mu_reference: -186,+ sigma_reference: 0,+ bias_raw: 0,+ bias_pct: 0,+ sem: 0,+ stat_type: "deterministic",+ stat: null,+ p: null,+ q: null,+ passes: true+ } satisfies ParityComparisonMetrics);+});++test("compareOutcomeDistribution uses candidate spread for single-observation stochastic references", () => {+ const metrics = compareOutcomeDistribution({+ candidate: { n: 5, mu: 10, sigma: 2 },+ reference: { n: 1, mu: 8, sigma: 0 },+ initialTroops: 100,+ deterministic: false,+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }+ });++ assert.equal(metrics.stat_type, "single_obs");+ assert.equal(metrics.passes, true);+ assert.equal(metrics.bias_raw, 2);+ assert.equal(metrics.bias_pct, 2);+ assert.equal(metrics.sem, 2);+ assert.equal(metrics.stat, 1);+});++test("compareOutcomeDistribution fails single-observation references outside candidate spread", () => {+ const metrics = compareOutcomeDistribution({+ candidate: { n: 100, mu: 862.15, sigma: 3.79 },+ reference: { n: 1, mu: 879, sigma: 0 },+ initialTroops: 1440,+ deterministic: false,+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }+ });++ assert.equal(metrics.stat_type, "single_obs");+ assert.equal(metrics.passes, false);+ assert.equal(metrics.bias_raw, -16.85);+ assert.equal(metrics.bias_pct, -1.17);+ assert.equal(metrics.sem, 3.79);+ assert.equal(metrics.stat, -4.4459);+});++test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {+ const rows = [+ { p: 0.01, q: null },+ { p: 0.03, q: null },+ { p: null, q: null }+ ];++ applyBenjaminiHochberg(rows);++ assert.equal(rows[0].q, 0.02);+ assert.equal(rows[1].q, 0.03);+ assert.equal(rows[2].q, null);+});++test("calibration lookup exposes full baseline snapshot metrics", () => {+ const comparison = loadCalibrationComparison();+ const row = readCalibrationCase(comparison, "testcases/emulator_verified/simple_001_nc.json", "simple_001");++ assert.equal(row?.biasRaw, 0);+ assert.equal(row?.sem, 0);+ assert.equal(row?.p, null);+ assert.equal(row?.q, null);+});++function tempDir(prefix: string): string {+ const dir = resolve(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`);+ mkdirSync(dir, { recursive: true });+ return dir;+}++function comparisonMetric(p: number): ParityComparisonMetrics {+ return {+ n_candidate: 2,+ mu_candidate: 10,+ sigma_candidate: 1,+ n_reference: 2,+ mu_reference: 8,+ sigma_reference: 1,+ bias_raw: 2,+ bias_pct: 1,+ sem: 1,+ stat_type: "t",+ stat: 2,+ p,+ q: null,+ passes: true+ };+}++function summaryEntry(testcaseId: string, idx: number, game: ParityComparisonMetrics | null, baseline: ParityComparisonMetrics | null): TestcaseSummaryEntry {+ return {+ file: `testcases/${testcaseId}.json`,+ testcase_id: testcaseId,+ idx,+ deterministic: false,+ sampleCount: 2,+ game,+ baseline+ };+}Index: simulator/src/testcases.ts===================================================================--- simulator/src/testcases.ts prev run+++ simulator/src/testcases.ts this run@@ -0,0 +1,803 @@+import { readdirSync, readFileSync, statSync } from "node:fs";+import { dirname, relative, resolve } from "node:path";+import { fileURLToPath } from "node:url";++import {+ loadCalibrationComparison,+ readCalibrationCase,+ type CalibrationCaseComparison,+ type SampleStats,+ sampleStats+} from "./calibration";+import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";+import { simulateBattle } from "./simulator";+import { DamageAggregationError } from "./damage";+import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "./types";++const DEFAULT_STOCHASTIC_REPEAT = 100;+const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;+const STAT_ROUNDING_SCAN_STEPS = 50;++export interface TestcaseRunOptions {+ testcaseRoot?: string;+ calibrationReportPath?: string;+ matching?: string;+ includeDisabled?: boolean;+ repeat?: number;+ seed?: string | number;+ trace?: boolean;+ workers?: number;+}++export interface TestcaseCaseReport {+ file: string;+ testcaseId: string;+ index: number;+ detailArtifact?: string;+ diagnostics: string[];+ gameResult?: unknown;+ calibration?: CalibrationCaseComparison;+ result?: BattleResult;+ simulatorScoreDelta?: number;+ simulatorStats?: SampleStats;+ simulatorSampleOutcomes?: TestcaseSampleOutcome[];+ simulatorSampleDeltas?: number[];+ gameStatAdjustment?: TestcaseStatAdjustment;+ deterministic?: boolean;+ sampleCount?: number;+ visibility: {+ attacker: CaseVisibility;+ defender: CaseVisibility;+ };+ error?: string;+ errorDetails?: TestcaseErrorDetails;+}++export interface TestcaseRunWarning {+ file: string;+ testcase_id: string;+ idx: number;+ stage: "parse" | "adapt" | "execute" | "game_comparison" | "baseline_comparison" | "artifact";+ reason: string;+ detailArtifact?: string;+}++export interface TestcaseSummaryEntry {+ file: string;+ testcase_id: string;+ idx: number;+ detailArtifact?: string;+ deterministic: boolean;+ sampleCount: number;+ game: ParityComparisonMetrics | null;+ baseline: ParityComparisonMetrics | null;+ gameStatAdjustment?: TestcaseStatAdjustment;+}++export interface TestcaseRunReport {+ reportKind: "simulator-parity-summary";+ schemaVersion: 1;+ createdAt: string;+ options: TestcaseRunOptions;+ calibrationReportPath?: string;+ artifactRoot?: string;+ counts: {+ filesFound: number;+ testcasesFound: number;+ executed: number;+ warnings: number;+ errors: number;+ comparedToGame: number;+ comparedToBaseline: number;+ };+ warnings: TestcaseRunWarning[];+ errors: TestcaseRunWarning[];+ testcases: Record<string, TestcaseSummaryEntry>;+ details: TestcaseCaseReport[];+}++export type TestcaseSummaryOutput = Omit<TestcaseRunReport, "details">;++interface CaseVisibility {+ heroes: string[];+ troopSkillIds: string[];+ troops: Partial<Record<UnitType, number>>;+ skillEffectActivations: number;+}++interface TestcaseErrorDetails {+ type: string;+ [key: string]: unknown;+}++export interface PreparedTestcaseCase {+ file: string;+ reportFile: string;+ entry: unknown;+ testcaseId: string;+ index: number;+ detail: TestcaseCaseReport;+ input?: BattleInput;+ key?: string;+ adaptError?: TestcaseRunWarning;+}++export interface TestcaseExecutionJob {+ file: string;+ reportFile: string;+ testcaseId: string;+ index: number;+ input: BattleInput;+ repeat: number;+ seed?: string | number;+}++export interface TestcaseExecutionResult {+ testcaseId: string;+ index: number;+ result?: BattleResult;+ deterministic?: boolean;+ sampleCount?: number;+ simulatorStats?: SampleStats;+ simulatorScoreDelta?: number;+ simulatorSampleOutcomes?: TestcaseSampleOutcome[];+ simulatorSampleDeltas?: number[];+ diagnostics: string[];+ error?: string;+ errorDetails?: TestcaseErrorDetails;+}++export interface TestcaseSampleOutcome {+ run: number;+ attackerHeroes: string[];+ defenderHeroes: string[];+ attackerTroops: Partial<Record<UnitType, number>>;+ defenderTroops: Partial<Record<UnitType, number>>;+ attackerRemainingByType: Partial<Record<UnitType, number>>;+ defenderRemainingByType: Partial<Record<UnitType, number>>;+ attackerRemaining: number;+ defenderRemaining: number;+ scoreDelta: number;+}++export interface TestcaseStatAdjustment {+ value: number;+ mode: "deterministic_exact" | "deterministic_within_one" | "stochastic_tolerance" | "best_effort";+ unadjusted: ParityComparisonMetrics;+}++export function defaultTestcaseRoot(): string {+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "testcases");+}++export function discoverTestcaseFiles(options: Pick<TestcaseRunOptions, "testcaseRoot" | "matching" | "includeDisabled"> = {}): string[] {+ const root = resolve(options.testcaseRoot ?? defaultTestcaseRoot());+ const files: string[] = [];+ walk(root, files);+ return files+ .filter((file) => isDiscoverableTestcaseFile(file, options.includeDisabled))+ .filter((file) => options.includeDisabled || (!file.endsWith(".disabled") && !file.endsWith(".stale_troops")))+ .filter((file) => !options.matching || file.includes(options.matching))+ .sort();+}++export function runTestcases(options: TestcaseRunOptions, config: SimulatorConfig): TestcaseRunReport {+ const prepared = prepareTestcaseCases(options);+ return runPreparedTestcases(options, config, prepared, (job) => executeTestcaseCase(job, config));+}++export function prepareTestcaseCases(options: TestcaseRunOptions): { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] } {+ const files = discoverTestcaseFiles(options);+ const cases: PreparedTestcaseCase[] = [];+ const parseErrors: TestcaseRunWarning[] = [];+ for (const file of files) {+ const reportFile = normalizeReportPath(relative(process.cwd(), file));+ let entries: unknown[];+ try {+ const parsed = JSON.parse(readFileSync(file, "utf8"));+ entries = Array.isArray(parsed) ? parsed : [parsed];+ } catch (error) {+ parseErrors.push({ file: reportFile, testcase_id: "(parse_error)", idx: 0, stage: "parse", reason: `Failed to parse JSON: ${errorMessage(error)}` });+ continue;+ }++ entries.forEach((entry, index) => {+ const testcaseId = testcaseIdFor(entry, index);+ const diagnostics: string[] = [];+ const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);+ const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };+ try {+ preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace }, diagnostics);+ preparedCase.key = snapshotKey(reportFile, index);+ } catch (error) {+ detail.error = errorMessage(error);+ diagnostics.push(detail.error);+ preparedCase.adaptError = { file: reportFile, testcase_id: testcaseId, idx: index, stage: "adapt", reason: detail.error };+ }+ cases.push(preparedCase);+ });+ }+ return { filesFound: files.length, cases, parseErrors };+}++export function runPreparedTestcases(+ options: TestcaseRunOptions,+ config: SimulatorConfig,+ prepared: { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] },+ execute: (job: TestcaseExecutionJob, config: SimulatorConfig) => TestcaseExecutionResult+): TestcaseRunReport {+ const comparison = loadCalibrationComparison(options.calibrationReportPath);+ const repeat = normalizeRepeat(options.repeat);+ const report: TestcaseRunReport = {+ reportKind: "simulator-parity-summary",+ schemaVersion: 1,+ createdAt: new Date().toISOString(),+ options: { ...options, repeat },+ calibrationReportPath: comparison.reportPath,+ counts: { filesFound: prepared.filesFound, testcasesFound: prepared.cases.length, executed: 0, warnings: 0, errors: 0, comparedToGame: 0, comparedToBaseline: 0 },+ warnings: [],+ errors: [...prepared.parseErrors],+ testcases: {},+ details: []+ };++ for (const preparedCase of prepared.cases) {+ const { file, reportFile, entry, testcaseId, index, detail } = preparedCase;+ if (!preparedCase.input || !preparedCase.key) {+ if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);+ report.details.push(detail);+ continue;+ }+ try {+ const execution = execute({ file, reportFile, testcaseId, index, input: preparedCase.input, repeat, seed: options.seed }, config);+ applyExecutionResult(report, comparison, preparedCase, execution, config);+ } catch (error) {+ detail.error = errorMessage(error);+ detail.errorDetails = errorDetails(error);+ detail.diagnostics.push(detail.error);+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: detail.error });+ }+ report.details.push(detail);+ }++ finalizeReport(report);+ return report;+}++export async function runPreparedTestcasesAsync(+ options: TestcaseRunOptions,+ config: SimulatorConfig,+ prepared: { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] },+ execute: (job: TestcaseExecutionJob) => Promise<TestcaseExecutionResult>+): Promise<TestcaseRunReport> {+ const comparison = loadCalibrationComparison(options.calibrationReportPath);+ const repeat = normalizeRepeat(options.repeat);+ const report: TestcaseRunReport = {+ reportKind: "simulator-parity-summary",+ schemaVersion: 1,+ createdAt: new Date().toISOString(),+ options: { ...options, repeat },+ calibrationReportPath: comparison.reportPath,+ counts: { filesFound: prepared.filesFound, testcasesFound: prepared.cases.length, executed: 0, warnings: 0, errors: 0, comparedToGame: 0, comparedToBaseline: 0 },+ warnings: [],+ errors: [...prepared.parseErrors],+ testcases: {},+ details: []+ };++ const jobs = prepared.cases.map(async (preparedCase) => {+ if (!preparedCase.input || !preparedCase.key) return { preparedCase };+ try {+ const execution = await execute({+ file: preparedCase.file,+ reportFile: preparedCase.reportFile,+ testcaseId: preparedCase.testcaseId,+ index: preparedCase.index,+ input: preparedCase.input,+ repeat,+ seed: options.seed+ });+ return { preparedCase, execution };+ } catch (error) {+ return { preparedCase, error };+ }+ });++ for (const { preparedCase, execution, error } of await Promise.all(jobs)) {+ const { reportFile, testcaseId, index, detail } = preparedCase;+ if (!preparedCase.input || !preparedCase.key) {+ if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);+ report.details.push(detail);+ continue;+ }+ if (error !== undefined) {+ detail.error = errorMessage(error);+ detail.errorDetails = errorDetails(error);+ detail.diagnostics.push(detail.error);+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: detail.error });+ } else {+ applyExecutionResult(report, comparison, preparedCase, execution!, config);+ }+ report.details.push(detail);+ }++ finalizeReport(report);+ return report;+}++function applyExecutionResult(+ report: TestcaseRunReport,+ comparison: ReturnType<typeof loadCalibrationComparison>,+ preparedCase: PreparedTestcaseCase,+ execution: TestcaseExecutionResult,+ config: SimulatorConfig+): void {+ const { reportFile, entry, testcaseId, index, detail } = preparedCase;+ if (execution.error) {+ detail.error = execution.error;+ detail.errorDetails = execution.errorDetails;+ detail.diagnostics.push(execution.error);+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: execution.error });+ return;+ }+ const result = execution.result;+ if (!result || !execution.simulatorStats || execution.deterministic === undefined || execution.sampleCount === undefined) {+ const reason = "Worker returned incomplete testcase execution result";+ detail.error = reason;+ detail.errorDetails = { type: "IncompleteExecutionResult" };+ detail.diagnostics.push(reason);+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason });+ return;+ }+ const stats = execution.simulatorStats;+ const gameResult = (entry as { game_report_result?: unknown }).game_report_result;+ const calibration = readCalibrationCase(comparison, reportFile, testcaseId, { index });+ const initialTroops = totalInputTroops(preparedCase.input!.attacker) + totalInputTroops(preparedCase.input!.defender);+ const simulatorDistribution = { n: stats.n, mu: stats.mu, sigma: stats.sigma };+ const gameDistribution = distributionFromGameResult(gameResult);+ let game = gameDistribution+ ? compareOutcomeDistribution({+ candidate: simulatorDistribution,+ reference: gameDistribution,+ initialTroops,+ deterministic: result.randomness.deterministic,+ thresholds: comparison.thresholds+ })+ : null;+ const gameStatAdjustment = game && preparedCase.input+ ? findGameStatAdjustment({+ game,+ input: preparedCase.input,+ config,+ job: { file: preparedCase.file, reportFile, testcaseId, index, input: preparedCase.input, repeat: execution.sampleCount, seed: undefined },+ reference: gameDistribution!,+ initialTroops,+ deterministic: result.randomness.deterministic,+ thresholds: comparison.thresholds+ })+ : undefined;+ if (gameStatAdjustment) game = gameStatAdjustment.adjusted;+ const baseline = calibration?.nSim !== undefined && calibration.muSim !== undefined && calibration.sigmaSim !== undefined+ ? compareOutcomeDistribution({+ candidate: simulatorDistribution,+ reference: { n: calibration.nSim, mu: calibration.muSim, sigma: calibration.sigmaSim },+ initialTroops,+ deterministic: result.randomness.deterministic,+ thresholds: comparison.thresholds+ })+ : null;++ detail.result = result;+ detail.deterministic = execution.deterministic;+ detail.sampleCount = execution.sampleCount;+ detail.simulatorStats = stats;+ detail.simulatorScoreDelta = execution.simulatorScoreDelta;+ detail.simulatorSampleOutcomes = execution.simulatorSampleOutcomes;+ detail.simulatorSampleDeltas = execution.simulatorSampleDeltas;+ detail.gameStatAdjustment = gameStatAdjustment ? statAdjustmentForReport(gameStatAdjustment) : undefined;+ detail.gameResult = gameResult;+ detail.calibration = calibration;+ detail.visibility = visibilityFromResult(result);+ detail.diagnostics.push(...execution.diagnostics);+ if (!game) {+ report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "game_comparison", reason: "Missing game_report_result" });+ }+ if (!baseline) {+ report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "baseline_comparison", reason: "No matching baseline snapshot row" });+ }+ report.counts.executed += 1;+ report.testcases[preparedCase.key!] = {+ file: reportFile,+ testcase_id: testcaseId,+ idx: index,+ deterministic: execution.deterministic,+ sampleCount: execution.sampleCount,+ game,+ baseline,+ ...(gameStatAdjustment ? { gameStatAdjustment: statAdjustmentForReport(gameStatAdjustment) } : {})+ };+}++interface InternalStatAdjustment {+ value: number;+ mode: TestcaseStatAdjustment["mode"];+ unadjusted: ParityComparisonMetrics;+ adjusted: ParityComparisonMetrics;+}++function findGameStatAdjustment(options: {+ game: ParityComparisonMetrics;+ input: BattleInput;+ config: SimulatorConfig;+ job: TestcaseExecutionJob;+ reference: { n: number; mu: number; sigma: number };+ initialTroops: number;+ deterministic: boolean;+ thresholds?: Record<string, number>;+}): InternalStatAdjustment | undefined {+ const shouldCorrect = options.deterministic ? options.game.bias_raw !== 0 : !options.game.passes;+ if (!shouldCorrect || options.game.bias_raw === 0) return undefined;+ const direction = -Math.sign(options.game.bias_raw);+ const candidates = statAdjustmentCandidates(direction);+ let best: InternalStatAdjustment | undefined;+ for (const value of candidates) {+ const adjustedInput = inputWithStatAdjustment(options.input, value);+ const candidateStats = simulateAdjustedDistribution(adjustedInput, options.job, options.config);+ const adjusted = compareOutcomeDistribution({+ candidate: { n: candidateStats.n, mu: candidateStats.mu, sigma: candidateStats.sigma },+ reference: options.reference,+ initialTroops: options.initialTroops,+ deterministic: options.deterministic,+ thresholds: options.thresholds+ });+ const candidate = {+ value,+ mode: adjustmentMode(options.game, adjusted, options.deterministic),+ unadjusted: options.game,+ adjusted: adjustedForRoundingRules(adjusted, options.deterministic)+ };+ if (!best || correctionScore(candidate.adjusted, options.deterministic) < correctionScore(best.adjusted, options.deterministic)) best = candidate;+ if (options.deterministic && candidate.mode === "deterministic_exact") return candidate;+ }+ if (best && !options.deterministic && best.adjusted.passes) return { ...best, mode: "stochastic_tolerance" };+ return best;+}++function statAdjustmentCandidates(direction: number): number[] {+ const values: number[] = [];+ for (let step = STAT_ROUNDING_SCAN_STEPS; step >= 1; step -= 1) {+ values.push(roundStatAdjustment(direction * (STAT_ROUNDING_MAX_ADJUSTMENT * step) / STAT_ROUNDING_SCAN_STEPS));+ }+ return values;+}++function simulateAdjustedDistribution(input: BattleInput, job: TestcaseExecutionJob, config: SimulatorConfig): SampleStats {+ const samples: number[] = [];+ for (let iteration = 0; iteration < job.repeat; iteration += 1) {+ const result = simulateBattle(sampleInput(input, job.seed, job.file, job.testcaseId, job.index, iteration), config, { detail: "fast" });+ const score = battleScoreDelta(result);+ if (score !== undefined) samples.push(score);+ }+ return sampleStats(samples);+}++function inputWithStatAdjustment(input: BattleInput, value: number): BattleInput {+ const adjusted = structuredClone(input);+ adjustFighterStats(adjusted.attacker, value);+ adjustFighterStats(adjusted.defender, -value);+ return adjusted;+}++function adjustFighterStats(fighter: FighterInput, value: number): void {+ for (const stats of Object.values(fighter.stats ?? {}) as Array<Partial<StatBlock>>) {+ for (const key of ["attack", "defense", "lethality", "health"] as Array<keyof StatBlock>) {+ if (stats[key] === undefined) continue;+ stats[key] = roundStatAdjustment(Number(stats[key]) + value);+ }+ }+}++function adjustmentMode(original: ParityComparisonMetrics, adjusted: ParityComparisonMetrics, deterministic: boolean): TestcaseStatAdjustment["mode"] {+ if (deterministic) {+ if (adjusted.bias_raw === 0) return "deterministic_exact";+ if (Math.abs(adjusted.bias_raw) <= 1) return "deterministic_within_one";+ return "best_effort";+ }+ return adjusted.passes ? "stochastic_tolerance" : "best_effort";+}++function adjustedForRoundingRules(metric: ParityComparisonMetrics, deterministic: boolean): ParityComparisonMetrics {+ if (!deterministic) return metric;+ return { ...metric, passes: Math.abs(metric.bias_raw) <= 1 };+}++function correctionScore(metric: ParityComparisonMetrics, deterministic: boolean): number {+ if (deterministic) return Math.abs(metric.bias_raw);+ if (metric.stat !== null && Number.isFinite(metric.stat)) return Math.abs(metric.stat);+ return Math.abs(metric.bias_raw);+}++function statAdjustmentForReport(adjustment: InternalStatAdjustment): TestcaseStatAdjustment {+ return {+ value: adjustment.value,+ mode: adjustment.mode,+ unadjusted: adjustment.unadjusted+ };+}++function roundStatAdjustment(value: number): number {+ return Number(value.toFixed(3));+}++function finalizeReport(report: TestcaseRunReport): void {+ applyComparisonQValues(report);+ report.counts.warnings = report.warnings.length;+ report.counts.errors = report.errors.length;+ report.counts.comparedToGame = Object.values(report.testcases).filter((entry) => entry.game).length;+ report.counts.comparedToBaseline = Object.values(report.testcases).filter((entry) => entry.baseline).length;+}++function normalizeRepeat(repeat: number | undefined): number {+ return Math.max(1, repeat ?? DEFAULT_STOCHASTIC_REPEAT);+}++export function executeTestcaseCase(job: TestcaseExecutionJob, config: SimulatorConfig): TestcaseExecutionResult {+ try {+ 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);+ const firstScore = battleScoreDelta(result);+ if (firstScore !== undefined) {+ samples.push(firstScore);+ sampleOutcomes.push(sampleOutcome(1, result, firstScore));+ sampleDeltas.push(firstScore);+ }+ 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);+ const score = battleScoreDelta(result);+ if (score !== undefined) {+ samples.push(score);+ if (sampleDeltas.length < 10) {+ sampleOutcomes.push(sampleOutcome(iteration + 1, result, score));+ sampleDeltas.push(score);+ }+ }+ }+ return {+ testcaseId: job.testcaseId,+ index: job.index,+ result,+ deterministic: result.randomness.deterministic,+ sampleCount,+ simulatorStats: sampleStats(samples),+ simulatorScoreDelta: battleScoreDelta(result),+ simulatorSampleOutcomes: sampleOutcomes,+ simulatorSampleDeltas: sampleDeltas,+ diagnostics: [...result.resolved.attacker.diagnostics, ...result.resolved.defender.diagnostics]+ };+ } catch (error) {+ return {+ testcaseId: job.testcaseId,+ index: job.index,+ diagnostics: [errorMessage(error)],+ error: errorMessage(error),+ errorDetails: errorDetails(error)+ };+ }+}++export function applyComparisonQValues(report: Pick<TestcaseRunReport, "testcases">): void {+ applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.game).filter((value): value is ParityComparisonMetrics => !!value));+ applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.baseline).filter((value): value is ParityComparisonMetrics => !!value));+}++export function assignDetailArtifactPaths(report: TestcaseRunReport, artifactRoot: string): void {+ report.artifactRoot = artifactRoot;+ report.details.forEach((detail, index) => {+ const detailArtifact = `${artifactRoot}/cases/${String(index + 1).padStart(6, "0")}.json`;+ detail.detailArtifact = detailArtifact;+ const testcase = report.testcases[snapshotKey(detail.file, detail.index)];+ if (testcase) testcase.detailArtifact = detailArtifact;+ for (const issue of [...report.warnings, ...report.errors]) {+ if (issue.file === detail.file && issue.idx === detail.index && issue.testcase_id === detail.testcaseId) {+ issue.detailArtifact = detailArtifact;+ }+ }+ });+}++export function buildSummaryForOutput(report: TestcaseRunReport): TestcaseSummaryOutput {+ const { details: _details, ...summary } = report;+ return summary;+}++export function adaptTestcaseEntry(entry: unknown, options: { seed?: string | number; trace?: boolean } = {}, diagnostics: string[] = []): BattleInput {+ const object = entry as {+ attacker?: FighterInput;+ defender?: FighterInput;+ test_id?: string;+ mechanics?: Record<string, unknown>;+ engagement_type?: unknown;+ engagementType?: unknown;+ maxRounds?: unknown;+ max_rounds?: unknown;+ };+ if (!object.attacker || !object.defender) throw new Error(`Testcase ${object.test_id ?? "(unknown)"} is missing attacker or defender`);+ diagnostics.push(...diagnoseFighterShape("attacker", object.attacker), ...diagnoseFighterShape("defender", object.defender));+ const mechanics = testcaseMechanics(object);+ const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);+ return {+ attacker: object.attacker,+ defender: object.defender,+ seed: options.seed,+ trace: options.trace,+ ...(maxRounds !== undefined ? { maxRounds } : {}),+ ...(mechanics ? { mechanics } : {})+ };+}++export function battleScoreDelta(value: unknown): number | undefined {+ if (isBattleResult(value)) return totalSide(value.remaining.attacker) - totalSide(value.remaining.defender);+ const gameResult = Array.isArray(value) ? value[0] : value;+ if (!gameResult || typeof gameResult !== "object") return undefined;+ const attacker = Number((gameResult as { attacker?: unknown }).attacker);+ const defender = Number((gameResult as { defender?: unknown }).defender);+ if (!Number.isFinite(attacker) || !Number.isFinite(defender)) return undefined;+ return attacker - defender;+}++function sampleOutcome(run: number, result: BattleResult, scoreDelta: number): TestcaseSampleOutcome {+ return {+ run,+ attackerHeroes: result.resolved.attacker.heroes.map((hero) => hero.name),+ defenderHeroes: result.resolved.defender.heroes.map((hero) => hero.name),+ attackerTroops: result.resolved.attacker.troops,+ defenderTroops: result.resolved.defender.troops,+ attackerRemainingByType: result.remaining.attacker,+ defenderRemainingByType: result.remaining.defender,+ attackerRemaining: totalSide(result.remaining.attacker),+ defenderRemaining: totalSide(result.remaining.defender),+ scoreDelta+ };+}++function isDiscoverableTestcaseFile(file: string, includeDisabled?: boolean): boolean {+ if (file.endsWith(".json")) return true;+ return !!includeDisabled && (file.endsWith(".json.disabled") || file.endsWith(".json.stale_troops"));+}++function walk(path: string, files: string[]): void {+ const stat = statSync(path);+ if (stat.isDirectory()) {+ for (const child of readdirSync(path)) walk(resolve(path, child), files);+ } else if (stat.isFile()) {+ files.push(path);+ }+}++function emptyCaseReport(file: string, testcaseId: string, index: number, diagnostics: string[]): TestcaseCaseReport {+ return {+ file,+ testcaseId,+ index,+ diagnostics,+ visibility: {+ attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },+ defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }+ }+ };+}++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);+}++function diagnoseFighterShape(side: string, fighter: FighterInput): string[] {+ const diagnostics: string[] = [];+ if (!fighter.troops || Object.keys(fighter.troops).length === 0) diagnostics.push(`${side} has no troops`);+ if (!fighter.stats) diagnostics.push(`${side} has no stats block`);+ return diagnostics;+}++function testcaseMechanics(entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown }): Record<string, unknown> | undefined {+ const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};+ if (entry.engagement_type !== undefined) mechanics.engagement_type = entry.engagement_type;+ if (entry.engagementType !== undefined) mechanics.engagementType = entry.engagementType;+ return Object.keys(mechanics).length > 0 ? mechanics : undefined;+}++function visibilityFromResult(result: BattleResult | undefined): TestcaseCaseReport["visibility"] {+ if (!result) {+ return {+ attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },+ defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }+ };+ }+ return {+ attacker: {+ heroes: result.resolved.attacker.heroes.map((hero) => hero.name),+ troopSkillIds: result.resolved.attacker.troopSkillIds,+ troops: result.resolved.attacker.troops,+ skillEffectActivations: result.effectActivationCounts.attacker+ },+ defender: {+ heroes: result.resolved.defender.heroes.map((hero) => hero.name),+ troopSkillIds: result.resolved.defender.troopSkillIds,+ troops: result.resolved.defender.troops,+ skillEffectActivations: result.effectActivationCounts.defender+ }+ };+}++function errorMessage(error: unknown): string {+ return error instanceof Error ? error.message : String(error);+}++function errorDetails(error: unknown): TestcaseErrorDetails | undefined {+ if (error instanceof DamageAggregationError) {+ return {+ type: error.name,+ groupId: error.groupId,+ round: error.round,+ jobId: error.jobId,+ netPct: error.netPct,+ factor: error.factor,+ contributors: error.contributors+ };+ }+ if (error instanceof Error) return { type: error.name };+ return undefined;+}++function optionalNumber(value: unknown): number | undefined {+ if (value === undefined) return undefined;+ const number = Number(value);+ return Number.isFinite(number) ? number : undefined;+}++function isBattleResult(value: unknown): value is BattleResult {+ return !!value && typeof value === "object" && "remaining" in value;+}++function totalSide(troops: Record<UnitType, number>): number {+ return (troops.infantry ?? 0) + (troops.lancer ?? 0) + (troops.marksman ?? 0);+}++function sampleSeed(baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): string {+ return `${baseSeed ?? "simulator-default"}:${relative(process.cwd(), file)}:${testcaseId}:${index}:${iteration}`;+}++function sampleInput(input: BattleInput, baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): BattleInput {+ return { ...input, seed: sampleSeed(baseSeed ?? input.seed, file, testcaseId, index, iteration) };+}++function snapshotKey(filePath: string, index: number): string {+ return `${normalizeReportPath(filePath)}#${index}`;+}++function normalizeReportPath(filePath: string): string {+ const normalized = filePath.replaceAll("\\", "/");+ const testcaseIndex = normalized.indexOf("testcases/");+ return testcaseIndex >= 0 ? normalized.slice(testcaseIndex) : normalized;+}++function distributionFromGameResult(value: unknown): { n: number; mu: number; sigma: number } | undefined {+ const outcomes = extractOutcomeScores(value);+ if (outcomes.length === 0) return undefined;+ const stats = sampleStats(outcomes);+ return { n: stats.n, mu: stats.mu, sigma: stats.sigma };+}++function extractOutcomeScores(value: unknown): number[] {+ const rows = Array.isArray(value) ? value : value ? [value] : [];+ return rows+ .map((row) => battleScoreDelta(row))+ .filter((score): score is number => score !== undefined);+}++function totalInputTroops(fighter: FighterInput): number {+ return Object.values(fighter.troops ?? {}).reduce((sum, count) => sum + Number(count || 0), 0);+}Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -0,0 +1,403 @@+export type SideId = "attacker" | "defender";+export type UnitType = "infantry" | "lancer" | "marksman";+export type DamageKind = "normal" | "skill";+export type UnitMask = number;+export type ActiveEffectKind = "modifier" | "extra_attack" | "control" | "battle_order";+export type SameEffectStacking = "add" | "max";++export const UNIT_TYPES: UnitType[] = ["infantry", "lancer", "marksman"];+export const UNIT_BITS: Record<UnitType, UnitMask> = {+ infantry: 1 << 0,+ lancer: 1 << 1,+ marksman: 1 << 2+};+export const ALL_UNIT_MASK: UnitMask = UNIT_TYPES.reduce((mask, unit) => mask | UNIT_BITS[unit], 0);++export interface ResolvedUnitScope {+ side: SideId;+ units: UnitMask;+}++export type SupportedTriggerDamageJobSelector =+ | "use.source"+ | "use.target"+ | "effect.applies_to"+ | "effect.applies_vs"+ | "enemy.living"+ | "self.living";+export type TriggerDamageJobSelector = SupportedTriggerDamageJobSelector | UnitType | UnitType[];+export type TriggerDamageJobMultiplier = number;++export interface TriggerDamageJobDefinition {+ source: TriggerDamageJobSelector;+ target: TriggerDamageJobSelector;+ multiplier?: TriggerDamageJobMultiplier;+}++export function unitMask(units: UnitType | UnitType[]): UnitMask {+ const list = Array.isArray(units) ? units : [units];+ return list.reduce((mask, unit) => mask | UNIT_BITS[unit], 0);+}++export function unitMaskHas(mask: UnitMask, unit: UnitType): boolean {+ return (mask & UNIT_BITS[unit]) !== 0;+}++export function unitsFromMask(mask: UnitMask): UnitType[] {+ return UNIT_TYPES.filter((unit) => unitMaskHas(mask, unit));+}++export interface StatBlock {+ attack: number;+ defense: number;+ lethality: number;+ health: number;+}++export type MainStat = keyof StatBlock;++export interface PassiveEffectBucket {+ up?: number;+ down?: number;+}++export type PassiveEffects = Partial<Record<MainStat, PassiveEffectBucket>>;++export interface TroopStatsRecord {+ id: string;+ type: UnitType | string;+ tier: number;+ fc?: number;+ stats: Record<string, number>;+}++export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;+export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;++export interface EffectDuration {+ type: "battle" | "round" | "attack";+ value: number;+ delay?: number;+}++export interface EffectIntentDefinition {+ id: string;+ type: string;+ value?: unknown;+ value_evolution?: { type?: string; step?: string; value?: number };+ units?: Record<string, unknown>;+ trigger_damage_jobs?: TriggerDamageJobDefinition[];+ duration?: { type?: string; value?: number; delay?: number };+ same_effect_stacking?: SameEffectStacking;+ reason?: string;+}++export interface TriggerDefinition {+ type: string;+ probability?: unknown;+ every?: number;+ source?: unknown;+ target?: unknown;+}++export interface SkillDefinition {+ id: string;+ name: string;+ description?: string;+ troop_type?: UnitType | string;+ requirements?: SkillRequirement[];+ trigger: TriggerDefinition;+ effects: Record<string, Omit<EffectIntentDefinition, "id">>;+}++export interface SkillRequirement {+ level: number;+ type: "tier" | "fc" | "engagement_type" | string;+ value: number | string;+}++export interface SkillFile {+ name: string;+ hero_generation?: string;+ troop_type?: UnitType | string;+ skills: Record<string, Omit<SkillDefinition, "id" | "name">>;+}++export interface ConfigDiagnostics {+ legacyFields: Array<{ file: string; path: string; field: string }>;+ effectTypes: Record<string, number>;+ unsupportedEffects: Array<{ file: string; skillId: string; effectId: string; type: string; reason: string }>;+ ambiguousTurnTriggerSelectors: Array<{ file: string; skillId: string; effectId: string; selector: string; reason: string }>;+}++export interface SimulatorConfig {+ troopStats: TroopStatsCatalogue;+ heroGenerationStats: HeroGenerationStatsCatalogue;+ heroDefinitions: Record<string, SkillFile>;+ heroAliasIndex?: Record<string, string>;+ troopSkills: SkillFile;+ diagnostics: ConfigDiagnostics;+}++export type HeroSkillLevels = Record<string, number>;++export interface HeroInputEntry {+ name: string;+ levels?: HeroSkillLevels;+}++export type HeroInputCollection = Record<string, HeroSkillLevels> | HeroInputEntry[];++export interface FighterInput {+ name?: string;+ troops: Record<string, number>;+ stats?: Record<string, Partial<StatBlock>>;+ passive?: PassiveEffects;+ heroes?: HeroInputCollection;+ joiner_heroes?: HeroInputCollection;+}++export interface BattleInput {+ attacker: FighterInput;+ defender: FighterInput;+ seed?: string | number;+ maxRounds?: number;+ trace?: boolean;+ mechanics?: Record<string, unknown>;+}++export type SimulationDetail = "full" | "fast";++export interface SimulationOptions {+ detail?: SimulationDetail;+}++export interface ResolvedTroopLine {+ id: string;+ type: UnitType;+ tier: number;+ fc: number;+ count: number;+ stats: StatBlock;+}++export interface ResolvedSkill {+ id: string;+ name: string;+ sourceKind: "hero_skill" | "troop_skill";+ side: SideId;+ heroName?: string;+ heroInstanceId?: string;+ heroRole?: "main" | "joiner";+ troopType?: UnitType;+ level: number;+ trigger: TriggerDefinition;+ effects: EffectIntentDefinition[];+}++export interface ResolvedHero {+ name: string;+ heroGeneration?: string;+ generationStats: StatBlock;+ skillIds: string[];+ instanceId?: string;+ role?: "main" | "joiner";+ missing?: boolean;+}++export interface ResolvedFighter {+ side: SideId;+ name: string;+ troops: Record<UnitType, number>;+ initialTroops: Record<UnitType, number>;+ troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>;+ statBonuses: Record<UnitType, StatBlock>;+ heroes: ResolvedHero[];+ troopSkills: ResolvedSkill[];+ heroSkills?: ResolvedSkill[];+ diagnostics: string[];+}++export interface EffectSource {+ kind: "hero_skill" | "troop_skill" | "input_stat" | "unknown";+ side: SideId;+ heroName?: string;+ heroInstanceId?: string;+ troopType?: UnitType;+ skillId?: string;+ skillName?: string;+ effectId?: string;+}++export interface ActiveEffect {+ id: string;+ source: EffectSource;+ intent: EffectIntentDefinition;+ ownerSide: SideId;+ kind: ActiveEffectKind;+ valuePct?: number;+ // Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",+ // trigger-relative selectors, or concrete unit selectors; it does not accept "all".+ appliesTo: ResolvedUnitScope;+ appliesVs: ResolvedUnitScope;+ triggerDamageJobs?: TriggerDamageJobDefinition[];+ createdRound: number;+ startRound: number;+ duration: EffectDuration;+ uses: number;+ stackingKey?: string;+ sameEffectStacking: SameEffectStacking;+}++export interface AttackIntent {+ id: string;+ round: number;+ source: "normal";+ attackerSide: SideId;+ attackerUnit: UnitType;+ defenderSide: SideId;+ defenderUnit: UnitType;+ orderIndex: number;+ previousAttackCount: number;+ projectedAttackCount: number;+ previousReceivedAttackCount: number;+ projectedReceivedAttackCount: number;+}++export interface DamageJob {+ id: string;+ round: number;+ kind: DamageKind;+ sourceIntentId: string;+ roundStartTroops: Record<SideId, Record<UnitType, number>>;+ attackerSide: SideId;+ attackerUnit: UnitType;+ defenderSide: SideId;+ defenderUnit: UnitType;+ sourceEffectId?: string;+ sourceSkillReportKey?: string;+ sourceMultiplier?: number;+ consumedEffectIds?: string[];+ consumedEffectUseKey?: string;+ consumedEffectUseId?: string;+ consumedEffectUseIds?: string[];+}++export interface CounterDelta {+ side: SideId;+ unit: UnitType;+ counter: "attacks" | "received_attacks";+ by: number;+ cause: "normal_attack" | "extra_skill_attack";+}++export interface DamageBucketTrace {+ totalPct?: number;+ factor: number;+ raw?: number;+ contributors: Array<{ effectId: string; source: string; sourceSide?: SideId; valuePct: number; bucket: string; stackingKey?: string; sameEffectStacking?: SameEffectStacking }>;+}++export interface DamageAggregationGroupTrace {+ id: string;+ mode: string;+ placement: "numerator" | "denominator";+ inputBuckets: string[];+ totalPct?: number;+ factor: number;+ contributors: DamageBucketTrace["contributors"];+}++export interface DamageEquationTrace {+ roundStartTroops: Record<SideId, Record<UnitType, number>>;+ armyTerm: number;+ atomicBuckets: Record<string, DamageBucketTrace>;+ aggregationGroups: Record<string, DamageAggregationGroupTrace>;+ appliedEffects: AppliedEffectTrace[];+ rejectedEffects: Array<{ effectId: string; reason: string }>;+ rawDamage: number;+ finalKills: number;+}++export interface AppliedEffectTrace {+ effectId: string;+ bucket: string;+ valuePct: number;+ source: string;+ sourceSide?: SideId;+ stackingKey?: string;+ sameEffectStacking?: SameEffectStacking;+}++export interface AttackOutcome {+ jobId: string;+ kind: DamageKind;+ sourceEffectId?: string;+ sourceSkillReportKey?: string;+ attackerSide: SideId;+ attackerUnit: UnitType;+ defenderSide: SideId;+ defenderUnit: UnitType;+ kills: number;+ counterDeltas: CounterDelta[];+ appliedEffectIds: string[];+ appliedEffects: AppliedEffectTrace[];+ consumedEffectIds: string[];+ consumedEffectUseKey?: string;+ consumedEffectUseId?: string;+ consumedEffectUseIds?: string[];+ cancelledBy?: string;+ cancelReason?: "dodge" | "no_attack";+ trace?: DamageEquationTrace;+}++export interface SkillReportEntry {+ sourceKind: "hero_skill" | "troop_skill";+ heroName?: string;+ troopType?: UnitType;+ skillId: string;+ skillName: string;+ level: number;+ triggersSeen: number;+ skillActivations: number;+ effectActivations: number;+ skillKills: number;+ unsupportedEffects: string[];+}++export interface BattleResult {+ winner: SideId | "draw";+ rounds: number;+ remaining: Record<SideId, Record<UnitType, number>>;+ attacks: AttackOutcome[];+ skillReport: Record<SideId, SkillReportEntry[]>;+ resolved: {+ attacker: {+ troops: Record<UnitType, number>;+ heroes: ResolvedHero[];+ troopSkillIds: string[];+ diagnostics: string[];+ };+ defender: {+ troops: Record<UnitType, number>;+ heroes: ResolvedHero[];+ troopSkillIds: string[];+ diagnostics: string[];+ };+ };+ effectActivationCounts: Record<SideId, number>;+ extraSkillAttackJobsByEffect: Record<string, number>;+ attackControlCounts: { dodge: number; no_attack: number };+ randomness: BattleRandomness;+ trace?: BattleTrace;+}++export interface BattleRandomness {+ deterministic: boolean;+ chanceSkillIds: Record<SideId, string[]>;+}++export interface BattleTrace {+ resolved: BattleResult["resolved"];+ rounds: Array<{ round: number; roundStartTroops: Record<SideId, Record<UnitType, number>>; intents: AttackIntent[]; jobs: DamageJob[] }>;+}Index: simulator/tsconfig.json===================================================================--- simulator/tsconfig.json prev run+++ simulator/tsconfig.json this run@@ -0,0 +1,15 @@+{+ "compilerOptions": {+ "target": "ES2022",+ "module": "ESNext",+ "moduleResolution": "Bundler",+ "strict": true,+ "esModuleInterop": true,+ "resolveJsonModule": true,+ "forceConsistentCasingInFileNames": true,+ "skipLibCheck": true,+ "rootDir": "src",+ "outDir": "dist"+ },+ "include": ["src/**/*.ts"]+}Index: testcases/emulator_verified/reina_logan_combo_simulator.json===================================================================--- testcases/emulator_verified/reina_logan_combo_simulator.json prev run+++ testcases/emulator_verified/reina_logan_combo_simulator.json this run@@ -0,0 +1,83 @@+[+ {+ "test_id": "reina_logan_combo_simulator",+ "description": "Reina+Logan combo simulator with conservative WIP troop counts. WIP attacking no heroes, minxxx defending with Reina(2/2)+Logan(3/3/3).",+ "attacker": {+ "name": "[BBQ]XxWIPxX",+ "heroes": {},+ "troops": {+ "infantry_t6": 600,+ "lancer_t6": 600,+ "marksman_t6": 600+ },+ "stats": {+ "inf": {+ "attack": 66.1,+ "defense": 63.9,+ "lethality": 23.3,+ "health": 26.4+ },+ "lanc": {+ "attack": 68.6,+ "defense": 65.1,+ "lethality": 26.3,+ "health": 21.1+ },+ "mark": {+ "attack": 69.1,+ "defense": 61.4,+ "lethality": 25.8,+ "health": 21.5+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[ARK]Piddlyminxxx",+ "heroes": {+ "Logan": {+ "skill_1": 3,+ "skill_2": 3,+ "skill_3": 3+ },+ "Reina": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 0+ }+ },+ "troops": {+ "infantry_t6": 200,+ "lancer_t6": 200,+ "marksman_t6": 200+ },+ "stats": {+ "inf": {+ "attack": 306.9,+ "defense": 288.5,+ "lethality": 118.0,+ "health": 131.1+ },+ "lanc": {+ "attack": 265.0,+ "defense": 257.6,+ "lethality": 120.2,+ "health": 121.0+ },+ "mark": {+ "attack": 205.7,+ "defense": 197.3,+ "lethality": 122.9,+ "health": 123.9+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 553+ }+ ]+ }+]Index: testcases/emulator_verified/reina_logan_combo_v3.json===================================================================--- testcases/emulator_verified/reina_logan_combo_v3.json prev run+++ testcases/emulator_verified/reina_logan_combo_v3.json this run@@ -1,83 +0,0 @@-[- {- "test_id": "reina_logan_combo_v3",- "description": "Reina+Logan combo v3 with conservative WIP troop counts. WIP attacking no heroes, minxxx defending with Reina(2/2)+Logan(3/3/3).",- "attacker": {- "name": "[BBQ]XxWIPxX",- "heroes": {},- "troops": {- "infantry_t6": 600,- "lancer_t6": 600,- "marksman_t6": 600- },- "stats": {- "inf": {- "attack": 66.1,- "defense": 63.9,- "lethality": 23.3,- "health": 26.4- },- "lanc": {- "attack": 68.6,- "defense": 65.1,- "lethality": 26.3,- "health": 21.1- },- "mark": {- "attack": 69.1,- "defense": 61.4,- "lethality": 25.8,- "health": 21.5- }- },- "joiner_heroes": {}- },- "defender": {- "name": "[ARK]Piddlyminxxx",- "heroes": {- "Logan": {- "skill_1": 3,- "skill_2": 3,- "skill_3": 3- },- "Reina": {- "skill_1": 2,- "skill_2": 2,- "skill_3": 0- }- },- "troops": {- "infantry_t6": 200,- "lancer_t6": 200,- "marksman_t6": 200- },- "stats": {- "inf": {- "attack": 306.9,- "defense": 288.5,- "lethality": 118.0,- "health": 131.1- },- "lanc": {- "attack": 265.0,- "defense": 257.6,- "lethality": 120.2,- "health": 121.0- },- "mark": {- "attack": 205.7,- "defense": 197.3,- "lethality": 122.9,- "health": 123.9- }- },- "joiner_heroes": {}- },- "game_report_result": [- {- "attacker": 0,- "defender": 553- }- ]- }-]\ No newline at end of fileIndex: testcases/emulator_verified/wos425_wip_nohero_control_nc.json===================================================================--- testcases/emulator_verified/wos425_wip_nohero_control_nc.json prev run+++ testcases/emulator_verified/wos425_wip_nohero_control_nc.json this run@@ -0,0 +1,68 @@+[+ {+ "test_id": "wos425_wip_nohero_control_nc",+ "description": "WOS-425 control. minxxx attacks WIP, no heroes, Heroic troops only. Deterministic baseline for damage-taken-down stacking checks.",+ "attacker": {+ "name": "[ARK]Piddlyminxx",+ "heroes": {},+ "troops": {+ "infantry_t6": 800+ },+ "stats": {+ "inf": {+ "attack": 260.9,+ "defense": 256.0,+ "lethality": 198.9,+ "health": 204.3+ },+ "lanc": {+ "attack": 261.9,+ "defense": 253.0,+ "lethality": 183.5,+ "health": 181.0+ },+ "mark": {+ "attack": 269.4,+ "defense": 264.5,+ "lethality": 217.2,+ "health": 208.0+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[BBQ]XxWPxX",+ "heroes": {},+ "troops": {+ "infantry_t6": 200+ },+ "stats": {+ "inf": {+ "attack": 176.7,+ "defense": 178.0,+ "lethality": 137.2,+ "health": 149.6+ },+ "lanc": {+ "attack": 161.2,+ "defense": 162.0,+ "lethality": 136.2,+ "health": 124.8+ },+ "mark": {+ "attack": 173.7,+ "defense": 169.5,+ "lethality": 142.5,+ "health": 139.4+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 774,+ "defender": 0+ }+ ]+ }+]\ No newline at end of fileIndex: testcases/emulator_verified/wos425_wip_patrick_health_nc.json===================================================================--- testcases/emulator_verified/wos425_wip_patrick_health_nc.json prev run+++ testcases/emulator_verified/wos425_wip_patrick_health_nc.json this run@@ -0,0 +1,73 @@+[+ {+ "test_id": "wos425_wip_patrick_health_nc",+ "description": "WOS-425 health-up fixture. minxxx attacks WIP; WIP defends with Patrick. Heroic troops only; deterministic current WIP kit is S1 health-up plus S2 attack-up.",+ "attacker": {+ "name": "[ARK]Piddlyminxx",+ "heroes": {},+ "troops": {+ "infantry_t6": 800+ },+ "stats": {+ "inf": {+ "attack": 260.9,+ "defense": 256.0,+ "lethality": 198.9,+ "health": 204.3+ },+ "lanc": {+ "attack": 261.9,+ "defense": 253.0,+ "lethality": 183.5,+ "health": 181.0+ },+ "mark": {+ "attack": 269.4,+ "defense": 264.5,+ "lethality": 217.2,+ "health": 208.0+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[BBQ]XxWPxX",+ "heroes": {+ "Patrick": {+ "skill_1": 4,+ "skill_2": 4+ }+ },+ "troops": {+ "infantry_t6": 200+ },+ "stats": {+ "inf": {+ "attack": 176.7,+ "defense": 178.0,+ "lethality": 137.2,+ "health": 149.6+ },+ "lanc": {+ "attack": 236.7,+ "defense": 237.5,+ "lethality": 136.2,+ "health": 124.8+ },+ "mark": {+ "attack": 173.7,+ "defense": 169.5,+ "lethality": 142.5,+ "health": 139.4+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 762,+ "defender": 0+ }+ ]+ }+]\ No newline at end of fileIndex: testcases/emulator_verified/wos425_wip_philly_defense.json===================================================================--- testcases/emulator_verified/wos425_wip_philly_defense.json prev run+++ testcases/emulator_verified/wos425_wip_philly_defense.json this run@@ -0,0 +1,90 @@+[+ {+ "test_id": "wos425_wip_philly_defense",+ "description": "WOS-425 defense-up fixture with relaxed RNG constraint. minxxx attacks WIP; WIP defends with Philly. Heroic infantry only. Current WIP Philly has deterministic S1 attack-up plus defense-up and active chance S2 damage-up, so collect repeated observations.",+ "attacker": {+ "name": "[ARK]Piddlyminxx",+ "heroes": {},+ "troops": {+ "infantry_t6": 800+ },+ "stats": {+ "inf": {+ "attack": 260.9,+ "defense": 256.0,+ "lethality": 198.9,+ "health": 204.3+ },+ "lanc": {+ "attack": 261.9,+ "defense": 253.0,+ "lethality": 183.5,+ "health": 181.0+ },+ "mark": {+ "attack": 269.4,+ "defense": 264.5,+ "lethality": 217.2,+ "health": 208.0+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[BBQ]XxWPxX",+ "heroes": {+ "Philly": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 0+ }+ },+ "troops": {+ "infantry_t6": 200+ },+ "stats": {+ "inf": {+ "attack": 176.7,+ "defense": 178.0,+ "lethality": 137.2,+ "health": 149.6+ },+ "lanc": {+ "attack": 230.4,+ "defense": 231.2,+ "lethality": 136.2,+ "health": 124.8+ },+ "mark": {+ "attack": 173.7,+ "defense": 169.5,+ "lethality": 142.5,+ "health": 139.4+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 769,+ "defender": 0+ },+ {+ "attacker": 769,+ "defender": 0+ },+ {+ "attacker": 769,+ "defender": 0+ },+ {+ "attacker": 768,+ "defender": 0+ },+ {+ "attacker": 768,+ "defender": 0+ }+ ]+ }+]\ No newline at end of fileIndex: testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.json===================================================================--- testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.json prev run+++ testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.json this run@@ -0,0 +1,73 @@+[+ {+ "test_id": "wos425_wip_sergey_damage_taken_nc",+ "description": "WOS-425 damage-taken-down fixture. minxxx attacks WIP; WIP defends with Sergey. Heroic infantry only. Sergey is infantry class and current WIP kit is deterministic.",+ "attacker": {+ "name": "[ARK]Piddlyminxx",+ "heroes": {},+ "troops": {+ "infantry_t6": 800+ },+ "stats": {+ "inf": {+ "attack": 260.9,+ "defense": 256.0,+ "lethality": 198.9,+ "health": 204.3+ },+ "lanc": {+ "attack": 261.9,+ "defense": 253.0,+ "lethality": 183.5,+ "health": 181.0+ },+ "mark": {+ "attack": 269.4,+ "defense": 264.5,+ "lethality": 217.2,+ "health": 208.0+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[BBQ]XxWPxX",+ "heroes": {+ "Sergey": {+ "skill_1": 2,+ "skill_2": 2+ }+ },+ "troops": {+ "infantry_t6": 200+ },+ "stats": {+ "inf": {+ "attack": 256.7,+ "defense": 258.0,+ "lethality": 137.2,+ "health": 149.6+ },+ "lanc": {+ "attack": 161.2,+ "defense": 162.0,+ "lethality": 136.2,+ "health": 124.8+ },+ "mark": {+ "attack": 173.7,+ "defense": 169.5,+ "lethality": 142.5,+ "health": 139.4+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 749,+ "defender": 0+ }+ ]+ }+]\ No newline at end of fileIndex: testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.json===================================================================--- testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.json prev run+++ testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.json this run@@ -0,0 +1,77 @@+[+ {+ "test_id": "wos425_wip_sergey_patrick_combo_nc",+ "description": "WOS-425 damage-taken-down plus health-up fixture. minxxx attacks WIP; WIP defends with Sergey+Patrick. Heroic infantry only. Sergey is infantry class and Patrick is lancer class; current WIP kits are deterministic.",+ "attacker": {+ "name": "[ARK]Piddlyminxx",+ "heroes": {},+ "troops": {+ "infantry_t6": 800+ },+ "stats": {+ "inf": {+ "attack": 260.9,+ "defense": 256.0,+ "lethality": 198.9,+ "health": 204.3+ },+ "lanc": {+ "attack": 261.9,+ "defense": 253.0,+ "lethality": 183.5,+ "health": 181.0+ },+ "mark": {+ "attack": 269.4,+ "defense": 264.5,+ "lethality": 217.2,+ "health": 208.0+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[BBQ]XxWPxX",+ "heroes": {+ "Sergey": {+ "skill_1": 2,+ "skill_2": 2+ },+ "Patrick": {+ "skill_1": 4,+ "skill_2": 4+ }+ },+ "troops": {+ "infantry_t6": 200+ },+ "stats": {+ "inf": {+ "attack": 256.7,+ "defense": 258.0,+ "lethality": 137.2,+ "health": 149.6+ },+ "lanc": {+ "attack": 236.7,+ "defense": 237.5,+ "lethality": 136.2,+ "health": 124.8+ },+ "mark": {+ "attack": 173.7,+ "defense": 169.5,+ "lethality": 142.5,+ "health": 139.4+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 726,+ "defender": 0+ }+ ]+ }+]\ No newline at end of fileIndex: testcases/emulator_verified/wos425_wip_sergey_philly_combo.json===================================================================--- testcases/emulator_verified/wos425_wip_sergey_philly_combo.json prev run+++ testcases/emulator_verified/wos425_wip_sergey_philly_combo.json this run@@ -0,0 +1,94 @@+[+ {+ "test_id": "wos425_wip_sergey_philly_combo",+ "description": "WOS-425 damage-taken-down plus defense-up fixture with relaxed RNG constraint. minxxx attacks WIP; WIP defends with Sergey+Philly. Heroic infantry only. Sergey is infantry class; Philly is lancer class and has active chance S2 damage-up, so collect repeated observations.",+ "attacker": {+ "name": "[ARK]Piddlyminxx",+ "heroes": {},+ "troops": {+ "infantry_t6": 800+ },+ "stats": {+ "inf": {+ "attack": 260.9,+ "defense": 256.0,+ "lethality": 198.9,+ "health": 204.3+ },+ "lanc": {+ "attack": 261.9,+ "defense": 253.0,+ "lethality": 183.5,+ "health": 181.0+ },+ "mark": {+ "attack": 269.4,+ "defense": 264.5,+ "lethality": 217.2,+ "health": 208.0+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[BBQ]XxWPxX",+ "heroes": {+ "Sergey": {+ "skill_1": 2,+ "skill_2": 2+ },+ "Philly": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 0+ }+ },+ "troops": {+ "infantry_t6": 200+ },+ "stats": {+ "inf": {+ "attack": 256.7,+ "defense": 258.0,+ "lethality": 137.2,+ "health": 149.6+ },+ "lanc": {+ "attack": 230.4,+ "defense": 231.2,+ "lethality": 136.2,+ "health": 124.8+ },+ "mark": {+ "attack": 173.7,+ "defense": 169.5,+ "lethality": 142.5,+ "health": 139.4+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 738,+ "defender": 0+ },+ {+ "attacker": 738,+ "defender": 0+ },+ {+ "attacker": 738,+ "defender": 0+ },+ {+ "attacker": 738,+ "defender": 0+ },+ {+ "attacker": 739,+ "defender": 0+ }+ ]+ }+]\ No newline at end of fileIndex: testcases/emulator_verified/wos427_wip_sergey_philly_840.json===================================================================--- testcases/emulator_verified/wos427_wip_sergey_philly_840.json prev run+++ testcases/emulator_verified/wos427_wip_sergey_philly_840.json this run@@ -0,0 +1,90 @@+[+ {+ "test_id": "wos427_wip_sergey_philly_840",+ "description": "WOS-427 high-signal damage-taken-down plus defense-up fixture. minxxx attacks WIP; WIP defends with Sergey+Philly and 840 heroic infantry to separate additive defense stacking from multiplicative defense stacking in simulator.",+ "attacker": {+ "name": "[ARK]Piddlyminxx",+ "heroes": {},+ "troops": {+ "infantry_t6": 800+ },+ "stats": {+ "inf": {+ "attack": 255.9,+ "defense": 256.0,+ "lethality": 198.9,+ "health": 204.3+ },+ "lanc": {+ "attack": 256.9,+ "defense": 253.0,+ "lethality": 183.5,+ "health": 181.0+ },+ "mark": {+ "attack": 264.4,+ "defense": 264.5,+ "lethality": 217.2,+ "health": 208.0+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[BBQ]XxWPxX",+ "heroes": {+ "Sergey": {+ "skill_1": 2,+ "skill_2": 2+ },+ "Philly": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 0+ }+ },+ "troops": {+ "infantry_t6": 840+ },+ "stats": {+ "inf": {+ "attack": 256.7,+ "defense": 258.0,+ "lethality": 137.2,+ "health": 149.6+ },+ "lanc": {+ "attack": 230.4,+ "defense": 231.2,+ "lethality": 136.2,+ "health": 124.8+ },+ "mark": {+ "attack": 173.7,+ "defense": 169.5,+ "lethality": 142.5,+ "health": 139.4+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 24,+ "defender": 0+ },+ {+ "attacker": 7,+ "defender": 0+ },+ {+ "attacker": 32,+ "defender": 0+ },+ {+ "attacker": 27,+ "defender": 0+ }+ ]+ }+]\ No newline at end of file
Show raw per-run patches
Run A dirty state patch
diff --git a/testcases/emulator_verified/gwen_solo_nc.json b/testcases/emulator_verified/gwen_solo_nc.json--- a/testcases/emulator_verified/gwen_solo_nc.json+++ b/testcases/emulator_verified/gwen_solo_nc.json@@ -1,7 +1,7 @@[{"test_id": "gwen_solo",- "description": "Gwen solo defender test. WIP attacking with no heroes, minxxx defending with Gwen. Gwen has per-attack triggers (S2: every 5 attacks, S3: every 4 attacks for marksmen). S2 and S3 have effect_op=101.",+ "description": "Gwen solo defender test. WIP attacking with no heroes, minxxx defending with Gwen.","attacker": {"name": "[BBQ]XxWIPxX","heroes": {},