← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error-0.00%
Changed77
Improved43
Regressed37
Added69
Retired1
Testcase Delta
268 / 268
Code / Config Changes
Commits (1f9d181a → e1679b69)
e1679b69remove unused troop avatar indexespiddlyminx03/08/2026, 18:14:16
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Gatot.json===================================================================--- simulator/config/hero_definitions/Gatot.json prev run+++ simulator/config/hero_definitions/Gatot.json this run@@ -43,9 +43,9 @@30],"value_formula": {"type": "percent_of",- "source": "trigger.total_kills"+ "source": "trigger.source_attack"},"units": {"applies_to": "trigger.source","applies_vs": "enemy.any"Index: simulator/config/hero_definitions/README.md===================================================================--- simulator/config/hero_definitions/README.md prev run+++ simulator/config/hero_definitions/README.md this run@@ -228,8 +228,9 @@| `value` | Usually a number or per-level numeric array | Percentage magnitude for modifiers; damage multiplier percentage for `extra_skill_attack`; fixed unit order for `attack_order`. || `units` | `{ applies_to?, applies_vs? }` | Resolves which troop lines may receive/use the effect and which opposing troop lines it applies against. || `duration` | `{ turns?, attacks? }` | Optional round window and/or use limit. Omitted means the effect is permanent. || `same_effect_stacking` | `add` or `max` | Controls overlap between live activations of the same modifier definition and scope. Omitted means `add`. |+| `requires_effect` | Effect ID string | Applies a runtime damage modifier only while the named effect is applicable to the same damage job. || `value_evolution` | Evolution object | Optionally changes the effect value as rounds or uses advance. || `trigger_damage_jobs` | Non-empty array of job definitions | Required for `extra_skill_attack`; describes the actual skill-damage jobs it emits. |### `value` and level selection@@ -252,8 +253,9 @@| --- | --- | --- || `passive.<stat>.<direction>` | `stat`: `attack`, `lethality`, `health`, `defense`; `direction`: `up`, `down` | Permanent deterministic battle-start stat modifiers folded into the static damage profile. || `active.hero.<property>.<direction>` | `property`: `attack`, `lethality`, `health`, `defense`, `damage`, `damageTaken`; `direction`: `up`, `down` | Runtime modifier in a hero-labelled bucket. All combinations are supported even if the current hero files use only a subset. || `active.troop.<property>.<direction>` | Same values as `active.hero` | Runtime modifier in a separate troop-labelled bucket. Supported by the engine but not currently used in hero definitions. A hero effect placed here is still reported as hero-sourced; only its equation bucket changes. |+| `active.hero.shield`, `active.troop.shield` | Non-negative raw value | Taker-side protection subtracted after the percentage damage equation. || `type.all.damage.<direction>` | `up`, `down` | Modifier applied to both normal and skill damage. || `type.normal.damage.<direction>` | `up`, `down` | Dealer-side modifier for normal attacks only. || `type.normal.damageTaken.<direction>` | `up`, `down` | Taker-side modifier for normal attacks only. || `type.skill.damage.<direction>` | `up`, `down` | Dealer-side modifier for generated skill attacks only. |@@ -275,8 +277,16 @@**Gotcha — `.down` is denominator-based, not subtractive.** A 20% `.down` contribution divides damage by `1.20`; it does not multiply damage by `0.80`. Likewise, reducing a taker's health or defense by 20% contributes a `1.20` numerator factor. This makes equal `.up` and `.down` magnitudes mathematical opposites in factor placement, but not ordinary `+20%`/`-20%` percentage arithmetic.Percentages in one ordinary bucket add: two live `active.hero.damage.up` contributions of 10 and 20 produce that bucket's `1 + 0.10 + 0.20 = 1.30` factor. Distinct buckets multiply in the final equation. `type.all.damage.up/down` is the exception: its contributions multiply as individual percentage factors rather than adding into one percentage total.+Shield buckets are raw rather than percentage factors. Their applicable values are summed and the result is subtracted after the ordinary numerator/denominator expression: `max(0, damageBeforeOffsets - offsetDamage)`.++### Conditional runtime modifiers++`requires_effect` names another effect in the same skill file. The dependent modifier contributes only when a live instance of the required effect is applicable to that exact damage-job shape. It is not enough for the required effect to be active elsewhere: an effect scoped to Marksman attacking Infantry does not enable the dependent modifier for a Lancer attacking that Infantry.++Both effects must be runtime damage modifiers, effect IDs must resolve uniquely, and dependency chains are rejected. Checking a dependency does not consume it; normal effect-use charging consumes the required effect after the completed damage job.+### Passive restrictions`passive.*` effects are static profile inputs and must satisfy all of these rules:Index: simulator/config/hero_definitions/Renee.json===================================================================--- simulator/config/hero_definitions/Renee.json prev run+++ simulator/config/hero_definitions/Renee.json this run@@ -11,19 +11,19 @@"source": "lancer"},"effects": {"NightmareTrace/1": {- "type": "extra_skill_attack",+ "type": "active.hero.health.down","value": [40,80,120,160,200],"units": {- "applies_to": "trigger.source",- "applies_vs": "trigger.target"+ "applies_to": "target",+ "applies_vs": "lancer"},"duration": {"turns": {"count": 1,@@ -32,14 +32,9 @@"attacks": {"count": 1}},- "trigger_damage_jobs": [- {- "source": "use.source",- "target": "use.target"- }- ]+ "same_effect_stacking": "max"}}},"Dreamcatcher": {@@ -50,32 +45,27 @@"source": "lancer"},"effects": {"Dreamcatcher/1": {- "type": "extra_skill_attack",+ "type": "active.hero.health.down","value": [30,60,90,120,150],"units": {- "applies_to": "trigger.source",- "applies_vs": "trigger.target"+ "applies_to": "target",+ "applies_vs": "lancer"},"duration": {"turns": {"count": 1,"delay": 1}},- "trigger_damage_jobs": [- {- "source": "use.source",- "target": "use.target"- }- ]+ "same_effect_stacking": "max"}}},"Dreamslice": {@@ -86,32 +76,27 @@"source": "lancer"},"effects": {"Dreamslice/1": {- "type": "extra_skill_attack",+ "type": "active.hero.health.down","value": [15,30,45,60,75],"units": {- "applies_to": "all",- "applies_vs": "trigger.target"+ "applies_to": "target",+ "applies_vs": "any"},"duration": {"turns": {"count": 1,"delay": 1}},- "trigger_damage_jobs": [- {- "source": "use.source",- "target": "use.target"- }- ]+ "same_effect_stacking": "max"}}},"DreamfangRally": {Index: simulator/config/troop_skills.json===================================================================--- simulator/config/troop_skills.json prev run+++ simulator/config/troop_skills.json this run@@ -86,9 +86,9 @@"target": "self.infantry"},"effects": {"CrystalShield/1": {- "type": "active.troop.defense.up",+ "type": "active.troop.damageTaken.down","value": [36,36],@@ -104,15 +104,20 @@}}},"BodyOfLight": {- "description": "Increase Infantry Defense by 4%, and reduce extra damage while Crystal Shield is active.",+ "description": "Increase Infantry Defense by X%, and reduce an extra Y% damage while Crystal Shield is active.","troop_type": "infantry","requirements": [{"level": 1,"type": "fc","value": 8+ },+ {+ "level": 2,+ "type": "fc",+ "value": 10}],"trigger": {"type": "battle_start"@@ -120,9 +125,10 @@"effects": {"BodyOfLight/1": {"type": "active.troop.defense.up","value": [- 4+ 4,+ 6],"units": {"applies_to": ["infantry"@@ -130,12 +136,14 @@"applies_vs": "any"}},"BodyOfLight/2": {- "type": "active.troop.defense.up",+ "type": "active.troop.damageTaken.down","value": [- 10+ 10,+ 15],+ "requires_effect": "CrystalShield/1","units": {"applies_to": ["infantry"],@@ -271,22 +279,29 @@{"level": 1,"type": "fc","value": 8+ },+ {+ "level": 2,+ "type": "fc",+ "value": 10}],"trigger": {"type": "attack","probability": [- 10+ 10,+ 15],"source": "enemy.any","target": "self.lancer"},"effects": {"IncandescentField/1": {"type": "active.troop.defense.up","value": [+ 100,100],"units": {"applies_to": "trigger.target",@@ -348,9 +363,9 @@"source": "marksman"},"effects": {"Volley/1": {- "type": "extra_skill_attack",+ "type": "active.troop.damage.up","value": [100],"units": {@@ -360,20 +375,14 @@"duration": {"attacks": {"count": 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.",+ "description": "Marksmen have a chance to make an extra skill attack for 50% damage, increased to 75% at FC8 and 87.5% at FC10.","troop_type": "marksman","requirements": [{"level": 1,@@ -383,14 +392,26 @@{"level": 2,"type": "fc","value": 5+ },+ {+ "level": 3,+ "type": "fc",+ "value": 8+ },+ {+ "level": 4,+ "type": "fc",+ "value": 10}],"trigger": {"type": "attack","probability": [20,+ 30,+ 30,30],"source": "marksman"},@@ -398,9 +419,11 @@"CrystalGunpowder/1": {"type": "extra_skill_attack","value": [50,- 50+ 50,+ 75,+ 87.5],"units": {"applies_to": ["marksman"@@ -421,15 +444,20 @@}}},"FlameCharge": {- "description": "Increase Marksmen's basic Attack by 4%.",+ "description": "Increase Marksmen's basic Attack by 4% at FC8 and 6% at FC10; also increases Crystal Gunpowder's extra attack damage.","troop_type": "marksman","requirements": [{"level": 1,"type": "fc","value": 8+ },+ {+ "level": 2,+ "type": "fc",+ "value": 10}],"trigger": {"type": "battle_start"@@ -437,9 +465,10 @@"effects": {"FlameCharge/1": {"type": "active.troop.attack.up","value": [- 4+ 4,+ 6],"units": {"applies_to": ["marksman"@@ -447,47 +476,7 @@"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": {- "attacks": {- "count": 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@@ -1,2714 +0,0 @@-{- "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_t10_fc9": {- "id": "infantry_t10_fc9",- "type": "infantry",- "tier": 10,- "fc": 9,- "stats": {- "Attack": 726,- "Defense": 10,- "Lethality": 10,- "Health": 2178- }- },- "infantry_t10_fc10": {- "id": "infantry_t10_fc10",- "type": "infantry",- "tier": 10,- "fc": 10,- "stats": {- "Attack": 762,- "Defense": 10,- "Lethality": 10,- "Health": 2286- }- },- "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- }- },- "infantry_t11_fc9": {- "id": "infantry_t11_fc9",- "type": "infantry",- "tier": 11,- "fc": 9,- "stats": {- "Attack": 800,- "Defense": 10,- "Lethality": 10,- "Health": 2400- }- },- "infantry_t11_fc10": {- "id": "infantry_t11_fc10",- "type": "infantry",- "tier": 11,- "fc": 10,- "stats": {- "Attack": 840,- "Defense": 10,- "Lethality": 10,- "Health": 2520- }- },- "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_t10_fc9": {- "id": "lancer_t10_fc9",- "type": "lancer",- "tier": 10,- "fc": 9,- "stats": {- "Attack": 2178,- "Defense": 10,- "Lethality": 10,- "Health": 726- }- },- "lancer_t10_fc10": {- "id": "lancer_t10_fc10",- "type": "lancer",- "tier": 10,- "fc": 10,- "stats": {- "Attack": 2286,- "Defense": 10,- "Lethality": 10,- "Health": 762- }- },- "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- }- },- "lancer_t11_fc9": {- "id": "lancer_t11_fc9",- "type": "lancer",- "tier": 11,- "fc": 9,- "stats": {- "Attack": 2400,- "Defense": 10,- "Lethality": 10,- "Health": 800- }- },- "lancer_t11_fc10": {- "id": "lancer_t11_fc10",- "type": "lancer",- "tier": 11,- "fc": 10,- "stats": {- "Attack": 2520,- "Defense": 10,- "Lethality": 10,- "Health": 840- }- },- "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_t10_fc9": {- "id": "marksman_t10_fc9",- "type": "marksman",- "tier": 10,- "fc": 9,- "stats": {- "Attack": 2904,- "Defense": 10,- "Lethality": 10,- "Health": 545- }- },- "marksman_t10_fc10": {- "id": "marksman_t10_fc10",- "type": "marksman",- "tier": 10,- "fc": 10,- "stats": {- "Attack": 3048,- "Defense": 10,- "Lethality": 10,- "Health": 572- }- },- "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- }- },- "marksman_t11_fc9": {- "id": "marksman_t11_fc9",- "type": "marksman",- "tier": 11,- "fc": 9,- "stats": {- "Attack": 3200,- "Defense": 10,- "Lethality": 10,- "Health": 600- }- },- "marksman_t11_fc10": {- "id": "marksman_t11_fc10",- "type": "marksman",- "tier": 11,- "fc": 10,- "stats": {- "Attack": 3360,- "Defense": 10,- "Lethality": 10,- "Health": 630- }- },- "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/src/config-node.ts===================================================================--- simulator/src/config-node.ts prev run+++ simulator/src/config-node.ts this run@@ -11,15 +11,13 @@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"));}};Index: simulator/src/config.test.ts===================================================================--- simulator/src/config.test.ts prev run+++ simulator/src/config.test.ts this run@@ -17,8 +17,17 @@attacks: { count: 1 }});});+test("Volley raises the triggering attack's damage instead of scheduling an extra skill attack", () => {+ const effect = loadSimulatorConfig().troopSkills.skills.Volley.effects["Volley/1"];++ assert.equal(effect.type, "active.troop.damage.up");+ assert.deepEqual(effect.value, [100]);+ assert.deepEqual(effect.duration, { attacks: { count: 1 } });+ assert.equal("trigger_damage_jobs" in effect, false);+});+test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {const root = writeConfigWithTroopEffect({type: "active.hero.lethality.up",value: 10,@@ -47,22 +56,11 @@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, "hero_generation_stats.json"), JSON.stringify({+ S1: { attack: 1, defense: 1, lethality: 1, health: 1, legacy: true }+ }));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);@@ -100,20 +98,8 @@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: {} }));@@ -372,8 +358,19 @@same_effect_stacking: "max"}, { type: "attack", source: "infantry" });assert.doesNotThrow(() => loadSimulatorConfigFromDir(root));++ const sourceAttackRoot = writeConfigWithTroopEffect({+ type: "active.hero.shield",+ value: 30,+ value_formula: { type: "percent_of", source: "trigger.source_attack" },+ units: { applies_to: "trigger.source", applies_vs: "enemy.any" },+ duration: { turns: { delay: 1, count: 1 } },+ same_effect_stacking: "max"+ }, { type: "attack", source: "infantry" });++ assert.doesNotThrow(() => loadSimulatorConfigFromDir(sourceAttackRoot));});test("loadSimulatorConfig rejects malformed or non-attack value formulas", () => {const badSource = writeConfigWithTroopEffect({@@ -408,23 +405,22 @@assert.throws(() => loadSimulatorConfigFromDir(evolving), /cannot combine value_formula with value_evolution/i);assert.throws(() => loadSimulatorConfigFromDir(missingCoefficient), /requires a numeric percentage coefficient/i);});+test("loadSimulatorConfig rejects missing required effects", () => {+ const root = writeConfigWithTroopEffect({+ type: "active.troop.damageTaken.down",+ value: 10,+ requires_effect: "MissingShield/1",+ units: { applies_to: ["infantry"], applies_vs: "any" }+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /requires_effect references missing effect MissingShield\/1/i);+});+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({Index: simulator/src/config.ts===================================================================--- simulator/src/config.ts prev run+++ simulator/src/config.ts this run@@ -1,5 +1,4 @@-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" };@@ -44,8 +43,9 @@isPassiveBucket,staticBucketDefinition} from "./damageBuckets";import { normalizeUnitType } from "./normalize";+import { generateTroopStatsCatalogue } from "./troopStats";const KNOWN_EFFECT_TYPES = new Set([...DYNAMIC_EFFECT_BUCKETS,...STATIC_EFFECT_BUCKETS,@@ -91,18 +91,16 @@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;+ fileLabel?: (kind: "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});@@ -115,9 +113,8 @@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)) {@@ -146,9 +143,9 @@throw new Error(`Legacy field found in simulator config: ${first.field} at ${first.file}:${first.path}`);}return {- troopStats: raw.troopStats,+ troopStats: generateTroopStatsCatalogue(),heroGenerationStats: raw.heroGenerationStats,heroDefinitions: raw.heroDefinitions,heroAliasIndex,troopSkills: raw.troopSkills,@@ -175,8 +172,17 @@return suffix === "op" || suffix === "type";}function collectEffectDiagnostics(skillFile: SkillFile, file: string, diagnostics: ConfigDiagnostics): void {+ const effectDefinitions = new Map<string, EffectIntentDefinition[]>();+ for (const skill of Object.values(skillFile.skills ?? {})) {+ for (const [effectId, effect] of Object.entries(skill.effects ?? {})) {+ const definitions = effectDefinitions.get(effectId);+ const definition = { id: effectId, ...effect } as EffectIntentDefinition;+ if (definitions) definitions.push(definition);+ else effectDefinitions.set(effectId, [definition]);+ }+ }for (const [skillId, skill] of Object.entries(skillFile.skills ?? {})) {validateTriggerDefinition(skill.trigger, file, skillId);if (skill.trigger.type === "pre_battle") validatePreBattleSkill(skill, file, skillId);for (const [effectId, effect] of Object.entries(skill.effects ?? {})) {@@ -185,8 +191,9 @@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);+ validateRequiredEffect(effect as EffectIntentDefinition, effectDefinitions, file, skillId, effectId);validateEffectValueFormula(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);if (type === "attack_order") validateAttackOrderEffect(effect as EffectIntentDefinition, file, skillId, effectId);validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);validateStaticBucketEffect(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);@@ -203,8 +210,41 @@}}}+function validateRequiredEffect(+ effect: EffectIntentDefinition,+ effectDefinitions: Map<string, EffectIntentDefinition[]>,+ file: string,+ skillId: string,+ effectId: string+): void {+ if (effect.requires_effect === undefined) return;+ const path = `${file}:${skillId}.${effectId}.requires_effect`;+ if (typeof effect.requires_effect !== "string" || effect.requires_effect.trim().length === 0) {+ throw new Error(`requires_effect must name a non-empty effect ID at ${path}`);+ }+ if (effect.requires_effect === effectId) {+ throw new Error(`effect cannot require itself at ${path}`);+ }+ if (dynamicBucketDefinition(effect.type)?.effectBucket !== true) {+ throw new Error(`requires_effect is only supported for runtime damage modifiers at ${path}`);+ }+ const required = effectDefinitions.get(effect.requires_effect) ?? [];+ if (required.length === 0) {+ throw new Error(`requires_effect references missing effect ${effect.requires_effect} at ${path}`);+ }+ if (required.length > 1) {+ throw new Error(`requires_effect references ambiguous effect ${effect.requires_effect} at ${path}`);+ }+ if (required[0].requires_effect !== undefined) {+ throw new Error(`requires_effect chains are not supported at ${path}`);+ }+ if (dynamicBucketDefinition(required[0].type)?.effectBucket !== true) {+ throw new Error(`requires_effect must reference a runtime damage modifier at ${path}`);+ }+}+function collectAmbiguousTurnTriggerSelectorDiagnostics(trigger: SkillFile["skills"][string]["trigger"],effect: EffectIntentDefinition,file: string,@@ -362,9 +402,9 @@}if (record.type !== "percent_of") {throw new Error(`effect value_formula type must be "percent_of" at ${path}`);}- if (!new Set(["trigger.normal_kills", "trigger.skill_kills", "trigger.total_kills"]).has(String(record.source))) {+ if (!new Set(["trigger.normal_kills", "trigger.skill_kills", "trigger.total_kills", "trigger.source_attack"]).has(String(record.source))) {throw new Error(`invalid value_formula source ${JSON.stringify(record.source)} at ${path}`);}}Index: simulator/src/damage.test.ts===================================================================--- simulator/src/damage.test.ts prev run+++ simulator/src/damage.test.ts this run@@ -1,8 +1,14 @@import assert from "node:assert/strict";import { test } from "node:test";-import { calculateDamageJob, createDamageScratch, evaluateDamageExpression } from "./damage";+import {+ calculateDamageJob,+ ceilIgnoringFloatResidue,+ ceilSqrtProduct,+ createDamageScratch,+ evaluateDamageExpression+} from "./damage";import { DYNAMIC_BUCKETS, STATIC_BUCKETS, type BucketJobSide, type BucketPlacement } from "./damageBuckets";import { createEffectIndex, damageShapeSlotsForEffect, DAMAGE_JOB_SHAPE_SLOTS, indexEffect } from "./effectIndex";import { activateEffect, evolvingActiveEffectValue, resolvedEffectScopeKey } from "./effects";import { buildStaticDamageBucketFactors, buildStaticDamageProfile } from "./staticDamageProfile";@@ -103,8 +109,88 @@test("damage calculator requires indexed effect candidates", () => {assert.throws(() => calculateDamageJob(job, simpleFighters(), { trace: true } as never), /effectIndex/i);});+test("damage calculator counts every positive fractional remainder as one living troop", () => {+ const fractionalJob: DamageJob = {+ ...job,+ roundStartTroops: {+ attacker: { infantry: 0.01, lancer: 0, marksman: 0 },+ defender: { infantry: 0, lancer: 1000, marksman: 0 }+ }+ };++ const outcome = calculateIndexedDamageJob(+ fractionalJob,+ simpleFighters(),+ [],+ { trace: true, minInitialArmy: 10000, capToTakerTroops: false }+ );++ assert.equal(outcome.trace?.armyTerm, 100);+});++test("float-safe ceilings ignore residue but preserve genuine fractional troops", () => {+ assert.equal(ceilIgnoringFloatResidue(200.00000000000003), 200);+ assert.equal(ceilIgnoringFloatResidue(200.001), 201);+ assert.equal(ceilIgnoringFloatResidue(0.01), 1);+});++test("army term uses an exact safe-integer square-root ceiling", () => {+ assert.equal(ceilSqrtProduct(2, 2), 2);+ assert.equal(ceilSqrtProduct(200, 200), 200);+ assert.equal(ceilSqrtProduct(2_000_000, 2_000_000), 2_000_000);+ assert.equal(ceilSqrtProduct(2_000_000, 1_999_999), 2_000_000);+ assert.throws(() => ceilSqrtProduct(1.5, 2), /safe integers/);+ assert.throws(() => ceilSqrtProduct(100_000_000, 100_000_000), /MAX_SAFE_INTEGER/);+});++test("damage calculator does not over-count a whole-number army term", () => {+ const exactSquareJob: DamageJob = {+ ...job,+ roundStartTroops: {+ attacker: { infantry: 200, lancer: 0, marksman: 0 },+ defender: { infantry: 0, lancer: 1000, marksman: 0 }+ }+ };++ const outcome = calculateIndexedDamageJob(+ exactSquareJob,+ simpleFighters(),+ [],+ { trace: true, minInitialArmy: 200, capToTakerTroops: false }+ );++ assert.equal(Math.sqrt(200) * Math.sqrt(200), 200.00000000000003);+ assert.equal(outcome.trace?.armyTerm, 200);+});++test("damage calculator rounds positive damage upward to three decimal places", () => {+ const outcome = calculateIndexedDamageJob(+ { ...job, kind: "skill", sourceMultiplier: 1.23456 },+ simpleFighters(),+ [],+ { trace: true, minInitialArmy: 1000, capToTakerTroops: false }+ );++ assert.ok(Math.abs((outcome.trace?.damageBeforeOffsets ?? 0) - 12.3456) < 1e-12);+ assert.equal(outcome.trace?.rawDamage, 12.346);+ assert.equal(outcome.kills, 12.346);+});++test("damage rounding does not advance a value already on the three-decimal grid", () => {+ const outcome = calculateIndexedDamageJob(+ { ...job, kind: "skill", sourceMultiplier: 2.007 },+ simpleFighters(),+ [],+ { trace: true, minInitialArmy: 10, capToTakerTroops: false }+ );++ assert.ok((outcome.trace?.damageBeforeOffsets ?? 0) * 1000 > 2007);+ assert.equal(outcome.trace?.rawDamage, 2.007);+ assert.equal(outcome.kills, 2.007);+});+test("damage calculator uses centralized bucket definitions for player stat routing", () => {const attacker: ResolvedFighter = {side: "attacker",name: "A",@@ -204,9 +290,12 @@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.dealer.attack.up"].factor, 1.3);assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.down"].factor, 1.05);- assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), Number((1.3 / 1.05).toFixed(6)));+ assert.equal(+ Number(((outcome.trace?.damageBeforeOffsets ?? 0) / (baseline.trace?.damageBeforeOffsets ?? 1)).toFixed(6)),+ Number((1.3 / 1.05).toFixed(6))+ );});test("static profile factoring is identical to evaluating its buckets unfactored", () => {const fighters = simpleFighters();@@ -267,9 +356,9 @@const unfactoredDamage =unfactoredDynamicProduct(scratch.factors, job.kind, "numerator") * unfactoredStaticNumerator /(100 * unfactoredDynamicProduct(scratch.factors, job.kind, "denominator") * unfactoredStaticDenominator);- assert.ok(Math.abs(profiled.kills - unfactoredDamage) < 1e-12);+ assert.ok(Math.abs((profiled.trace?.damageBeforeOffsets ?? 0) - unfactoredDamage) < 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);@@ -333,9 +422,14 @@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);+ assert.ok(+ Math.abs(+ (combined.trace?.damageBeforeOffsets ?? 0) -+ (baseline.trace?.damageBeforeOffsets ?? 0) * 1.2 * 1.1+ ) < 1e-12+ );});test("multiplicative all-damage buckets compound instead of adding", () => {const fighters = simpleFighters();@@ -364,9 +458,9 @@assert.ok(Math.abs(damageTakenUp.kills - baseline.kills * 1.25) < 1e-12);assert.ok(Math.abs(damageTakenDown.kills - baseline.kills / 1.25) < 1e-12);});-test("a shield subtracts its full raw value from every normal and skill damage job", () => {+test("a durationless shield subtracts its full raw value from every normal and skill damage job", () => {const fighters = simpleFighters();const shield = {...effect("active.hero.shield", "defender", 25),kind: "shield" as const,@@ -396,8 +490,79 @@sameEffectStacking: "max"}]);});+test("a turn-duration shield carries unused protection to the next damage job", () => {+ const fighters = simpleFighters();+ fighters.attacker.initialTroops = { infantry: 1000, lancer: 10, marksman: 0 };+ fighters.attacker.troopDetails.lancer = {+ id: "lancer_t1",+ type: "lancer",+ tier: 1,+ fc: 0,+ count: 10,+ stats: { attack: 100, defense: 100, lethality: 100, health: 100 }+ };+ fighters.defender.initialTroops = { infantry: 0, lancer: 1010, marksman: 0 };+ fighters.defender.troopDetails.lancer!.count = 1010;++ const roundStartTroops = {+ attacker: { infantry: 1000, lancer: 10, marksman: 0 },+ defender: { infantry: 0, lancer: 1010, marksman: 0 }+ };+ const infantryJob = { ...job, roundStartTroops };+ const lancerJob = { ...job, roundStartTroops, dealerUnit: "lancer" as const };+ const infantryBaseline = calculateIndexedDamageJob(infantryJob, fighters, [], { trace: true });+ const lancerBaseline = calculateIndexedDamageJob(lancerJob, fighters, [], { trace: true });+ const shieldValue = infantryBaseline.kills + 25;+ const shield = {+ ...effect("active.hero.shield", "defender", shieldValue),+ kind: "shield" as const,+ duration: { turns: { count: 1 } },+ intent: { id: "turn-shield", type: "active.hero.shield", value: shieldValue },+ sameEffectStacking: "max" as const+ };++ const infantry = calculateIndexedDamageJob(infantryJob, fighters, [shield], { trace: true });+ const lancer = calculateIndexedDamageJob(lancerJob, fighters, [shield], { trace: true });++ assert.equal(infantry.kills, 0);+ assert.equal(infantry.trace?.offsetDamage, infantryBaseline.kills);+ assert.equal(lancer.trace?.offsetDamage, Math.min(25, lancerBaseline.kills));+ assert.equal(lancer.kills, Math.max(0, lancerBaseline.kills - 25));+ const infantryApplied = infantry.appliedEffects?.[0] && "value" in infantry.appliedEffects[0]+ ? infantry.appliedEffects[0].value+ : 0;+ const lancerApplied = lancer.appliedEffects?.[0] && "value" in lancer.appliedEffects[0]+ ? lancer.appliedEffects[0].value+ : 0;+ assert.equal(infantryApplied + lancerApplied, Math.min(shieldValue, infantryBaseline.kills + lancerBaseline.kills));+});++test("an attack-duration shield keeps its full value in a mixed-formation army", () => {+ const fighters = simpleFighters();+ fighters.attacker.initialTroops = { infantry: 1000, lancer: 10, marksman: 0 };+ fighters.attacker.troopDetails.lancer = {+ id: "lancer_t1",+ type: "lancer",+ tier: 1,+ fc: 0,+ count: 10,+ stats: { attack: 100, defense: 100, lethality: 100, health: 100 }+ };+ const shield = {+ ...effect("active.troop.shield", "defender", 25, "troop_skill"),+ kind: "shield" as const,+ duration: { attacks: { count: 1 } },+ intent: { id: "attack-shield", type: "active.troop.shield", value: 25 },+ sameEffectStacking: "max" as const+ };++ const outcome = calculateIndexedDamageJob(job, fighters, [shield], { trace: true });++ assert.equal(outcome.trace?.offsetDamage, 25);+});+test("max-stacked shields use the largest resolved absolute value", () => {const fighters = simpleFighters();const smaller = {...effect("active.hero.shield", "defender", 10),Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -23,9 +23,9 @@type BucketUpdate,type DynamicDamageBucket,type StaticDamageBucket} from "./damageBuckets";-import { advanceEffectAttackDelay } from "./effects";+import { advanceEffectAttackDelay, isEffectAttackReady } from "./effects";import { damageJobShapeSlot, damageJobSlot, type EffectIndex } from "./effectIndex";import type { BattleRecorder, DamageJobRecorder } from "./recorder";type NumericBucketId = number;@@ -37,8 +37,10 @@}interface NumericDamageBuckets {factors: Float64Array;+ turnShield?: ActiveEffect;+ turnShieldGroup?: ActiveEffect[];}interface CompiledDamageExpression {numeratorSlots: Int32Array;@@ -91,12 +93,19 @@const STATIC_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(STATIC_BUCKETS);const DYNAMIC_NEUTRAL_VALUES = Float64Array.from(DYNAMIC_BUCKETS.map(({ update }) => bucketNeutralValue(update)));const STATIC_NEUTRAL_VALUES = Float64Array.from(STATIC_BUCKETS.map(({ update }) => bucketNeutralValue(update)));const DAMAGE_SCALE_BUCKET = createEvaluatedDamageBucket(1 / 100);+const DAMAGE_DECIMAL_SCALE = 1000;-/** sqrt of the smaller side's initial army; battle-invariant, so callers running many jobs should compute it once. */-export function sqrtMinInitialArmy(fighters: Record<SideId, ResolvedFighter>): number {- return Math.sqrt(Math.max(1, Math.min(totalTroops(fighters.attacker.initialTroops), totalTroops(fighters.defender.initialTroops))));+/** Smaller side's initial army; battle-invariant. */+export function minInitialArmy(fighters: Record<SideId, ResolvedFighter>): number {+ return Math.max(+ 1,+ Math.min(+ totalTroops(fighters.attacker.initialTroops),+ totalTroops(fighters.defender.initialTroops)+ )+ );}export interface DamageJobOptions {recorder: BattleRecorder;@@ -104,9 +113,9 @@staticDamageProfile: StaticDamageProfile;scratch?: DamageScratch;capToTakerTroops?: boolean;usedEffects?: ActiveEffect[];- sqrtMinInitialArmy?: number;+ minInitialArmy?: number;}export function calculateDamageJob(job: DamageJob,@@ -117,29 +126,43 @@if (!options.recorder) throw new Error("calculateDamageJob requires a recorder");if (!options.staticDamageProfile) throw new Error("calculateDamageJob requires a staticDamageProfile");const recording = options.recorder.startDamageJob();const staticProfile = options.staticDamageProfile;- const dealerTroops = job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0;+ // Fractional casualties carry between rounds, but every positive remainder is+ // still one living troop and contributes fully to the next attack.+ const dealerTroops = ceilIgnoringFloatResidue(+ Math.max(0, job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0)+ );const takerTroops = job.roundStartTroops[job.takerSide][job.takerUnit] ?? 0;- const sqrtMinArmy = options.sqrtMinInitialArmy ?? sqrtMinInitialArmy(fighters);- const armyTerm = Math.ceil(Math.sqrt(Math.max(0, dealerTroops)) * sqrtMinArmy);+ const initialArmy = options.minInitialArmy ?? minInitialArmy(fighters);+ const armyTerm = ceilSqrtProduct(dealerTroops, initialArmy);const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);const usedEffects = options.usedEffects ?? [];+ const jobSlot = damageJobSlot(job);applyBucketEffects(- options.effectIndex.damageGroupsByJobShape[damageJobSlot(job)],+ options.effectIndex.damageGroupsByJobShape[jobSlot],options.effectIndex.liveEffectsByGroup,job.round,+ jobSlot,buckets,recording,usedEffects);const damageBeforeOffsets = evaluateDamageExpressionForJob(job, buckets, staticProfile);- const offsetDamage = sumSlots(buckets.factors, DYNAMIC_EXPRESSIONS[job.kind].postSubtractSlots);- const rawDamage = Math.max(0, damageBeforeOffsets - offsetDamage);+ let offsetDamage = sumSlots(buckets.factors, DYNAMIC_EXPRESSIONS[job.kind].postSubtractSlots);+ offsetDamage += applyTurnShield(+ buckets,+ job.round,+ Math.max(0, damageBeforeOffsets - offsetDamage),+ recording,+ usedEffects+ );+ const unroundedDamage = Math.max(0, damageBeforeOffsets - offsetDamage);+ const rawDamage = ceilIgnoringFloatResidue(unroundedDamage * DAMAGE_DECIMAL_SCALE) / DAMAGE_DECIMAL_SCALE;const uncappedKills = rawDamage;const kills = options.capToTakerTroops === false ? uncappedKills : Math.min(takerTroops, uncappedKills);return recording.finish({ job, factors: buckets.factors, armyTerm, damageBeforeOffsets, offsetDamage, rawDamage, kills });}@@ -147,13 +170,16 @@function applyBucketEffects(groups: ActiveEffectGroup[],liveEffectsByGroup: ActiveEffect[][],round: number,+ jobSlot: number,buckets: NumericDamageBuckets,recording: DamageJobRecorder,usedEffects: ActiveEffect[]): void {for (const group of groups) {+ const dependency = group.requiredGroupOrdinalsByJobShape;+ if (dependency && !requiredEffectIsApplicable(dependency, liveEffectsByGroup, round, jobSlot)) continue;const effects = liveEffectsByGroup[group.ordinal];if (effects.length === 0) continue;if (group.sameEffectStacking !== "max") {for (const effect of effects) {@@ -169,29 +195,49 @@}continue;}let selected: ActiveEffect | undefined;+ let selectedValue = Number.NEGATIVE_INFINITY;const eligible: ActiveEffect[] = [];for (const effect of effects) {if (!advanceEffectAttackDelay(effect)) continue;eligible.push(effect);- if (!selected || effect.getCurrentValue(round) > selected.getCurrentValue(round)) selected = effect;+ const appliedValue = effect.getCurrentValue(round);+ if (!selected || appliedValue > selectedValue) {+ selected = effect;+ selectedValue = appliedValue;+ }}if (selected) {- applyBucketEffectGroup(selected, eligible, round, buckets, recording, usedEffects);+ applyBucketEffectGroup(selected, eligible, selectedValue, buckets, recording, usedEffects);}}}+function requiredEffectIsApplicable(+ dependency: Array<number[] | undefined>,+ liveEffectsByGroup: ActiveEffect[][],+ round: number,+ jobSlot: number+): boolean {+ const requiredOrdinals = dependency[jobSlot];+ if (!requiredOrdinals) return false;+ for (const ordinal of requiredOrdinals) {+ for (const effect of liveEffectsByGroup[ordinal]) {+ if (isEffectAttackReady(effect) && effect.getCurrentValue(round) !== 0) return true;+ }+ }+ return false;+}+// Apply the selected candidate's value to its bucket and emit the observation to the recorder;// returns the applied value. Shared by the single-candidate and max-group paths.function applySelectedBucket(selected: ActiveEffect,- round: number,+ appliedValue: number,buckets: NumericDamageBuckets,recording: DamageJobRecorder): number {- const appliedValue = selected.getCurrentValue(round);const bucketIndex = selected.bucketIndex;applyBucketValue(buckets.factors, bucketIndex, appliedValue, BUCKET_UPDATE_BY_INDEX);recording.recordEffect(selected, appliedValue);return appliedValue;@@ -205,9 +251,18 @@buckets: NumericDamageBuckets,recording: DamageJobRecorder,usedEffects: ActiveEffect[]): void {- const appliedValue = applySelectedBucket(effect, round, buckets, recording);+ if (isTurnShield(effect)) {+ buckets.turnShield = effect;+ return;+ }+ const appliedValue = applySelectedBucket(+ effect,+ effect.getCurrentValue(round),+ buckets,+ recording+ );if (appliedValue !== 0) {usedEffects.push(effect);} else {recording.recordRejected(effect, "same_effect_max_superseded");@@ -216,14 +271,19 @@function applyBucketEffectGroup(selected: ActiveEffect,effects: ActiveEffect[],- round: number,+ selectedValue: number,buckets: NumericDamageBuckets,recording: DamageJobRecorder,usedEffects: ActiveEffect[]): void {- const appliedValue = applySelectedBucket(selected, round, buckets, recording);+ if (isTurnShield(selected)) {+ buckets.turnShield = selected;+ buckets.turnShieldGroup = effects;+ return;+ }+ const appliedValue = applySelectedBucket(selected, selectedValue, buckets, recording);if (appliedValue !== 0) {// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.for (const effect of effects) {usedEffects.push(effect);@@ -233,8 +293,62 @@for (const effect of effects) recording.recordRejected(effect, "same_effect_max_superseded");}}+function isTurnShield(effect: ActiveEffect): boolean {+ return effect.kind === "shield" && effect.duration.turns !== undefined;+}++function applyTurnShield(+ buckets: NumericDamageBuckets,+ round: number,+ damage: number,+ recording: DamageJobRecorder,+ usedEffects: ActiveEffect[]+): number {+ const selected = buckets.turnShield;+ if (!selected || damage <= 0) return 0;+ const appliedValue = Math.min(selected.getCurrentValue(round), damage);+ if (appliedValue <= 0) return 0;++ applySelectedBucket(selected, appliedValue, buckets, recording);+ const group = buckets.turnShieldGroup;+ if (!group) {+ selected.initialValue = Math.max(0, selected.initialValue - appliedValue);+ usedEffects.push(selected);+ return appliedValue;+ }++ for (const effect of group) {+ effect.initialValue = Math.max(0, effect.initialValue - appliedValue);+ usedEffects.push(effect);+ if (effect !== selected) recording.recordRejected(effect, "same_effect_max_suppressed");+ }+ return appliedValue;+}++/**+ * Preserve the integer ceiling while ignoring a one-ULP overshoot from operations+ * such as sqrt(200) * sqrt(200) = 200.00000000000003.+ */+export function ceilIgnoringFloatResidue(value: number): number {+ if (value === 0) return 0;+ return Math.ceil(value - Number.EPSILON * Math.max(1, value));+}++export function ceilSqrtProduct(left: number, right: number): number {+ if (!Number.isSafeInteger(left) || left < 0 || !Number.isSafeInteger(right) || right < 0) {+ throw new RangeError(`ceilSqrtProduct requires non-negative safe integers; received ${left} and ${right}`);+ }+ const product = left * right;+ if (!Number.isSafeInteger(product)) {+ throw new RangeError(`ceilSqrtProduct product exceeds Number.MAX_SAFE_INTEGER: ${left} * ${right}`);+ }++ const root = Math.floor(Math.sqrt(product));+ return root * root >= product ? root : root + 1;+}+function compileDamageTerms(definitions: readonly BucketSpec[]): DamageFactorTerm[] {return definitions.map((definition, bucket) => ({bucket,jobSide: definition.jobSide,@@ -294,9 +408,9 @@function createNumericDamageBuckets(): NumericDamageBuckets {const factors = new Float64Array(DYNAMIC_BUCKETS.length);factors.set(DYNAMIC_NEUTRAL_VALUES);- return { factors };+ return { factors, turnShield: undefined, turnShieldGroup: undefined };}export function createDamageScratch(): DamageScratch {return createNumericDamageBuckets();@@ -326,8 +440,10 @@}function resetDamageScratch(buckets: DamageScratch): DamageScratch {buckets.factors.set(DYNAMIC_NEUTRAL_VALUES);+ buckets.turnShield = undefined;+ buckets.turnShieldGroup = undefined;return buckets;}/** Pure, composable reduction of bucket sets to an ordinary one-value bucket set. */Index: simulator/src/damageBuckets.test.ts===================================================================--- simulator/src/damageBuckets.test.ts prev run+++ simulator/src/damageBuckets.test.ts this run@@ -49,11 +49,13 @@assert.equal("pct" in scratch, false);assert.equal("rawSet" in scratch, false);});-test("hero shields are raw post-subtract offsets applying to normal and skill damage", () => {- const shield = dynamicBucketDefinition("active.hero.shield");- assert.equal(shield?.jobSide, "taker");- assert.equal(shield?.update, "add_raw");- assert.equal(shield?.placement, "post_subtract");- assert.equal(shield?.damageKind, undefined);+test("hero and troop shields are raw post-subtract offsets applying to normal and skill damage", () => {+ for (const bucket of ["active.hero.shield", "active.troop.shield"] as const) {+ const shield = dynamicBucketDefinition(bucket);+ assert.equal(shield?.jobSide, "taker", bucket);+ assert.equal(shield?.update, "add_raw", bucket);+ assert.equal(shield?.placement, "post_subtract", bucket);+ assert.equal(shield?.damageKind, undefined, bucket);+ }});Index: simulator/src/damageBuckets.ts===================================================================--- simulator/src/damageBuckets.ts prev run+++ simulator/src/damageBuckets.ts this run@@ -62,8 +62,9 @@{ name: "active.troop.health.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },{ name: "active.troop.health.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },{ name: "active.troop.lethality.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },{ name: "active.troop.lethality.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },+ { name: "active.troop.shield", jobSide: "taker", update: "add_raw", placement: "post_subtract", effectBucket: true },{ name: "source.extraSkill", jobSide: "dealer", update: "assign_factor", placement: "numerator", damageKind: "skill" },{ name: "troops.count", jobSide: "dealer", update: "assign_factor", placement: "numerator" },{ name: "type.all.damage.down", jobSide: "dealer", update: "multiply_pct_factor", placement: "denominator", effectBucket: true },{ name: "type.all.damage.up", jobSide: "dealer", update: "multiply_pct_factor", placement: "numerator", effectBucket: true },Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -337,9 +337,9 @@return typeof value === "string" && (value === "self" || value === "enemy" || value.startsWith("self.") || value.startsWith("enemy."));}function kindForIntent(intent: EffectIntentDefinition): ActiveEffectKind {- if (intent.type === "active.hero.shield") return "shield";+ if (intent.type === "active.hero.shield" || intent.type === "active.troop.shield") return "shield";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";Index: simulator/src/fighterResolution.ts===================================================================--- simulator/src/fighterResolution.ts prev run+++ simulator/src/fighterResolution.ts this run@@ -31,16 +31,10 @@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);+ const type = record.type;+ const stats = record.stats;initialTroops[type] += count;const existing = weightedStats[type] ?? { count: 0, stats: zeroStats(), tier: 0, fc: 0, ids: [] };existing.count += count;existing.stats = addStats(existing.stats, {Index: simulator/src/gen8Heroes.test.ts===================================================================--- simulator/src/gen8Heroes.test.ts prev run+++ simulator/src/gen8Heroes.test.ts this run@@ -2,8 +2,10 @@import { test } from "node:test";import { loadSimulatorConfig } from "./config";import { resolveFighter } from "./fighterResolution";+import { simulateBattles } from "./simulator";+import type { BattleInput } from "./types";test("Gen8 heroes are registered with the shared S8 expedition stats", () => {const config = loadSimulatorConfig();@@ -71,9 +73,9 @@assert.deepEqual(gatot.KingsBestowal.effects["KingsBestowal/1"].value, [6, 12, 18, 24, 30]);assert.deepEqual(gatot.KingsBestowal.effects["KingsBestowal/1"], {type: "active.hero.shield",value: [6, 12, 18, 24, 30],- value_formula: { type: "percent_of", source: "trigger.total_kills" },+ value_formula: { type: "percent_of", source: "trigger.source_attack" },units: { applies_to: "trigger.source", applies_vs: "enemy.any" },duration: { turns: { delay: 1, count: 1 } },same_effect_stacking: "max"});@@ -103,4 +105,304 @@assert.deepEqual(hendrik.DragonsHeir.effects["DragonsHeir/1"].trigger_damage_jobs, [{ source: "use.source", target: "enemy.living" }]);});++test("Gatot converts source formation Attack into next-turn shield protection", () => {+ const config = loadSimulatorConfig();+ const input = gatotInfantryBattle(5000, 1000, 2);+ const result = simulateBattles(input, config, { mode: "trace" })[0]!;+ const roundTwoLeftAttack = result.attacks.find(+ (attack) => attack.round === 2 && attack.kind === "normal" && attack.dealerSide === "attacker"+ );+ const rightShield = roundTwoLeftAttack?.appliedEffects?.find((effect) => "kind" in effect && effect.kind === "shield");+ const expectedRightShield = Math.sqrt(1000) * (243 * (1 + 326.1 / 100)) / (730 * (1 + 18.2 / 100)) * 0.12;++ assert.ok(rightShield && "value" in rightShield);+ assert.ok(Math.abs(rightShield.value - expectedRightShield) < 1e-12);+ const unroundedDamage = Math.max(0, (roundTwoLeftAttack?.trace?.damageBeforeOffsets ?? 0) - expectedRightShield);+ assert.equal(roundTwoLeftAttack?.kills, Math.ceil(unroundedDamage * 1000) / 1000);+});++test("Gatot caps the shield formation count at the smaller initial army", () => {+ const config = loadSimulatorConfig();+ const cases = [+ { attacker: 450, defender: 200, attackerRemaining: 173, defenderRemaining: 200 },+ { attacker: 2500, defender: 500, attackerRemaining: 974, defenderRemaining: 498 }+ ] as const;++ for (const expected of cases) {+ const result = simulateBattles(+ gatotReciprocalMagnitudeBattle(expected.attacker, expected.defender),+ config,+ { mode: "trace" }+ )[0]!;+ const roundTwoDefenderAttack = result.attacks.find(+ (attack) =>+ attack.round === 2 &&+ attack.kind === "normal" &&+ attack.dealerSide === "defender"+ );+ const shield = roundTwoDefenderAttack?.appliedEffects?.find(+ (effect) => "kind" in effect && effect.kind === "shield"+ );+ const expectedShield =+ Math.sqrt(expected.defender) *+ (243 * (1 + 472.7 / 100)) /+ (730 * (1 + 164 / 100)) *+ 0.06;++ assert.ok(shield && "value" in shield);+ assert.ok(Math.abs(shield.value - expectedShield) < 1e-12);+ assert.deepEqual(+ {+ rounds: result.rounds,+ attackerRemaining: result.remaining.attacker.infantry,+ defenderRemaining: result.remaining.defender.infantry+ },+ {+ rounds: 1500,+ attackerRemaining: expected.attackerRemaining,+ defenderRemaining: expected.defenderRemaining+ }+ );+ }+});++test("Gatot shields normal and genuine extra-skill attacks", () => {+ const config = loadSimulatorConfig();+ const input: BattleInput = {+ maxRounds: 4,+ seed: "gatot-all-attack-shield",+ attacker: {+ troops: { infantry_t6: 200 },+ stats: {+ infantry: { attack: 530.8, defense: 527.5, lethality: 218.4, health: 219.6 }+ },+ heroes: { Gatot: { skill_1: 1, skill_2: 3, skill_3: 1 } }+ },+ defender: {+ troops: { infantry_t6: 450 },+ stats: {+ infantry: { attack: 472.6, defense: 471.9, lethality: 147.5, health: 160.9 }+ },+ heroes: {+ Gatot: { skill_1: 1, skill_2: 1, skill_3: 1 },+ Wayne: { skill_1: 3, skill_2: 3, skill_3: 3 }+ }+ }+ };++ const result = simulateBattles(input, config, { mode: "trace" })[0]!;+ const roundFourNormal = result.attacks.find(+ (attack) =>+ attack.round === 4 &&+ attack.kind === "normal" &&+ attack.dealerSide === "defender" &&+ attack.dealerUnit === "infantry"+ );+ const thunderStrike = result.attacks.find(+ (attack) =>+ attack.round === 4 &&+ attack.kind === "skill" &&+ attack.dealerSide === "defender" &&+ attack.sourceEffectId === "ThunderStrike/1"+ );++ assert.ok(roundFourNormal?.appliedEffects?.some((effect) => "kind" in effect && effect.kind === "shield"));+ assert.ok(thunderStrike?.appliedEffects?.some((effect) => "kind" in effect && effect.kind === "shield"));+ assert.equal(roundFourNormal?.kills, 0);+ assert.equal(thunderStrike?.kills, 0);+});++test("Gatot source-Attack shields reproduce the observed army-size threshold", () => {+ const config = loadSimulatorConfig();+ const expectations = [+ { left: 4900, right: 1000, winner: "draw", rounds: 1500, leftLosses: 3, rightLosses: 4 },+ { left: 5100, right: 1000, winner: "attacker", rounds: 1378, leftLosses: 3, rightLosses: 1000 },+ { left: 10000, right: 2000, winner: "attacker", rounds: 452, leftLosses: 14, rightLosses: 2000 },+ { left: 20000, right: 4000, winner: "attacker", rounds: 342, leftLosses: 541, rightLosses: 4000 }+ ] as const;++ for (const expected of expectations) {+ const result = simulateBattles(gatotInfantryBattle(expected.left, expected.right), config, { mode: "fast" })[0]!;+ assert.deepEqual(+ {+ winner: result.winner,+ rounds: result.rounds,+ leftLosses: expected.left - result.remaining.attacker.infantry,+ rightLosses: expected.right - result.remaining.defender.infantry+ },+ {+ winner: expected.winner,+ rounds: expected.rounds,+ leftLosses: expected.leftLosses,+ rightLosses: expected.rightLosses+ }+ );+ }+});++test("Gatot conserves one complete turn shield across enemy formations", () => {+ const input: BattleInput = {+ maxRounds: 1500,+ seed: "10024f8d-b3e3-423a-832c-5176cfbbbd7d",+ attacker: {+ troops: { infantry_t6: 5000 },+ stats: { infantry: { attack: 295.3, defense: 293.3, lethality: 10, health: 10 } },+ heroes: { Gatot: { skill_1: 1, skill_2: 3, skill_3: 3 } }+ },+ defender: {+ troops: { infantry_t6: 1000, lancer_t6: 10 },+ stats: {+ infantry: { attack: 326.1, defense: 330.1, lethality: 18.2, health: 18.2 },+ lancer: { attack: 284.8, defense: 288.8, lethality: 18.2, health: 18.2 }+ },+ heroes: {+ Gatot: { skill_1: 2, skill_2: 2, skill_3: 2 },+ Gordon: { skill_1: 3, skill_2: 3, skill_3: 3 }+ }+ }+ };++ const result = simulateBattles(input, loadSimulatorConfig(), { mode: "fast" })[0]!;++ assert.deepEqual(+ { winner: result.winner, rounds: result.rounds, leftInfantry: result.remaining.attacker.infantry },+ { winner: "attacker", rounds: 1036, leftInfantry: 4996 }+ );+ assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });+});++test("Gatot turn-shield pooling reproduces the mixed-formation family", () => {+ const config = loadSimulatorConfig();+ const expectations = [+ {+ infantry: 1000,+ lancer: 1000,+ marksman: 0,+ winner: "defender",+ rounds: 266,+ attackerSurvivors: 0,+ defenderSurvivors: 4972+ },+ {+ infantry: 1000,+ lancer: 0,+ marksman: 1000,+ winner: "defender",+ rounds: 278,+ attackerSurvivors: 0,+ defenderSurvivors: 4487+ },+ {+ infantry: 2000,+ lancer: 1000,+ marksman: 0,+ winner: "defender",+ rounds: 546,+ attackerSurvivors: 0,+ defenderSurvivors: 4332+ },+ {+ infantry: 1000,+ lancer: 1000,+ marksman: 1000,+ winner: "defender",+ rounds: 239,+ attackerSurvivors: 0,+ defenderSurvivors: 3052+ },+ {+ infantry: 2000,+ lancer: 0,+ marksman: 1000,+ winner: "attacker",+ rounds: 895,+ attackerSurvivors: 1337,+ defenderSurvivors: 0+ }+ ] as const;++ for (const expected of expectations) {+ const result = simulateBattles(+ gatotMixedBattle(expected.infantry, expected.lancer, expected.marksman),+ config,+ { mode: "fast" }+ )[0]!;+ const attackerSurvivors = Object.values(result.remaining.attacker).reduce((sum, count) => sum + count, 0);+ const defenderSurvivors = Object.values(result.remaining.defender).reduce((sum, count) => sum + count, 0);+ assert.deepEqual(+ {+ winner: result.winner,+ rounds: result.rounds,+ attackerSurvivors,+ defenderSurvivors+ },+ {+ winner: expected.winner,+ rounds: expected.rounds,+ attackerSurvivors: expected.attackerSurvivors,+ defenderSurvivors: expected.defenderSurvivors+ }+ );+ }+});++function gatotInfantryBattle(left: number, right: number, maxRounds = 1500): BattleInput {+ return {+ maxRounds,+ seed: "gatot-source-attack-shield",+ attacker: {+ troops: { infantry_t6: left },+ stats: { infantry: { attack: 285.3, defense: 283.3, lethality: 0, health: 0 } },+ heroes: { Gatot: { skill_1: 1, skill_2: 2, skill_3: 3 } }+ },+ defender: {+ troops: { infantry_t6: right },+ stats: { infantry: { attack: 326.1, defense: 330.1, lethality: 18.2, health: 18.2 } },+ heroes: { Gatot: { skill_1: 2, skill_2: 2, skill_3: 2 } }+ }+ };+}++function gatotReciprocalMagnitudeBattle(attacker: number, defender: number): BattleInput {+ return {+ maxRounds: 1500,+ seed: `gatot-reciprocal-magnitude-${attacker}-${defender}`,+ attacker: {+ troops: { infantry_t6: attacker },+ stats: {+ infantry: { attack: 472.7, defense: 472, lethality: 150.6, health: 164 }+ },+ heroes: { Gatot: { skill_1: 1, skill_2: 1, skill_3: 1 } }+ },+ defender: {+ troops: { infantry_t6: defender },+ stats: {+ infantry: { attack: 530.8, defense: 527.5, lethality: 218.4, health: 219.6 }+ },+ heroes: { Gatot: { skill_1: 1, skill_2: 3, skill_3: 1 } }+ }+ };+}++function gatotMixedBattle(infantry: number, lancer: number, marksman: number): BattleInput {+ return {+ maxRounds: 1500,+ seed: `gatot-mixed-${infantry}-${lancer}-${marksman}`,+ attacker: {+ troops: { infantry_t6: infantry, lancer_t6: lancer, marksman_t6: marksman },+ stats: {+ infantry: { attack: 326.1, defense: 330.1, lethality: 18.2, health: 18.2 },+ lancer: { attack: 78.1, defense: 82.1, lethality: 18.2, health: 18.2 },+ marksman: { attack: 78.1, defense: 82.1, lethality: 18.2, health: 18.2 }+ },+ heroes: { Gatot: { skill_1: 2, skill_2: 2, skill_3: 2 } }+ },+ defender: {+ troops: { infantry_t6: 5000 },+ stats: { infantry: { attack: 295.3, defense: 293.3, lethality: 10, health: 10 } },+ heroes: { Gatot: { skill_1: 1, skill_2: 3, skill_3: 3 } }+ }+ };+}Index: simulator/src/index.ts===================================================================--- simulator/src/index.ts prev run+++ simulator/src/index.ts this run@@ -1,6 +1,7 @@export { buildSimulatorConfig, loadSimulatorConfig } from "./config";export type { RawSimulatorConfig } from "./config";+export { createTroopStatsRecord, fireCrystalMultiplier, generateTroopStats, generateTroopStatsCatalogue } from "./troopStats";export { prepareBattle } from "./prepare";export type { CompiledBattle } from "./prepare";export { runPrepared, simulateBattles } from "./simulator";export { BattleInputBuilder } from "./battleInputBuilder";Index: simulator/src/runtime.ts===================================================================--- simulator/src/runtime.ts prev run+++ simulator/src/runtime.ts this run@@ -151,10 +151,11 @@return activated;}// Attack-triggered effects with a value_formula are gated here with the rest of-// their skill, but materialized only after the normal attack and its immediate-// extra-skill jobs have produced finalized kill counts.+// their skill, but materialized after the normal attack and its immediate+// extra-skill jobs. Kill-derived formulas therefore use finalized kill counts;+// source-attack formulas retain the same next-turn scheduling path.export function triggerAttackSkills(round: number,preparedSkills: PreparedAttackSkill[],runtime: Runtime,@@ -187,19 +188,22 @@round: number,intent: AttackIntent,normalKills: number,skillKills: number,+ sourceAttack: number,runtime: Runtime,recorder: BattleRecorder): void {if (!plans) return;for (const { skill, intent: effectIntent } of plans) {const formula = effectIntent.value_formula!;- const basis = formula.source === "trigger.normal_kills"- ? normalKills- : formula.source === "trigger.skill_kills"- ? skillKills- : normalKills + skillKills;+ const basis = formula.source === "trigger.source_attack"+ ? sourceAttack+ : formula.source === "trigger.normal_kills"+ ? normalKills+ : formula.source === "trigger.skill_kills"+ ? skillKills+ : normalKills + skillKills;const coefficientPct = Number(effectIntent.value);const resolvedValue = basis * coefficientPct / 100;if (!Number.isFinite(resolvedValue) || resolvedValue <= 0) continue;const effect = activateEffect(skill, effectIntent, round, intent, resolvedValue);Index: simulator/src/runtimeSkills.ts===================================================================--- simulator/src/runtimeSkills.ts prev run+++ simulator/src/runtimeSkills.ts this run@@ -50,8 +50,9 @@const attackDeclaredByJobShape: Array<PreparedAttackSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });const chanceSkillIds: Record<SideId, string[]> = { attacker: [], defender: [] };const effectGroups: ActiveEffectGroup[] = [];const damageGroupsByJobShape: ActiveEffectGroup[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => []);+ const groupMetadata = new Map<ActiveEffectGroup, { ownerSide: SideId; effectId: string; requiresEffectId?: string }>();// One scope-key table per (side, config definition); duplicate main/joiner copies of the// same config effect share it, so their activations land in the same groups.const groupTablesByDefinition: Record<SideId, Map<object, Array<ActiveEffectGroup | undefined>>> = {attacker: new Map(),@@ -113,8 +114,13 @@ordinal: effectGroups.length,bucketIndex: damageBucketIndex(definition.name),sameEffectStacking: candidate.sameEffectStacking};+ groupMetadata.set(group, {+ ownerSide: skill.side,+ effectId: intent.id,+ ...(intent.requires_effect === undefined ? {} : { requiresEffectId: intent.requires_effect })+ });groupsByScopeKey[scopeKey] = group;groupsForDefinition.push(group);slotsForDefinition.push(slots);effectGroups.push(group);@@ -124,8 +130,9 @@groupTablesByDefinition[skill.side].set(intent.sourceDefinition, groupsByScopeKey);intent.effectGroupsByScopeKey = groupsByScopeKey;}}+ linkRequiredEffectGroups(effectGroups, damageGroupsByJobShape, groupMetadata);return {preBattle,battleStart,roundStartGlobal,@@ -139,8 +146,38 @@}};}+function linkRequiredEffectGroups(+ effectGroups: ActiveEffectGroup[],+ damageGroupsByJobShape: ActiveEffectGroup[][],+ metadata: Map<ActiveEffectGroup, { ownerSide: SideId; effectId: string; requiresEffectId?: string }>+): void {+ for (const group of effectGroups) {+ const dependent = metadata.get(group);+ if (!dependent?.requiresEffectId) continue;+ const byJobShape: Array<number[] | undefined> = [];+ let linked = false;+ for (let slot = 0; slot < damageGroupsByJobShape.length; slot += 1) {+ const jobGroups = damageGroupsByJobShape[slot];+ if (!jobGroups.includes(group)) continue;+ const requiredOrdinals = jobGroups+ .filter((candidate) => {+ const required = metadata.get(candidate);+ return required?.ownerSide === dependent.ownerSide && required.effectId === dependent.requiresEffectId;+ })+ .map((candidate) => candidate.ordinal);+ if (requiredOrdinals.length === 0) continue;+ byJobShape[slot] = requiredOrdinals;+ linked = true;+ }+ if (!linked) {+ throw new Error(`Effect ${dependent.effectId} requires effect ${dependent.requiresEffectId}, but no applicable runtime effect group was prepared`);+ }+ group.requiredGroupOrdinalsByJobShape = byJobShape;+ }+}+function hasPerUnitRoundTrigger(skill: ResolvedSkill): boolean {return skill.trigger.type === "turn" && skill.trigger.source !== undefined;}Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -4,12 +4,13 @@import { fileURLToPath } from "node:url";import { BattleInputBuilder } from "./battleInputBuilder";import { loadSimulatorConfig } from "./config";-import { createSeededRng, chancePasses } from "./effects";+import { createSeededRng, chancePasses, compiledTriggerForSkill } from "./effects";import { applyHeroGenerationStats, resolveFighter } from "./fighterResolution";import { prepareBattle, runPrepared, simulateBattles, simulateBearBattle, signedRemainingScore } from "./simulator";-import type { AppliedEffect, BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";+import { BEAR_TROOP_ID, createTroopStatsRecord, generateTroopStatsCatalogue } from "./troopStats";+import type { AppliedEffect, BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, TroopStatsCatalogue, UnitType } from "./types";function hasEffectKind(effect: AppliedEffect, kind: string): boolean {return "kind" in effect && effect.kind === kind;}@@ -462,9 +463,9 @@const result = runOnce(input!, config, { mode: "trace" });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");+ assert.equal(result.trace?.rounds[1]?.roundStartTroops.defender.lancer.toFixed(6), "198.013000");});test("runPrepared defaults to a 1500 round cap when no explicit maxRounds is provided", () => {const result = runOnce(@@ -1521,48 +1522,149 @@assert.equal(roundTwoSkillOutcomes.length, 2);assert.equal(roundTwoSkillOutcomes.every((outcome) => !(outcome.appliedEffects ?? []).some((event) => hasEffectKind(event, "extra_attack"))), true);});-test("requires_effect is ignored by native simulator effect activation", () => {+test("requires_effect gates a modifier to damage jobs where the required effect is applicable", () => {const result = runOnce({- maxRounds: 0,+ maxRounds: 1,attacker: {- troops: { infantry_t1: 10 },- heroes: { LegacyDependency: { skill_1: 1 } }+ troops: { infantry_t1: 1000, marksman_t1: 1000 },+ heroes: {}},defender: {- troops: { infantry_t1: 10 },- heroes: {}+ troops: { infantry_t1: 10000 },+ heroes: { Guard: { skill_1: 1, skill_2: 1 } }}},minimalConfig({- LegacyDependency: {- name: "LegacyDependency",+ Guard: {+ name: "Guard",+ troop_type: "infantry",skills: {- BothEffectsActivate: {- trigger: { type: "pre_battle" },+ CrystalShield: {+ trigger: { type: "attack", probability: 100, source: "enemy.marksman", target: "self.infantry" },effects: {- base: {- type: "passive.attack.up",- value: 10+ shield: {+ type: "active.troop.damageTaken.down",+ value: 36,+ units: { applies_to: "trigger.target", applies_vs: "trigger.source" },+ duration: { attacks: { count: 1 } }+ }+ }+ },+ BodyOfLight: {+ trigger: { type: "battle_start" },+ effects: {+ defense: {+ type: "active.troop.defense.up",+ value: 4,+ units: { applies_to: ["infantry"], applies_vs: "any" }},- legacyDependent: {- ...({ requires_effect: "missing-effect" } as Record<string, unknown>),- type: "passive.defense.up",- value: 10+ conditionalReduction: {+ type: "active.troop.damageTaken.down",+ value: 10,+ requires_effect: "shield",+ units: { applies_to: ["infantry"], applies_vs: "any" }}}}}}- })+ }),+ { mode: "trace" });- const report = result.skillReport.attacker.find((entry) => entry.skillId === "BothEffectsActivate");- assert.equal(report?.effectActivations, 2);+ const infantryAttack = result.attacks.find((attack) =>+ attack.kind === "normal" && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry"+ );+ const marksmanAttack = result.attacks.find((attack) =>+ attack.kind === "normal" && attack.dealerSide === "attacker" && attack.dealerUnit === "marksman"+ );+ const infantryEffects = new Set((infantryAttack?.appliedEffects ?? []).map((effect) => effect.effectId));+ const marksmanEffects = new Set((marksmanAttack?.appliedEffects ?? []).map((effect) => effect.effectId));++ assert.equal(infantryEffects.has("defense"), true);+ assert.equal(infantryEffects.has("conditionalReduction"), false);+ assert.equal(infantryEffects.has("shield"), false);+ assert.equal(marksmanEffects.has("defense"), true);+ assert.equal(marksmanEffects.has("conditionalReduction"), true);+ assert.equal(marksmanEffects.has("shield"), true);+ assert.equal(marksmanAttack?.trace?.atomicBuckets["active.troop.damageTaken.down"].totalPct, 46);+ assert.equal(marksmanAttack?.trace?.aggregationGroups["active.troop.damageTaken.down"].factor, 1.46);+ assert.equal(marksmanAttack?.trace?.offsetDamage, 0);+ assert.equal(+ marksmanAttack?.kills,+ Math.ceil((marksmanAttack?.trace?.damageBeforeOffsets ?? 0) * 1000) / 1000+ );});+test("Crystal Shield and Body of Light resolve additive FC8 and FC10 damage reductions", () => {+ const config = loadSimulatorConfig();+ const expectations = [+ { troopId: "infantry_t10_fc8", level: 1, defense: 4, conditionalReduction: 10, combinedReduction: 46 },+ { troopId: "infantry_t10_fc10", level: 2, defense: 6, conditionalReduction: 15, combinedReduction: 51 }+ ] as const;++ for (const expected of expectations) {+ const fighter = resolveFighter({ troops: { [expected.troopId]: 1 }, heroes: {} }, "attacker", config);+ const crystalShield = fighter.troopSkills.find((skill) => skill.id === "CrystalShield");+ const bodyOfLight = fighter.troopSkills.find((skill) => skill.id === "BodyOfLight");+ const crystalReduction = crystalShield?.effects.find((effect) => effect.id === "CrystalShield/1");+ const conditionalReduction = bodyOfLight?.effects.find((effect) => effect.id === "BodyOfLight/2");++ assert.equal(crystalReduction?.type, "active.troop.damageTaken.down");+ assert.equal(crystalReduction?.value, 36);+ assert.equal(bodyOfLight?.level, expected.level);+ assert.equal(bodyOfLight?.effects.find((effect) => effect.id === "BodyOfLight/1")?.value, expected.defense);+ assert.equal(conditionalReduction?.type, "active.troop.damageTaken.down");+ assert.equal(conditionalReduction?.value, expected.conditionalReduction);+ assert.equal(crystalReduction!.value + conditionalReduction!.value, expected.combinedReduction);+ }+});++test("Incandescent Field upgrades its trigger chance at FC10 while retaining half damage", () => {+ const config = loadSimulatorConfig();+ const expectations = [+ { troopId: "lancer_t10_fc8", level: 1, probabilityPct: 10 },+ { troopId: "lancer_t10_fc10", level: 2, probabilityPct: 15 }+ ] as const;++ for (const expected of expectations) {+ const fighter = resolveFighter({ troops: { [expected.troopId]: 1 }, heroes: {} }, "attacker", config);+ const incandescentField = fighter.troopSkills.find((skill) => skill.id === "IncandescentField");+ const halfDamage = incandescentField?.effects.find((effect) => effect.id === "IncandescentField/1");++ assert.equal(incandescentField?.level, expected.level);+ assert.equal(compiledTriggerForSkill(incandescentField!).probabilityPct, expected.probabilityPct);+ assert.equal(halfDamage?.type, "active.troop.defense.up");+ assert.equal(halfDamage?.value, 100);+ }+});++test("Marksman Fire Crystal skills resolve Crystal Gunpowder and Flame Charge milestones", () => {+ const config = loadSimulatorConfig();+ const expectations = [+ { troopId: "marksman_t10_fc3", gunpowderLevel: 1, probabilityPct: 20, skillDamagePct: 50, flameLevel: undefined, attackPct: undefined },+ { troopId: "marksman_t10_fc5", gunpowderLevel: 2, probabilityPct: 30, skillDamagePct: 50, flameLevel: undefined, attackPct: undefined },+ { troopId: "marksman_t10_fc8", gunpowderLevel: 3, probabilityPct: 30, skillDamagePct: 75, flameLevel: 1, attackPct: 4 },+ { troopId: "marksman_t10_fc10", gunpowderLevel: 4, probabilityPct: 30, skillDamagePct: 87.5, flameLevel: 2, attackPct: 6 }+ ] as const;++ for (const expected of expectations) {+ const fighter = resolveFighter({ troops: { [expected.troopId]: 1 }, heroes: {} }, "attacker", config);+ const crystalGunpowder = fighter.troopSkills.find((skill) => skill.id === "CrystalGunpowder");+ const flameCharge = fighter.troopSkills.find((skill) => skill.id === "FlameCharge");++ assert.equal(crystalGunpowder?.level, expected.gunpowderLevel);+ assert.equal(compiledTriggerForSkill(crystalGunpowder!).probabilityPct, expected.probabilityPct);+ assert.equal(crystalGunpowder?.effects.find((effect) => effect.id === "CrystalGunpowder/1")?.value, expected.skillDamagePct);+ assert.equal(flameCharge?.level, expected.flameLevel);+ assert.equal(flameCharge?.effects.find((effect) => effect.id === "FlameCharge/1")?.value, expected.attackPct);+ assert.equal(fighter.troopSkills.some((skill) => skill.id === "FlameChargeExtra"), false);+ }+});+test("fighter passive effects are added to the static profile after pre_battle skill effects", () => {const result = runOnce({maxRounds: 1,@@ -1820,9 +1922,9 @@const result = runOnce(input, config);assert.equal(result.winner, "attacker");- assert.equal(result.remaining.attacker.marksman, 1349);+ assert.equal(result.remaining.attacker.marksman, 1348);assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });});test("committed losses clamp exhausted floating point residue before next-round target selection", () => {@@ -1984,9 +2086,9 @@assert.equal(skillJobs[0]?.takerSide, normalJobs[0]?.takerSide);assert.equal(skillJobs[0]?.takerUnit, normalJobs[0]?.takerUnit);});-test("deferred shields use finalized normal plus linked skill kills and reapply to every hit next turn", () => {+test("deferred shields use finalized normal plus linked skill kills as one next-turn pool", () => {const result = runOnce({maxRounds: 2,attacker: {@@ -2062,16 +2164,23 @@const roundTwoIncoming = result.attacks.filter((attack) => attack.round === 2 && attack.dealerSide === "defender" && attack.takerSide === "attacker");assert.deepEqual(roundTwoIncoming.map((attack) => attack.kind).sort(), ["normal", "skill"]);+ let appliedShield = 0;+ let incomingDamage = 0;for (const attack of roundTwoIncoming) {const shield = (attack.appliedEffects ?? []).find((effect) => hasEffectKind(effect, "shield"));- assert.notEqual(shield, undefined);- const shieldValue = shield && "value" in shield ? shield.value : NaN;- assert.ok(Math.abs(shieldValue - expectedShield) < 1e-9);- assert.ok(Math.abs((attack.trace?.offsetDamage ?? NaN) - expectedShield) < 1e-9);- assert.ok(Math.abs((attack.trace?.rawDamage ?? NaN) - Math.max(0, (attack.trace?.damageBeforeOffsets ?? 0) - expectedShield)) < 1e-9);+ const shieldValue = shield && "value" in shield ? shield.value : 0;+ appliedShield += shieldValue;+ incomingDamage += attack.trace?.damageBeforeOffsets ?? 0;+ assert.ok(+ Math.abs(+ (attack.trace?.rawDamage ?? NaN) -+ Math.max(0, (attack.trace?.damageBeforeOffsets ?? 0) - shieldValue)+ ) < 1e-9+ );}+ assert.ok(Math.abs(appliedShield - Math.min(expectedShield, incomingDamage)) < 1e-9);});test("cancelled normal attacks do not consume extra skill attack uses", () => {const result = runOnce(@@ -2924,15 +3033,33 @@}});}+const MINIMAL_TROOP_STATS: TroopStatsCatalogue = Object.freeze({+ infantry_t1: createTroopStatsRecord({+ id: "infantry_t1",+ type: "infantry",+ tier: 1,+ stats: { attack: 100, defense: 100, lethality: 100, health: 100 }+ }),+ lancer_t1: createTroopStatsRecord({+ id: "lancer_t1",+ type: "lancer",+ tier: 1,+ stats: { attack: 100, defense: 100, lethality: 100, health: 100 }+ }),+ marksman_t1: createTroopStatsRecord({+ id: "marksman_t1",+ type: "marksman",+ tier: 1,+ stats: { attack: 100, defense: 100, lethality: 100, health: 100 }+ }),+ [BEAR_TROOP_ID]: generateTroopStatsCatalogue()[BEAR_TROOP_ID]+});+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 } }- },+ troopStats: MINIMAL_TROOP_STATS,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@@ -15,9 +15,9 @@SimulatorConfig,UnitType} from "./types";import { UNIT_TYPES, unitMaskHas } from "./types";-import { calculateDamageJob, sqrtMinInitialArmy, type DamageResult } from "./damage";+import { calculateDamageJob, ceilIgnoringFloatResidue, minInitialArmy, type DamageResult } from "./damage";import { createRecorder, type BattleRecorder } from "./recorder";import {activateEffect,advanceEffectAttackDelay,@@ -53,14 +53,13 @@// Re-exported so the public battle API stays importable from one module.export { prepareBattle, type CompiledBattle } from "./prepare";import { emptyTroops, resolveFighter } from "./fighterResolution";-import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";+import { buildStaticDamageProfile, unitBaseStats, unitPlayerBonuses, type StaticDamageProfile } from "./staticDamageProfile";+import { BEAR_TROOP_ID } from "./troopStats";const DEFAULT_MAX_ROUNDS = 1500;const BEAR_ROUNDS = 10;-const BEAR_DEFENSE = 250/3;-const BEAR_TROOP_ID = "bear_infantry";interface BattleRun {fighters: Record<SideId, ResolvedFighter>;runtime: Runtime;@@ -113,9 +112,9 @@seed,maxRounds: BEAR_ROUNDS,engagement_type: "rally"};- const run = runBattle(input, configWithBearTroop(config), options, undefined, {+ const run = runBattle(input, config, options, undefined, {capRoundKills: false,capJobKills: false,commitLosses: false,scoreSide: { dealerSide: "attacker", takerSide: "defender" }@@ -200,28 +199,8 @@}}}-function configWithBearTroop(config: SimulatorConfig): SimulatorConfig {- return {- ...config,- troopStats: {- ...config.troopStats,- [BEAR_TROOP_ID]: {- id: BEAR_TROOP_ID,- type: "infantry",- tier: 1,- stats: {- attack: 0,- defense: BEAR_DEFENSE,- lethality: 0,- health: 10- }- }- }- };-}-function runBattle(input: BattleInput,config: SimulatorConfig,options: SimulationOptions,@@ -259,16 +238,17 @@const useEffectsOnCancel = {dodge: options.useEffectsOnDodge ?? true,no_attack: options.useEffectsOnNoAttack ?? true};+ const initialArmyCount = minInitialArmy(fighters);const damageJobOptions = {recorder,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: runtime.damageScratch,capToTakerTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects,- sqrtMinInitialArmy: sqrtMinInitialArmy(fighters)+ minInitialArmy: initialArmyCount};let rounds = 0;let score = 0;@@ -340,9 +320,20 @@const triggeredKills = processExtraSkillAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {score += triggeredKills;}- materializeDeferredEffects(deferredEffects, round, intent, normalResult.kills, triggeredKills, runtime, recorder);+ if (deferredEffects) {+ materializeDeferredEffects(+ deferredEffects,+ round,+ intent,+ normalResult.kills,+ triggeredKills,+ sourceAttackProtectionBasis(job, fighters, initialArmyCount),+ runtime,+ recorder+ );+ }}if (loopOptions.commitLosses) commitRound(cancelled, results, runtime);else commitRoundCounters(cancelled, results, runtime);@@ -364,8 +355,27 @@score};}+// Runtime troop counts carry fractional casualties, but a partially damaged troop+// remains alive and contributes to the shield until it is defeated.+function sourceAttackProtectionBasis(+ job: DamageJob,+ fighters: Record<SideId, ResolvedFighter>,+ initialArmyCount: number+): number {+ const livingTroopCount = Math.min(+ ceilIgnoringFloatResidue(Math.max(0, job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0)),+ initialArmyCount+ );+ const fighter = fighters[job.dealerSide];+ const base = unitBaseStats(fighter, job.dealerUnit);+ const bonuses = unitPlayerBonuses(fighter, job.dealerUnit);+ const attack = base.attack * (1 + bonuses.attack / 100);+ const health = base.health * (1 + bonuses.health / 100);+ return health > 0 ? Math.sqrt(livingTroopCount) * attack / health : 0;+}+function triggerRoundStartSkills(round: number,runtime: Runtime,roundStartTroops: DamageJob["roundStartTroops"],@@ -632,6 +642,8 @@};}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>;+ return Object.fromEntries(+ UNIT_TYPES.map((unit) => [unit, ceilIgnoringFloatResidue(troops[unit] ?? 0)])+ ) as Record<UnitType, number>;}Index: simulator/src/tooling/exportGatotTestcases.test.ts===================================================================--- simulator/src/tooling/exportGatotTestcases.test.ts prev run+++ simulator/src/tooling/exportGatotTestcases.test.ts this run@@ -0,0 +1,26 @@+import assert from "node:assert/strict";+import { readdirSync, readFileSync } from "node:fs";+import { resolve } from "node:path";+import { test } from "node:test";++import { gatotTestcaseArtifacts } from "./exportGatotTestcases";++test("generated Gatot testcase files stay in sync with the evidence inventory", () => {+ const directory = resolve(import.meta.dirname, "../../../testcases/gatot_verified");+ const artifacts = gatotTestcaseArtifacts();+ const filenames = readdirSync(directory).sort();++ assert.equal(artifacts.length, 74);+ assert.equal(artifacts.filter((artifact) => artifact.runnable).length, 69);+ assert.equal(artifacts.filter((artifact) => !artifact.runnable).length, 5);+ assert.deepEqual(filenames, artifacts.map((artifact) => artifact.filename).sort());++ for (const artifact of artifacts) {+ const testcase = artifact.testcase[0]!;+ assert.equal("seed" in testcase, false, `${artifact.filename}: seed`);+ assert.equal("max_rounds" in testcase, false, `${artifact.filename}: max_rounds`);+ assert.equal("evidence" in testcase, false, `${artifact.filename}: evidence`);+ const stored = JSON.parse(readFileSync(resolve(directory, artifact.filename), "utf8"));+ assert.deepEqual(stored, artifact.testcase, artifact.filename);+ }+});Index: simulator/src/tooling/exportGatotTestcases.ts===================================================================--- simulator/src/tooling/exportGatotTestcases.ts prev run+++ simulator/src/tooling/exportGatotTestcases.ts this run@@ -0,0 +1,88 @@+import { mkdirSync, writeFileSync } from "node:fs";+import { resolve } from "node:path";+import { pathToFileURL } from "node:url";++import { loadSimulatorConfig } from "../config";+import type { BattleInput, SimulatorConfig } from "../types";+import { GATOT_GAME_OBSERVATIONS, type GatotGameObservation } from "./gatotEvidence";++export interface GatotTestcaseArtifact {+ filename: string;+ testcase: readonly [Record<string, unknown>];+ runnable: boolean;+}++function runnableTestcase(+ observation: GatotGameObservation,+ input: BattleInput+): Record<string, unknown> {+ return {+ test_id: observation.id,+ description: `Gatot evidence section ${observation.section}: ${observation.caseLabel}`,+ attacker: input.attacker,+ defender: input.defender,+ ...(input.engagement_type !== undefined ? { engagement_type: input.engagement_type } : {}),+ game_report_result: [+ {+ attacker: observation.game.survivors.attacker,+ defender: observation.game.survivors.defender+ }+ ]+ };+}++function disabledTestcase(+ observation: GatotGameObservation,+ reason: string+): Record<string, unknown> {+ return {+ test_id: observation.id,+ description: `Gatot evidence section ${observation.section}: ${observation.caseLabel}`,+ disabled_reason: reason+ };+}++export function gatotTestcaseArtifacts(+ config: SimulatorConfig = loadSimulatorConfig()+): GatotTestcaseArtifact[] {+ return GATOT_GAME_OBSERVATIONS.map((observation) => {+ const hasCompleteOutcome =+ observation.game.survivors.attacker !== undefined &&+ observation.game.survivors.defender !== undefined;+ const runnable = Boolean(observation.buildInput && hasCompleteOutcome);+ const reason = observation.notRunnableReason ??+ "The recorded evidence does not contain both survivor counts, so it cannot be used for parity scoring.";+ const testcase = runnable+ ? runnableTestcase(observation, observation.buildInput!(config))+ : disabledTestcase(observation, reason);+ return {+ filename: `${observation.id}.json${runnable ? "" : ".disabled"}`,+ testcase: [testcase],+ runnable+ };+ });+}++export function writeGatotTestcases(+ outputDirectory = resolve(import.meta.dirname, "../../../testcases/gatot_verified")+): GatotTestcaseArtifact[] {+ const artifacts = gatotTestcaseArtifacts();+ mkdirSync(outputDirectory, { recursive: true });+ for (const artifact of artifacts) {+ writeFileSync(+ resolve(outputDirectory, artifact.filename),+ `${JSON.stringify(artifact.testcase, null, 2)}\n`,+ "utf8"+ );+ }+ return artifacts;+}++const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : undefined;+if (invokedPath === import.meta.url) {+ const artifacts = writeGatotTestcases(process.argv[2]);+ const runnable = artifacts.filter((artifact) => artifact.runnable).length;+ process.stdout.write(+ `Wrote ${artifacts.length} Gatot testcase files (${runnable} active, ${artifacts.length - runnable} disabled).\n`+ );+}Index: simulator/src/tooling/gatotEvidence.test.ts===================================================================--- simulator/src/tooling/gatotEvidence.test.ts prev run+++ simulator/src/tooling/gatotEvidence.test.ts this run@@ -0,0 +1,280 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import { loadSimulatorConfig } from "../config";+import {+ GATOT_GAME_OBSERVATIONS,+ renderGatotEvidenceMarkdown,+ runGatotEvidence,+ type GatotEvidenceResult+} from "./gatotEvidence";++test("Gatot evidence inventory contains every distinct game observation", () => {+ assert.equal(GATOT_GAME_OBSERVATIONS.length, 74);+ assert.equal(new Set(GATOT_GAME_OBSERVATIONS.map((entry) => entry.id)).size, 74);++ const sectionCounts = Object.fromEntries(+ [...new Set(GATOT_GAME_OBSERVATIONS.map((entry) => entry.section))].map((section) => [+ section,+ GATOT_GAME_OBSERVATIONS.filter((entry) => entry.section === section).length+ ])+ );+ assert.deepEqual(sectionCounts, {+ "1": 7,+ "2": 5,+ "3": 1,+ "4": 4,+ "5": 3,+ "6": 2,+ "7": 1,+ "8": 15,+ "9": 6,+ "10": 1,+ "15.2": 1,+ "15.3": 4,+ "15.1 / 15.3": 1,+ "16": 4,+ "25": 5,+ "31": 1,+ "32": 1,+ "33": 1,+ "34": 1,+ "35": 1,+ "36": 1,+ "37": 1,+ "38": 1,+ "39": 1,+ "40": 1,+ "41": 1,+ "42": 1,+ "43": 1,+ "44": 1+ });++ const notRunnable = GATOT_GAME_OBSERVATIONS.filter((entry) => !entry.buildInput);+ assert.deepEqual(+ notRunnable.map((entry) => entry.id),+ [+ "s6-historical-t11-fc10-vs-10000-t6-marksmen",+ "s6-historical-t11-fc10-vs-3000-t6-marksmen"+ ]+ );+ assert.ok(notRunnable.every((entry) => entry.notRunnableReason?.includes("attacker stat block")));++ const section9 = GATOT_GAME_OBSERVATIONS.filter((entry) => entry.section === "9");+ assert.equal(section9.length, 6);+ assert.ok(section9.every((entry) => entry.buildInput && entry.game.rounds === undefined));++ const section16 = GATOT_GAME_OBSERVATIONS.filter((entry) => entry.section === "16");+ assert.equal(section16.length, 4);+ assert.ok(section16.every((entry) => entry.buildInput && entry.game.rounds === undefined));++ const section25 = GATOT_GAME_OBSERVATIONS.filter((entry) => entry.section === "25");+ assert.equal(section25.length, 5);+ assert.deepEqual(section25.map((entry) => entry.game.rounds), [92, 115, 136, 109, 241]);+ assert.ok(section25.every((entry) => entry.buildInput));++ const maxRoundDraws = GATOT_GAME_OBSERVATIONS.filter((entry) =>+ ["42", "43", "44"].includes(entry.section)+ );+ assert.ok(maxRoundDraws.every((entry) => entry.game.winner === "draw" && entry.game.rounds === 1500));+});++test("Section 25 preserves the observed defender stat change", () => {+ const config = loadSimulatorConfig();+ const observation = GATOT_GAME_OBSERVATIONS.find(+ (entry) => entry.id === "s25-2000-inf-2000-marksman-vs-3000-inf"+ );+ assert.ok(observation?.buildInput);++ const input = observation.buildInput(config);+ assert.deepEqual(input.defender.stats?.infantry, {+ attack: 486.6,+ defense: 491.9,+ lethality: 157.5,+ health: 170.9+ });+});++test("Section 31 preserves the displayed defender infantry stats", () => {+ const config = loadSimulatorConfig();+ const observation = GATOT_GAME_OBSERVATIONS.find(+ (entry) => entry.id === "s31-current-2000-inf-2000-marksman-vs-5000-inf"+ );+ assert.ok(observation?.buildInput);++ const input = observation.buildInput(config);+ assert.deepEqual(input.defender.stats?.infantry, {+ attack: 472.6,+ defense: 471.9,+ lethality: 147.5,+ health: 160.9+ });+});++test("Section 9 preserves its two observed stat profiles", () => {+ const config = loadSimulatorConfig();+ const inputFor = (id: string) => {+ const observation = GATOT_GAME_OBSERVATIONS.find((entry) => entry.id === id);+ assert.ok(observation?.buildInput);+ return observation.buildInput(config);+ };++ const firstProfile = inputFor("s9-3000-t9-lancers-vs-146-t1-fc10-lancers");+ assert.deepEqual(firstProfile.attacker.stats?.lancer, {+ attack: 208.8,+ defense: 207.1,+ lethality: 167.5,+ health: 167.6+ });+ assert.deepEqual(firstProfile.defender.stats?.lancer, {+ attack: 1179,+ defense: 1202.9,+ lethality: 1362.1,+ health: 1134.5+ });++ const secondProfile = inputFor("s9-1950-t9-lancers-vs-146-t1-fc10-lancers");+ assert.deepEqual(secondProfile.attacker.stats?.lancer, {+ attack: 208.8,+ defense: 179.2,+ lethality: 154.7,+ health: 154.8+ });+ assert.deepEqual(secondProfile.defender.stats?.lancer, {+ attack: 1306.9,+ defense: 1333.2,+ lethality: 1508.3,+ health: 1258+ });+});++test("Gatot evidence runner executes the real deterministic battle path", () => {+ const [result] = runGatotEvidence({ matching: "s15.3-2000-inf-0-lancer-1000-marksman", replicates: 1 });+ assert.ok(result);+ assert.equal(result.error, undefined);+ assert.equal(result.sampleCount, 1);+ assert.equal(result.initialTroopCount, 8000);+ assert.equal(Object.values(result.winnerCounts ?? {}).reduce((sum, count) => sum + count, 0), 1);+ assert.ok(result.attackerSurvivors);+ assert.ok(result.defenderSurvivors);+ assert.ok(result.rounds);+ assert.equal(result.attackerSurvivors.mean, result.attackerSurvivors.min);+ assert.equal(result.attackerSurvivors.mean, result.attackerSurvivors.max);+ assert.equal(result.rounds.mean, result.rounds.min);+ assert.equal(result.rounds.mean, result.rounds.max);+});++test("Gatot evidence Markdown uses explicit simulator-minus-game differences", () => {+ const observation = GATOT_GAME_OBSERVATIONS.find((entry) => entry.id === "s1-4900-vs-1000");+ assert.ok(observation);+ const result: GatotEvidenceResult = {+ observation,+ sampleCount: 1,+ initialTroopCount: 5900,+ winnerCounts: { attacker: 0, defender: 0, draw: 1 },+ attackerSurvivors: { mean: 4900, min: 4900, max: 4900 },+ defenderSurvivors: { mean: 990, min: 990, max: 990 },+ rounds: { mean: 1498, min: 1498, max: 1498 }+ };++ const markdown = renderGatotEvidenceMarkdown([result]);+ assert.match(markdown, /Differences are `simulator − game`/);+ assert.match(markdown, /A \+3 \/ D −6/);+ assert.match(markdown, /\| 1,500 \| 1,498 \| −2 \|/);+ assert.match(markdown, /## Threshold summary/);+ assert.match(markdown, /survivor differences within 2% of the two armies' combined initial troop count/);+ assert.match(markdown, /\| Deterministic \| 1 \| 0 \| 0 \| 1 \|/);+});++test("deterministic survivor error is normalized by both armies' initial troops", () => {+ const baseObservation = GATOT_GAME_OBSERVATIONS.find((entry) => entry.id === "s1-5100-vs-1000");+ assert.ok(baseObservation);+ const result: GatotEvidenceResult = {+ observation: {+ ...baseObservation,+ game: {+ winner: "attacker",+ survivors: { attacker: 12, defender: 0 },+ rounds: 100+ }+ },+ sampleCount: 1,+ initialTroopCount: 6100,+ winnerCounts: { attacker: 1, defender: 0, draw: 0 },+ attackerSurvivors: { mean: 13, min: 13, max: 13 },+ defenderSurvivors: { mean: 0, min: 0, max: 0 },+ rounds: { mean: 100, min: 100, max: 100 }+ };++ const markdown = renderGatotEvidenceMarkdown([result]);+ assert.match(markdown, /OK; deterministic/);+ assert.match(markdown, /greater of 3 rounds or 2%/);+ assert.match(markdown, /\| Deterministic \| 1 \| 0 \| 0 \| 1 \|/);+});++test("deterministic round tolerance allows the greater of three rounds or two percent", () => {+ const baseObservation = GATOT_GAME_OBSERVATIONS.find((entry) => entry.id === "s1-5100-vs-1000");+ assert.ok(baseObservation);+ const resultForRounds = (rounds: number): GatotEvidenceResult => ({+ observation: {+ ...baseObservation,+ game: {+ winner: "attacker",+ survivors: { attacker: 12, defender: 0 },+ rounds: 100+ }+ },+ sampleCount: 1,+ initialTroopCount: 6100,+ winnerCounts: { attacker: 1, defender: 0, draw: 0 },+ attackerSurvivors: { mean: 12, min: 12, max: 12 },+ defenderSurvivors: { mean: 0, min: 0, max: 0 },+ rounds: { mean: rounds, min: rounds, max: rounds }+ });++ const withinThree = renderGatotEvidenceMarkdown([resultForRounds(103)]);+ const outsideThree = renderGatotEvidenceMarkdown([resultForRounds(104)]);++ assert.match(withinThree, /OK; deterministic/);+ assert.match(outsideThree, /NOT OK; deterministic rounds differ by 4; allowed 3/);+});++test("Gatot evidence Markdown retains non-runnable and missing-round observations", () => {+ const results = runGatotEvidence({ matching: "s6-historical", replicates: 1 });+ const markdown = renderGatotEvidenceMarkdown(results);++ assert.equal(results.length, 2);+ assert.match(markdown, /Observations: 2; runnable: 0; not runnable: 2/);+ assert.match(markdown, /Not runnable: The complete attacker stat block/);+ assert.match(markdown, /\| Deterministic \| 0 \| 0 \| 2 \| 2 \|/);+});++test("Gatot evidence Markdown assesses stochastic observations against a conditional 99% interval", () => {+ const observation = GATOT_GAME_OBSERVATIONS.find(+ (entry) => entry.id === "s9-1500-t9-lancers-vs-146-t1-fc10-lancers"+ );+ assert.ok(observation);+ const result: GatotEvidenceResult = {+ observation,+ sampleCount: 100,+ winnerCounts: { attacker: 0, defender: 100, draw: 0 },+ attackerSurvivors: { mean: 0, min: 0, max: 0 },+ defenderSurvivors: { mean: 95, min: 80, max: 110 },+ rounds: { mean: 45, min: 40, max: 50 },+ matchingGameOutcome: {+ sampleCount: 100,+ attackerSurvivors: { mean: 0, min: 0, max: 0, lowerBound: 0, upperBound: 0 },+ defenderSurvivors: { mean: 95, min: 80, max: 110, lowerBound: 90, upperBound: 100 },+ rounds: { mean: 45, min: 40, max: 50, lowerBound: 41, upperBound: 49 }+ }+ };++ const markdown = renderGatotEvidenceMarkdown([result]);+ assert.match(markdown, /Stochastic: \*\*OK\*\* requires the recorded winner in at least 5%/);+ assert.match(markdown, /central 99% interval/);+ assert.match(markdown, /NOT OK; stochastic; n=100/);+ assert.match(markdown, /defender survivors 85 outside conditional 99% interval 90–100/);+ assert.match(markdown, /\| Stochastic \| 0 \| 1 \| 0 \| 1 \|/);+ assert.match(markdown, /### Outside threshold/);+});Index: simulator/src/tooling/gatotEvidence.ts===================================================================--- simulator/src/tooling/gatotEvidence.ts prev run+++ simulator/src/tooling/gatotEvidence.ts this run@@ -0,0 +1,1787 @@+import { pathToFileURL } from "node:url";++import { BattleInputBuilder } from "../battleInputBuilder";+import { loadSimulatorConfig } from "../config";+import { simulateBattles } from "../simulator";+import type {+ BattleInput,+ BattleResult,+ FighterInput,+ HeroSkillLevels,+ SideId,+ SimulatorConfig,+ StatBlock,+ UnitType+} from "../types";++export type EvidenceWinner = SideId | "draw";++export interface GatotGameObservation {+ id: string;+ section: string;+ caseLabel: string;+ stochastic: boolean;+ game: {+ winner: EvidenceWinner;+ survivors: { attacker?: number; defender?: number };+ rounds?: number;+ /**+ * Per-formation kills credited on Battle Details. These sum to the+ * opponent's Injured count; use their proportions as damage attribution,+ * not as the complete casualty total (Lightly Injured is uncredited).+ */+ attackerCreditedKills?: Partial<Record<UnitType, number>>;+ };+ buildInput?: (config: SimulatorConfig) => BattleInput;+ notRunnableReason?: string;+ notes?: string;+}++interface NumericSummary {+ mean: number;+ min: number;+ max: number;+}++interface PredictionSummary extends NumericSummary {+ lowerBound: number;+ upperBound: number;+}++interface MatchingOutcomeSummary {+ sampleCount: number;+ attackerSurvivors: PredictionSummary;+ defenderSurvivors: PredictionSummary;+ rounds: PredictionSummary;+}++export interface GatotEvidenceResult {+ observation: GatotGameObservation;+ sampleCount: number;+ initialTroopCount?: number;+ winnerCounts?: Record<EvidenceWinner, number>;+ attackerSurvivors?: NumericSummary;+ defenderSurvivors?: NumericSummary;+ rounds?: NumericSummary;+ matchingGameOutcome?: MatchingOutcomeSummary;+ error?: string;+}++export interface GatotEvidenceRunOptions {+ replicates?: number;+ matching?: string;+}++const DEFAULT_REPLICATES = 100;+const MAX_ROUNDS = 1500;+const DETERMINISTIC_RELATIVE_TOLERANCE = 0.02;+const DETERMINISTIC_MINIMUM_ROUND_TOLERANCE = 3;+const STOCHASTIC_MINIMUM_WINNER_PROBABILITY = 0.05;+const STOCHASTIC_PREDICTION_INTERVAL_TAIL = 0.005;++const T6_THRESHOLD_ATTACKER_STATS: StatBlock = {+ attack: 285.3,+ defense: 283.3,+ lethality: 0,+ health: 0+};+const T6_STRONG_INFANTRY_STATS: StatBlock = {+ attack: 326.1,+ defense: 330.1,+ lethality: 18.2,+ health: 18.2+};+const T6_WEAK_INFANTRY_STATS: StatBlock = {+ attack: 295.3,+ defense: 293.3,+ lethality: 10,+ health: 10+};+const T6_STRONG_BACKLINE_STATS: StatBlock = {+ attack: 284.8,+ defense: 288.8,+ lethality: 18.2,+ health: 18.2+};+const T6_MIXED_BACKLINE_STATS: StatBlock = {+ attack: 78.1,+ defense: 82.1,+ lethality: 18.2,+ health: 18.2+};+const FC9_ATTACKER_STATS: StatBlock = {+ attack: 1513,+ defense: 1634.1,+ lethality: 831.2,+ health: 1103.1+};+const FC9_DEFENDER_STATS: StatBlock = {+ attack: 1354,+ defense: 1413.7,+ lethality: 634.3,+ health: 812.6+};+const T6_MARKSMAN_STATS: StatBlock = {+ attack: 208.8,+ defense: 207.1,+ lethality: 158.8,+ health: 164.7+};+const FC10_INFANTRY_STATS: StatBlock = {+ attack: 2159.6,+ defense: 2185.6,+ lethality: 1942.2,+ health: 2139.7+};+const SECTION9_FIRST_TWO_T9_LANCER_STATS: StatBlock = {+ attack: 208.8,+ defense: 207.1,+ lethality: 167.5,+ health: 167.6+};+const SECTION9_FIRST_TWO_FC10_LANCER_STATS: StatBlock = {+ attack: 1179,+ defense: 1202.9,+ lethality: 1362.1,+ health: 1134.5+};+const SECTION9_LAST_FOUR_T9_LANCER_STATS: StatBlock = {+ attack: 208.8,+ defense: 179.2,+ lethality: 154.7,+ health: 154.8+};+const SECTION9_LAST_FOUR_FC10_LANCER_STATS: StatBlock = {+ attack: 1306.9,+ defense: 1333.2,+ lethality: 1508.3,+ health: 1258+};+const EMULATOR_ATTACKER_INFANTRY_STATS: StatBlock = {+ attack: 271.7,+ defense: 259.5,+ lethality: 208.4,+ health: 209.6+};+const EMULATOR_ATTACKER_MARKSMAN_STATS: StatBlock = {+ attack: 270,+ defense: 265,+ lethality: 214.9,+ health: 210+};+const EMULATOR_DEFENDER_INFANTRY_STATS: StatBlock = {+ attack: 483.6,+ defense: 478.9,+ lethality: 157.5,+ health: 170.9+};+const PRESSURE_SERIES_DEFENDER_INFANTRY_STATS: StatBlock = {+ attack: 486.6,+ defense: 491.9,+ lethality: 157.5,+ health: 170.9+};+const LATEST_MIDPOINT_ATTACKER_INFANTRY_STATS: StatBlock = {+ attack: 285.7,+ defense: 279.5,+ lethality: 218.4,+ health: 219.6+};+const LATEST_MIDPOINT_ATTACKER_MARKSMAN_STATS: StatBlock = {+ attack: 284,+ defense: 286,+ lethality: 224.9,+ health: 220+};+const LATEST_WAYNE_ATTACKER_MARKSMAN_STATS: StatBlock = {+ attack: 383.9,+ defense: 385.9,+ lethality: 224.9,+ health: 220+};+const LATEST_WIP_WAYNE_ATTACKER_MARKSMAN_STATS: StatBlock = {+ attack: 369.6,+ defense: 365.9,+ lethality: 202.3,+ health: 183.7+};+const LATEST_WIP_NO_HERO_LANCER_STATS: StatBlock = {+ attack: 197.8,+ defense: 195.1,+ lethality: 161.5,+ health: 161.6+};+const LATEST_WIP_NO_HERO_MARKSMAN_STATS: StatBlock = {+ attack: 197.8,+ defense: 194.1,+ lethality: 148.8,+ health: 154.7+};+const LATEST_MIDPOINT_DEFENDER_INFANTRY_STATS: StatBlock = {+ attack: 472.6,+ defense: 471.9,+ lethality: 147.5,+ health: 160.9+};+const LATEST_MINXXX_GATOT_DEFENDER_INFANTRY_STATS: StatBlock = {+ attack: 533.8,+ defense: 527.5,+ lethality: 218.4,+ health: 219.6+};+const LOW_PRESSURE_MINXXX_GATOT_DEFENDER_INFANTRY_STATS: StatBlock = {+ attack: 530.8,+ defense: 527.5,+ lethality: 218.4,+ health: 219.6+};+const SECTION40_BRIDGE_ATTACKER_MARKSMAN_STATS: StatBlock = {+ attack: 281,+ defense: 286,+ lethality: 224.9,+ health: 220+};++interface LancerCalibrationProfile {+ attacker: StatBlock;+ defender: StatBlock;+}++const SECTION9_FIRST_TWO_PROFILE: LancerCalibrationProfile = {+ attacker: SECTION9_FIRST_TWO_T9_LANCER_STATS,+ defender: SECTION9_FIRST_TWO_FC10_LANCER_STATS+};+const SECTION9_LAST_FOUR_PROFILE: LancerCalibrationProfile = {+ attacker: SECTION9_LAST_FOUR_T9_LANCER_STATS,+ defender: SECTION9_LAST_FOUR_FC10_LANCER_STATS+};++const GATOT_123 = skills(1, 2, 3);+const GATOT_222 = skills(2, 2, 2);+const GATOT_133 = skills(1, 3, 3);+const GATOT_555 = skills(5, 5, 5);++function skills(skill1: number, skill2?: number, skill3?: number): HeroSkillLevels {+ return {+ skill_1: skill1,+ ...(skill2 !== undefined ? { skill_2: skill2 } : {}),+ ...(skill3 !== undefined ? { skill_3: skill3 } : {})+ };+}++function buildBattle(+ id: string,+ attacker: FighterInput,+ defender: FighterInput,+ config: SimulatorConfig+): BattleInput {+ return new BattleInputBuilder(config)+ .fighter("attacker", attacker)+ .fighter("defender", defender)+ .seed(`gatot-evidence:${id}`)+ .maxRounds(MAX_ROUNDS)+ .build();+}++function thresholdInput(id: string, attackerCount: number, defenderCount: number, defenderHealth = 18.2) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { infantry_t6: attackerCount },+ stats: { infantry: T6_THRESHOLD_ATTACKER_STATS },+ heroes: { Gatot: GATOT_123 }+ },+ {+ troops: { infantry_t6: defenderCount },+ stats: { infantry: { ...T6_STRONG_INFANTRY_STATS, health: defenderHealth } },+ heroes: { Gatot: GATOT_222 }+ },+ config+ );+}++function fc9GatotInput(id: string, count: number, withLumak: boolean) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { infantry_t11_fc9: count },+ stats: { infantry: FC9_ATTACKER_STATS },+ heroes: {+ Gatot: GATOT_555,+ ...(withLumak ? { Lumak: skills(5) } : {})+ }+ },+ {+ troops: { infantry_t10_fc9: count },+ stats: { infantry: FC9_DEFENDER_STATS },+ heroes: { Gatot: GATOT_555 }+ },+ config+ );+}++function singleFc10InfantryInput(id: string, marksmanTier: 6 | 9, marksmen: number) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { [`marksman_t${marksmanTier}`]: marksmen },+ stats: { marksman: T6_MARKSMAN_STATS },+ heroes: {}+ },+ {+ troops: { infantry_t1_fc10: 1 },+ stats: { infantry: FC10_INFANTRY_STATS },+ heroes: { Gatot: GATOT_555 }+ },+ config+ );+}++function lancerCalibrationInput(id: string, attackerCount: number, profile: LancerCalibrationProfile) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { lancer_t9: attackerCount },+ stats: { lancer: profile.attacker },+ heroes: {}+ },+ {+ troops: { lancer_t1_fc10: 146 },+ stats: { lancer: profile.defender },+ heroes: {}+ },+ config+ );+}++function gatotMixedInput(id: string, infantry: number, lancer: number, marksman: number) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: {+ ...(infantry > 0 ? { infantry_t6: infantry } : {}),+ ...(lancer > 0 ? { lancer_t6: lancer } : {}),+ ...(marksman > 0 ? { marksman_t6: marksman } : {})+ },+ stats: {+ infantry: T6_STRONG_INFANTRY_STATS,+ lancer: T6_MIXED_BACKLINE_STATS,+ marksman: T6_MIXED_BACKLINE_STATS+ },+ heroes: { Gatot: GATOT_222 }+ },+ {+ troops: { infantry_t6: 5000 },+ stats: { infantry: T6_WEAK_INFANTRY_STATS },+ heroes: { Gatot: GATOT_133 }+ },+ config+ );+}++function emulatorGatotProbeInput(+ id: string,+ attackerInfantry: number,+ attackerMarksmen: number,+ defenderInfantry: number,+ defenderStats = EMULATOR_DEFENDER_INFANTRY_STATS+) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: {+ infantry_t6: attackerInfantry,+ ...(attackerMarksmen > 0 ? { marksman_t6: attackerMarksmen } : {})+ },+ stats: {+ infantry: EMULATOR_ATTACKER_INFANTRY_STATS,+ ...(attackerMarksmen > 0 ? { marksman: EMULATOR_ATTACKER_MARKSMAN_STATS } : {})+ },+ heroes: {}+ },+ {+ troops: { infantry_t6: defenderInfantry },+ stats: { infantry: defenderStats },+ heroes: { Gatot: skills(1, 1, 1) }+ },+ config+ );+}++function latestMidpointInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { infantry_t6: 2000, marksman_t6: 2000 },+ stats: {+ infantry: LATEST_MIDPOINT_ATTACKER_INFANTRY_STATS,+ marksman: LATEST_MIDPOINT_ATTACKER_MARKSMAN_STATS+ },+ heroes: {}+ },+ {+ troops: { infantry_t6: 5000 },+ stats: { infantry: LATEST_MIDPOINT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 1, 1) }+ },+ config+ );+}++function latestBilateralPressureInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { infantry_t6: 2000, marksman_t6: 2000 },+ stats: {+ infantry: LATEST_MINXXX_GATOT_DEFENDER_INFANTRY_STATS,+ marksman: LATEST_MIDPOINT_ATTACKER_MARKSMAN_STATS+ },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ {+ troops: { infantry_t6: 5000 },+ stats: { infantry: LATEST_MIDPOINT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 1, 1) }+ },+ config+ );+}++function latestBilateralBreakInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { infantry_t6: 800, marksman_t6: 800 },+ stats: {+ infantry: LATEST_MIDPOINT_DEFENDER_INFANTRY_STATS,+ marksman: LATEST_WIP_NO_HERO_MARKSMAN_STATS+ },+ heroes: { Gatot: skills(1, 1, 1) }+ },+ {+ troops: { infantry_t6: 1100 },+ stats: { infantry: LATEST_MINXXX_GATOT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ config+ );+}++function latestWayneExtraSkillInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { marksman_t6: 750 },+ stats: { marksman: LATEST_WAYNE_ATTACKER_MARKSMAN_STATS },+ heroes: { Wayne: skills(1) }+ },+ {+ troops: { infantry_t6: 3000 },+ stats: { infantry: LATEST_MIDPOINT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 1, 1) }+ },+ config+ );+}++function latestWayneLevel3ScopeInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { marksman_t6: 500 },+ stats: { marksman: LATEST_WIP_WAYNE_ATTACKER_MARKSMAN_STATS },+ heroes: { Wayne: skills(3, 3, 3) }+ },+ {+ troops: { infantry_t6: 5000 },+ stats: { infantry: LATEST_MINXXX_GATOT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ config+ );+}++function latestDistributionProbeInput(id: string, lancers: number, marksmen: number) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { lancer_t6: lancers, marksman_t6: marksmen },+ stats: {+ lancer: LATEST_WIP_NO_HERO_LANCER_STATS,+ marksman: LATEST_WIP_NO_HERO_MARKSMAN_STATS+ },+ heroes: {}+ },+ {+ troops: { infantry_t6: 1100 },+ stats: { infantry: LATEST_MINXXX_GATOT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ config+ );+}++function latestLowPressurePoolProbeInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { lancer_t6: 1000, marksman_t6: 400 },+ stats: {+ lancer: LATEST_WIP_NO_HERO_LANCER_STATS,+ marksman: LATEST_WIP_NO_HERO_MARKSMAN_STATS+ },+ heroes: {}+ },+ {+ troops: { infantry_t6: 500 },+ stats: { infantry: LOW_PRESSURE_MINXXX_GATOT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ config+ );+}++function latestSection15BridgeInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { infantry_t6: 2000, marksman_t6: 1000 },+ stats: {+ infantry: LOW_PRESSURE_MINXXX_GATOT_DEFENDER_INFANTRY_STATS,+ marksman: SECTION40_BRIDGE_ATTACKER_MARKSMAN_STATS+ },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ {+ troops: { infantry_t6: 5000 },+ stats: { infantry: LATEST_MIDPOINT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 1, 1) }+ },+ config+ );+}++function latestThreeFormationPoolChecklistInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: {+ infantry_t6: 300,+ lancer_t6: 200,+ marksman_t6: 600+ },+ stats: {+ infantry: LATEST_MIDPOINT_DEFENDER_INFANTRY_STATS,+ lancer: LATEST_WIP_NO_HERO_LANCER_STATS,+ marksman: LATEST_WIP_NO_HERO_MARKSMAN_STATS+ },+ heroes: { Gatot: skills(1, 1, 1) }+ },+ {+ troops: { infantry_t6: 500 },+ stats: { infantry: LOW_PRESSURE_MINXXX_GATOT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ config+ );+}++function latestWayneShieldScopeInput(id: string) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { infantry_t6: 200 },+ stats: { infantry: LOW_PRESSURE_MINXXX_GATOT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ {+ troops: { infantry_t6: 450 },+ stats: { infantry: LATEST_MIDPOINT_DEFENDER_INFANTRY_STATS },+ heroes: {+ Gatot: skills(1, 1, 1),+ Wayne: skills(3, 3, 3)+ }+ },+ config+ );+}++function latestReciprocalShieldMagnitudeInput(+ id: string,+ attackerInfantry: number,+ defenderInfantry: number+) {+ return (config: SimulatorConfig): BattleInput =>+ buildBattle(+ id,+ {+ troops: { infantry_t6: attackerInfantry },+ stats: {+ infantry: { attack: 472.7, defense: 472, lethality: 150.6, health: 164 }+ },+ heroes: { Gatot: skills(1, 1, 1) }+ },+ {+ troops: { infantry_t6: defenderInfantry },+ stats: { infantry: LOW_PRESSURE_MINXXX_GATOT_DEFENDER_INFANTRY_STATS },+ heroes: { Gatot: skills(1, 3, 1) }+ },+ config+ );+}++function observation(+ value: Omit<GatotGameObservation, "stochastic"> & { stochastic?: boolean }+): GatotGameObservation {+ return { stochastic: false, ...value };+}++const section1 = [+ { count: 4900, defender: 1000, winner: "draw", rounds: 1500, attackerSurvivors: 4897, defenderSurvivors: 996 },+ { count: 4950, defender: 1000, winner: "draw", rounds: 1500, attackerSurvivors: 4947, defenderSurvivors: 855 },+ { count: 5000, defender: 1000, winner: "draw", rounds: 1500, attackerSurvivors: 4997, defenderSurvivors: 513 },+ { count: 5050, defender: 1000, winner: "draw", rounds: 1500, attackerSurvivors: 5047, defenderSurvivors: 74 },+ { count: 5100, defender: 1000, winner: "attacker", rounds: 1381, defenderSurvivors: 0 },+ { count: 10000, defender: 2000, winner: "attacker", rounds: 452, defenderSurvivors: 0 },+ { count: 20000, defender: 4000, winner: "attacker", rounds: 337, defenderSurvivors: 0 }+] as const;++const section2 = [+ { count: 4950, defenderSurvivors: 904, attackerSurvivors: 4947, winner: "draw", rounds: 1500 },+ { count: 5000, defenderSurvivors: 670, attackerSurvivors: 4997, winner: "draw", rounds: 1500 },+ { count: 5050, defenderSurvivors: 406, attackerSurvivors: 5047, winner: "draw", rounds: 1500 },+ { count: 5100, defenderSurvivors: 80, attackerSurvivors: 5097, winner: "draw", rounds: 1500 },+ { count: 5150, defenderSurvivors: 0, attackerSurvivors: 5147, winner: "attacker" }+] as const;++const section4 = [+ { count: 10000, rounds: 923, attackerSurvivors: 9992 },+ { count: 20000, rounds: 702, attackerSurvivors: 19983 },+ { count: 40000, rounds: 610, attackerSurvivors: 39135 },+ { count: 90000, rounds: 560, attackerSurvivors: 83565 }+] as const;++const section5 = [+ { count: 20000, rounds: 705, attackerSurvivors: 19986 },+ { count: 40000, rounds: 598, attackerSurvivors: 39914 },+ { count: 90000, rounds: 554, attackerSurvivors: 86767 }+] as const;++const section8 = [+ { start: 33510, survivors: 33481, rounds: 157 },+ { start: 33481, survivors: 33452, rounds: 160 },+ { start: 33452, survivors: 33427, rounds: 135 },+ { start: 33427, survivors: 33401, rounds: 144 },+ { start: 30228, survivors: 30205, rounds: 123 },+ { start: 30205, survivors: 30179, rounds: 139 },+ { start: 30179, survivors: 30139, rounds: 216 },+ { start: 30139, survivors: 30116, rounds: 123 },+ { start: 30116, survivors: 30081, rounds: 189 },+ { start: 25000, survivors: 24929, rounds: 329 },+ { start: 20000, survivors: 19943, rounds: 308 },+ { start: 12000, survivors: 11791, rounds: 1121 },+ { start: 11791, survivors: 11548, rounds: 1300 },+ { start: 11548, survivors: 11268, rounds: 1500, winner: "draw" },+ { start: 10000, survivors: 9720, rounds: 1500, winner: "draw" }+] as const;++const section9 = [+ { start: 3000, winner: "attacker", attackerSurvivors: 2201, defenderSurvivors: 0, profile: SECTION9_FIRST_TWO_PROFILE },+ { start: 2201, winner: "attacker", attackerSurvivors: 1233, defenderSurvivors: 0, profile: SECTION9_FIRST_TWO_PROFILE },+ { start: 1500, winner: "defender", attackerSurvivors: 0, defenderSurvivors: 85, profile: SECTION9_LAST_FOUR_PROFILE },+ { start: 1950, winner: "defender", attackerSurvivors: 0, defenderSurvivors: 45, profile: SECTION9_LAST_FOUR_PROFILE },+ { start: 2100, winner: "defender", attackerSurvivors: 0, defenderSurvivors: 37, profile: SECTION9_LAST_FOUR_PROFILE },+ { start: 2200, winner: "defender", attackerSurvivors: 0, defenderSurvivors: 20, profile: SECTION9_LAST_FOUR_PROFILE }+] as const;++const section15Mixed = [+ { infantry: 1000, lancer: 1000, marksman: 0, winner: "defender", survivors: 4973, rounds: 266 },+ { infantry: 1000, lancer: 0, marksman: 1000, winner: "defender", survivors: 4491, rounds: 278 },+ { infantry: 2000, lancer: 1000, marksman: 0, winner: "defender", survivors: 4338, rounds: 545 },+ { infantry: 1000, lancer: 1000, marksman: 1000, winner: "defender", survivors: 3064, rounds: 238 },+ { infantry: 2000, lancer: 0, marksman: 1000, winner: "attacker", survivors: 1281, rounds: 907 }+] as const;++const section16EmulatorProbes = [+ {+ id: "s16-1500-inf-1500-marksman-vs-1000-inf",+ attackerInfantry: 1500,+ attackerMarksmen: 1500,+ defenderInfantry: 1000,+ winner: "attacker",+ attackerSurvivors: 2777,+ defenderSurvivors: 0+ },+ {+ id: "s16-2000-inf-2000-marksman-vs-1000-inf",+ attackerInfantry: 2000,+ attackerMarksmen: 2000,+ defenderInfantry: 1000,+ winner: "attacker",+ attackerSurvivors: 3809,+ defenderSurvivors: 0+ },+ {+ id: "s16-2000-inf-2000-marksman-vs-5000-inf",+ attackerInfantry: 2000,+ attackerMarksmen: 2000,+ defenderInfantry: 5000,+ winner: "attacker",+ attackerSurvivors: 396,+ defenderSurvivors: 0+ },+ {+ id: "s16-7500-inf-vs-5000-inf",+ attackerInfantry: 7500,+ attackerMarksmen: 0,+ defenderInfantry: 5000,+ winner: "defender",+ attackerSurvivors: 0,+ defenderSurvivors: 2513+ }+] as const;++const section25PressureSeries = [+ {+ id: "s25-2000-inf-2000-marksman-vs-2000-inf",+ attackerInfantry: 2000,+ attackerMarksmen: 2000,+ defenderInfantry: 2000,+ winner: "attacker",+ attackerSurvivors: 3441,+ defenderSurvivors: 0,+ rounds: 92,+ attackerCreditedKills: { infantry: 83, marksman: 617 }+ },+ {+ id: "s25-2000-inf-2000-marksman-vs-3000-inf",+ attackerInfantry: 2000,+ attackerMarksmen: 2000,+ defenderInfantry: 3000,+ winner: "attacker",+ attackerSurvivors: 2959,+ defenderSurvivors: 0,+ rounds: 115,+ attackerCreditedKills: { infantry: 110, marksman: 940 }+ },+ {+ id: "s25-2000-inf-2000-marksman-vs-4000-inf",+ attackerInfantry: 2000,+ attackerMarksmen: 2000,+ defenderInfantry: 4000,+ winner: "attacker",+ attackerSurvivors: 2365,+ defenderSurvivors: 0,+ rounds: 136,+ attackerCreditedKills: { infantry: 114, marksman: 1286 }+ },+ {+ id: "s25-1000-inf-3000-marksman-vs-5000-inf",+ attackerInfantry: 1000,+ attackerMarksmen: 3000,+ defenderInfantry: 5000,+ winner: "defender",+ attackerSurvivors: 0,+ defenderSurvivors: 2110,+ rounds: 109,+ attackerCreditedKills: { infantry: 9, marksman: 1003 }+ },+ {+ id: "s25-3000-inf-1000-marksman-vs-5000-inf",+ attackerInfantry: 3000,+ attackerMarksmen: 1000,+ defenderInfantry: 5000,+ winner: "attacker",+ attackerSurvivors: 554,+ defenderSurvivors: 0,+ rounds: 241,+ attackerCreditedKills: { infantry: 173, marksman: 1577 }+ }+] as const;++export const GATOT_GAME_OBSERVATIONS: readonly GatotGameObservation[] = [+ ...section1.map((row) => {+ const id = `s1-${row.count}-vs-${row.defender}`;+ return observation({+ id,+ section: "1",+ caseLabel: `${row.count.toLocaleString("en-US")} T6 infantry vs ${row.defender.toLocaleString("en-US")} T6 infantry`,+ game: {+ winner: row.winner,+ survivors: {+ ...("attackerSurvivors" in row ? { attacker: row.attackerSurvivors } : {}),+ defender: row.defenderSurvivors+ },+ rounds: row.rounds+ },+ buildInput: thresholdInput(id, row.count, row.defender),+ ...(!("attackerSurvivors" in row)+ ? { notes: "Game attacker survivor count was not recorded; defender zero follows from the recorded attacker win." }+ : {})+ });+ }),+ ...section2.map((row) => {+ const id = `s2-health-1.1-${row.count}-vs-1000`;+ return observation({+ id,+ section: "2",+ caseLabel: `${row.count.toLocaleString("en-US")} T6 infantry vs 1,000 T6 infantry; defender Health x1.1`,+ game: {+ winner: row.winner,+ survivors: { attacker: row.attackerSurvivors, defender: row.defenderSurvivors },+ ...("rounds" in row ? { rounds: row.rounds } : {})+ },+ buildInput: thresholdInput(id, row.count, 1000, 30.02),+ ...(!("rounds" in row) ? { notes: "Winning round was not recorded." } : {})+ });+ }),+ observation({+ id: "s3-5000-inf-vs-1000-inf-10-lancer",+ section: "3",+ caseLabel: "5,000 T6 infantry vs 1,000 T6 infantry + 10 T6 lancers",+ game: { winner: "attacker", survivors: { attacker: 4573, defender: 0 }, rounds: 1151 },+ buildInput: (config) =>+ buildBattle(+ "s3-5000-inf-vs-1000-inf-10-lancer",+ {+ troops: { infantry_t6: 5000 },+ stats: { infantry: { attack: 295.3, defense: 293.3, lethality: 10, health: 10 } },+ heroes: { Gatot: skills(1, 3, 3) }+ },+ {+ troops: { infantry_t6: 1000, lancer_t6: 10 },+ stats: {+ infantry: T6_STRONG_INFANTRY_STATS,+ lancer: T6_STRONG_BACKLINE_STATS+ },+ heroes: { Gatot: GATOT_222, Gordon: skills(3, 3, 3) }+ },+ config+ )+ }),+ ...section4.map((row) => {+ const id = `s4-fc9-gatot-${row.count}`;+ return observation({+ id,+ section: "4",+ caseLabel: `${row.count.toLocaleString("en-US")} T11 FC9 infantry vs same count T10 FC9 infantry`,+ stochastic: true,+ game: {+ winner: "attacker",+ survivors: { attacker: row.attackerSurvivors, defender: 0 },+ rounds: row.rounds+ },+ buildInput: fc9GatotInput(id, row.count, false),+ notes: "Attacker win and zero defender survivors are inferred from the recorded positive attacker survivor count."+ });+ }),+ ...section5.map((row) => {+ const id = `s5-fc9-gatot-lumak-${row.count}`;+ return observation({+ id,+ section: "5",+ caseLabel: `${row.count.toLocaleString("en-US")} T11 FC9 infantry + Lumak S1 vs same count T10 FC9 infantry`,+ stochastic: true,+ game: {+ winner: "attacker",+ survivors: { attacker: row.attackerSurvivors, defender: 0 },+ rounds: row.rounds+ },+ buildInput: fc9GatotInput(id, row.count, true),+ notes: "Attacker win and zero defender survivors are inferred from the recorded positive attacker survivor count."+ });+ }),+ observation({+ id: "s6-historical-t11-fc10-vs-10000-t6-marksmen",+ section: "6",+ caseLabel: "10,000 T6 marksmen vs one historical T11 FC10 infantry",+ game: { winner: "draw", survivors: { attacker: 5895, defender: 1 }, rounds: 1500 },+ notRunnableReason:+ "The complete attacker stat block was supplied only by screenshot and is not recoverable from the evidence document; the historical T11 FC10 catalogue has also been replaced."+ }),+ observation({+ id: "s6-historical-t11-fc10-vs-3000-t6-marksmen",+ section: "6",+ caseLabel: "3,000 T6 marksmen vs one historical T11 FC10 infantry",+ game: { winner: "defender", survivors: { attacker: 0, defender: 1 }, rounds: 1086 },+ notRunnableReason:+ "The complete attacker stat block was supplied only by screenshot and is not recoverable from the evidence document; the historical T11 FC10 catalogue has also been replaced."+ }),+ observation({+ id: "s7-10000-t6-marksmen-vs-one-t1-fc10-infantry",+ section: "7",+ caseLabel: "10,000 T6 marksmen vs one T1 FC10 infantry",+ game: { winner: "draw", survivors: { attacker: 9540, defender: 1 }, rounds: 1500 },+ buildInput: singleFc10InfantryInput("s7-10000-t6-marksmen-vs-one-t1-fc10-infantry", 6, 10000)+ }),+ ...section8.map((row) => {+ const id = `s8-${row.start}-t9-marksmen-vs-one-t1-fc10-infantry`;+ const winner = "winner" in row ? row.winner : "attacker";+ return observation({+ id,+ section: "8",+ caseLabel: `${row.start.toLocaleString("en-US")} T9 marksmen vs one T1 FC10 infantry`,+ stochastic: true,+ game: {+ winner,+ survivors: { attacker: row.survivors, defender: winner === "draw" ? 1 : 0 },+ rounds: row.rounds+ },+ buildInput: singleFc10InfantryInput(id, 9, row.start)+ });+ }),+ ...section9.map((row) => {+ const id = `s9-${row.start}-t9-lancers-vs-146-t1-fc10-lancers`;+ return observation({+ id,+ section: "9",+ caseLabel: `${row.start.toLocaleString("en-US")} T9 lancers vs 146 T1 FC10 lancers`,+ stochastic: true,+ game: {+ winner: row.winner,+ survivors: { attacker: row.attackerSurvivors, defender: row.defenderSurvivors }+ },+ buildInput: lancerCalibrationInput(id, row.start, row.profile),+ notes: "No direct game round count was supplied; only activation-based estimates exist for one row."+ });+ }),+ observation({+ id: "s10-bradley-1000-inf-125-marksman-vs-5000-inf",+ section: "10",+ caseLabel: "1,000 T6 infantry + 125 T6 marksmen vs 5,000 T6 infantry; Bradley",+ game: { winner: "defender", survivors: { attacker: 0, defender: 2296 }, rounds: 875 },+ buildInput: (config) =>+ buildBattle(+ "s10-bradley-1000-inf-125-marksman-vs-5000-inf",+ {+ troops: { infantry_t6: 1000, marksman_t6: 125 },+ stats: {+ infantry: T6_STRONG_INFANTRY_STATS,+ marksman: T6_STRONG_BACKLINE_STATS+ },+ heroes: { Gatot: GATOT_222, Bradley: skills(3, 3, 3) }+ },+ {+ troops: { infantry_t6: 5000 },+ stats: { infantry: T6_WEAK_INFANTRY_STATS },+ heroes: { Gatot: GATOT_133 }+ },+ config+ )+ }),+ observation({+ id: "s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman",+ section: "15.2",+ caseLabel: "1,000 T6 infantry + 125 lancers + 125 marksmen vs 5,000 infantry; secondary heroes",+ game: { winner: "attacker", survivors: { attacker: 851, defender: 0 }, rounds: 366 },+ buildInput: (config) =>+ buildBattle(+ "s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman",+ {+ troops: { infantry_t6: 1000, lancer_t6: 125, marksman_t6: 125 },+ stats: {+ infantry: T6_STRONG_INFANTRY_STATS,+ lancer: T6_STRONG_BACKLINE_STATS,+ marksman: T6_STRONG_BACKLINE_STATS+ },+ heroes: {+ Gatot: GATOT_222,+ Gordon: skills(3, 3, 3),+ Bradley: skills(3, 3, 3)+ }+ },+ {+ troops: { infantry_t6: 5000 },+ stats: { infantry: T6_WEAK_INFANTRY_STATS },+ heroes: {+ Gatot: GATOT_133,+ Patrick: skills(1, 0),+ Bradley: skills(1, 3, 3)+ }+ },+ config+ )+ }),+ ...section15Mixed.map((row) => {+ const id = `s15.3-${row.infantry}-inf-${row.lancer}-lancer-${row.marksman}-marksman`;+ const isTriple = row.infantry === 1000 && row.lancer === 1000 && row.marksman === 1000;+ return observation({+ id,+ section: isTriple ? "15.1 / 15.3" : "15.3",+ caseLabel: `${row.infantry.toLocaleString("en-US")} infantry + ${row.lancer.toLocaleString(+ "en-US"+ )} lancer + ${row.marksman.toLocaleString("en-US")} marksman vs 5,000 infantry`,+ game: {+ winner: row.winner,+ survivors:+ row.winner === "attacker"+ ? { attacker: row.survivors, defender: 0 }+ : { attacker: 0, defender: row.survivors },+ rounds: row.rounds+ },+ buildInput: gatotMixedInput(id, row.infantry, row.lancer, row.marksman),+ ...(isTriple ? { notes: "One observation cross-referenced in sections 15.1 and 15.3; counted once." } : {})+ });+ }),+ ...section16EmulatorProbes.map((row) =>+ observation({+ id: row.id,+ section: "16",+ caseLabel:+ `${row.attackerInfantry.toLocaleString("en-US")} T6 infantry` ++ (row.attackerMarksmen > 0+ ? ` + ${row.attackerMarksmen.toLocaleString("en-US")} T6 marksmen`+ : "") ++ ` vs ${row.defenderInfantry.toLocaleString("en-US")} T6 infantry; defender Gatot S2 level 1`,+ game: {+ winner: row.winner,+ survivors: {+ attacker: row.attackerSurvivors,+ defender: row.defenderSurvivors+ }+ },+ buildInput: emulatorGatotProbeInput(+ row.id,+ row.attackerInfantry,+ row.attackerMarksmen,+ row.defenderInfantry+ ),+ notes: "Captured from the minxxx/WIP emulator verification run; game round count was not recorded."+ })+ ),+ ...section25PressureSeries.map((row) =>+ observation({+ id: row.id,+ section: "25",+ caseLabel:+ `${row.attackerInfantry.toLocaleString("en-US")} T6 infantry` ++ ` + ${row.attackerMarksmen.toLocaleString("en-US")} T6 marksmen` ++ ` vs ${row.defenderInfantry.toLocaleString("en-US")} T6 infantry; defender Gatot S2 level 1`,+ game: {+ winner: row.winner,+ survivors: {+ attacker: row.attackerSurvivors,+ defender: row.defenderSurvivors+ },+ rounds: row.rounds,+ attackerCreditedKills: row.attackerCreditedKills+ },+ buildInput: emulatorGatotProbeInput(+ row.id,+ row.attackerInfantry,+ row.attackerMarksmen,+ row.defenderInfantry,+ PRESSURE_SERIES_DEFENDER_INFANTRY_STATS+ ),+ notes:+ "Captured as one matched minxxx-attacker/WIP-defender pressure series. Defender WIP Infantry stats were 486.6/491.9/157.5/170.9. Battle Details supplies exact rounds and attacker Infantry/Marksman kill credits."+ })+ ),+ observation({+ id: "s31-current-2000-inf-2000-marksman-vs-5000-inf",+ section: "31",+ caseLabel:+ "2,000 T6 infantry + 2,000 T6 marksmen vs 5,000 T6 infantry; current displayed stats; defender Gatot S2 level 1",+ game: {+ winner: "attacker",+ survivors: { attacker: 2291, defender: 0 },+ rounds: 148,+ attackerCreditedKills: { infantry: 139, marksman: 1611 }+ },+ buildInput: latestMidpointInput("s31-current-2000-inf-2000-marksman-vs-5000-inf"),+ notes:+ "Captured 2026-07-28 from minxxx attacking WIP, Mail ID 2734688471638830. Battle Details records 148 Gatot S2 triggers; with one uncontrolled defender formation this is one trigger per battle round. Displayed stats differ materially from sections 16 and 25, so this is a separate observation rather than a continuation of either pressure series."+ }),+ observation({+ id: "s32-current-750-marksman-wayne-vs-3000-inf",+ section: "32",+ caseLabel:+ "750 T6 marksmen with Wayne S1 vs 3,000 T6 infantry; current displayed stats; defender Gatot S2 level 1",+ game: {+ winner: "defender",+ survivors: { attacker: 0, defender: 2738 }+ },+ buildInput: latestWayneExtraSkillInput("s32-current-750-marksman-wayne-vs-3000-inf"),+ notes:+ "Captured 2026-07-28 from minxxx attacking WIP, report timestamp 1785255283. Wayne S1 adds a deterministic ThunderStrike extra-skill attack every four turns. Displayed attacker stats are authoritative and must not be replaced by the config's full hero-generation block. Game round count was not recorded."+ }),+ observation({+ id: "s33-current-500-marksman-wayne3-vs-5000-gatot3-inf",+ section: "33",+ caseLabel:+ "500 T6 marksmen with Wayne 3/3/3 vs 5,000 T6 infantry; current displayed stats; defender Gatot S2 level 3",+ stochastic: true,+ game: {+ winner: "defender",+ survivors: { attacker: 0, defender: 4977 }+ },+ buildInput: latestWayneLevel3ScopeInput(+ "s33-current-500-marksman-wayne3-vs-5000-gatot3-inf"+ ),+ notes:+ "Captured 2026-07-28 from WIP attacking minxxx, report timestamp 1785256420. Wayne S1 adds a deterministic 60% ThunderStrike every four turns and Wayne S3 is stochastic. This observation distinguishes Gatot normal-only shielding from shielding genuine extra-skill jobs. Game round count was not recorded."+ }),+ observation({+ id: "s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf",+ section: "34",+ caseLabel:+ "2,100 T6 lancers + 2,100 T6 marksmen vs 1,100 T6 infantry; current displayed stats; defender Gatot S2 level 3",+ game: {+ winner: "defender",+ survivors: { attacker: 0, defender: 236 },+ rounds: 179,+ attackerCreditedKills: { lancer: 14, marksman: 289 }+ },+ buildInput: latestDistributionProbeInput(+ "s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf",+ 2100,+ 2100+ ),+ notes:+ "Captured 2026-07-28 from WIP attacking minxxx after WIP moved alliance, Mail ID 2734688471640946, report timestamp 1785258335. Attacker troops were T6 Lancers and Marksmen. Battle Details records 179 Gatot S2 triggers and credits 14 Lancer kills plus 289 Marksman kills."+ }),+ observation({+ id: "s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf",+ section: "35",+ caseLabel:+ "3,000 T6 lancers + 1,000 T6 marksmen vs 1,100 T6 infantry; current displayed stats; defender Gatot S2 level 3",+ game: {+ winner: "defender",+ survivors: { attacker: 0, defender: 314 },+ rounds: 167,+ attackerCreditedKills: { lancer: 45, marksman: 231 }+ },+ buildInput: latestDistributionProbeInput(+ "s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf",+ 3000,+ 1000+ ),+ notes:+ "Captured 2026-07-28 from WIP attacking minxxx after WIP moved alliance, Mail ID 2734688471644964, report timestamp 1785260326. Attacker troops were 3,000 T6 Lancers and 1,000 T6 Marksmen. Battle Details records 167 Gatot S2 triggers and credits 45 Lancer kills plus 231 Marksman kills."+ }),+ observation({+ id: "s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf",+ section: "36",+ caseLabel:+ "600 T6 lancers + 600 T6 marksmen vs 1,100 T6 infantry; current displayed stats; defender Gatot S2 level 3",+ game: {+ winner: "defender",+ survivors: { attacker: 0, defender: 1058 },+ rounds: 36,+ attackerCreditedKills: { lancer: 1, marksman: 14 }+ },+ buildInput: latestDistributionProbeInput(+ "s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf",+ 600,+ 600+ ),+ notes:+ "Captured 2026-07-28 from WIP attacking minxxx after WIP moved alliance, report timestamp 1785261214. Attacker troops were 600 T6 Lancers and 600 T6 Marksmen. Battle Details records 36 Gatot S2 triggers and credits 1 Lancer kill plus 14 Marksman kills."+ }),+ observation({+ id: "s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf",+ section: "37",+ caseLabel:+ "2,000 T6 infantry + 2,000 T6 marksmen with Gatot S2 level 3 vs 5,000 T6 infantry with Gatot S2 level 1; current displayed stats",+ game: {+ winner: "attacker",+ survivors: { attacker: 3782, defender: 0 },+ rounds: 125,+ attackerCreditedKills: { infantry: 391, marksman: 1359 }+ },+ buildInput: latestBilateralPressureInput(+ "s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf"+ ),+ notes:+ "Captured 2026-07-28 from minxxx attacking WIP, report timestamp 1785263161. This is a bilateral Gatot probe using the accounts' saved 1/3/1 and 1/1/1 skill levels. Battle Details records round 125 and credits 391 Infantry kills plus 1,359 Marksman kills; those 1,750 credits equal the defender's Injured count, while the other 3,250 casualties are Lightly Injured. Full Marksman priority predicts A3782 exactly in aggregate, but its per-formation damage attribution is incompatible with those credited-kill proportions."+ }),+ observation({+ id: "s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf",+ section: "38",+ caseLabel:+ "800 T6 infantry + 800 T6 marksmen with Gatot S2 level 1 vs 1,100 T6 infantry with Gatot S2 level 3; current displayed stats",+ game: {+ winner: "defender",+ survivors: { attacker: 0, defender: 224 },+ rounds: 343,+ attackerCreditedKills: { infantry: 1, marksman: 306 }+ },+ buildInput: latestBilateralBreakInput(+ "s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf"+ ),+ notes:+ "Captured 2026-07-28 from WIP attacking minxxx, report timestamp 1785264150. Battle Details records round 343 and credits 1 Infantry kill plus 306 Marksman kills; those 307 credits equal the defender's Injured count, while the other 569 casualties are Lightly Injured. Across all 4,096 corners of the displayed stats' one-decimal rounding intervals, full Marksman priority predicts a defender win with 214–223 survivors, while fixed-hypot distribution predicts an attacker win with 866–869 survivors. The aggregate result alone favored full Marksman priority, but the credited-kill attribution decisively contradicts it."+ }),+ observation({+ id: "s39-1000-lancer-400-marksman-vs-500-gatot3-inf",+ section: "39",+ caseLabel:+ "1,000 T6 lancers + 400 T6 marksmen vs 500 T6 infantry; current displayed stats; defender Gatot S2 level 3; every initial hit below one complete shield",+ game: {+ winner: "defender",+ survivors: { attacker: 0, defender: 397 },+ rounds: 101+ },+ buildInput: latestLowPressurePoolProbeInput(+ "s39-1000-lancer-400-marksman-vs-500-gatot3-inf"+ ),+ notes:+ "Captured 2026-07-29 from WIP attacking minxxx, report timestamp 1785314318. The Battle Runner deployment log is authoritative for 1,000 T6 Lancers plus 400 T6 Marksmen; the cropped report-avatar parser repeated its known Lancer-as-Infantry misclassification. Battle Details records 101 Gatot S2 triggers. Exact report stats give attack-order pool D397@101 versus fixed-hypot D346@103. Before capture, every ±0.05 displayed-stat corner predicted pool D398–399@100–101 and fixed-hypot D347@103, so this directly validates conserved pooling below the one-complete-shield break threshold."+ }),+ observation({+ id: "s40-2000-inf-1000-marksman-vs-5000-inf",+ section: "40",+ caseLabel:+ "2,000 T6 infantry + 1,000 T6 marksmen with Gatot S2 level 3 vs 5,000 T6 infantry with Gatot S2 level 1; Section 15 bridge",+ game: {+ winner: "attacker",+ survivors: { attacker: 2802, defender: 0 },+ rounds: 189,+ attackerCreditedKills: { infantry: 499, marksman: 1251 }+ },+ buildInput: latestSection15BridgeInput(+ "s40-2000-inf-1000-marksman-vs-5000-inf"+ ),+ notes:+ "Captured 2026-07-29 from minxxx attacking WIP at 12:05:47 as the pre-registered direct bridge to the failed external Section 15 composition. The historical uncapped pool gives A2799@190; the smaller-initial-army count cap gives the exact A2802@189 report endpoint. Live credited Infantry/Marksman shares are 28.514/71.486% versus capped-pool 28.506/71.494%."+ }),+ observation({+ id: "s41-300-inf-200-lancer-600-marksman-vs-500-inf",+ section: "41",+ caseLabel:+ "300 T6 infantry + 200 T6 lancers + 600 T6 marksmen with Gatot S2 level 1 vs 500 T6 infantry with Gatot S2 level 3; three-formation pool checklist",+ game: {+ winner: "attacker",+ survivors: { attacker: 939, defender: 0 },+ rounds: 196,+ attackerCreditedKills: { infantry: 1, lancer: 10, marksman: 164 }+ },+ buildInput: latestThreeFormationPoolChecklistInput(+ "s41-300-inf-200-lancer-600-marksman-vs-500-inf"+ ),+ notes:+ "Captured 2026-07-29 from WIP attacking minxxx as a pre-registered combined pool discriminator. Exact displayed stats and production pool predict A939@196; the two opposing all-favorable ±0.05 stat corners give A939–940@196. On round 2, unshielded Infantry/Lancer/Marksman damage is 0.818/1.106/2.670 against one 2.644 shield: Infantry and Lancer are fully blocked and the remaining 0.721 offsets Marksman. Live credited kills are I1/L10/M164 (0.571/5.714/93.714%) versus pool 0.310/6.322/93.368%; reverse pool predicts 26.077/43.162/30.761%. Fixed initial per-side shield capacities predict A930@282, fixed-hypot A950@186, and Infantry-only no-spill A1008@132. This directly validates one conserved pool spilling across both attack-order boundaries while its source magnitude shrinks."+ }),+ observation({+ id: "s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf",+ section: "42",+ caseLabel:+ "200 T6 infantry with Gatot S2 level 3 vs 450 T6 infantry with Gatot S2 level 1 and Wayne 3/3/3; extra-skill shield scope",+ stochastic: true,+ game: {+ winner: "draw",+ survivors: { attacker: 200, defender: 168 },+ rounds: 1500+ },+ buildInput: latestWayneShieldScopeInput(+ "s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf"+ ),+ notes:+ "Captured 2026-07-29 from minxxx attacking WIP as a pre-registered extra-skill scope discriminator. Both Gatot shields triggered 1,500 times; WIP Wayne recorded 375 Thunder Strike and 234 Fleet triggers. Minxxx retained all 200 Infantry with zero casualties, decisively showing that Gatot shields genuine extra-skill damage. The smaller-initial-army shield-count cap reproduces WIP's exact 168 survivors across the sampled seeds and leaves minxxx at 199–200. The report UI awards WIP the max-round win; the evidence comparison normalizes live/live at the round cap to a draw."+ }),+ observation({+ id: "s43-current-reciprocal-shield-cap-450-inf-vs-200-inf",+ section: "43",+ caseLabel:+ "450 T6 infantry with Gatot S2 level 1 vs 200 T6 infantry with Gatot S2 level 3; reciprocal shield-count cap",+ game: {+ winner: "draw",+ survivors: { attacker: 173, defender: 200 },+ rounds: 1500+ },+ buildInput: latestReciprocalShieldMagnitudeInput(+ "s43-current-reciprocal-shield-cap-450-inf-vs-200-inf",+ 450,+ 200+ ),+ notes:+ "Captured 2026-07-29 from WIP attacking minxxx without Wayne. Both Gatot shields triggered 1,500 times. Capping each shield's living Infantry count at the smaller initial army reproduces the exact 173/200 endpoint; the uncapped model predicts 450/200. The report UI awards minxxx the max-round win; the evidence comparison normalizes live/live at the round cap to a draw."+ }),+ observation({+ id: "s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf",+ section: "44",+ caseLabel:+ "2,500 T6 infantry with Gatot S2 level 1 vs 500 T6 infantry with Gatot S2 level 3; reciprocal shield-count cap",+ game: {+ winner: "draw",+ survivors: { attacker: 975, defender: 498 },+ rounds: 1500+ },+ buildInput: latestReciprocalShieldMagnitudeInput(+ "s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf",+ 2500,+ 500+ ),+ notes:+ "Captured 2026-07-29 from WIP attacking minxxx. Both Gatot shields triggered 1,500 times; WIP lost 1,525 Infantry and minxxx lost 2. Capping each shield's living Infantry count at the smaller initial army reproduces the exact 975/498 endpoint; the uncapped model predicts 2,499/498. The report UI awards minxxx the max-round win; the evidence comparison normalizes live/live at the round cap to a draw."+ })+];++function totalSurvivors(result: BattleResult, side: SideId): number {+ return Object.values(result.remaining[side]).reduce((sum, count) => sum + count, 0);+}++function totalInputTroops(fighter: FighterInput): number {+ return Object.values(fighter.troops ?? {}).reduce((sum, count) => sum + Number(count || 0), 0);+}++function summarize(values: number[]): NumericSummary {+ if (values.length === 0) throw new Error("Cannot summarize an empty sample");+ return {+ mean: values.reduce((sum, value) => sum + value, 0) / values.length,+ min: Math.min(...values),+ max: Math.max(...values)+ };+}++function quantile(values: readonly number[], probability: number): number {+ if (values.length === 0) throw new Error("Cannot calculate a quantile for an empty sample");+ const sorted = [...values].sort((left, right) => left - right);+ const position = (sorted.length - 1) * probability;+ const lowerIndex = Math.floor(position);+ const upperIndex = Math.ceil(position);+ const lower = sorted[lowerIndex]!;+ const upper = sorted[upperIndex]!;+ return lower + (upper - lower) * (position - lowerIndex);+}++function summarizePrediction(values: number[]): PredictionSummary {+ return {+ ...summarize(values),+ lowerBound: quantile(values, STOCHASTIC_PREDICTION_INTERVAL_TAIL),+ upperBound: quantile(values, 1 - STOCHASTIC_PREDICTION_INTERVAL_TAIL)+ };+}++export function runGatotEvidence(options: GatotEvidenceRunOptions = {}): GatotEvidenceResult[] {+ const replicates = options.replicates ?? DEFAULT_REPLICATES;+ if (!Number.isInteger(replicates) || replicates < 1) {+ throw new Error(`replicates must be a positive integer, got ${JSON.stringify(replicates)}`);+ }++ const config = loadSimulatorConfig();+ const observations = GATOT_GAME_OBSERVATIONS.filter(+ (entry) =>+ !options.matching ||+ entry.id.includes(options.matching) ||+ entry.section.includes(options.matching) ||+ entry.caseLabel.toLowerCase().includes(options.matching.toLowerCase())+ );++ return observations.map((entry) => {+ if (!entry.buildInput) return { observation: entry, sampleCount: 0 };+ const sampleCount = entry.stochastic ? replicates : 1;+ try {+ const input = entry.buildInput(config);+ const initialTroopCount = totalInputTroops(input.attacker) + totalInputTroops(input.defender);+ const results = simulateBattles(input, config, {+ mode: "fast",+ count: sampleCount+ });+ const winnerCounts: Record<EvidenceWinner, number> = { attacker: 0, defender: 0, draw: 0 };+ for (const result of results) winnerCounts[result.winner] += 1;+ const matchingResults = results.filter((result) => result.winner === entry.game.winner);+ return {+ observation: entry,+ sampleCount,+ initialTroopCount,+ winnerCounts,+ attackerSurvivors: summarize(results.map((result) => totalSurvivors(result, "attacker"))),+ defenderSurvivors: summarize(results.map((result) => totalSurvivors(result, "defender"))),+ rounds: summarize(results.map((result) => result.rounds)),+ ...(matchingResults.length > 0+ ? {+ matchingGameOutcome: {+ sampleCount: matchingResults.length,+ attackerSurvivors: summarizePrediction(+ matchingResults.map((result) => totalSurvivors(result, "attacker"))+ ),+ defenderSurvivors: summarizePrediction(+ matchingResults.map((result) => totalSurvivors(result, "defender"))+ ),+ rounds: summarizePrediction(matchingResults.map((result) => result.rounds))+ }+ }+ : {})+ };+ } catch (error) {+ return {+ observation: entry,+ sampleCount,+ error: error instanceof Error ? error.message : String(error)+ };+ }+ });+}++function winnerLabel(winner: EvidenceWinner): string {+ if (winner === "attacker") return "Attacker";+ if (winner === "defender") return "Defender";+ return "Draw";+}++function formatNumber(value: number): string {+ const rounded = Math.round(value);+ const shown = Math.abs(value - rounded) < 0.05 ? String(rounded) : value.toFixed(1);+ const [integer, decimal] = shown.split(".");+ const signed = integer.startsWith("-") ? `-${integer.slice(1).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}` : integer.replace(/\B(?=(\d{3})+(?!\d))/g, ",");+ return decimal === undefined ? signed : `${signed}.${decimal}`;+}++function formatSigned(value: number): string {+ if (Math.abs(value) < 0.05) return "0";+ return `${value > 0 ? "+" : "−"}${formatNumber(Math.abs(value))}`;+}++function formatSummary(summary: NumericSummary, stochastic: boolean): string {+ const mean = formatNumber(summary.mean);+ return stochastic ? `${mean} [${formatNumber(summary.min)}–${formatNumber(summary.max)}]` : mean;+}++function gameSurvivors(entry: GatotGameObservation): string {+ const attacker = entry.game.survivors.attacker;+ const defender = entry.game.survivors.defender;+ return `A ${attacker === undefined ? "—" : formatNumber(attacker)} / D ${+ defender === undefined ? "—" : formatNumber(defender)+ }`;+}++function simulatedWinner(result: GatotEvidenceResult): string {+ if (!result.winnerCounts || result.sampleCount === 0) return "—";+ if (!result.observation.stochastic) {+ const winner = (Object.entries(result.winnerCounts) as Array<[EvidenceWinner, number]>).find(+ ([, count]) => count > 0+ )?.[0];+ return winner ? winnerLabel(winner) : "—";+ }+ return (["attacker", "defender", "draw"] as const)+ .filter((winner) => result.winnerCounts![winner] > 0)+ .map(+ (winner) =>+ `${winnerLabel(winner)} ${formatNumber((100 * result.winnerCounts![winner]) / result.sampleCount)}%`+ )+ .join(" / ");+}++function simulatedSurvivors(result: GatotEvidenceResult): string {+ if (!result.attackerSurvivors || !result.defenderSurvivors) return "—";+ return `A ${formatSummary(result.attackerSurvivors, result.observation.stochastic)} / D ${formatSummary(+ result.defenderSurvivors,+ result.observation.stochastic+ )}`;+}++function survivorDifference(result: GatotEvidenceResult): string {+ if (!result.attackerSurvivors || !result.defenderSurvivors) return "—";+ const game = result.observation.game.survivors;+ return `A ${+ game.attacker === undefined ? "—" : formatSigned(result.attackerSurvivors.mean - game.attacker)+ } / D ${game.defender === undefined ? "—" : formatSigned(result.defenderSurvivors.mean - game.defender)}`;+}++type AssessmentStatus = "OK" | "NOT OK" | "NOT ASSESSED";++interface EvidenceAssessment {+ status: AssessmentStatus;+ reasons: string[];+}++function metricLabel(metric: "attackerSurvivors" | "defenderSurvivors" | "rounds"): string {+ if (metric === "attackerSurvivors") return "attacker survivors";+ if (metric === "defenderSurvivors") return "defender survivors";+ return "rounds";+}++function recordedMetrics(+ result: GatotEvidenceResult+): Array<{+ metric: "attackerSurvivors" | "defenderSurvivors" | "rounds";+ gameValue: number;+ simulated: NumericSummary;+}> {+ const metrics: Array<{+ metric: "attackerSurvivors" | "defenderSurvivors" | "rounds";+ gameValue: number;+ simulated: NumericSummary;+ }> = [];+ const game = result.observation.game;+ if (game.survivors.attacker !== undefined && result.attackerSurvivors) {+ metrics.push({+ metric: "attackerSurvivors",+ gameValue: game.survivors.attacker,+ simulated: result.attackerSurvivors+ });+ }+ if (game.survivors.defender !== undefined && result.defenderSurvivors) {+ metrics.push({+ metric: "defenderSurvivors",+ gameValue: game.survivors.defender,+ simulated: result.defenderSurvivors+ });+ }+ if (game.rounds !== undefined && result.rounds) {+ metrics.push({ metric: "rounds", gameValue: game.rounds, simulated: result.rounds });+ }+ return metrics;+}++function assessEvidenceResult(result: GatotEvidenceResult): EvidenceAssessment {+ if (!result.observation.buildInput) return { status: "NOT ASSESSED", reasons: ["not runnable"] };+ if (result.error) return { status: "NOT OK", reasons: [`simulation error: ${result.error}`] };+ if (!result.winnerCounts || !result.attackerSurvivors || !result.defenderSurvivors || !result.rounds) {+ return { status: "NOT OK", reasons: ["simulation result is incomplete"] };+ }++ const gameWinner = result.observation.game.winner;+ if (!result.observation.stochastic) {+ const reasons: string[] = [];+ if (!result.initialTroopCount || result.initialTroopCount <= 0) {+ return { status: "NOT OK", reasons: ["initial troop count is unavailable"] };+ }+ if (result.winnerCounts[gameWinner] !== result.sampleCount) {+ reasons.push(`winner is ${simulatedWinner(result)}, game is ${winnerLabel(gameWinner)}`);+ }+ for (const { metric, gameValue, simulated } of recordedMetrics(result)) {+ const absoluteDifference = Math.abs(simulated.mean - gameValue);+ if (metric === "rounds") {+ const allowedDifference = Math.max(+ DETERMINISTIC_MINIMUM_ROUND_TOLERANCE,+ Math.abs(gameValue) * DETERMINISTIC_RELATIVE_TOLERANCE+ );+ if (absoluteDifference > allowedDifference) {+ reasons.push(+ `rounds differ by ${formatNumber(absoluteDifference)}; allowed ${formatNumber(allowedDifference)}`+ );+ }+ continue;+ }+ const relativeDifference = absoluteDifference / result.initialTroopCount;+ if (relativeDifference > DETERMINISTIC_RELATIVE_TOLERANCE) {+ reasons.push(+ `${metricLabel(metric)} differ by ${formatNumber(+ 100 * relativeDifference+ )}% of initial battlefield troops`+ );+ }+ }+ return { status: reasons.length === 0 ? "OK" : "NOT OK", reasons };+ }++ const matching = result.matchingGameOutcome;+ const winnerProbability = result.winnerCounts[gameWinner] / result.sampleCount;+ const reasons: string[] = [];+ if (winnerProbability < STOCHASTIC_MINIMUM_WINNER_PROBABILITY) {+ reasons.push(+ `${winnerLabel(gameWinner)} occurs in ${formatNumber(100 * winnerProbability)}% of simulations`+ );+ }+ if (!matching) {+ reasons.push("recorded winner never occurs");+ return { status: "NOT OK", reasons };+ }++ const conditionalMetrics = {+ attackerSurvivors: matching.attackerSurvivors,+ defenderSurvivors: matching.defenderSurvivors,+ rounds: matching.rounds+ };+ for (const { metric, gameValue } of recordedMetrics(result)) {+ const values = conditionalMetrics[metric];+ const lower = values.lowerBound;+ const upper = values.upperBound;+ if (gameValue < lower || gameValue > upper) {+ reasons.push(+ `${metricLabel(metric)} ${formatNumber(gameValue)} outside conditional ${formatNumber(+ 100 * (1 - 2 * STOCHASTIC_PREDICTION_INTERVAL_TAIL)+ )}% interval ${formatNumber(lower)}–${formatNumber(upper)}`+ );+ }+ }+ return { status: reasons.length === 0 ? "OK" : "NOT OK", reasons };+}++function statusAndNotes(result: GatotEvidenceResult): string {+ const pieces: string[] = [];+ if (result.observation.notRunnableReason) pieces.push(`Not runnable: ${result.observation.notRunnableReason}`);+ else if (result.error) pieces.push(`Error: ${result.error}`);+ else {+ const assessment = assessEvidenceResult(result);+ pieces.push(+ `${assessment.status}; ${result.observation.stochastic ? `stochastic; n=${result.sampleCount}` : "deterministic"}`+ );+ if (assessment.reasons.length > 0) pieces.push(assessment.reasons.join("; "));+ }+ if (result.observation.notes) pieces.push(result.observation.notes);+ return pieces.join(" ").replaceAll("|", "\\|");+}++export function renderGatotEvidenceMarkdown(results: readonly GatotEvidenceResult[]): string {+ const stochasticSamples = results.reduce(+ (sum, result) => sum + (result.observation.stochastic ? result.sampleCount : 0),+ 0+ );+ const runnable = results.filter((result) => result.observation.buildInput).length;+ const notRunnable = results.length - runnable;+ const lines = [+ "# Gatot battle evidence check",+ "",+ `Observations: ${results.length}; runnable: ${runnable}; not runnable: ${notRunnable}; stochastic samples executed: ${stochasticSamples}.`,+ "",+ "Differences are `simulator − game`. Stochastic simulator cells show `mean [min–max]`.",+ "Displayed report stats are passed through unchanged; hero-generation stats are not added again. Each stochastic case uses a stable case-id seed and the simulator's `<seed>#<replicate>` derivation.",+ "",+ "| Section | Case | Game outcome | Game survivors | Simulated outcome | Simulated survivors | Survivor difference | Game rounds | Simulated rounds | Round difference | Status / notes |",+ "|---|---|---|---:|---|---:|---:|---:|---:|---:|---|"+ ];++ for (const result of results) {+ const entry = result.observation;+ const simulatedRounds = result.rounds+ ? formatSummary(result.rounds, entry.stochastic)+ : "—";+ const roundDifference =+ result.rounds && entry.game.rounds !== undefined+ ? formatSigned(result.rounds.mean - entry.game.rounds)+ : "—";+ lines.push(+ `| ${entry.section} | ${entry.caseLabel.replaceAll("|", "\\|")} | ${winnerLabel(entry.game.winner)} | ${gameSurvivors(+ entry+ )} | ${simulatedWinner(result)} | ${simulatedSurvivors(result)} | ${survivorDifference(result)} | ${+ entry.game.rounds === undefined ? "—" : formatNumber(entry.game.rounds)+ } | ${simulatedRounds} | ${roundDifference} | ${statusAndNotes(result)} |`+ );+ }++ const assessments = results.map((result) => ({+ result,+ assessment: assessEvidenceResult(result)+ }));+ const countsFor = (stochastic: boolean, status: AssessmentStatus) =>+ assessments.filter(+ ({ result, assessment }) =>+ result.observation.stochastic === stochastic && assessment.status === status+ ).length;+ const totalFor = (stochastic: boolean) =>+ assessments.filter(({ result }) => result.observation.stochastic === stochastic).length;+ const outsideThreshold = assessments.filter(({ assessment }) => assessment.status === "NOT OK");++ lines.push(+ "",+ "## Threshold summary",+ "",+ `- Deterministic: **OK** requires the recorded winner, survivor differences within ${formatNumber(+ 100 * DETERMINISTIC_RELATIVE_TOLERANCE+ )}% of the two armies' combined initial troop count, and round differences within the greater of ${formatNumber(+ DETERMINISTIC_MINIMUM_ROUND_TOLERANCE+ )} rounds or ${formatNumber(+ 100 * DETERMINISTIC_RELATIVE_TOLERANCE+ )}% of the game round count.`,+ `- Stochastic: **OK** requires the recorded winner in at least ${formatNumber(+ 100 * STOCHASTIC_MINIMUM_WINNER_PROBABILITY+ )}% of replicates and every recorded survivor/round value inside the central ${formatNumber(+ 100 * (1 - 2 * STOCHASTIC_PREDICTION_INTERVAL_TAIL)+ )}% interval among replicates with that winner.`,+ "- Unrecorded values are ignored. Non-runnable observations are not assessed.",+ "",+ "| Case type | OK | Not OK | Not assessed | Total |",+ "|---|---:|---:|---:|---:|",+ `| Deterministic | ${countsFor(false, "OK")} | ${countsFor(false, "NOT OK")} | ${countsFor(+ false,+ "NOT ASSESSED"+ )} | ${totalFor(false)} |`,+ `| Stochastic | ${countsFor(true, "OK")} | ${countsFor(true, "NOT OK")} | ${countsFor(+ true,+ "NOT ASSESSED"+ )} | ${totalFor(true)} |`,+ `| **Total** | ${assessments.filter(({ assessment }) => assessment.status === "OK").length} | ${+ outsideThreshold.length+ } | ${assessments.filter(({ assessment }) => assessment.status === "NOT ASSESSED").length} | ${+ assessments.length+ } |`+ );++ if (outsideThreshold.length > 0) {+ lines.push(+ "",+ "### Outside threshold",+ "",+ "| Section | Case | Reason |",+ "|---|---|---|"+ );+ for (const { result, assessment } of outsideThreshold) {+ lines.push(+ `| ${result.observation.section} | ${result.observation.caseLabel.replaceAll("|", "\\|")} | ${assessment.reasons+ .join("; ")+ .replaceAll("|", "\\|")} |`+ );+ }+ }++ return `${lines.join("\n")}\n`;+}++interface CliOptions extends GatotEvidenceRunOptions {+ help: boolean;+}++function parseCliArgs(args: string[]): CliOptions {+ const options: CliOptions = { help: false };+ for (let index = 0; index < args.length; index += 1) {+ const arg = args[index]!;+ if (arg === "--help" || arg === "-h") {+ options.help = true;+ } else if (arg === "--replicates") {+ options.replicates = Number(args[++index]);+ } else if (arg.startsWith("--replicates=")) {+ options.replicates = Number(arg.slice("--replicates=".length));+ } else if (arg === "--matching") {+ options.matching = args[++index];+ } else if (arg.startsWith("--matching=")) {+ options.matching = arg.slice("--matching=".length);+ } else {+ throw new Error(`Unknown argument: ${arg}`);+ }+ }+ return options;+}++function usage(): string {+ return [+ "Usage: npx tsx src/tooling/gatotEvidence.ts [--replicates N] [--matching TEXT]",+ "",+ `Stochastic observations use ${DEFAULT_REPLICATES} replicates by default.`,+ "Each case has a stable base seed; replicate i uses the simulator's documented '<seed>#<i>' derivation.",+ "Deterministic observations always run once. Matching filters by id, section, or case label."+ ].join("\n");+}++async function main(): Promise<void> {+ const options = parseCliArgs(process.argv.slice(2));+ if (options.help) {+ process.stdout.write(`${usage()}\n`);+ return;+ }+ process.stdout.write(renderGatotEvidenceMarkdown(runGatotEvidence(options)));+}++const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : undefined;+if (invokedPath === import.meta.url) {+ await main();+}Index: simulator/src/tooling/testcases.test.ts===================================================================--- simulator/src/tooling/testcases.test.ts prev run+++ simulator/src/tooling/testcases.test.ts this run@@ -6,9 +6,9 @@import { loadSimulatorConfig } from "../config";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";+import { adaptTestcaseEntry, applyComparisonQValues, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, deterministicRoundTolerancePct, discoverTestcaseFiles, runTestcases, type TestcaseSummaryEntry } from "./testcases";test("discoverTestcaseFiles follows simulator/testcases symlink and skips disabled or stale files by default", () => {const files = discoverTestcaseFiles();@@ -223,20 +223,40 @@});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 report = runTestcases({ matching: "determinism_test_normal" }, config);const summary = Object.values(report.testcases)[0];- assert.equal(summary?.testcase_id, "norah_s2_inf_only_B");+ assert.equal(summary?.testcase_id, "determinism_test_normal");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);+ assert.equal(summary?.gameStatAdjustment?.value, 0.025);+ assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -1);});+test("deterministic testcase tolerance increases by 0.1 percent every ten rounds and caps at 0.7 percent", () => {+ assert.equal(deterministicRoundTolerancePct(0), 0.2);+ assert.equal(deterministicRoundTolerancePct(9), 0.2);+ assert.equal(deterministicRoundTolerancePct(10), 0.3);+ assert.equal(deterministicRoundTolerancePct(39), 0.5);+ assert.equal(deterministicRoundTolerancePct(49), 0.6);+ assert.equal(deterministicRoundTolerancePct(50), 0.7);+ assert.equal(deterministicRoundTolerancePct(1500), 0.7);+});++test("round-scaled deterministic tolerance accepts close Seo-yoon cases", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "Seo-yoon_tc_nc.json" }, config);+ const fourth = Object.values(report.testcases).find((summary) => summary.testcase_id === "daut_viper_4");+ const fifth = Object.values(report.testcases).find((summary) => summary.testcase_id === "daut_viper_5");++ assert.equal(fourth?.game?.passes, true);+ assert.equal(fifth?.game?.passes, true);+});+test("runTestcases applies stat rounding correction for stochastic misses", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "greg_mia_combo", repeat: 5, calibrationReportPath: "/tmp/does-not-exist.json" }, config);const summaries = Object.values(report.testcases);Index: simulator/src/tooling/testcases.ts===================================================================--- simulator/src/tooling/testcases.ts prev run+++ simulator/src/tooling/testcases.ts this run@@ -16,8 +16,10 @@const DEFAULT_STOCHASTIC_REPEAT = 100;const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;const STAT_ROUNDING_SCAN_STEPS = 50;const STAT_ROUNDING_INTERPOLATION_LIMIT = STAT_ROUNDING_SCAN_STEPS;+const DETERMINISTIC_BASE_TOLERANCE_TENTHS_PCT = 2;+const DETERMINISTIC_MAX_TOLERANCE_TENTHS_PCT = 7;export interface TestcaseRunOptions {testcaseRoot?: string;calibrationReportPath?: string;@@ -361,16 +363,21 @@deterministic: result.randomness.deterministic,thresholds: comparison.thresholds}): null;+ if (game) {+ const unroundedBiasRaw = simulatorDistribution.mu - gameDistribution!.mu;+ game = adjustedForRoundingRules(game, result.randomness.deterministic, initialTroops, result.rounds, unroundedBiasRaw);+ }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,+ averageRounds: result.rounds,deterministic: result.randomness.deterministic,thresholds: comparison.thresholds}): undefined;@@ -416,8 +423,9 @@config: SimulatorConfig;job: TestcaseExecutionJob;reference: { n: number; mu: number; sigma: number };initialTroops: number;+ averageRounds: number;deterministic: boolean;thresholds?: Record<string, number>;}): InternalStatAdjustment | undefined {// Deterministic cases correct any nonzero bias; stochastic cases correct only outright misses.@@ -459,8 +467,9 @@config: SimulatorConfig;job: TestcaseExecutionJob;reference: { n: number; mu: number; sigma: number };initialTroops: number;+ averageRounds: number;deterministic: boolean;thresholds?: Record<string, number>;}, value: number): InternalStatAdjustment {const adjustedInput = inputWithStatAdjustment(options.input, value);@@ -475,9 +484,15 @@return {value: roundStatAdjustment(value),mode: adjustmentMode(adjusted, options.deterministic),unadjusted: options.game,- adjusted: adjustedForRoundingRules(adjusted, options.deterministic)+ adjusted: adjustedForRoundingRules(+ adjusted,+ options.deterministic,+ options.initialTroops,+ options.averageRounds,+ candidateStats.mu - options.reference.mu+ )};}function biasesBracketZero(first: number, second: number): boolean {@@ -530,13 +545,26 @@}return adjusted.passes ? "stochastic_tolerance" : "best_effort";}-function adjustedForRoundingRules(metric: ParityComparisonMetrics, deterministic: boolean): ParityComparisonMetrics {+function adjustedForRoundingRules(+ metric: ParityComparisonMetrics,+ deterministic: boolean,+ initialTroops: number,+ averageRounds: number,+ unroundedBiasRaw: number+): ParityComparisonMetrics {if (!deterministic) return metric;- return { ...metric, passes: Math.abs(metric.bias_raw) <= 1 };+ const unroundedBiasPct = Math.abs(unroundedBiasRaw / (initialTroops || 1)) * 100;+ return { ...metric, passes: unroundedBiasPct <= deterministicRoundTolerancePct(averageRounds) };}+export function deterministicRoundTolerancePct(averageRounds: number): number {+ const completedTenRoundBlocks = Math.max(0, Math.floor(averageRounds / 10));+ const toleranceTenthsPct = DETERMINISTIC_BASE_TOLERANCE_TENTHS_PCT + completedTenRoundBlocks;+ return Math.min(DETERMINISTIC_MAX_TOLERANCE_TENTHS_PCT, toleranceTenthsPct) / 10;+}+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);Index: simulator/src/troopStats.test.ts===================================================================--- simulator/src/troopStats.test.ts prev run+++ simulator/src/troopStats.test.ts this run@@ -0,0 +1,144 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import {+ BEAR_TROOP_ID,+ createTroopStatsRecord,+ fireCrystalMultiplier,+ generateTroopStats,+ generateTroopStatsCatalogue+} from "./troopStats";+import { UNIT_TYPES } from "./types";++test("generated catalogue contains supported standard troop ids and the built-in Bear", () => {+ const catalogue = generateTroopStatsCatalogue();++ assert.equal(Object.keys(catalogue).length, 352);+ assert.deepEqual(catalogue[BEAR_TROOP_ID], {+ id: BEAR_TROOP_ID,+ type: "infantry",+ tier: 1,+ fc: 0,+ stats: {+ attack: 0,+ defense: 250 / 3,+ lethality: 0,+ health: 10+ }+ });+ assert.equal(Object.keys(catalogue).filter((id) => id !== BEAR_TROOP_ID).every((id) =>+ /^(infantry|lancer|marksman)_t\d+(?:_fc\d+)?$/.test(id)+ ), true);+ assert.equal(Object.keys(catalogue).some((id) => id.includes("boss")), false);++ for (const type of UNIT_TYPES) {+ assert.ok(catalogue[`${type}_t1`]);+ assert.ok(catalogue[`${type}_t1_fc10`]);+ assert.ok(catalogue[`${type}_t9_fc10`]);+ assert.ok(catalogue[`${type}_t10_fc10`]);+ assert.ok(catalogue[`${type}_t11`]);+ assert.ok(catalogue[`${type}_t11_fc5`]);+ assert.ok(catalogue[`${type}_t11_fc10`]);+ assert.equal(catalogue[`${type}_t11_fc4`], undefined);+ }+});++test("generated troop records and canonical stats are immutable process-local singletons", () => {+ const firstCatalogue = generateTroopStatsCatalogue();+ const secondCatalogue = generateTroopStatsCatalogue();+ const firstRecord = generateTroopStats("infantry", 9, 10);+ const secondRecord = generateTroopStats("infantry", 9, 10);++ assert.strictEqual(secondCatalogue, firstCatalogue);+ assert.strictEqual(secondRecord, firstRecord);+ assert.strictEqual(firstCatalogue.infantry_t9_fc10, firstRecord);+ assert.strictEqual(secondRecord.stats, firstRecord.stats);+ assert.equal(Object.isFrozen(firstCatalogue), true);+ assert.equal(Object.isFrozen(firstRecord), true);+ assert.equal(Object.isFrozen(firstRecord.stats), true);+});++test("troop record construction normalizes legacy stat keys once", () => {+ const record = createTroopStatsRecord({+ id: "test_infantry",+ type: "inf",+ tier: 1,+ stats: { Attack: 100, Defense: 90, Lethality: 80, Health: 70 }+ });++ assert.deepEqual(record, {+ id: "test_infantry",+ type: "infantry",+ tier: 1,+ fc: 0,+ stats: { attack: 100, defense: 90, lethality: 80, health: 70 }+ });+ assert.equal(Object.isFrozen(record), true);+ assert.equal(Object.isFrozen(record.stats), true);+});++test("Fire Crystal scaling preserves the validated early levels and applies the FC8-FC10 continuation", () => {+ assert.equal(fireCrystalMultiplier(0), 1);+ assert.equal(fireCrystalMultiplier(1), 1.04);+ assert.equal(fireCrystalMultiplier(5), 1.04 * 1.05 ** 4);+ assert.equal(fireCrystalMultiplier(7), 1.04 * 1.05 ** 6);+ assert.equal(fireCrystalMultiplier(8), 1.04 * 1.05 ** 6 * 1.04);+ assert.equal(fireCrystalMultiplier(10), 1.04 * 1.05 ** 6 * 1.04 ** 3);++ assert.deepEqual(generateTroopStats("infantry", 3, 1).stats, {+ attack: 137,+ defense: 10,+ lethality: 10,+ health: 413+ });+ assert.deepEqual(generateTroopStats("marksman", 10, 5).stats, {+ attack: 2387,+ defense: 10,+ lethality: 10,+ health: 448+ });+ assert.deepEqual(generateTroopStats("infantry", 1, 10).stats, {+ attack: 99,+ defense: 10,+ lethality: 10,+ health: 296+ });+});++test("T11 uses the fitted Labyrinth base and inferred FC5-FC10 continuation", () => {+ assert.deepEqual(generateTroopStats("infantry", 11).stats, {+ attack: 551,+ defense: 10,+ lethality: 10,+ health: 1653+ });+ assert.deepEqual(generateTroopStats("infantry", 11, 5).stats, {+ attack: 697,+ defense: 10,+ lethality: 10,+ health: 2090+ });+ assert.deepEqual(generateTroopStats("infantry", 11, 10).stats, {+ attack: 864,+ defense: 10,+ lethality: 10,+ health: 2591+ });+ assert.deepEqual(generateTroopStats("lancer", 11, 10).stats, {+ attack: 2591,+ defense: 10,+ lethality: 10,+ health: 864+ });+ assert.deepEqual(generateTroopStats("marksman", 11, 10).stats, {+ attack: 3455,+ defense: 10,+ lethality: 10,+ health: 647+ });+});++test("troop stat generation rejects out-of-range coordinates", () => {+ assert.throws(() => generateTroopStats("infantry", 0), /tier must be an integer from 1 to 11/);+ assert.throws(() => generateTroopStats("infantry", 1, 11), /Fire Crystal level must be an integer from 0 to 10/);+});Index: simulator/src/troopStats.ts===================================================================--- simulator/src/troopStats.ts prev run+++ simulator/src/troopStats.ts this run@@ -0,0 +1,166 @@+import { UNIT_TYPES } from "./types";+import type { StatBlock, TroopStatsCatalogue, TroopStatsRecord, UnitType } from "./types";+import { normalizeStatBlock, normalizeUnitType } from "./normalize";++type AttackHealth = Readonly<{+ Attack: number;+ Health: number;+}>;++// Validated FC0 anchors. Lancer values are the exact Infantry Attack/Health+// swap; Marksman anchors remain explicit because each type was rounded+// independently in the source data.+const INFANTRY_BASE_STATS: readonly AttackHealth[] = [+ { Attack: 63, Health: 189 },+ { Attack: 94, Health: 283 },+ { Attack: 132, Health: 397 },+ { Attack: 172, Health: 516 },+ { Attack: 206, Health: 619 },+ { Attack: 243, Health: 730 },+ { Attack: 287, Health: 862 },+ { Attack: 339, Health: 1017 },+ { Attack: 400, Health: 1200 },+ { Attack: 472, Health: 1416 }+];++const MARKSMAN_BASE_STATS: readonly AttackHealth[] = [+ { Attack: 252, Health: 47 },+ { Attack: 378, Health: 71 },+ { Attack: 529, Health: 99 },+ { Attack: 688, Health: 129 },+ { Attack: 825, Health: 155 },+ { Attack: 974, Health: 183 },+ { Attack: 1149, Health: 215 },+ { Attack: 1356, Health: 254 },+ { Attack: 1600, Health: 300 },+ { Attack: 1888, Health: 354 }+];++// T11 FC0 is the Labyrinth-only troop calibrated from observed battle reports.+// Player T11 troops begin at FC5; FC5-FC10 use the same FC continuation model.+const T11_INFANTRY_BASE: AttackHealth = { Attack: 551, Health: 1653 };++export const BEAR_TROOP_ID = "bear_infantry";+const MAX_TIER = 11;+const MAX_FIRE_CRYSTAL_LEVEL = 10;+const ALL_FIRE_CRYSTAL_LEVELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;+const T11_FIRE_CRYSTAL_LEVELS = [0, 5, 6, 7, 8, 9, 10] as const;+const generatedRecords = new Map<string, TroopStatsRecord>();+let generatedCatalogue: TroopStatsCatalogue | undefined;++interface TroopStatsRecordInput {+ id: string;+ type: UnitType | string;+ tier: number;+ fc?: number;+ stats: StatBlock | Record<string, unknown>;+}++export function createTroopStatsRecord(input: TroopStatsRecordInput): TroopStatsRecord {+ return Object.freeze({+ id: input.id,+ type: normalizeUnitType(input.type),+ tier: input.tier,+ fc: input.fc ?? 0,+ stats: Object.freeze(normalizeStatBlock(input.stats as Record<string, unknown>))+ });+}++export function generateTroopStats(type: UnitType, tier: number, fc = 0): TroopStatsRecord {+ assertIntegerInRange("tier", tier, 1, MAX_TIER);+ assertIntegerInRange("Fire Crystal level", fc, 0, MAX_FIRE_CRYSTAL_LEVEL);++ const id = `${type}_t${tier}${fc === 0 ? "" : `_fc${fc}`}`;+ const cached = generatedRecords.get(id);+ if (cached) return cached;++ const base = baseStats(type, tier);+ const multiplier = fireCrystalMultiplier(fc);+ const record = createTroopStatsRecord({+ id,+ type,+ tier,+ fc,+ stats: {+ attack: Math.round(base.Attack * multiplier),+ defense: 10,+ lethality: 10,+ health: Math.round(base.Health * multiplier)+ }+ });+ generatedRecords.set(id, record);+ return record;+}++export function generateTroopStatsCatalogue(): TroopStatsCatalogue {+ if (generatedCatalogue) return generatedCatalogue;++ const catalogue: TroopStatsCatalogue = {};+ for (const type of UNIT_TYPES) {+ for (let tier = 1; tier <= MAX_TIER; tier += 1) {+ for (const fc of supportedFireCrystalLevels(tier)) {+ const troop = generateTroopStats(type, tier, fc);+ catalogue[troop.id] = troop;+ }+ }+ }+ catalogue[BEAR_TROOP_ID] = createTroopStatsRecord({+ id: BEAR_TROOP_ID,+ type: "infantry",+ tier: 1,+ stats: {+ attack: 0,+ defense: 250 / 3,+ lethality: 0,+ health: 10+ }+ });+ generatedCatalogue = Object.freeze(catalogue);+ return generatedCatalogue;+}++export function fireCrystalMultiplier(fc: number): number {+ assertIntegerInRange("Fire Crystal level", fc, 0, MAX_FIRE_CRYSTAL_LEVEL);+ // FC1 starts at 1.04; FC2-FC7 grow by 1.05 per level, then FC8-FC10+ // grow by 1.04 per level.+ if (fc === 0) return 1;+ if (fc <= 7) return 1.04 * 1.05 ** (fc - 1);+ return 1.04 * 1.05 ** 6 * 1.04 ** (fc - 7);+}++function baseStats(type: UnitType, tier: number): AttackHealth {+ if (tier === 11) {+ if (type === "infantry") return T11_INFANTRY_BASE;+ if (type === "lancer") {+ return {+ Attack: T11_INFANTRY_BASE.Health,+ Health: T11_INFANTRY_BASE.Attack+ };+ }+ return {+ Attack: Math.round(T11_INFANTRY_BASE.Health * 4 / 3),+ Health: Math.round(T11_INFANTRY_BASE.Attack * 3 / 4)+ };+ }++ const sourceTier = Math.min(tier, 10);+ const infantry = INFANTRY_BASE_STATS[sourceTier - 1];++ if (type === "infantry") {+ return infantry;+ }+ if (type === "lancer") {+ return { Attack: infantry.Health, Health: infantry.Attack };+ }+ return MARKSMAN_BASE_STATS[sourceTier - 1];+}++function supportedFireCrystalLevels(tier: number): readonly number[] {+ return tier <= 10 ? ALL_FIRE_CRYSTAL_LEVELS : T11_FIRE_CRYSTAL_LEVELS;+}++function assertIntegerInRange(label: string, value: number, min: number, max: number): void {+ if (!Number.isInteger(value) || value < min || value > max) {+ throw new RangeError(`${label} must be an integer from ${min} to ${max}; received ${value}`);+ }+}Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -61,13 +61,13 @@export type PassiveEffects = Partial<Record<MainStat, PassiveEffectBucket>>;export interface TroopStatsRecord {- id: string;- type: UnitType | string;- tier: number;- fc?: number;- stats: Record<string, number>;+ readonly id: string;+ readonly type: UnitType;+ readonly tier: number;+ readonly fc: number;+ readonly stats: Readonly<StatBlock>;}export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;@@ -85,8 +85,10 @@export interface EffectIntentDefinition {id: string;type: string;value?: unknown;+ /** Apply this modifier only while the named effect is applicable to the same damage job. */+ requires_effect?: string;value_formula?: PercentOfValueFormula;value_evolution?: { type?: string; step?: string; value?: number };units?: Record<string, unknown>;trigger_damage_jobs?: TriggerDamageJobDefinition[];@@ -94,9 +96,13 @@same_effect_stacking?: SameEffectStacking;reason?: string;}-export type TriggerValueSource = "trigger.normal_kills" | "trigger.skill_kills" | "trigger.total_kills";+export type TriggerValueSource =+ | "trigger.normal_kills"+ | "trigger.skill_kills"+ | "trigger.total_kills"+ | "trigger.source_attack";export interface PercentOfValueFormula {type: "percent_of";source: TriggerValueSource;@@ -271,8 +277,11 @@// Index into RuntimeSkills.effectGroups and every runtime's liveEffectsByGroup.ordinal: number;bucketIndex: number;sameEffectStacking: SameEffectStacking;+ // Prepared dependency lookup indexed by damage-job shape. An empty slot means the+ // required effect cannot apply to that job, so this group must not contribute.+ requiredGroupOrdinalsByJobShape?: Array<number[] | undefined>;}export interface ActiveEffect {expired?: boolean;Index: testcases/emulator_verified/ambusher_no_marksmen.json===================================================================--- testcases/emulator_verified/ambusher_no_marksmen.json prev run+++ testcases/emulator_verified/ambusher_no_marksmen.json this run@@ -1,61 +0,0 @@-[- {- "test_id": "ambusher_no_marksmen",- "description": "Field battle (direct attack, not a rally) , battle 2026-06-07 21:33:44. Pins Ambusher target-order fallback: attacker t10fc8 lancers have Ambusher (tier 7+) and the game report shows it triggering, but the defender fields ZERO marksmen. Game ground truth (defender per-type table): lancers 0 losses / 0 injured / 0 lightly injured, survivors 81,191 of 81,191; ALL defender damage fell on infantry (-3,085 injured, 7,198 lightly injured, 24,495 of 34,778 survivors). Proves ambush order falls back to infantry before lancers when marksmen are absent, i.e. order marksman -> infantry -> lancer, matching config troop_skills.json Ambusher. All hero skills level 5 (skills 1-3 only, widget skill 4 not relevant, no rally/garrison engagement). Survivor totals from Battle Overview Survivors row; per-skill triggers from Battle Details: defender Edith 1/1/1(+31), Renee 10/1/1 (2,373 skill kills), Wayne 10/-/8 (7,266 + 6,333 skill kills); attacker Edith 1/1/1(+25), Renee 10/1/1 (225 skill kills), Lynn 8/1/2 (146 skill kills).",- "attacker": {- "name": "[PWR]Longlive _Anata",- "heroes": {- "Edith": { "skill_1": 5, "skill_2": 5, "skill_3": 5 },- "Renee": { "skill_1": 5, "skill_2": 5, "skill_3": 5 },- "Lynn": { "skill_1": 5, "skill_2": 5, "skill_3": 5 }- },- "troops": {- "infantry_t11_fc8": 94385,- "lancer_t10_fc8": 37754,- "marksman_t10_fc8": 56632- },- "stats": {- "inf": { "attack": 1214.6, "defense": 1298.7, "lethality": 640.4, "health": 490.2 },- "lanc": { "attack": 1220.3, "defense": 1320.2, "lethality": 659.6, "health": 517.7 },- "mark": { "attack": 1134.9, "defense": 1177.3, "lethality": 739.1, "health": 565.4 }- },- "joiner_heroes": {}- },- "defender": {- "name": "[ARK]Piddlyminx",- "heroes": {- "Edith": { "skill_1": 5, "skill_2": 5, "skill_3": 5 },- "Renee": { "skill_1": 5, "skill_2": 5, "skill_3": 5 },- "Wayne": { "skill_1": 5, "skill_2": 5, "skill_3": 5 }- },- "troops": {- "infantry_t11_fc8": 34778,- "lancer_t11_fc8": 81191- },- "stats": {- "inf": { "attack": 1890.1, "defense": 1873.8, "lethality": 1595.0, "health": 1824.8 },- "lanc": { "attack": 1710.0, "defense": 1669.8, "lethality": 1183.3, "health": 1081.2 },- "mark": { "attack": 1810.0, "defense": 1766.9, "lethality": 1926.7, "health": 1667.3 }- },- "joiner_heroes": {}- },- "game_report_result": [- {- "timestamp": "2026-06-07 21:33",- "attacker": 0,- "defender": 105686- }- ],- "game_report_detail": {- "source": "read-only manual capture of open report, Piddlyminx instance, mail_id 2734682983265503",- "battle_overview": {- "attacker": { "troops": 188771, "losses": 0, "injured": 56633, "lightly_injured": 132138, "survivors": 0 },- "defender": { "troops": 115969, "losses": 0, "injured": 3085, "lightly_injured": 7198, "survivors": 105686 }- },- "defender_by_type": {- "infantry": { "kills": 7132, "losses": 0, "injured": 3085, "lightly_injured": 7198, "survivors": 24495 },- "lancer": { "kills": 49501, "losses": 0, "injured": 0, "lightly_injured": 0, "survivors": 81191 }- }- }- }-]Index: testcases/gatot_verified/s1-10000-vs-2000.json.disabled===================================================================--- testcases/gatot_verified/s1-10000-vs-2000.json.disabled prev run+++ testcases/gatot_verified/s1-10000-vs-2000.json.disabled this run@@ -0,0 +1,7 @@+[+ {+ "test_id": "s1-10000-vs-2000",+ "description": "Gatot evidence section 1: 10,000 T6 infantry vs 2,000 T6 infantry",+ "disabled_reason": "The recorded evidence does not contain both survivor counts, so it cannot be used for parity scoring."+ }+]Index: testcases/gatot_verified/s1-20000-vs-4000.json.disabled===================================================================--- testcases/gatot_verified/s1-20000-vs-4000.json.disabled prev run+++ testcases/gatot_verified/s1-20000-vs-4000.json.disabled this run@@ -0,0 +1,7 @@+[+ {+ "test_id": "s1-20000-vs-4000",+ "description": "Gatot evidence section 1: 20,000 T6 infantry vs 4,000 T6 infantry",+ "disabled_reason": "The recorded evidence does not contain both survivor counts, so it cannot be used for parity scoring."+ }+]Index: testcases/gatot_verified/s1-4900-vs-1000.json===================================================================--- testcases/gatot_verified/s1-4900-vs-1000.json prev run+++ testcases/gatot_verified/s1-4900-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s1-4900-vs-1000",+ "description": "Gatot evidence section 1: 4,900 T6 infantry vs 1,000 T6 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 4900+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 4897,+ "defender": 996+ }+ ]+ }+]Index: testcases/gatot_verified/s1-4950-vs-1000.json===================================================================--- testcases/gatot_verified/s1-4950-vs-1000.json prev run+++ testcases/gatot_verified/s1-4950-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s1-4950-vs-1000",+ "description": "Gatot evidence section 1: 4,950 T6 infantry vs 1,000 T6 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 4950+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 4947,+ "defender": 855+ }+ ]+ }+]Index: testcases/gatot_verified/s1-5000-vs-1000.json===================================================================--- testcases/gatot_verified/s1-5000-vs-1000.json prev run+++ testcases/gatot_verified/s1-5000-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s1-5000-vs-1000",+ "description": "Gatot evidence section 1: 5,000 T6 infantry vs 1,000 T6 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 4997,+ "defender": 513+ }+ ]+ }+]Index: testcases/gatot_verified/s1-5050-vs-1000.json===================================================================--- testcases/gatot_verified/s1-5050-vs-1000.json prev run+++ testcases/gatot_verified/s1-5050-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s1-5050-vs-1000",+ "description": "Gatot evidence section 1: 5,050 T6 infantry vs 1,000 T6 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 5050+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 5047,+ "defender": 74+ }+ ]+ }+]Index: testcases/gatot_verified/s1-5100-vs-1000.json.disabled===================================================================--- testcases/gatot_verified/s1-5100-vs-1000.json.disabled prev run+++ testcases/gatot_verified/s1-5100-vs-1000.json.disabled this run@@ -0,0 +1,7 @@+[+ {+ "test_id": "s1-5100-vs-1000",+ "description": "Gatot evidence section 1: 5,100 T6 infantry vs 1,000 T6 infantry",+ "disabled_reason": "The recorded evidence does not contain both survivor counts, so it cannot be used for parity scoring."+ }+]Index: testcases/gatot_verified/s10-bradley-1000-inf-125-marksman-vs-5000-inf.json===================================================================--- testcases/gatot_verified/s10-bradley-1000-inf-125-marksman-vs-5000-inf.json prev run+++ testcases/gatot_verified/s10-bradley-1000-inf-125-marksman-vs-5000-inf.json this run@@ -0,0 +1,64 @@+[+ {+ "test_id": "s10-bradley-1000-inf-125-marksman-vs-5000-inf",+ "description": "Gatot evidence section 10: 1,000 T6 infantry + 125 T6 marksmen vs 5,000 T6 infantry; Bradley",+ "attacker": {+ "troops": {+ "infantry_t6": 1000,+ "marksman_t6": 125+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "marksman": {+ "attack": 284.8,+ "defense": 288.8,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ },+ "Bradley": {+ "skill_1": 3,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 295.3,+ "defense": 293.3,+ "lethality": 10,+ "health": 10+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 2296+ }+ ]+ }+]Index: testcases/gatot_verified/s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman.json===================================================================--- testcases/gatot_verified/s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman.json prev run+++ testcases/gatot_verified/s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman.json this run@@ -0,0 +1,85 @@+[+ {+ "test_id": "s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman",+ "description": "Gatot evidence section 15.2: 1,000 T6 infantry + 125 lancers + 125 marksmen vs 5,000 infantry; secondary heroes",+ "attacker": {+ "troops": {+ "infantry_t6": 1000,+ "lancer_t6": 125,+ "marksman_t6": 125+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "lancer": {+ "attack": 284.8,+ "defense": 288.8,+ "lethality": 18.2,+ "health": 18.2+ },+ "marksman": {+ "attack": 284.8,+ "defense": 288.8,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ },+ "Gordon": {+ "skill_1": 3,+ "skill_2": 3,+ "skill_3": 3+ },+ "Bradley": {+ "skill_1": 3,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 295.3,+ "defense": 293.3,+ "lethality": 10,+ "health": 10+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ },+ "Patrick": {+ "skill_1": 1,+ "skill_2": 0+ },+ "Bradley": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 851,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.json===================================================================--- testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.json prev run+++ testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.json this run@@ -0,0 +1,65 @@+[+ {+ "test_id": "s15.3-1000-inf-0-lancer-1000-marksman",+ "description": "Gatot evidence section 15.3: 1,000 infantry + 0 lancer + 1,000 marksman vs 5,000 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 1000,+ "marksman_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "lancer": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "marksman": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 295.3,+ "defense": 293.3,+ "lethality": 10,+ "health": 10+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 4491+ }+ ]+ }+]Index: testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.json===================================================================--- testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.json prev run+++ testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.json this run@@ -0,0 +1,65 @@+[+ {+ "test_id": "s15.3-1000-inf-1000-lancer-0-marksman",+ "description": "Gatot evidence section 15.3: 1,000 infantry + 1,000 lancer + 0 marksman vs 5,000 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 1000,+ "lancer_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "lancer": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "marksman": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 295.3,+ "defense": 293.3,+ "lethality": 10,+ "health": 10+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 4973+ }+ ]+ }+]Index: testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.json===================================================================--- testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.json prev run+++ testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.json this run@@ -0,0 +1,66 @@+[+ {+ "test_id": "s15.3-1000-inf-1000-lancer-1000-marksman",+ "description": "Gatot evidence section 15.1 / 15.3: 1,000 infantry + 1,000 lancer + 1,000 marksman vs 5,000 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 1000,+ "lancer_t6": 1000,+ "marksman_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "lancer": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "marksman": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 295.3,+ "defense": 293.3,+ "lethality": 10,+ "health": 10+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 3064+ }+ ]+ }+]Index: testcases/gatot_verified/s15.3-2000-inf-0-lancer-1000-marksman.json===================================================================--- testcases/gatot_verified/s15.3-2000-inf-0-lancer-1000-marksman.json prev run+++ testcases/gatot_verified/s15.3-2000-inf-0-lancer-1000-marksman.json this run@@ -0,0 +1,65 @@+[+ {+ "test_id": "s15.3-2000-inf-0-lancer-1000-marksman",+ "description": "Gatot evidence section 15.3: 2,000 infantry + 0 lancer + 1,000 marksman vs 5,000 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "lancer": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "marksman": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 295.3,+ "defense": 293.3,+ "lethality": 10,+ "health": 10+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 1281,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s15.3-2000-inf-1000-lancer-0-marksman.json===================================================================--- testcases/gatot_verified/s15.3-2000-inf-1000-lancer-0-marksman.json prev run+++ testcases/gatot_verified/s15.3-2000-inf-1000-lancer-0-marksman.json this run@@ -0,0 +1,65 @@+[+ {+ "test_id": "s15.3-2000-inf-1000-lancer-0-marksman",+ "description": "Gatot evidence section 15.3: 2,000 infantry + 1,000 lancer + 0 marksman vs 5,000 infantry",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "lancer_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "lancer": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "marksman": {+ "attack": 78.1,+ "defense": 82.1,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 295.3,+ "defense": 293.3,+ "lethality": 10,+ "health": 10+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 4338+ }+ ]+ }+]Index: testcases/gatot_verified/s16-1500-inf-1500-marksman-vs-1000-inf.json===================================================================--- testcases/gatot_verified/s16-1500-inf-1500-marksman-vs-1000-inf.json prev run+++ testcases/gatot_verified/s16-1500-inf-1500-marksman-vs-1000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s16-1500-inf-1500-marksman-vs-1000-inf",+ "description": "Gatot evidence section 16: 1,500 T6 infantry + 1,500 T6 marksmen vs 1,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 1500,+ "marksman_t6": 1500+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ },+ "marksman": {+ "attack": 270,+ "defense": 265,+ "lethality": 214.9,+ "health": 210+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 483.6,+ "defense": 478.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 2777,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-1000-inf.json===================================================================--- testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-1000-inf.json prev run+++ testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-1000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s16-2000-inf-2000-marksman-vs-1000-inf",+ "description": "Gatot evidence section 16: 2,000 T6 infantry + 2,000 T6 marksmen vs 1,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 2000+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ },+ "marksman": {+ "attack": 270,+ "defense": 265,+ "lethality": 214.9,+ "health": 210+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 483.6,+ "defense": 478.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 3809,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-5000-inf.json===================================================================--- testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-5000-inf.json prev run+++ testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-5000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s16-2000-inf-2000-marksman-vs-5000-inf",+ "description": "Gatot evidence section 16: 2,000 T6 infantry + 2,000 T6 marksmen vs 5,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 2000+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ },+ "marksman": {+ "attack": 270,+ "defense": 265,+ "lethality": 214.9,+ "health": 210+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 483.6,+ "defense": 478.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 396,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s16-7500-inf-vs-5000-inf.json===================================================================--- testcases/gatot_verified/s16-7500-inf-vs-5000-inf.json prev run+++ testcases/gatot_verified/s16-7500-inf-vs-5000-inf.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s16-7500-inf-vs-5000-inf",+ "description": "Gatot evidence section 16: 7,500 T6 infantry vs 5,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 7500+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 483.6,+ "defense": 478.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 2513+ }+ ]+ }+]Index: testcases/gatot_verified/s2-health-1.1-4950-vs-1000.json===================================================================--- testcases/gatot_verified/s2-health-1.1-4950-vs-1000.json prev run+++ testcases/gatot_verified/s2-health-1.1-4950-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s2-health-1.1-4950-vs-1000",+ "description": "Gatot evidence section 2: 4,950 T6 infantry vs 1,000 T6 infantry; defender Health x1.1",+ "attacker": {+ "troops": {+ "infantry_t6": 4950+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 30.02+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 4947,+ "defender": 904+ }+ ]+ }+]Index: testcases/gatot_verified/s2-health-1.1-5000-vs-1000.json===================================================================--- testcases/gatot_verified/s2-health-1.1-5000-vs-1000.json prev run+++ testcases/gatot_verified/s2-health-1.1-5000-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s2-health-1.1-5000-vs-1000",+ "description": "Gatot evidence section 2: 5,000 T6 infantry vs 1,000 T6 infantry; defender Health x1.1",+ "attacker": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 30.02+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 4997,+ "defender": 670+ }+ ]+ }+]Index: testcases/gatot_verified/s2-health-1.1-5050-vs-1000.json===================================================================--- testcases/gatot_verified/s2-health-1.1-5050-vs-1000.json prev run+++ testcases/gatot_verified/s2-health-1.1-5050-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s2-health-1.1-5050-vs-1000",+ "description": "Gatot evidence section 2: 5,050 T6 infantry vs 1,000 T6 infantry; defender Health x1.1",+ "attacker": {+ "troops": {+ "infantry_t6": 5050+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 30.02+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 5047,+ "defender": 406+ }+ ]+ }+]Index: testcases/gatot_verified/s2-health-1.1-5100-vs-1000.json===================================================================--- testcases/gatot_verified/s2-health-1.1-5100-vs-1000.json prev run+++ testcases/gatot_verified/s2-health-1.1-5100-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s2-health-1.1-5100-vs-1000",+ "description": "Gatot evidence section 2: 5,100 T6 infantry vs 1,000 T6 infantry; defender Health x1.1",+ "attacker": {+ "troops": {+ "infantry_t6": 5100+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 30.02+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 5097,+ "defender": 80+ }+ ]+ }+]Index: testcases/gatot_verified/s2-health-1.1-5150-vs-1000.json===================================================================--- testcases/gatot_verified/s2-health-1.1-5150-vs-1000.json prev run+++ testcases/gatot_verified/s2-health-1.1-5150-vs-1000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s2-health-1.1-5150-vs-1000",+ "description": "Gatot evidence section 2: 5,150 T6 infantry vs 1,000 T6 infantry; defender Health x1.1",+ "attacker": {+ "troops": {+ "infantry_t6": 5150+ },+ "stats": {+ "infantry": {+ "attack": 285.3,+ "defense": 283.3,+ "lethality": 0,+ "health": 0+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 2,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 30.02+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 5147,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s25-1000-inf-3000-marksman-vs-5000-inf.json===================================================================--- testcases/gatot_verified/s25-1000-inf-3000-marksman-vs-5000-inf.json prev run+++ testcases/gatot_verified/s25-1000-inf-3000-marksman-vs-5000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s25-1000-inf-3000-marksman-vs-5000-inf",+ "description": "Gatot evidence section 25: 1,000 T6 infantry + 3,000 T6 marksmen vs 5,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 1000,+ "marksman_t6": 3000+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ },+ "marksman": {+ "attack": 270,+ "defense": 265,+ "lethality": 214.9,+ "health": 210+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 486.6,+ "defense": 491.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 2110+ }+ ]+ }+]Index: testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-2000-inf.json===================================================================--- testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-2000-inf.json prev run+++ testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-2000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s25-2000-inf-2000-marksman-vs-2000-inf",+ "description": "Gatot evidence section 25: 2,000 T6 infantry + 2,000 T6 marksmen vs 2,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 2000+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ },+ "marksman": {+ "attack": 270,+ "defense": 265,+ "lethality": 214.9,+ "health": 210+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 2000+ },+ "stats": {+ "infantry": {+ "attack": 486.6,+ "defense": 491.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 3441,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-3000-inf.json===================================================================--- testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-3000-inf.json prev run+++ testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-3000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s25-2000-inf-2000-marksman-vs-3000-inf",+ "description": "Gatot evidence section 25: 2,000 T6 infantry + 2,000 T6 marksmen vs 3,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 2000+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ },+ "marksman": {+ "attack": 270,+ "defense": 265,+ "lethality": 214.9,+ "health": 210+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 3000+ },+ "stats": {+ "infantry": {+ "attack": 486.6,+ "defense": 491.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 2959,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-4000-inf.json===================================================================--- testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-4000-inf.json prev run+++ testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-4000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s25-2000-inf-2000-marksman-vs-4000-inf",+ "description": "Gatot evidence section 25: 2,000 T6 infantry + 2,000 T6 marksmen vs 4,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 2000+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ },+ "marksman": {+ "attack": 270,+ "defense": 265,+ "lethality": 214.9,+ "health": 210+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 4000+ },+ "stats": {+ "infantry": {+ "attack": 486.6,+ "defense": 491.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 2365,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.json===================================================================--- testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.json prev run+++ testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s25-3000-inf-1000-marksman-vs-5000-inf",+ "description": "Gatot evidence section 25: 3,000 T6 infantry + 1,000 T6 marksmen vs 5,000 T6 infantry; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 3000,+ "marksman_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 271.7,+ "defense": 259.5,+ "lethality": 208.4,+ "health": 209.6+ },+ "marksman": {+ "attack": 270,+ "defense": 265,+ "lethality": 214.9,+ "health": 210+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 486.6,+ "defense": 491.9,+ "lethality": 157.5,+ "health": 170.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 554,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s3-5000-inf-vs-1000-inf-10-lancer.json===================================================================--- testcases/gatot_verified/s3-5000-inf-vs-1000-inf-10-lancer.json prev run+++ testcases/gatot_verified/s3-5000-inf-vs-1000-inf-10-lancer.json this run@@ -0,0 +1,64 @@+[+ {+ "test_id": "s3-5000-inf-vs-1000-inf-10-lancer",+ "description": "Gatot evidence section 3: 5,000 T6 infantry vs 1,000 T6 infantry + 10 T6 lancers",+ "attacker": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 295.3,+ "defense": 293.3,+ "lethality": 10,+ "health": 10+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1000,+ "lancer_t6": 10+ },+ "stats": {+ "infantry": {+ "attack": 326.1,+ "defense": 330.1,+ "lethality": 18.2,+ "health": 18.2+ },+ "lancer": {+ "attack": 284.8,+ "defense": 288.8,+ "lethality": 18.2,+ "health": 18.2+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 2,+ "skill_2": 2,+ "skill_3": 2+ },+ "Gordon": {+ "skill_1": 3,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 4573,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s31-current-2000-inf-2000-marksman-vs-5000-inf.json===================================================================--- testcases/gatot_verified/s31-current-2000-inf-2000-marksman-vs-5000-inf.json prev run+++ testcases/gatot_verified/s31-current-2000-inf-2000-marksman-vs-5000-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s31-current-2000-inf-2000-marksman-vs-5000-inf",+ "description": "Gatot evidence section 31: 2,000 T6 infantry + 2,000 T6 marksmen vs 5,000 T6 infantry; current displayed stats; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 2000+ },+ "stats": {+ "infantry": {+ "attack": 285.7,+ "defense": 279.5,+ "lethality": 218.4,+ "health": 219.6+ },+ "marksman": {+ "attack": 284,+ "defense": 286,+ "lethality": 224.9,+ "health": 220+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 472.6,+ "defense": 471.9,+ "lethality": 147.5,+ "health": 160.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 2291,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s32-current-750-marksman-wayne-vs-3000-inf.json===================================================================--- testcases/gatot_verified/s32-current-750-marksman-wayne-vs-3000-inf.json prev run+++ testcases/gatot_verified/s32-current-750-marksman-wayne-vs-3000-inf.json this run@@ -0,0 +1,50 @@+[+ {+ "test_id": "s32-current-750-marksman-wayne-vs-3000-inf",+ "description": "Gatot evidence section 32: 750 T6 marksmen with Wayne S1 vs 3,000 T6 infantry; current displayed stats; defender Gatot S2 level 1",+ "attacker": {+ "troops": {+ "marksman_t6": 750+ },+ "stats": {+ "marksman": {+ "attack": 383.9,+ "defense": 385.9,+ "lethality": 224.9,+ "health": 220+ }+ },+ "heroes": {+ "Wayne": {+ "skill_1": 1+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 3000+ },+ "stats": {+ "infantry": {+ "attack": 472.6,+ "defense": 471.9,+ "lethality": 147.5,+ "health": 160.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 2738+ }+ ]+ }+]Index: testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.json===================================================================--- testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.json prev run+++ testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s33-current-500-marksman-wayne3-vs-5000-gatot3-inf",+ "description": "Gatot evidence section 33: 500 T6 marksmen with Wayne 3/3/3 vs 5,000 T6 infantry; current displayed stats; defender Gatot S2 level 3",+ "attacker": {+ "troops": {+ "marksman_t6": 500+ },+ "stats": {+ "marksman": {+ "attack": 369.6,+ "defense": 365.9,+ "lethality": 202.3,+ "health": 183.7+ }+ },+ "heroes": {+ "Wayne": {+ "skill_1": 3,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 533.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 4977+ }+ ]+ }+]Index: testcases/gatot_verified/s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf.json===================================================================--- testcases/gatot_verified/s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf.json prev run+++ testcases/gatot_verified/s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf",+ "description": "Gatot evidence section 34: 2,100 T6 lancers + 2,100 T6 marksmen vs 1,100 T6 infantry; current displayed stats; defender Gatot S2 level 3",+ "attacker": {+ "troops": {+ "lancer_t6": 2100,+ "marksman_t6": 2100+ },+ "stats": {+ "lancer": {+ "attack": 197.8,+ "defense": 195.1,+ "lethality": 161.5,+ "health": 161.6+ },+ "marksman": {+ "attack": 197.8,+ "defense": 194.1,+ "lethality": 148.8,+ "health": 154.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 1100+ },+ "stats": {+ "infantry": {+ "attack": 533.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 236+ }+ ]+ }+]Index: testcases/gatot_verified/s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf.json===================================================================--- testcases/gatot_verified/s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf.json prev run+++ testcases/gatot_verified/s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf",+ "description": "Gatot evidence section 35: 3,000 T6 lancers + 1,000 T6 marksmen vs 1,100 T6 infantry; current displayed stats; defender Gatot S2 level 3",+ "attacker": {+ "troops": {+ "lancer_t6": 3000,+ "marksman_t6": 1000+ },+ "stats": {+ "lancer": {+ "attack": 197.8,+ "defense": 195.1,+ "lethality": 161.5,+ "health": 161.6+ },+ "marksman": {+ "attack": 197.8,+ "defense": 194.1,+ "lethality": 148.8,+ "health": 154.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 1100+ },+ "stats": {+ "infantry": {+ "attack": 533.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 314+ }+ ]+ }+]Index: testcases/gatot_verified/s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf.json===================================================================--- testcases/gatot_verified/s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf.json prev run+++ testcases/gatot_verified/s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf",+ "description": "Gatot evidence section 36: 600 T6 lancers + 600 T6 marksmen vs 1,100 T6 infantry; current displayed stats; defender Gatot S2 level 3",+ "attacker": {+ "troops": {+ "lancer_t6": 600,+ "marksman_t6": 600+ },+ "stats": {+ "lancer": {+ "attack": 197.8,+ "defense": 195.1,+ "lethality": 161.5,+ "health": 161.6+ },+ "marksman": {+ "attack": 197.8,+ "defense": 194.1,+ "lethality": 148.8,+ "health": 154.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 1100+ },+ "stats": {+ "infantry": {+ "attack": 533.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 1058+ }+ ]+ }+]Index: testcases/gatot_verified/s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf.json===================================================================--- testcases/gatot_verified/s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf.json prev run+++ testcases/gatot_verified/s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf.json this run@@ -0,0 +1,59 @@+[+ {+ "test_id": "s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf",+ "description": "Gatot evidence section 37: 2,000 T6 infantry + 2,000 T6 marksmen with Gatot S2 level 3 vs 5,000 T6 infantry with Gatot S2 level 1; current displayed stats",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 2000+ },+ "stats": {+ "infantry": {+ "attack": 533.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ },+ "marksman": {+ "attack": 284,+ "defense": 286,+ "lethality": 224.9,+ "health": 220+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 472.6,+ "defense": 471.9,+ "lethality": 147.5,+ "health": 160.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 3782,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf.json===================================================================--- testcases/gatot_verified/s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf.json prev run+++ testcases/gatot_verified/s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf.json this run@@ -0,0 +1,59 @@+[+ {+ "test_id": "s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf",+ "description": "Gatot evidence section 38: 800 T6 infantry + 800 T6 marksmen with Gatot S2 level 1 vs 1,100 T6 infantry with Gatot S2 level 3; current displayed stats",+ "attacker": {+ "troops": {+ "infantry_t6": 800,+ "marksman_t6": 800+ },+ "stats": {+ "infantry": {+ "attack": 472.6,+ "defense": 471.9,+ "lethality": 147.5,+ "health": 160.9+ },+ "marksman": {+ "attack": 197.8,+ "defense": 194.1,+ "lethality": 148.8,+ "health": 154.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 1100+ },+ "stats": {+ "infantry": {+ "attack": 533.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 224+ }+ ]+ }+]Index: testcases/gatot_verified/s39-1000-lancer-400-marksman-vs-500-gatot3-inf.json===================================================================--- testcases/gatot_verified/s39-1000-lancer-400-marksman-vs-500-gatot3-inf.json prev run+++ testcases/gatot_verified/s39-1000-lancer-400-marksman-vs-500-gatot3-inf.json this run@@ -0,0 +1,53 @@+[+ {+ "test_id": "s39-1000-lancer-400-marksman-vs-500-gatot3-inf",+ "description": "Gatot evidence section 39: 1,000 T6 lancers + 400 T6 marksmen vs 500 T6 infantry; current displayed stats; defender Gatot S2 level 3; every initial hit below one complete shield",+ "attacker": {+ "troops": {+ "lancer_t6": 1000,+ "marksman_t6": 400+ },+ "stats": {+ "lancer": {+ "attack": 197.8,+ "defense": 195.1,+ "lethality": 161.5,+ "health": 161.6+ },+ "marksman": {+ "attack": 197.8,+ "defense": 194.1,+ "lethality": 148.8,+ "health": 154.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t6": 500+ },+ "stats": {+ "infantry": {+ "attack": 530.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 397+ }+ ]+ }+]Index: testcases/gatot_verified/s4-fc9-gatot-10000.json===================================================================--- testcases/gatot_verified/s4-fc9-gatot-10000.json prev run+++ testcases/gatot_verified/s4-fc9-gatot-10000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s4-fc9-gatot-10000",+ "description": "Gatot evidence section 4: 10,000 T11 FC9 infantry vs same count T10 FC9 infantry",+ "attacker": {+ "troops": {+ "infantry_t11_fc9": 10000+ },+ "stats": {+ "infantry": {+ "attack": 1513,+ "defense": 1634.1,+ "lethality": 831.2,+ "health": 1103.1+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t10_fc9": 10000+ },+ "stats": {+ "infantry": {+ "attack": 1354,+ "defense": 1413.7,+ "lethality": 634.3,+ "health": 812.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 9992,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s4-fc9-gatot-20000.json===================================================================--- testcases/gatot_verified/s4-fc9-gatot-20000.json prev run+++ testcases/gatot_verified/s4-fc9-gatot-20000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s4-fc9-gatot-20000",+ "description": "Gatot evidence section 4: 20,000 T11 FC9 infantry vs same count T10 FC9 infantry",+ "attacker": {+ "troops": {+ "infantry_t11_fc9": 20000+ },+ "stats": {+ "infantry": {+ "attack": 1513,+ "defense": 1634.1,+ "lethality": 831.2,+ "health": 1103.1+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t10_fc9": 20000+ },+ "stats": {+ "infantry": {+ "attack": 1354,+ "defense": 1413.7,+ "lethality": 634.3,+ "health": 812.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 19983,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s4-fc9-gatot-40000.json===================================================================--- testcases/gatot_verified/s4-fc9-gatot-40000.json prev run+++ testcases/gatot_verified/s4-fc9-gatot-40000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s4-fc9-gatot-40000",+ "description": "Gatot evidence section 4: 40,000 T11 FC9 infantry vs same count T10 FC9 infantry",+ "attacker": {+ "troops": {+ "infantry_t11_fc9": 40000+ },+ "stats": {+ "infantry": {+ "attack": 1513,+ "defense": 1634.1,+ "lethality": 831.2,+ "health": 1103.1+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t10_fc9": 40000+ },+ "stats": {+ "infantry": {+ "attack": 1354,+ "defense": 1413.7,+ "lethality": 634.3,+ "health": 812.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 39135,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s4-fc9-gatot-90000.json===================================================================--- testcases/gatot_verified/s4-fc9-gatot-90000.json prev run+++ testcases/gatot_verified/s4-fc9-gatot-90000.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s4-fc9-gatot-90000",+ "description": "Gatot evidence section 4: 90,000 T11 FC9 infantry vs same count T10 FC9 infantry",+ "attacker": {+ "troops": {+ "infantry_t11_fc9": 90000+ },+ "stats": {+ "infantry": {+ "attack": 1513,+ "defense": 1634.1,+ "lethality": 831.2,+ "health": 1103.1+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t10_fc9": 90000+ },+ "stats": {+ "infantry": {+ "attack": 1354,+ "defense": 1413.7,+ "lethality": 634.3,+ "health": 812.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 83565,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s40-2000-inf-1000-marksman-vs-5000-inf.json===================================================================--- testcases/gatot_verified/s40-2000-inf-1000-marksman-vs-5000-inf.json prev run+++ testcases/gatot_verified/s40-2000-inf-1000-marksman-vs-5000-inf.json this run@@ -0,0 +1,59 @@+[+ {+ "test_id": "s40-2000-inf-1000-marksman-vs-5000-inf",+ "description": "Gatot evidence section 40: 2,000 T6 infantry + 1,000 T6 marksmen with Gatot S2 level 3 vs 5,000 T6 infantry with Gatot S2 level 1; Section 15 bridge",+ "attacker": {+ "troops": {+ "infantry_t6": 2000,+ "marksman_t6": 1000+ },+ "stats": {+ "infantry": {+ "attack": 530.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ },+ "marksman": {+ "attack": 281,+ "defense": 286,+ "lethality": 224.9,+ "health": 220+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 5000+ },+ "stats": {+ "infantry": {+ "attack": 472.6,+ "defense": 471.9,+ "lethality": 147.5,+ "health": 160.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 2802,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s41-300-inf-200-lancer-600-marksman-vs-500-inf.json===================================================================--- testcases/gatot_verified/s41-300-inf-200-lancer-600-marksman-vs-500-inf.json prev run+++ testcases/gatot_verified/s41-300-inf-200-lancer-600-marksman-vs-500-inf.json this run@@ -0,0 +1,66 @@+[+ {+ "test_id": "s41-300-inf-200-lancer-600-marksman-vs-500-inf",+ "description": "Gatot evidence section 41: 300 T6 infantry + 200 T6 lancers + 600 T6 marksmen with Gatot S2 level 1 vs 500 T6 infantry with Gatot S2 level 3; three-formation pool checklist",+ "attacker": {+ "troops": {+ "infantry_t6": 300,+ "lancer_t6": 200,+ "marksman_t6": 600+ },+ "stats": {+ "infantry": {+ "attack": 472.6,+ "defense": 471.9,+ "lethality": 147.5,+ "health": 160.9+ },+ "lancer": {+ "attack": 197.8,+ "defense": 195.1,+ "lethality": 161.5,+ "health": 161.6+ },+ "marksman": {+ "attack": 197.8,+ "defense": 194.1,+ "lethality": 148.8,+ "health": 154.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 500+ },+ "stats": {+ "infantry": {+ "attack": 530.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 939,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf.json===================================================================--- testcases/gatot_verified/s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf.json prev run+++ testcases/gatot_verified/s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf.json this run@@ -0,0 +1,57 @@+[+ {+ "test_id": "s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf",+ "description": "Gatot evidence section 42: 200 T6 infantry with Gatot S2 level 3 vs 450 T6 infantry with Gatot S2 level 1 and Wayne 3/3/3; extra-skill shield scope",+ "attacker": {+ "troops": {+ "infantry_t6": 200+ },+ "stats": {+ "infantry": {+ "attack": 530.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 450+ },+ "stats": {+ "infantry": {+ "attack": 472.6,+ "defense": 471.9,+ "lethality": 147.5,+ "health": 160.9+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ },+ "Wayne": {+ "skill_1": 3,+ "skill_2": 3,+ "skill_3": 3+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 200,+ "defender": 168+ }+ ]+ }+]Index: testcases/gatot_verified/s43-current-reciprocal-shield-cap-450-inf-vs-200-inf.json===================================================================--- testcases/gatot_verified/s43-current-reciprocal-shield-cap-450-inf-vs-200-inf.json prev run+++ testcases/gatot_verified/s43-current-reciprocal-shield-cap-450-inf-vs-200-inf.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s43-current-reciprocal-shield-cap-450-inf-vs-200-inf",+ "description": "Gatot evidence section 43: 450 T6 infantry with Gatot S2 level 1 vs 200 T6 infantry with Gatot S2 level 3; reciprocal shield-count cap",+ "attacker": {+ "troops": {+ "infantry_t6": 450+ },+ "stats": {+ "infantry": {+ "attack": 472.7,+ "defense": 472,+ "lethality": 150.6,+ "health": 164+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 200+ },+ "stats": {+ "infantry": {+ "attack": 530.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 173,+ "defender": 200+ }+ ]+ }+]Index: testcases/gatot_verified/s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf.json===================================================================--- testcases/gatot_verified/s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf.json prev run+++ testcases/gatot_verified/s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf.json this run@@ -0,0 +1,52 @@+[+ {+ "test_id": "s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf",+ "description": "Gatot evidence section 44: 2,500 T6 infantry with Gatot S2 level 1 vs 500 T6 infantry with Gatot S2 level 3; reciprocal shield-count cap",+ "attacker": {+ "troops": {+ "infantry_t6": 2500+ },+ "stats": {+ "infantry": {+ "attack": 472.7,+ "defense": 472,+ "lethality": 150.6,+ "health": 164+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 1+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t6": 500+ },+ "stats": {+ "infantry": {+ "attack": 530.8,+ "defense": 527.5,+ "lethality": 218.4,+ "health": 219.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 1,+ "skill_2": 3,+ "skill_3": 1+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 975,+ "defender": 498+ }+ ]+ }+]Index: testcases/gatot_verified/s5-fc9-gatot-lumak-20000.json===================================================================--- testcases/gatot_verified/s5-fc9-gatot-lumak-20000.json prev run+++ testcases/gatot_verified/s5-fc9-gatot-lumak-20000.json this run@@ -0,0 +1,55 @@+[+ {+ "test_id": "s5-fc9-gatot-lumak-20000",+ "description": "Gatot evidence section 5: 20,000 T11 FC9 infantry + Lumak S1 vs same count T10 FC9 infantry",+ "attacker": {+ "troops": {+ "infantry_t11_fc9": 20000+ },+ "stats": {+ "infantry": {+ "attack": 1513,+ "defense": 1634.1,+ "lethality": 831.2,+ "health": 1103.1+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ },+ "Lumak": {+ "skill_1": 5+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t10_fc9": 20000+ },+ "stats": {+ "infantry": {+ "attack": 1354,+ "defense": 1413.7,+ "lethality": 634.3,+ "health": 812.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 19986,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s5-fc9-gatot-lumak-40000.json===================================================================--- testcases/gatot_verified/s5-fc9-gatot-lumak-40000.json prev run+++ testcases/gatot_verified/s5-fc9-gatot-lumak-40000.json this run@@ -0,0 +1,55 @@+[+ {+ "test_id": "s5-fc9-gatot-lumak-40000",+ "description": "Gatot evidence section 5: 40,000 T11 FC9 infantry + Lumak S1 vs same count T10 FC9 infantry",+ "attacker": {+ "troops": {+ "infantry_t11_fc9": 40000+ },+ "stats": {+ "infantry": {+ "attack": 1513,+ "defense": 1634.1,+ "lethality": 831.2,+ "health": 1103.1+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ },+ "Lumak": {+ "skill_1": 5+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t10_fc9": 40000+ },+ "stats": {+ "infantry": {+ "attack": 1354,+ "defense": 1413.7,+ "lethality": 634.3,+ "health": 812.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 39914,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s5-fc9-gatot-lumak-90000.json===================================================================--- testcases/gatot_verified/s5-fc9-gatot-lumak-90000.json prev run+++ testcases/gatot_verified/s5-fc9-gatot-lumak-90000.json this run@@ -0,0 +1,55 @@+[+ {+ "test_id": "s5-fc9-gatot-lumak-90000",+ "description": "Gatot evidence section 5: 90,000 T11 FC9 infantry + Lumak S1 vs same count T10 FC9 infantry",+ "attacker": {+ "troops": {+ "infantry_t11_fc9": 90000+ },+ "stats": {+ "infantry": {+ "attack": 1513,+ "defense": 1634.1,+ "lethality": 831.2,+ "health": 1103.1+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ },+ "Lumak": {+ "skill_1": 5+ }+ }+ },+ "defender": {+ "troops": {+ "infantry_t10_fc9": 90000+ },+ "stats": {+ "infantry": {+ "attack": 1354,+ "defense": 1413.7,+ "lethality": 634.3,+ "health": 812.6+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 86767,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s6-historical-t11-fc10-vs-10000-t6-marksmen.json.disabled===================================================================--- testcases/gatot_verified/s6-historical-t11-fc10-vs-10000-t6-marksmen.json.disabled prev run+++ testcases/gatot_verified/s6-historical-t11-fc10-vs-10000-t6-marksmen.json.disabled this run@@ -0,0 +1,7 @@+[+ {+ "test_id": "s6-historical-t11-fc10-vs-10000-t6-marksmen",+ "description": "Gatot evidence section 6: 10,000 T6 marksmen vs one historical T11 FC10 infantry",+ "disabled_reason": "The complete attacker stat block was supplied only by screenshot and is not recoverable from the evidence document; the historical T11 FC10 catalogue has also been replaced."+ }+]Index: testcases/gatot_verified/s6-historical-t11-fc10-vs-3000-t6-marksmen.json.disabled===================================================================--- testcases/gatot_verified/s6-historical-t11-fc10-vs-3000-t6-marksmen.json.disabled prev run+++ testcases/gatot_verified/s6-historical-t11-fc10-vs-3000-t6-marksmen.json.disabled this run@@ -0,0 +1,7 @@+[+ {+ "test_id": "s6-historical-t11-fc10-vs-3000-t6-marksmen",+ "description": "Gatot evidence section 6: 3,000 T6 marksmen vs one historical T11 FC10 infantry",+ "disabled_reason": "The complete attacker stat block was supplied only by screenshot and is not recoverable from the evidence document; the historical T11 FC10 catalogue has also been replaced."+ }+]Index: testcases/gatot_verified/s7-10000-t6-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s7-10000-t6-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s7-10000-t6-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s7-10000-t6-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 7: 10,000 T6 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t6": 10000+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 9540,+ "defender": 1+ }+ ]+ }+]Index: testcases/gatot_verified/s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-10000-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 10,000 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 10000+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 9720,+ "defender": 1+ }+ ]+ }+]Index: testcases/gatot_verified/s8-11548-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-11548-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-11548-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-11548-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 11,548 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 11548+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 11268,+ "defender": 1+ }+ ]+ }+]Index: testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-11791-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 11,791 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 11791+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 11548,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-12000-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 12,000 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 12000+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 11791,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-20000-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-20000-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-20000-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-20000-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 20,000 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 20000+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 19943,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-25000-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-25000-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-25000-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-25000-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 25,000 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 25000+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 24929,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-30116-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-30116-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-30116-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-30116-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 30,116 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 30116+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 30081,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-30139-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-30139-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-30139-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-30139-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 30,139 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 30139+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 30116,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-30179-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-30179-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-30179-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-30179-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 30,179 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 30179+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 30139,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-30205-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-30205-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-30205-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-30205-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 30,205 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 30205+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 30179,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-30228-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-30228-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-30228-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-30228-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 30,228 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 30228+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 30205,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-33427-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-33427-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-33427-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-33427-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 33,427 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 33427+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 33401,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-33452-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-33452-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-33452-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-33452-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 33,452 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 33452+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 33427,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-33481-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-33481-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-33481-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-33481-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 33,481 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 33481+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 33452,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s8-33510-t9-marksmen-vs-one-t1-fc10-infantry.json===================================================================--- testcases/gatot_verified/s8-33510-t9-marksmen-vs-one-t1-fc10-infantry.json prev run+++ testcases/gatot_verified/s8-33510-t9-marksmen-vs-one-t1-fc10-infantry.json this run@@ -0,0 +1,46 @@+[+ {+ "test_id": "s8-33510-t9-marksmen-vs-one-t1-fc10-infantry",+ "description": "Gatot evidence section 8: 33,510 T9 marksmen vs one T1 FC10 infantry",+ "attacker": {+ "troops": {+ "marksman_t9": 33510+ },+ "stats": {+ "marksman": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 158.8,+ "health": 164.7+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "infantry_t1_fc10": 1+ },+ "stats": {+ "infantry": {+ "attack": 2159.6,+ "defense": 2185.6,+ "lethality": 1942.2,+ "health": 2139.7+ }+ },+ "heroes": {+ "Gatot": {+ "skill_1": 5,+ "skill_2": 5,+ "skill_3": 5+ }+ }+ },+ "game_report_result": [+ {+ "attacker": 33481,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.json===================================================================--- testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.json prev run+++ testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.json this run@@ -0,0 +1,40 @@+[+ {+ "test_id": "s9-1500-t9-lancers-vs-146-t1-fc10-lancers",+ "description": "Gatot evidence section 9: 1,500 T9 lancers vs 146 T1 FC10 lancers",+ "attacker": {+ "troops": {+ "lancer_t9": 1500+ },+ "stats": {+ "lancer": {+ "attack": 208.8,+ "defense": 179.2,+ "lethality": 154.7,+ "health": 154.8+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "lancer_t1_fc10": 146+ },+ "stats": {+ "lancer": {+ "attack": 1306.9,+ "defense": 1333.2,+ "lethality": 1508.3,+ "health": 1258+ }+ },+ "heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 85+ }+ ]+ }+]Index: testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.json===================================================================--- testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.json prev run+++ testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.json this run@@ -0,0 +1,40 @@+[+ {+ "test_id": "s9-1950-t9-lancers-vs-146-t1-fc10-lancers",+ "description": "Gatot evidence section 9: 1,950 T9 lancers vs 146 T1 FC10 lancers",+ "attacker": {+ "troops": {+ "lancer_t9": 1950+ },+ "stats": {+ "lancer": {+ "attack": 208.8,+ "defense": 179.2,+ "lethality": 154.7,+ "health": 154.8+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "lancer_t1_fc10": 146+ },+ "stats": {+ "lancer": {+ "attack": 1306.9,+ "defense": 1333.2,+ "lethality": 1508.3,+ "health": 1258+ }+ },+ "heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 45+ }+ ]+ }+]Index: testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.json===================================================================--- testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.json prev run+++ testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.json this run@@ -0,0 +1,40 @@+[+ {+ "test_id": "s9-2100-t9-lancers-vs-146-t1-fc10-lancers",+ "description": "Gatot evidence section 9: 2,100 T9 lancers vs 146 T1 FC10 lancers",+ "attacker": {+ "troops": {+ "lancer_t9": 2100+ },+ "stats": {+ "lancer": {+ "attack": 208.8,+ "defense": 179.2,+ "lethality": 154.7,+ "health": 154.8+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "lancer_t1_fc10": 146+ },+ "stats": {+ "lancer": {+ "attack": 1306.9,+ "defense": 1333.2,+ "lethality": 1508.3,+ "health": 1258+ }+ },+ "heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 37+ }+ ]+ }+]Index: testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.json===================================================================--- testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.json prev run+++ testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.json this run@@ -0,0 +1,40 @@+[+ {+ "test_id": "s9-2200-t9-lancers-vs-146-t1-fc10-lancers",+ "description": "Gatot evidence section 9: 2,200 T9 lancers vs 146 T1 FC10 lancers",+ "attacker": {+ "troops": {+ "lancer_t9": 2200+ },+ "stats": {+ "lancer": {+ "attack": 208.8,+ "defense": 179.2,+ "lethality": 154.7,+ "health": 154.8+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "lancer_t1_fc10": 146+ },+ "stats": {+ "lancer": {+ "attack": 1306.9,+ "defense": 1333.2,+ "lethality": 1508.3,+ "health": 1258+ }+ },+ "heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 0,+ "defender": 20+ }+ ]+ }+]Index: testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.json===================================================================--- testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.json prev run+++ testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.json this run@@ -0,0 +1,40 @@+[+ {+ "test_id": "s9-2201-t9-lancers-vs-146-t1-fc10-lancers",+ "description": "Gatot evidence section 9: 2,201 T9 lancers vs 146 T1 FC10 lancers",+ "attacker": {+ "troops": {+ "lancer_t9": 2201+ },+ "stats": {+ "lancer": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 167.5,+ "health": 167.6+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "lancer_t1_fc10": 146+ },+ "stats": {+ "lancer": {+ "attack": 1179,+ "defense": 1202.9,+ "lethality": 1362.1,+ "health": 1134.5+ }+ },+ "heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 1233,+ "defender": 0+ }+ ]+ }+]Index: testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.json===================================================================--- testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.json prev run+++ testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.json this run@@ -0,0 +1,40 @@+[+ {+ "test_id": "s9-3000-t9-lancers-vs-146-t1-fc10-lancers",+ "description": "Gatot evidence section 9: 3,000 T9 lancers vs 146 T1 FC10 lancers",+ "attacker": {+ "troops": {+ "lancer_t9": 3000+ },+ "stats": {+ "lancer": {+ "attack": 208.8,+ "defense": 207.1,+ "lethality": 167.5,+ "health": 167.6+ }+ },+ "heroes": {}+ },+ "defender": {+ "troops": {+ "lancer_t1_fc10": 146+ },+ "stats": {+ "lancer": {+ "attack": 1179,+ "defense": 1202.9,+ "lethality": 1362.1,+ "health": 1134.5+ }+ },+ "heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 2201,+ "defender": 0+ }+ ]+ }+]
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json--- a/simulator/config/hero_definitions/Renee.json+++ b/simulator/config/hero_definitions/Renee.json@@ -12,7 +12,7 @@},"effects": {"NightmareTrace/1": {- "type": "active.hero.health.down",+ "type": "extra_skill_attack","value": [40,80,@@ -21,8 +21,8 @@200],"units": {- "applies_to": "target",- "applies_vs": "lancer"+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"},"duration": {"turns": {@@ -33,7 +33,12 @@"count": 1}},- "same_effect_stacking": "max"+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"+ }+ ]}}},@@ -46,7 +51,7 @@},"effects": {"Dreamcatcher/1": {- "type": "active.hero.health.down",+ "type": "extra_skill_attack","value": [30,60,@@ -55,8 +60,8 @@150],"units": {- "applies_to": "target",- "applies_vs": "lancer"+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"},"duration": {"turns": {@@ -64,7 +69,12 @@"delay": 1}},- "same_effect_stacking": "max"+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"+ }+ ]}}},@@ -77,7 +87,7 @@},"effects": {"Dreamslice/1": {- "type": "active.hero.health.down",+ "type": "extra_skill_attack","value": [15,30,@@ -86,8 +96,8 @@75],"units": {- "applies_to": "target",- "applies_vs": "any"+ "applies_to": "all",+ "applies_vs": "trigger.target"},"duration": {"turns": {@@ -95,7 +105,12 @@"delay": 1}},- "same_effect_stacking": "max"+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"+ }+ ]}}},diff --git a/simulator/src/fighterResolution.ts b/simulator/src/fighterResolution.ts--- a/simulator/src/fighterResolution.ts+++ b/simulator/src/fighterResolution.ts@@ -189,6 +189,9 @@let index = 0;for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {index += 1;+ // Joiners contribute exactly their first expedition skill. Treat any later levels in+ // low-level input as inert rather than rejecting an otherwise valid battle request.+ if (instance.role === "joiner" && index > 1) continue;const level = Number(instance.levels[`skill_${index}`] ?? instance.levels[skillId] ?? 0);if (level <= 0) continue;if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, engagementType)) continue;diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts--- a/simulator/src/simulator.test.ts+++ b/simulator/src/simulator.test.ts@@ -1024,6 +1024,62 @@);});+test("joiner heroes contribute only their first skill when later levels are supplied", () => {+ const config = minimalConfig({+ Joiner: {+ name: "Joiner",+ skills: {+ FirstSkill: {+ trigger: { type: "pre_battle" },+ effects: { first: { type: "passive.attack.up", value: 10 } }+ },+ SecondSkill: {+ trigger: { type: "pre_battle" },+ effects: { second: { type: "passive.attack.up", value: 100 } }+ },+ ThirdSkill: {+ trigger: { type: "pre_battle" },+ effects: { third: { type: "passive.lethality.up", value: 100 } }+ },+ FourthSkill: {+ trigger: { type: "pre_battle" },+ effects: { fourth: { type: "passive.lethality.up", value: 100 } }+ }+ }+ }+ });+ const battle = (levels: Record<string, number>): BattleInput => ({+ maxRounds: 1,+ seed: "joiner-first-skill-only",+ attacker: {+ troops: { infantry_t1: 100 },+ joiner_heroes: [{ name: "Joiner", levels }]+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ });++ const firstOnly = runOnce(battle({ skill_1: 1 }), config, { mode: "trace" });+ const laterLevelsPresent = runOnce(+ battle({ skill_1: 1, skill_2: 1, skill_3: 1, skill_4: 1 }),+ config,+ { mode: "trace" }+ );++ assert.deepEqual(semanticBattleSummary(laterLevelsPresent), semanticBattleSummary(firstOnly));+ assert.deepEqual(laterLevelsPresent.resolved.attacker.heroes[0]?.skillIds, ["FirstSkill"]);+ assert.deepEqual(+ laterLevelsPresent.skillReport.attacker.map((entry) => entry.skillId),+ ["FirstSkill"]+ );+ const attack = laterLevelsPresent.attacks.find(+ (entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry"+ );+ assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 10);+});+test("joiner hero generation stats are not applied when main hero stats are enabled", () => {const config = minimalConfig({Main: {