WOS Sim
← Back to Runs

Compare Runs

A (baseline)
22a6b6c0-48e38c4f1b021/06/2026, 03:20:31
B (current)
f9c4e7fa-81b38c4f1b021/06/2026, 23:45:59
Avg Error A0.21%
Avg Error B0.21%
Δ Avg Error-0.00%
Improved0
Regressed0
Added0
Retired0

Testcase Delta

178 / 178
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/ambusher_no_marksmen.jsonambusher_no_marksmen02.19%2.09%-0.10%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1010.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1120.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1230.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1340.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1450.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_156+0.013%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_167+0.008%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_178-0.003%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_189+0.017%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_19100.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2011-0.006%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2112+0.013%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_22130.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_23140.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2415-0.008%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_25160.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2617-0.002%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_900.00%0.00%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_10+0.05%-0.32%-0.32%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_21+0.05%-0.06%-0.06%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_32+0.05%-0.38%-0.38%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_43+0.05%-0.45%-0.45%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_54+0.043%0.00%0.00%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_65+0.05%-0.07%-0.07%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_10+0.05%0.19%0.19%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_32-0.42%-0.42%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_430.02%0.02%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_540.64%0.64%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_750.17%0.17%0.00%unchanged
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_10-0.22%-0.22%0.00%unchanged
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo0-0.05%0.06%0.06%0.00%unchanged
testcases/emulator_verified/alonso_attacker_600_all.jsonalonso_attacker_600_all0-0.14%-0.14%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer00.04%0.04%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer10.02%0.02%0.00%unchanged
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender00.14%0.14%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo1-0.26%-0.26%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2-0.19%-0.19%0.00%unchanged
testcases/emulator_verified/alonso_solo.jsonalonso_solo02.97%2.97%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201.58%1.58%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21-1.59%-1.59%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.17%-0.17%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_minxxx_attacks_nc.jsonattdef_jasser_patrick_minxxx_attacks0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_nc.jsonattdef_jasser_patrick00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_swap_nc.jsonattdef_jasser_patrick_swap00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks1-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_test_minxxx_attacks_nc.jsonattdef_test_minxxx_attacks_nc0-0.05%0.08%0.08%0.00%unchanged
testcases/emulator_verified/attdef_test_wip_attacks_nc.jsonattdef_test_wip_attacks_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc0-2.99%-2.99%0.00%unchanged
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo00.32%0.32%0.00%unchanged
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc00.04%0.04%0.00%unchanged
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal00.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped00.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo0-0.15%-0.15%0.00%unchanged
testcases/emulator_verified/flint_solo_nc.jsonflint_solo0+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/flint_zinman_combo_nc.jsonflint_zinman_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo0-1.97%-1.97%0.00%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo11.07%1.07%0.00%unchanged
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0-0.09%-0.09%0.00%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current10.01%0.01%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/greg_solo.jsongreg_solo00.16%0.16%0.00%unchanged
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc00.64%0.64%0.00%unchanged
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0-0.05%0.24%0.24%0.00%unchanged
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo0-0.05%-0.05%0.00%unchanged
testcases/emulator_verified/hector_renee_wayne.jsondeterminism_test_normal0-0.44%-0.44%0.00%unchanged
testcases/emulator_verified/hector_solo.jsonhector_solo00.12%0.12%0.00%unchanged
testcases/emulator_verified/jasser_solo_nc.jsonjasser_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/jessie_solo_nc.jsonjessie_solo0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo1+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/logan_bahiti_combo_nc.jsonlogan_bahiti_combo_nc00.55%0.55%0.00%unchanged
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc0-0.05%0.13%0.13%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo00.08%0.08%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo10.03%0.03%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo20.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0-0.50%-0.50%0.00%unchanged
testcases/emulator_verified/logan_solo_minxxx_nc.jsonlogan_solo_minxxx00.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_solo_v2_nc.jsonlogan_solo_v200.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lynn_solo.jsonlynn_solo0-0.08%-0.08%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1-0.21%-0.21%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.27%-0.27%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo10.22%0.22%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.32%-0.32%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo30.04%0.04%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo40.21%0.21%0.00%unchanged
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo00.00%0.00%0.00%unchanged
testcases/emulator_verified/molly_solo.jsonmolly_solo0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0-1.11%-1.11%0.00%unchanged
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo00.08%0.08%0.00%unchanged
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0-0.68%-0.68%0.00%unchanged
testcases/emulator_verified/norah_s2_inf_only_B_nc.jsonnorah_s2_inf_only_B0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_A.jsonnorah_s2_splash_A00.23%0.23%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_B_nc.jsonnorah_s2_splash_B00.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_C_nc.jsonnorah_s2_splash_C00.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_D.jsonnorah_s2_splash_D00.12%0.12%0.00%unchanged
testcases/emulator_verified/norah_solo.jsonnorah_solo0-0.28%-0.28%0.00%unchanged
testcases/emulator_verified/norah_zinman_combo_nc.jsonnorah_zinman_combo_nc00.39%0.39%0.00%unchanged
testcases/emulator_verified/patrick_solo_nc.jsonpatrick_solo0+0.001%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo0-0.13%-0.13%0.00%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo10.24%0.24%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo00.24%0.24%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo10.03%0.03%0.00%unchanged
testcases/emulator_verified/reina_attacker_wip.jsonreina_attacker_wip00.01%0.01%0.00%unchanged
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc01.12%1.12%0.00%unchanged
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator00.14%0.14%0.00%unchanged
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc0+0.05%-0.48%-0.48%0.00%unchanged
testcases/emulator_verified/seo_yoon_solo_nc.jsonseo_yoon_solo0-0.05%0.10%0.10%0.00%unchanged
testcases/emulator_verified/sergey_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc0-1.44%-1.44%0.00%unchanged
testcases/emulator_verified/sergey_reina_combo_nc.jsonsergey_reina_combo_nc0-0.35%-0.35%0.00%unchanged
testcases/emulator_verified/sergey_solo_nc.jsonsergey_solo0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/simple_001_nc.jsonsimple_00100.00%0.00%0.00%unchanged
testcases/emulator_verified/troop_check_nc.jsontroop_check00.00%0.00%0.00%unchanged
testcases/emulator_verified/type_triangle_A_nc.jsontype_triangle_A_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/type_triangle_B_nc.jsontype_triangle_B_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo0-0.23%-0.23%0.00%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1-2.01%-2.01%0.00%unchanged
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0-0.37%-0.37%0.00%unchanged
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo00.18%0.18%0.00%unchanged
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo00.36%0.36%0.00%unchanged
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo00.43%0.43%0.00%unchanged
testcases/emulator_verified/wos425_wip_nohero_control_nc.jsonwos425_wip_nohero_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_patrick_health_nc.jsonwos425_wip_patrick_health_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_philly_defense.jsonwos425_wip_philly_defense0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.jsonwos425_wip_sergey_damage_taken_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.jsonwos425_wip_sergey_patrick_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo0-0.01%-0.01%0.00%unchanged
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_8400-0.28%-0.28%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo00.00%0.00%0.00%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.05%0.27%0.27%0.00%unchanged
testcases/heroes_unittests/Hector_tc.jsondaut_viper_100.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43-0.017%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_540.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_870.00%0.00%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.17%0.17%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21-0.99%-0.99%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_32-0.10%-0.10%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43-0.13%-0.13%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54-0.11%-0.11%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_430.33%0.33%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_54-0.02%-0.02%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_10+0.05%-0.18%-0.18%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_42-0.46%-0.46%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_530.01%0.01%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43+0.05%-0.04%-0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65-0.006%0.04%0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76-0.004%0.04%0.04%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10-0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_32-0.05%0.00%0.00%0.00%unchanged

Code / Config Changes

Code Changes (Run A → Run B)

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