WOS Sim
← Back to Runs

Compare Runs

A (baseline)
97bcaa8d-96840ddd33611/06/2026, 05:27:58
B (current)
c666bc1b-5ca29120cde17/06/2026, 15:57:04
Avg Error A0.21%
Avg Error B0.20%
Δ Avg Error-0.00%
Improved1
Regressed3
Added0
Retired0

Testcase Delta

178 / 178
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo0-2.47%-1.58%-0.89%improved
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0-0.35%-0.68%+0.33%regressed
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0-0.20%0.00%-0.20%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo10.97%1.13%+0.16%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.32%-0.16%-0.16%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43-0.13%-0.29%+0.16%unchanged
testcases/emulator_verified/greg_solo.jsongreg_solo0-0.04%0.16%+0.12%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_32-0.10%-0.21%+0.11%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.27%-0.18%-0.09%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1-0.21%-0.13%-0.08%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current0-0.07%0.00%-0.07%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2-0.08%-0.02%-0.06%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0-0.07%0.01%-0.06%unchanged
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0-0.10%-0.04%-0.06%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current1-0.06%0.01%-0.05%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo10.22%0.27%+0.05%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo30.04%0.09%+0.05%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo40.21%0.26%+0.05%unchanged
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc0+0.05%-0.43%-0.48%+0.05%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.17%0.21%+0.04%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65-0.006%0.00%0.04%+0.04%regressed
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76-0.004%0.02%0.04%+0.02%regressed
testcases/emulator_verified/hector_renee_wayne.jsondeterminism_test_normal00.54%0.55%+0.01%unchanged
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0-1.10%-1.11%+0.01%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54-0.11%-0.10%-0.01%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.16%0.16%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.18%-0.18%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/ambusher_no_marksmen.jsonambusher_no_marksmen00.64%0.64%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.10%-0.10%0.00%unchanged
testcases/emulator_verified/flint_solo_nc.jsonflint_solo00.00%0.00%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_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/gwen_norah_combo_nc.jsongwen_norah_combo_nc00.65%0.65%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_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/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/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo00.08%0.08%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/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.01%-0.01%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.05%0.56%0.56%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_21-0.77%-0.77%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/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

Commits (40ddd33629120cde)

29120cdecode cleanuppiddlyminx17/06/2026, 15:53:53

Code Changes (Run A → Run B)

Index: simulator/config/hero_definitions/Greg.json
===================================================================
--- simulator/config/hero_definitions/Greg.json prev run
+++ simulator/config/hero_definitions/Greg.json this run
@@ -24,9 +24,9 @@
24,
32,
40
],
- "same_effect_stacking": "add",
+ "same_effect_stacking": "max",
"units": {
"applies_to": "all",
"applies_vs": "any"
},
Index: simulator/config/hero_definitions/Mia.json
===================================================================
--- simulator/config/hero_definitions/Mia.json prev run
+++ simulator/config/hero_definitions/Mia.json this run
@@ -29,9 +29,10 @@
"applies_to": "target"
},
"duration": {
"type": "turn",
- "value": 1
+ "value": 1,
+ "delay": 1
},
"same_effect_stacking": "max"
}
}
Index: simulator/src/battleInputBuilder.ts
===================================================================
--- simulator/src/battleInputBuilder.ts prev run
+++ simulator/src/battleInputBuilder.ts this run
@@ -0,0 +1,66 @@
+import type { BattleInput, FighterInput, SideId, SimulatorConfig } from "./types";
+import { applyHeroGenerationStats } from "./resolve";
+
+/**
+ * Ergonomic, order-independent constructor for a BattleInput.
+ *
+ * This is build-time scaffolding only — it does not run a simulation. Its job is to produce a
+ * BattleInput whose FighterInput.stats are the final, authoritative player stats, so the
+ * simulator core never has to massage the input. For example, addHeroGenerationStats() bakes
+ * the main heroes' generation stats into each fighter's stat block, which the simulator used to
+ * apply itself via the (now removed) hero_generation_stats mechanic.
+ */
+export class BattleInputBuilder {
+ private readonly fighters: Partial<Record<SideId, FighterInput>> = {};
+ private seedValue?: string | number;
+ private maxRoundsValue?: number;
+ private engagementTypeValue?: string;
+ private bakeHeroGenerationStats = false;
+
+ constructor(private readonly config: SimulatorConfig) {}
+
+ fighter(side: SideId, input: FighterInput): this {
+ this.fighters[side] = input;
+ return this;
+ }
+
+ seed(seed: string | number): this {
+ this.seedValue = seed;
+ return this;
+ }
+
+ maxRounds(maxRounds: number): this {
+ this.maxRoundsValue = maxRounds;
+ return this;
+ }
+
+ engagement(engagementType: string): this {
+ this.engagementTypeValue = engagementType;
+ return this;
+ }
+
+ addHeroGenerationStats(add = true): this {
+ this.bakeHeroGenerationStats = add;
+ return this;
+ }
+
+ build(): BattleInput {
+ const attacker = this.requireFighter("attacker");
+ const defender = this.requireFighter("defender");
+ const prepare = (fighter: FighterInput): FighterInput =>
+ this.bakeHeroGenerationStats ? applyHeroGenerationStats(fighter, this.config) : fighter;
+ return {
+ attacker: prepare(attacker),
+ defender: prepare(defender),
+ ...(this.seedValue !== undefined ? { seed: this.seedValue } : {}),
+ ...(this.maxRoundsValue !== undefined ? { maxRounds: this.maxRoundsValue } : {}),
+ ...(this.engagementTypeValue !== undefined ? { engagement_type: this.engagementTypeValue } : {})
+ };
+ }
+
+ private requireFighter(side: SideId): FighterInput {
+ const fighter = this.fighters[side];
+ if (!fighter) throw new Error(`BattleInputBuilder is missing the ${side} fighter`);
+ return fighter;
+ }
+}
Index: simulator/src/calibration.ts
===================================================================
--- simulator/src/calibration.ts prev run
+++ simulator/src/calibration.ts this run
@@ -1,6 +1,6 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
-import { dirname, relative, resolve } from "node:path";
+import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export interface CalibrationCaseComparison {
file: string;
@@ -134,69 +134,8 @@
}
return [...variants];
}
-export function addCalibrationTableRow(
- comparison: CalibrationComparison,
- caseReport: {
- file: string;
- testcaseId: string;
- index: number;
- simulatorScoreDelta?: number;
- simulatorStats?: SampleStats;
- calibration?: CalibrationCaseComparison;
- }
-): CalibrationComparisonRow {
- const calibration = caseReport.calibration;
- const simulator = caseReport.simulatorStats;
- const simulatorVsBaseline = compareDistributions(simulator, calibration?.muSim, calibration?.sigmaSim, calibration?.nSim, comparison.thresholds, calibration?.statType);
- const simulatorVsGame = compareDistributions(simulator, calibration?.muGame, calibration?.sigmaGame, calibration?.nGame, comparison.thresholds, calibration?.statType);
- const simulatorVsGameRaw = simulatorVsGame.biasRaw;
- const simulatorVsGamePct = simulatorVsGame.biasPct;
- const row: CalibrationComparisonRow = {
- file: relativeDisplayPath(caseReport.file),
- testcaseId: caseReport.testcaseId,
- idx: caseReport.index,
- matched: !!calibration,
- nSim: calibration?.nSim,
- muSim: calibration?.muSim,
- sigmaSim: calibration?.sigmaSim,
- nGame: calibration?.nGame,
- muGame: calibration?.muGame,
- sigmaGame: calibration?.sigmaGame,
- statType: calibration?.statType,
- referenceBiasPct: calibration?.biasPct,
- referencePasses: calibration?.passes,
- passes: calibration?.passes,
- biasRaw: calibration?.biasRaw,
- biasPct: calibration?.biasPct,
- sem: calibration?.sem,
- p: calibration?.p,
- q: calibration?.q,
- simulatorScoreDelta: caseReport.simulatorScoreDelta,
- simulator,
- simulatorVsBaseline,
- simulatorVsGame,
- simulatorVsGameRaw,
- simulatorVsGamePct,
- simulatorPasses: simulatorVsGame.passes,
- simulatorN: simulator?.n,
- simulatorMu: simulator?.mu,
- simulatorSigma: simulator?.sigma,
- simulatorSem: simulator?.sem,
- simulatorVsBaselineBiasRaw: simulatorVsBaseline.biasRaw,
- simulatorVsBaselineBiasPct: simulatorVsBaseline.biasPct,
- simulatorVsBaselineZ: simulatorVsBaseline.z,
- simulatorVsBaselinePasses: simulatorVsBaseline.passes,
- simulatorVsGameBiasRaw: simulatorVsGame.biasRaw,
- simulatorVsGameBiasPct: simulatorVsGame.biasPct,
- simulatorVsGameZ: simulatorVsGame.z,
- simulatorVsGamePasses: simulatorVsGame.passes
- };
- comparison.table.push(row);
- return row;
-}
-
export function sampleStats(samples: number[], options: { includeSamples?: boolean } = {}): SampleStats {
const n = samples.length;
const mu = n > 0 ? samples.reduce((sum, value) => sum + value, 0) / n : 0;
const variance = n > 1 ? samples.reduce((sum, value) => sum + (value - mu) ** 2, 0) / (n - 1) : 0;
@@ -292,64 +231,8 @@
.filter((entry): entry is readonly [string, number] => entry[1] !== undefined);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
-function compareDistributions(
- simulator: SampleStats | undefined,
- referenceMu: number | undefined,
- referenceSigma: number | undefined,
- referenceN: number | undefined,
- thresholds?: Record<string, number>,
- referenceStatType?: string
-): DistributionCompatibility {
- if (!simulator || referenceMu === undefined || referenceSigma === undefined || referenceN === undefined) return { statType: "unmatched" };
- const biasRaw = simulator.mu - referenceMu;
- const biasPct = percentDelta(biasRaw, referenceMu);
- const combinedSem = Math.sqrt((simulator.sigma ** 2) / Math.max(1, simulator.n) + (referenceSigma ** 2) / Math.max(1, referenceN));
- const deterministic = referenceStatType === "deterministic" || (simulator.sigma === 0 && referenceSigma === 0);
- if (deterministic || combinedSem === 0) {
- return {
- biasRaw,
- biasPct,
- statType: "deterministic",
- passes: deterministicPasses(biasRaw, biasPct, thresholds)
- };
- }
- const z = biasRaw / combinedSem;
- return {
- biasRaw,
- biasPct,
- z,
- statType: "distribution",
- passes: distributionPasses(z, biasPct, thresholds)
- };
-}
-
-function deterministicPasses(raw: number, pct: number | undefined, thresholds?: Record<string, number>): boolean {
- if (pct === undefined) return raw === 0;
- const maxDiffRatio = thresholds?.max_diff_ratio_deterministic ?? thresholds?.max_diff_ratio ?? 0.01;
- return Math.abs(pct) <= maxDiffRatio * 100;
-}
-
-function distributionPasses(z: number, pct: number | undefined, thresholds?: Record<string, number>): boolean {
- const minBiasPct = thresholds?.min_bias_pct ?? 0.5;
- if (pct !== undefined && Math.abs(pct) < minBiasPct) return true;
- const zThreshold = thresholds?.z_threshold ?? 2;
- return Math.abs(z) <= zThreshold;
-}
-
-function percentDelta(rawDelta: number, expected: number): number | undefined {
- if (expected === 0) return rawDelta === 0 ? 0 : undefined;
- return (rawDelta / Math.abs(expected)) * 100;
-}
-
-function relativeDisplayPath(path: string): string {
- const normalized = normalizePath(path);
- const cwdRelative = normalizePath(relative(process.cwd(), path));
- if (!cwdRelative.startsWith("..")) return cwdRelative;
- return normalized;
-}
-
function normalizePath(path: string): string {
return path.replaceAll("\\", "/");
}
Index: simulator/src/classifierDamage.test.ts
===================================================================
--- simulator/src/classifierDamage.test.ts prev run
+++ simulator/src/classifierDamage.test.ts this run
@@ -395,12 +395,12 @@
source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },
duration: { type: "round" as const, value: 1 }
};
- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: false });
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });
assert.equal(outcome.consumedEffectIds.includes("bad-luck-like"), false);
- assert.ok(outcome.appliedEffectIds.includes("BadLuckStreak/1"));
+ assert.ok(outcome.appliedEffectIds?.includes("BadLuckStreak/1"));
assert.deepEqual(outcome.appliedEffects, [
{
effectId: "BadLuckStreak/1",
bucket: "active.hero.defense.down",
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -5,10 +5,9 @@
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { loadSimulatorConfigFromDir } from "./config-node";
-import { UNIT_TYPES } from "./types";
-import type { SkillFile, TriggerDamageJobDefinition } from "./types";
+import type { SkillFile } from "./types";
test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {
const root = writeConfigWithTroopEffect({
type: "active.hero.lethality.up",
@@ -252,22 +251,8 @@
assert.throws(() => loadSimulatorConfigFromDir(omittedRoot), /effect\.applies_vs.*concrete applies_vs/i);
assert.throws(() => loadSimulatorConfigFromDir(allRoot), /applies_vs.*all/i);
});
-function isAllowedTriggerDamageJobSelector(selector: TriggerDamageJobDefinition["source"]): boolean {
- const supported = new Set(["use.source", "use.target", "effect.applies_to", "effect.applies_vs", "enemy.living", "self.living"]);
- if (typeof selector === "string") return supported.has(selector) || (UNIT_TYPES as string[]).includes(selector);
- if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));
- return false;
-}
-
-function isEffectAppliesVsConcrete(selector: unknown): boolean {
- if (selector === "trigger.target" || selector === "target") return true;
- if (typeof selector === "string") return (UNIT_TYPES as string[]).includes(selector);
- if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));
- return false;
-}
-
function writeConfigWithTroopEffect(effect: Record<string, unknown>, trigger: Record<string, unknown> = { type: "turn" }): string {
const root = join(tmpdir(), `wos-simulator-config-trigger-jobs-${Date.now()}-${Math.random().toString(16).slice(2)}`);
mkdirSync(join(root, "hero_definitions"), { recursive: true });
writeFileSync(
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -1,8 +1,7 @@
import type {
ActiveEffect,
DamageAggregationGroupTrace,
- AttackOutcome,
DamageBucketTrace,
DamageEquationTrace,
DamageJob,
ResolvedFighter,
@@ -37,9 +36,8 @@
interface BucketCandidate {
effect: ActiveEffect;
bucket: AtomicBucket;
valuePct: number;
- carriedInactive?: boolean;
}
interface MaxBucketCandidateGroup {
selected: BucketCandidate;
@@ -57,13 +55,25 @@
}
export type DamageScratch = NumericDamageBuckets;
+// Lean result of a single damage job: the correctness-relevant outputs (kills, consumed
+// effect bookkeeping) plus, only when tracing, the recording detail. The heavy per-attack
+// AttackOutcome is assembled by the recorder, not here, so fast mode allocates nothing extra.
+export interface DamageResult {
+ kills: number;
+ consumedEffectIds: string[];
+ consumedEffectUseKey?: string;
+ consumedEffectUseId?: string;
+ consumedEffectUseIds?: string[];
+ appliedEffectIds?: string[];
+ appliedEffects?: DamageEquationTrace["appliedEffects"];
+ trace?: DamageEquationTrace;
+}
+
const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;
const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};
-const EMPTY_APPLIED_EFFECT_IDS: AttackOutcome["appliedEffectIds"] = [];
-const EMPTY_COUNTER_DELTAS: AttackOutcome["counterDeltas"] = [];
-const EMPTY_CONSUMED_EFFECT_IDS: AttackOutcome["consumedEffectIds"] = [];
+const EMPTY_CONSUMED_EFFECT_IDS: string[] = [];
const TROOPS_COUNT_TERM = factorTerm("troops.count");
const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");
const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));
const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");
@@ -101,13 +111,15 @@
export function calculateDamageJob(
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
activeEffects: ActiveEffect[],
- options: { trace?: boolean; effectIndex: EffectIndex; detail?: DamageDetail; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch }
-): AttackOutcome {
+ options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch }
+): DamageResult {
if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
- const detail = options.detail ?? "full";
- const traceEnabled = detail === "full" && options.trace === true;
+ // The damage math is one path; `trace` only decides whether we also capture the (expensive)
+ // per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.
+ const traceEnabled = options.trace === true;
+ const detail: DamageDetail = traceEnabled ? "full" : "fast";
const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);
const attacker = fighters[job.attackerSide];
const defender = fighters[job.defenderSide];
const attackerTroops = job.roundStartTroops[job.attackerSide][job.attackerUnit] ?? 0;
@@ -123,19 +135,15 @@
const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];
const consumedEffectIds = new Set<string>();
const candidates: BucketCandidate[] = [];
const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
- const carriedAttackDurationEffectIds = job.carriedAttackDurationEffectIds ? new Set(job.carriedAttackDurationEffectIds) : undefined;
for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
- const active = isEffectActive(candidate.effect, job.round);
- const carriedInactive = !active && carriedAttackDurationEffectIds?.has(candidate.effect.id) === true;
- if (!active && !carriedInactive) continue;
+ if (!isEffectActive(candidate.effect, job.round)) continue;
handledCandidateEffectIds?.add(candidate.effect.id);
candidates.push({
effect: candidate.effect,
bucket: candidate.bucket,
- valuePct: currentEffectValuePct(candidate.effect, job.round),
- carriedInactive
+ valuePct: currentEffectValuePct(candidate.effect, job.round)
});
}
if (traceEnabled) {
@@ -177,34 +185,19 @@
finalKills: kills
}
: undefined;
- const fast = detail === "fast";
const returnedConsumedEffectIds =
- fast && consumedEffectIds.size === 0 ? job.consumedEffectIds ?? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds, ...(job.consumedEffectIds ?? [])];
+ consumedEffectIds.size === 0 ? job.consumedEffectIds ?? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds, ...(job.consumedEffectIds ?? [])];
return {
- jobId: job.id,
- kind: job.kind,
- sourceEffectId: job.sourceEffectId,
- sourceSkillReportKey: job.sourceSkillReportKey,
- attackerSide: job.attackerSide,
- attackerUnit: job.attackerUnit,
- defenderSide: job.defenderSide,
- defenderUnit: job.defenderUnit,
kills,
- counterDeltas: fast
- ? EMPTY_COUNTER_DELTAS
- : [
- { side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause: job.kind === "skill" ? "extra_skill_attack" : "normal_attack" },
- { side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause: job.kind === "skill" ? "extra_skill_attack" : "normal_attack" }
- ],
- appliedEffectIds: fast ? EMPTY_APPLIED_EFFECT_IDS : appliedEffects.map((effect) => effect.effectId),
- appliedEffects,
consumedEffectIds: returnedConsumedEffectIds,
consumedEffectUseKey: job.consumedEffectUseKey,
consumedEffectUseId: job.consumedEffectUseId,
consumedEffectUseIds: job.consumedEffectUseIds,
+ appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,
+ appliedEffects: traceEnabled ? appliedEffects : undefined,
trace
};
}
@@ -271,9 +264,9 @@
if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;
appliedEffects.push(appliedEffect);
}
for (const candidate of candidates) {
- if (candidate.effect.duration.type === "attack" && !candidate.carriedInactive) consumedEffectIds.add(candidate.effect.id);
+ if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
if (detail === "full" && candidate !== selected) {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
}
}
Index: simulator/src/damageBuckets.ts
===================================================================
--- simulator/src/damageBuckets.ts prev run
+++ simulator/src/damageBuckets.ts this run
@@ -3,8 +3,13 @@
export type BucketRole = "attacker" | "defender";
export type BucketValueType = "raw" | "pct";
export type BucketUpdate = "assign_factor" | "add_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.
+// "dynamic" pools are open: triggered/timed/conditional effects can feed them per job,
+// so they are aggregated in the per-job runtime scratch.
+export type BucketPhase = "static" | "dynamic";
export interface BucketLeaf {
role: BucketRole;
valueType: BucketValueType;
@@ -18,10 +23,38 @@
};
export const BUCKETS = {
troops: {
- count: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" }
+ count: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
+ baseAttack: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
+ baseLethality: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
+ baseHealth: { role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" },
+ baseDefense: { role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" }
},
+ player: {
+ attack: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ lethality: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ health: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ defense: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
+ },
+ passive: {
+ attack: {
+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
+ },
+ lethality: {
+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
+ },
+ health: {
+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
+ },
+ defense: {
+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
+ }
+ },
active: {
hero: {
attack: {
up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
@@ -87,29 +120,43 @@
} as const satisfies BucketTree;
export interface BucketDefinition extends BucketLeaf {
path: string;
+ phase: BucketPhase;
}
+// A pool is closed at compile time (static phase) when no triggered/timed/conditional
+// effect can feed it: base troop stats, player stat bonuses, and battle-start passives.
+// Everything else is an open pool aggregated per damage job.
+const STATIC_BUCKET_FAMILIES = ["troops.base", "player.", "passive."];
+
+function phaseForPath(path: string): BucketPhase {
+ return STATIC_BUCKET_FAMILIES.some((family) => path.startsWith(family)) ? "static" : "dynamic";
+}
+
export const BUCKET_DEFINITIONS = flattenBuckets(BUCKETS);
-export const ATOMIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS).sort();
+// ATOMIC_BUCKETS is the runtime (open-pool) bucket set used by the per-job scratch and
+// damage expression. Static-phase buckets are aggregated by the static damage profile and
+// never enter the runtime scratch, so they are intentionally excluded here.
+export const ATOMIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS)
+ .filter((path) => BUCKET_DEFINITIONS[path].phase === "dynamic")
+ .sort();
+export const STATIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS)
+ .filter((path) => BUCKET_DEFINITIONS[path].phase === "static")
+ .sort();
export type AtomicBucket = string;
export type BucketName = AtomicBucket;
export function bucketDefinition(path: string): BucketDefinition | undefined {
return BUCKET_DEFINITIONS[path];
}
-export function isAtomicBucket(path: string): path is AtomicBucket {
- return path in BUCKET_DEFINITIONS;
-}
-
function flattenBuckets(tree: BucketTree, prefix = ""): Record<string, BucketDefinition> {
const buckets: Record<string, BucketDefinition> = {};
for (const [key, value] of Object.entries(tree)) {
const path = prefix ? `${prefix}.${key}` : key;
- if (isBucketLeaf(value)) buckets[path] = { ...value, path };
+ if (isBucketLeaf(value)) buckets[path] = { ...value, path, phase: phaseForPath(path) };
else Object.assign(buckets, flattenBuckets(value, path));
}
return buckets;
}
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -2,11 +2,8 @@
import { unitsFromMask } from "./types";
import { bucketDefinition, type AtomicBucket } from "./damageBuckets";
import { isStaticProfileBucket } from "./staticDamageProfile";
-export type DamageEffectKey = `${DamageKind}:${SideId}:${UnitType}:${SideId}:${UnitType}:${AtomicBucket}`;
-export type DamageJobShapeKey = `${DamageKind}:${SideId}:${UnitType}:${SideId}:${UnitType}`;
-
export interface IndexedBucketEffect {
effect: ActiveEffect;
bucket: AtomicBucket;
}
@@ -46,9 +43,11 @@
return;
}
const definition = bucketDefinition(effect.intent.type);
- if (!definition || definition.valueType !== "pct") return;
+ // Static-phase buckets (passive.*) are aggregated by the static damage profile, not the
+ // per-job runtime path, so they must not enter the damage job-shape index.
+ if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;
const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
const appliesToUnits = unitsFromMask(effect.appliesTo.units);
const appliesVsUnits = unitsFromMask(effect.appliesVs.units);
@@ -85,42 +84,17 @@
compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));
for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
const candidates = index.damageByJobShape[slot];
if (!candidates) continue;
- compactCandidates(candidates, (effect, candidate) => !isStaticProfileBucket(candidate.bucket));
+ compactCandidates(candidates, (_effect, candidate) => !isStaticProfileBucket(candidate.bucket));
if (candidates.length === 0) index.damageByJobShape[slot] = undefined;
}
}
export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {
return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];
}
-export function bucketEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {
- return bucketCandidatesForJob(index, job).map((candidate) => candidate.effect);
-}
-
-export function damageJobShapeKey(
- jobKind: DamageKind,
- attackerSide: SideId,
- attackerUnit: UnitType,
- defenderSide: SideId,
- defenderUnit: UnitType
-): DamageJobShapeKey {
- return `${jobKind}:${attackerSide}:${attackerUnit}:${defenderSide}:${defenderUnit}`;
-}
-
-export function damageEffectKey(
- jobKind: DamageKind,
- attackerSide: SideId,
- attackerUnit: UnitType,
- defenderSide: SideId,
- defenderUnit: UnitType,
- bucket: AtomicBucket
-): DamageEffectKey {
- return `${jobKind}:${attackerSide}:${attackerUnit}:${defenderSide}:${defenderUnit}:${bucket}`;
-}
-
function damageJobShapeSlot(
jobKind: DamageKind,
attackerSide: SideId,
attackerUnit: UnitType,
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -9,9 +9,9 @@
SameEffectStacking,
SideId,
UnitType
} from "./types";
-import { ALL_UNIT_MASK, UNIT_TYPES, unitMask } from "./types";
+import { ALL_UNIT_MASK, unitMask } from "./types";
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
Index: simulator/src/index.ts
===================================================================
--- simulator/src/index.ts prev run
+++ simulator/src/index.ts this run
@@ -1,7 +1,9 @@
export { buildSimulatorConfig, loadSimulatorConfig } from "./config";
export type { RawSimulatorConfig } from "./config";
-export { simulateBattle } from "./simulator";
+export { simulateBattle, signedRemainingScore } from "./simulator";
+export { BattleInputBuilder } from "./battleInputBuilder";
+export { applyHeroGenerationStats } from "./resolve";
export { discoverTestcaseFiles, runTestcases, adaptTestcaseEntry } from "./testcases";
export { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";
export { classifyEffectForJob } from "./classifier";
export { calculateDamageJob } from "./damage";
Index: simulator/src/recorder.ts
===================================================================
--- simulator/src/recorder.ts prev run
+++ simulator/src/recorder.ts this run
@@ -0,0 +1,119 @@
+import type {
+ AttackIntent,
+ AttackOutcome,
+ BattleTrace,
+ DamageJob,
+ SideId,
+ SkillReportEntry,
+ SimulationMode
+} from "./types";
+import type { DamageResult } from "./damage";
+
+/**
+ * Observes a battle and accumulates whatever the chosen mode needs to report. The battle loop
+ * does all simulation-affecting work itself (kills, counters, effect consumption); the recorder
+ * only records, so swapping recorders never changes the outcome. This keeps a single, linear
+ * loop with no `if (mode === ...)` branches scattered through it.
+ *
+ * - fast -> NULL_RECORDER: records nothing; zero per-attack allocation.
+ * - standard -> RecordingRecorder: per-attack AttackOutcome[] (no equation traces).
+ * - trace -> RecordingRecorder + capturesTrace: outcomes with traces + per-round trace.
+ */
+export interface BattleRecorder {
+ /** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */
+ readonly capturesTrace: boolean;
+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void;
+ recordDamageJob(job: DamageJob, result: DamageResult): void;
+ recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;
+ readonly attacks: AttackOutcome[];
+ readonly trace: BattleTrace | undefined;
+}
+
+const NO_ATTACKS: AttackOutcome[] = [];
+
+export const NULL_RECORDER: BattleRecorder = {
+ capturesTrace: false,
+ recordCancelled() {},
+ recordDamageJob() {},
+ recordRound() {},
+ attacks: NO_ATTACKS,
+ trace: undefined
+};
+
+export function createRecorder(
+ mode: SimulationMode,
+ skillReports: Record<SideId, Map<string, SkillReportEntry>>,
+ makeResolved: () => BattleTrace["resolved"]
+): BattleRecorder {
+ if (mode === "fast") return NULL_RECORDER;
+ return new RecordingRecorder(skillReports, mode === "trace" ? { resolved: makeResolved(), rounds: [] } : undefined);
+}
+
+class RecordingRecorder implements BattleRecorder {
+ readonly attacks: AttackOutcome[] = [];
+ readonly trace: BattleTrace | undefined;
+ readonly capturesTrace: boolean;
+
+ constructor(
+ private readonly skillReports: Record<SideId, Map<string, SkillReportEntry>>,
+ trace: BattleTrace | undefined
+ ) {
+ this.trace = trace;
+ this.capturesTrace = trace !== undefined;
+ }
+
+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void {
+ this.attacks.push({
+ jobId: `${intent.id}:cancelled`,
+ kind: "normal",
+ attackerSide: intent.attackerSide,
+ attackerUnit: intent.attackerUnit,
+ defenderSide: intent.defenderSide,
+ defenderUnit: intent.defenderUnit,
+ kills: 0,
+ counterDeltas: [
+ { side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },
+ { side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }
+ ],
+ appliedEffectIds: [],
+ appliedEffects: [],
+ consumedEffectIds,
+ cancelledBy: effectId,
+ cancelReason: reason
+ });
+ }
+
+ recordDamageJob(job: DamageJob, result: DamageResult): void {
+ if (job.kind === "skill" && job.sourceSkillReportKey && result.kills > 0) {
+ const report = this.skillReports[job.attackerSide].get(job.sourceSkillReportKey);
+ if (report) report.skillKills += result.kills;
+ }
+ const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";
+ this.attacks.push({
+ jobId: job.id,
+ kind: job.kind,
+ sourceEffectId: job.sourceEffectId,
+ sourceSkillReportKey: job.sourceSkillReportKey,
+ attackerSide: job.attackerSide,
+ attackerUnit: job.attackerUnit,
+ defenderSide: job.defenderSide,
+ defenderUnit: job.defenderUnit,
+ kills: result.kills,
+ counterDeltas: [
+ { side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause },
+ { side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause }
+ ],
+ appliedEffectIds: result.appliedEffectIds ?? [],
+ appliedEffects: result.appliedEffects ?? [],
+ consumedEffectIds: result.consumedEffectIds,
+ consumedEffectUseKey: result.consumedEffectUseKey,
+ consumedEffectUseId: result.consumedEffectUseId,
+ consumedEffectUseIds: result.consumedEffectUseIds,
+ trace: result.trace
+ });
+ }
+
+ recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void {
+ this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });
+ }
+}
Index: simulator/src/resolve.ts
===================================================================
--- simulator/src/resolve.ts prev run
+++ simulator/src/resolve.ts this run
@@ -1,9 +1,8 @@
import type {
EffectIntentDefinition,
FighterInput,
HeroInputCollection,
- BattleInput,
ResolvedFighter,
ResolvedHero,
ResolvedSkill,
ResolvedTroopLine,
@@ -18,9 +17,9 @@
import { UNIT_TYPES } from "./types";
import { normalizeEngagementType } from "./effects";
import { addStats, normalizeStatBlock, normalizeUnitType, valueAtLevel, zeroStats } from "./normalize";
-export function resolveFighter(input: FighterInput, side: SideId, config: SimulatorConfig, mechanics?: BattleInput["mechanics"]): ResolvedFighter {
+export function resolveFighter(input: FighterInput, side: SideId, config: SimulatorConfig, engagementType?: string): ResolvedFighter {
const diagnostics: string[] = [];
const troops = emptyTroops();
const initialTroops = emptyTroops();
const troopDetails: Partial<Record<UnitType, ResolvedTroopLine>> = {};
@@ -74,11 +73,11 @@
};
}
const statBonuses = resolveInputStatBonuses(input.stats);
- const heroes = resolveHeroes(input, side, config, statBonuses, diagnostics, mechanics);
- const heroSkills = resolveHeroSkills(input, side, config, diagnostics, mechanics);
- const troopSkills = resolveTroopSkills(side, troopDetails, config, mechanics);
+ const heroes = resolveHeroes(input, side, config, diagnostics, engagementType);
+ const heroSkills = resolveHeroSkills(input, side, config, diagnostics, engagementType);
+ const troopSkills = resolveTroopSkills(side, troopDetails, config, engagementType);
return {
side,
name: input.name ?? side,
@@ -112,8 +111,27 @@
}
return byUnit;
}
+// Build-time scaffolding: fold the summed generation-stat block of all MAIN heroes into
+// each unit's stat block, so the simulator can treat FighterInput.stats as authoritative.
+// Equivalent to the former in-resolver hero_generation_stats fold, but materialised into the
+// input rather than applied during resolution.
+export function applyHeroGenerationStats(input: FighterInput, config: SimulatorConfig): FighterInput {
+ let bonus = zeroStats();
+ for (const instance of heroInputInstances(input)) {
+ if (instance.role !== "main") continue;
+ const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
+ const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;
+ if (!definition) continue;
+ bonus = addStats(bonus, normalizeStatBlock(config.heroGenerationStats[definition.hero_generation ?? ""] as Record<string, unknown>));
+ }
+ const byUnit = resolveInputStatBonuses(input.stats);
+ const stats: Record<string, StatBlock> = {};
+ for (const unit of UNIT_TYPES) stats[unit] = addStats(byUnit[unit], bonus);
+ return { ...input, stats };
+}
+
interface HeroInputInstance {
name: string;
levels: Record<string, number>;
role: "main" | "joiner";
@@ -146,11 +164,10 @@
function resolveHeroes(
input: FighterInput,
side: SideId,
config: SimulatorConfig,
- statBonuses: Record<UnitType, StatBlock>,
diagnostics: string[],
- mechanics?: BattleInput["mechanics"]
+ engagementType?: string
): ResolvedHero[] {
const heroes: ResolvedHero[] = [];
for (const instance of heroInputInstances(input)) {
const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
@@ -158,27 +175,19 @@
if (!definition) {
diagnostics.push(`Missing hero definition for ${instance.name}`);
heroes.push({
name: instance.name,
- generationStats: zeroStats(),
skillIds: [],
instanceId: instance.instanceId,
role: instance.role,
missing: true
});
continue;
}
- const generationStats = normalizeStatBlock(config.heroGenerationStats[definition.hero_generation ?? ""] as Record<string, unknown>);
- if (instance.role === "main" && shouldApplyHeroGenerationStats(mechanics)) {
- for (const unit of UNIT_TYPES) {
- statBonuses[unit] = addStats(statBonuses[unit], generationStats);
- }
- }
heroes.push({
name: definition.name ?? instance.name,
heroGeneration: definition.hero_generation,
- generationStats,
- skillIds: resolveHeroSkillIds(definition, instance.levels, side, mechanics),
+ skillIds: resolveHeroSkillIds(definition, instance.levels, side, engagementType),
instanceId: instance.instanceId,
role: instance.role
});
}
@@ -190,9 +199,9 @@
input: FighterInput,
side: SideId,
config: SimulatorConfig,
diagnostics: string[],
- mechanics?: BattleInput["mechanics"]
+ engagementType?: string
): ResolvedSkill[] {
const skills: ResolvedSkill[] = [];
for (const instance of heroInputInstances(input)) {
const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
@@ -202,32 +211,32 @@
for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {
index += 1;
const level = Number(instance.levels[`skill_${index}`] ?? instance.levels[skillId] ?? 0);
if (level <= 0) continue;
- if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, mechanics)) continue;
+ if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, engagementType)) continue;
skills.push(hydrateSkill(skillId, rawSkill, side, level, "hero_skill", definition.name ?? resolvedHeroName, undefined, instance.instanceId, instance.role));
}
}
void diagnostics;
return skills;
}
-function resolveHeroSkillIds(definition: SkillFile, levelMap: Record<string, number>, side: SideId, mechanics?: BattleInput["mechanics"]): string[] {
+function resolveHeroSkillIds(definition: SkillFile, levelMap: Record<string, number>, side: SideId, engagementType?: string): string[] {
const ids: string[] = [];
let index = 0;
for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {
index += 1;
const level = Number(levelMap[`skill_${index}`] ?? levelMap[skillId] ?? 0);
- if (level > 0 && heroRequirementsSatisfied(rawSkill.requirements, level, side, mechanics)) ids.push(skillId);
+ if (level > 0 && heroRequirementsSatisfied(rawSkill.requirements, level, side, engagementType)) ids.push(skillId);
}
return ids;
}
function resolveTroopSkills(
side: SideId,
troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>,
config: SimulatorConfig,
- mechanics?: BattleInput["mechanics"]
+ engagementType?: string
): ResolvedSkill[] {
const skills: ResolvedSkill[] = [];
for (const [skillId, rawSkill] of Object.entries(config.troopSkills.skills ?? {})) {
let troopType: UnitType;
@@ -243,50 +252,42 @@
const satisfied = troopRequirements
.filter((req) => (req.type === "tier" ? troop.tier >= Number(req.value) : req.type === "fc" ? troop.fc >= Number(req.value) : false))
.sort((a, b) => b.level - a.level)[0];
if (!satisfied) continue;
- if (!battleRequirementsSatisfied(requirements, satisfied.level, mechanics)) continue;
+ if (!battleRequirementsSatisfied(requirements, satisfied.level, engagementType)) continue;
skills.push(hydrateSkill(skillId, rawSkill, side, satisfied.level, "troop_skill", undefined, troopType));
}
return skills;
}
-function heroRequirementsSatisfied(requirements: SkillRequirement[] | undefined, level: number, side: SideId, mechanics?: BattleInput["mechanics"]): boolean {
+function heroRequirementsSatisfied(requirements: SkillRequirement[] | undefined, level: number, side: SideId, engagementType?: string): boolean {
return (requirements ?? []).every((requirement) => {
- if (requirement.type !== "engagement_type") return battleRequirementsSatisfied([requirement], level, mechanics);
+ if (requirement.type !== "engagement_type") return battleRequirementsSatisfied([requirement], level, engagementType);
if (level < Number(requirement.level ?? 1)) return true;
- return heroEngagementRequirementSatisfied(requirement, side, mechanics);
+ return heroEngagementRequirementSatisfied(requirement, side, engagementType);
});
}
-function battleRequirementsSatisfied(requirements: SkillRequirement[], level: number, mechanics?: BattleInput["mechanics"]): boolean {
+function battleRequirementsSatisfied(requirements: SkillRequirement[], level: number, engagementType?: string): boolean {
return requirements.every((requirement) => {
if ((requirement.type === "tier" || requirement.type === "fc") && level < Number(requirement.level ?? 1)) return true;
if (requirement.type !== "engagement_type") return true;
if (level < Number(requirement.level ?? 1)) return true;
- return normalizeEngagementType(requirement.value) === currentEngagementType(mechanics);
+ return normalizeEngagementType(requirement.value) === normalizeEngagementType(engagementType);
});
}
-function heroEngagementRequirementSatisfied(requirement: SkillRequirement, side: SideId, mechanics?: BattleInput["mechanics"]): boolean {
+function heroEngagementRequirementSatisfied(requirement: SkillRequirement, side: SideId, engagementType?: string): boolean {
const required = normalizeEngagementType(requirement.value);
- const current = currentEngagementType(mechanics);
+ const current = normalizeEngagementType(engagementType);
if (required === current) {
if (current !== "rally") return true;
return side === "attacker";
}
if (required === "garrison" && current === "rally") return side === "defender";
return false;
}
-function currentEngagementType(mechanics?: BattleInput["mechanics"]): string | undefined {
- return normalizeEngagementType(mechanics?.engagement_type ?? mechanics?.engagementType);
-}
-
-function shouldApplyHeroGenerationStats(mechanics?: BattleInput["mechanics"]): boolean {
- return mechanics?.hero_generation_stats === true || mechanics?.heroGenerationStats === true;
-}
-
function hydrateSkill(
skillId: string,
rawSkill: Omit<SkillDefinition, "id" | "name">,
side: SideId,
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -4,18 +4,17 @@
import { fileURLToPath } from "node:url";
import { loadSimulatorConfig } from "./config";
import { createSeededRng, chancePasses } from "./effects";
-import { resolveFighter } from "./resolve";
-import { simulateBattle, simulateBattleScore } from "./simulator";
+import { applyHeroGenerationStats, resolveFighter } from "./resolve";
+import { simulateBattle, signedRemainingScore } from "./simulator";
import type { BattleInput, EffectIntentDefinition, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";
test("simulateBattle returns structured result for a no-hero battle", () => {
const config = loadSimulatorConfig();
const result = simulateBattle(
{
maxRounds: 3,
- trace: true,
attacker: {
name: "A",
troops: { infantry_t6: 8000 },
stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },
@@ -27,9 +26,10 @@
stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },
heroes: {}
}
},
- config
+ config,
+ { mode: "trace" }
);
assert.match(result.winner, /attacker|defender|draw/);
assert.ok(result.rounds >= 1);
@@ -43,9 +43,8 @@
const config = loadSimulatorConfig();
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
name: "A",
troops: { infantry_t1: 1 },
stats: { inf: { attack: 100000, lethality: 100000 } },
@@ -57,9 +56,10 @@
stats: { inf: { attack: 100000, lethality: 100000 } },
heroes: {}
}
},
- config
+ config,
+ { mode: "trace" }
);
const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");
assert.equal(normalAttacks.length, 2);
@@ -80,9 +80,9 @@
const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;
const input = testcases.find((testcase) => testcase.test_id === "simple_001");
assert.notEqual(input, undefined);
- const result = simulateBattle({ ...input!, trace: true }, config);
+ const result = simulateBattle(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");
@@ -233,9 +233,9 @@
assert.equal(fighter.heroes.some((hero) => hero.missing), false);
assert.equal(fighter.diagnostics.some((line) => line.includes("Missing hero definition")), false);
});
-test("hero generation stats are opt-in because testcase stats are authoritative", () => {
+test("applyHeroGenerationStats bakes main hero generation stats into authoritative input stats", () => {
const config = minimalConfig({
Example: {
name: "Example",
hero_generation: "S1",
@@ -249,12 +249,12 @@
heroes: { Example: { skill_1: 1 } }
};
const defaultFighter = resolveFighter(input, "attacker", config);
- const optInFighter = resolveFighter(input, "attacker", config, { hero_generation_stats: true });
+ const bakedFighter = resolveFighter(applyHeroGenerationStats(input, config), "attacker", config);
assert.deepEqual(defaultFighter.statBonuses.infantry, { attack: 1, defense: 2, lethality: 3, health: 4 });
- assert.deepEqual(optInFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });
+ assert.deepEqual(bakedFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });
});
test("array joiner heroes preserve duplicate skill instances", () => {
const result = simulateBattle(
@@ -323,17 +323,19 @@
config.heroGenerationStats.S1 = { attack: 10, defense: 20, lethality: 30, health: 40 };
config.heroGenerationStats.S2 = { attack: 100, defense: 200, lethality: 300, health: 400 };
const fighter = resolveFighter(
- {
- troops: { infantry_t1: 10 },
- stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },
- heroes: [{ name: "Main", levels: {} }],
- joiner_heroes: [{ name: "Joiner", levels: { skill_1: 1 } }]
- },
+ applyHeroGenerationStats(
+ {
+ troops: { infantry_t1: 10 },
+ stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },
+ heroes: [{ name: "Main", levels: {} }],
+ joiner_heroes: [{ name: "Joiner", levels: { skill_1: 1 } }]
+ },
+ config
+ ),
"attacker",
- config,
- { hero_generation_stats: true }
+ config
);
assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });
});
@@ -427,10 +429,10 @@
);
});
test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {
- const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"));
- const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"));
+ const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });
+ const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });
const maxRoundTwo = maxResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
const addRoundTwo = addResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
@@ -443,9 +445,8 @@
test("same_effect_stacking max caps duplicate hero instances of the same skill effect", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { infantry_t1: 100 },
heroes: [{ name: "Repeat", levels: { skill_1: 1 } }],
joiner_heroes: [{ name: "Repeat", levels: { skill_1: 1 } }]
@@ -454,9 +455,10 @@
troops: { infantry_t1: 100 },
heroes: {}
}
},
- sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up")
+ sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up"),
+ { mode: "trace" }
);
const attack = result.attacks.find((entry) => entry.jobId.startsWith("r1:attacker:infantry") && entry.kind === "normal");
assert.equal(result.skillReport.attacker.filter((entry) => entry.heroName === "Repeat" && entry.skillId === "Overlap").length, 2);
@@ -464,10 +466,10 @@
assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
});
test("same_effect_stacking max caps overlapping extra skill attacks while add keeps all activations", () => {
- const maxResult = simulateBattle(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"));
- const addResult = simulateBattle(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"));
+ const maxResult = simulateBattle(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"), { mode: "trace" });
+ const addResult = simulateBattle(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"), { mode: "trace" });
const maxRoundTwoSkillJobs = maxResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
const addRoundTwoSkillJobs = addResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
@@ -480,9 +482,8 @@
test("same_effect_stacking max consumes overlapping attack-duration extra skill effects as one group", () => {
const result = simulateBattle(
{
maxRounds: 2,
- trace: true,
attacker: {
troops: { infantry_t1: 10000 },
heroes: { MaxExtraAttackDuration: { skill_1: 1 } }
},
@@ -509,9 +510,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));
@@ -562,9 +564,8 @@
test("fighter passive effects are added to the static profile after battle_start effects", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { infantry_t1: 100 },
stats: { infantry: { attack: 0, lethality: 0, defense: 0, health: 0 } },
passive: {
@@ -592,9 +593,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");
assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);
@@ -608,9 +610,8 @@
test("extra skill attacks with array trigger damage targets hit those defender unit types", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { Router: { skill_1: 1 } }
},
@@ -643,9 +644,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.deepEqual(
@@ -660,9 +662,8 @@
test('extra skill attacks with applies_vs "any" keep current-target compatibility', () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { Router: { skill_1: 1 } }
},
@@ -688,9 +689,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.deepEqual(
@@ -702,9 +704,8 @@
test("skill report attributes kills only to the skill damage source", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { Shooter: { skill_1: 1 } }
},
@@ -746,9 +747,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const powerShot = result.skillReport.attacker.find((entry) => entry.skillId === "PowerShot");
const crystalShield = result.skillReport.defender.find((entry) => entry.skillId === "CrystalShield");
@@ -760,9 +762,8 @@
test("same-round outcomes are capped to available target troops before tracing skill kills", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 1000 },
heroes: { Blaster: { skill_1: 1 } }
},
@@ -795,9 +796,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const defenderLosses = result.attacks
.filter((attack) => attack.defenderSide === "defender" && attack.defenderUnit === "infantry")
@@ -840,11 +842,9 @@
if (stats[key] !== undefined) stats[key] = Number((Number(stats[key]) + adjustment).toFixed(3));
}
}
}
- adjusted.trace = true;
-
- const result = simulateBattle(adjusted, config);
+ const result = simulateBattle(adjusted, config, { mode: "trace" });
const roundSix = result.trace?.rounds.find((round) => round.round === 6);
const defenderTargets = roundSix?.intents
.filter((intent) => intent.attackerSide === "defender")
.map((intent) => intent.defenderUnit);
@@ -858,9 +858,8 @@
() =>
simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { Malformed: { skill_1: 1 } }
},
@@ -886,9 +885,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
),
/target selector is required/i
);
});
@@ -934,9 +934,8 @@
test("attack-triggered extra skill attacks activate then resolve trigger damage jobs on normal attack use", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { FollowUp: { skill_1: 1 } }
},
@@ -962,9 +961,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const jobs = result.trace?.rounds[0]?.jobs ?? [];
const normalJobs = jobs.filter((job) => job.kind === "normal");
@@ -981,9 +981,8 @@
test("cancelled normal attacks do not consume extra skill attack uses", () => {
const result = simulateBattle(
{
maxRounds: 2,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { FollowUp: { skill_1: 1 } }
},
@@ -1025,9 +1024,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
@@ -1041,9 +1041,8 @@
test("extra skill attack effects cannot be used by later enemy normal attacks", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { FollowUp: { skill_1: 1 } }
},
@@ -1069,9 +1068,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.deepEqual(
@@ -1084,9 +1084,8 @@
test("extra skill trigger damage jobs can resolve to multiple living enemy targets without recursive attack triggers", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { MultiTarget: { skill_1: 1, skill_2: 1 } }
},
@@ -1124,9 +1123,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.deepEqual(
@@ -1140,9 +1140,8 @@
test("extra skill attack consumes one use regardless of multiple generated target jobs", () => {
const result = simulateBattle(
{
maxRounds: 2,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { MultiTarget: { skill_1: 1 } }
},
@@ -1168,9 +1167,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
assert.deepEqual(skillJobsByRound, [3, 3]);
@@ -1218,9 +1218,8 @@
test("extra skill attack consumes one use when multiple same-round normal attacks match the effect", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { infantry_t1: 100, marksman_t1: 100 },
heroes: { FollowUp: { skill_1: 1 } }
},
@@ -1246,9 +1245,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.equal(skillJobs.length, 1);
@@ -1258,9 +1258,8 @@
test("extra skill attack applies_vs must match the current normal attack target", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { FollowUp: { skill_1: 1 } }
},
@@ -1286,9 +1285,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const jobs = result.trace?.rounds[0]?.jobs ?? [];
assert.deepEqual(
@@ -1301,9 +1301,8 @@
test("extra skill de-dupe does not suppress unrelated attack-duration effect consumption", () => {
const result = simulateBattle(
{
maxRounds: 2,
- trace: true,
attacker: {
troops: { marksman_t1: 100 },
heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
},
@@ -1340,9 +1339,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
const skillBoosts = result.attacks
@@ -1352,94 +1352,12 @@
assert.deepEqual(skillBoosts, [100, 100, 0, 0]);
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });
});
-test("attack-duration normal damage effects do not carry to triggered extra skill damage by default", () => {
- const result = simulateBattle(
- {
- maxRounds: 1,
- trace: true,
- attacker: {
- troops: { marksman_t1: 100 },
- heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
- },
- defender: {
- troops: { infantry_t1: 100 },
- heroes: {}
- }
- },
- attackDurationCarryConfig()
- );
-
- const normalBoosts = result.attacks
- .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")
- .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
- const skillBoosts = result.attacks
- .filter((attack) => attack.kind === "skill")
- .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
- assert.deepEqual(normalBoosts, [100]);
- assert.deepEqual(skillBoosts, [0]);
-});
-
-test("mechanics flag carries consumed attack-duration effects to matching triggered extra skill damage only", () => {
- const result = simulateBattle(
- {
- maxRounds: 1,
- trace: true,
- mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },
- attacker: {
- troops: { marksman_t1: 100 },
- heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
- },
- defender: {
- troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
- heroes: {}
- }
- },
- attackDurationCarryConfig({ buffAppliesVs: "infantry", extraTargets: "enemy.living" })
- );
-
- const normalBoosts = result.attacks
- .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")
- .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
- const skillBoostsByTarget = result.attacks
- .filter((attack) => attack.kind === "skill")
- .map((attack) => `${attack.defenderUnit}:${attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0}`)
- .sort();
- assert.deepEqual(normalBoosts, [100]);
- assert.deepEqual(skillBoostsByTarget, ["infantry:100", "lancer:0", "marksman:0"]);
-});
-
-test("mechanics flag carries consumed attack-duration effects with any target scope to all matching triggered extra skill damage", () => {
- const result = simulateBattle(
- {
- maxRounds: 1,
- trace: true,
- mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },
- attacker: {
- troops: { marksman_t1: 100 },
- heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
- },
- defender: {
- troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
- heroes: {}
- }
- },
- attackDurationCarryConfig({ buffAppliesVs: "any", extraTargets: "enemy.living" })
- );
-
- const skillBoosts = result.attacks
- .filter((attack) => attack.kind === "skill")
- .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0)
- .sort((left, right) => left - right);
- assert.deepEqual(skillBoosts, [100, 100, 100]);
-});
-
test("attack-triggered source and target selectors resolve to concrete active scopes", () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { infantry_t1: 100 },
heroes: { Debuffer: { skill_1: 1 } }
},
@@ -1464,9 +1382,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");
const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");
@@ -1480,9 +1399,8 @@
test('attack-triggered target selector with applies_vs "any" gates later opposing attacks', () => {
const result = simulateBattle(
{
maxRounds: 1,
- trace: true,
attacker: {
troops: { infantry_t1: 100 },
heroes: { Debuffer: { skill_1: 1 } }
},
@@ -1507,9 +1425,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");
const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");
@@ -1550,10 +1469,10 @@
}
};
const defaultResult = simulateBattle(input, config);
- const rallyResult = simulateBattle({ ...input, mechanics: { engagement_type: "rally" } }, config);
- const garrisonResult = simulateBattle({ ...input, mechanics: { engagement_type: "garrison" } }, config);
+ const rallyResult = simulateBattle({ ...input, engagement_type: "rally" }, config);
+ const garrisonResult = simulateBattle({ ...input, engagement_type: "garrison" }, config);
assert.equal(skillActivations(defaultResult, "RallyOnly"), 0);
assert.equal(skillActivations(defaultResult, "GarrisonOnly"), 0);
assert.equal(defaultResult.skillReport.attacker.some((entry) => entry.skillId === "RallyOnly"), false);
@@ -1585,9 +1504,9 @@
}
});
const input = {
maxRounds: 0,
- mechanics: { engagement_type: "rally" },
+ engagement_type: "rally",
attacker: {
troops: { infantry_t1: 10 },
heroes: { Gated: { skill_1: 1, skill_2: 1 } }
},
@@ -1745,9 +1664,9 @@
name: "stochastic real hero battle",
input: {
maxRounds: 8,
seed: "fast-real-heroes",
- mechanics: { hero_generation_stats: true, engagement_type: "rally" },
+ engagement_type: "rally",
attacker: {
troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },
heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } },
joiner_heroes: [{ name: "Jessie", levels: { skill_1: 5 } }]
@@ -1762,23 +1681,23 @@
}
];
for (const { name, input, config } of cases) {
- const full = simulateBattle({ ...input, trace: true }, config);
- const fast = simulateBattle({ ...input, trace: true }, config, { detail: "fast" });
+ const full = simulateBattle(input, config);
+ const fast = simulateBattle(input, config, { mode: "fast" });
assert.deepEqual(semanticBattleSummary(fast), semanticBattleSummary(full), name);
assert.deepEqual(fast.attacks, [], name);
assert.equal(fast.trace, undefined, name);
}
});
-test("simulateBattleScore returns signed remaining troops from the same battle semantics", () => {
+test("signedRemainingScore returns signed remaining troops from a fast-mode result", () => {
const config = loadSimulatorConfig();
const input: BattleInput = {
maxRounds: 8,
seed: "score-real-heroes",
- mechanics: { hero_generation_stats: true, engagement_type: "rally" },
+ engagement_type: "rally",
attacker: {
troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },
heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } },
joiner_heroes: [{ name: "Jessie", levels: { skill_1: 5 } }]
@@ -1789,12 +1708,19 @@
joiner_heroes: [{ name: "Norah", levels: { skill_1: 5 } }]
}
};
- const result = simulateBattle(input, config, { detail: "fast" });
- const score = simulateBattleScore(input, config);
+ const result = simulateBattle(input, config, { mode: "fast" });
+ const score = signedRemainingScore(result);
- assert.equal(score, signedRemainingScore(result));
+ const expected =
+ result.winner === "attacker"
+ ? totalRemaining(result.remaining.attacker)
+ : result.winner === "defender"
+ ? -totalRemaining(result.remaining.defender)
+ : 0;
+
+ assert.equal(score, expected);
});
test("seeded probability rolls are deterministic and compare percentage thresholds", () => {
const skill: ResolvedSkill = {
@@ -1832,14 +1758,8 @@
function totalRemaining(troops: Record<UnitType, number>): number {
return Object.values(troops).reduce((sum, count) => sum + count, 0);
}
-function signedRemainingScore(result: ReturnType<typeof simulateBattle>): number {
- if (result.winner === "attacker") return totalRemaining(result.remaining.attacker);
- if (result.winner === "defender") return -totalRemaining(result.remaining.defender);
- return 0;
-}
-
function semanticBattleSummary(result: ReturnType<typeof simulateBattle>): Pick<
ReturnType<typeof simulateBattle>,
"winner" | "rounds" | "remaining" | "effectActivationCounts" | "extraSkillAttackJobsByEffect" | "attackControlCounts"
> {
@@ -1855,9 +1775,8 @@
function sameEffectStackingInput(heroName: string): BattleInput {
return {
maxRounds: 2,
- trace: true,
attacker: {
troops: { infantry_t1: 10000 },
heroes: { [heroName]: { skill_1: 1 } }
},
@@ -1912,40 +1831,4 @@
diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }
};
}
-function attackDurationCarryConfig(
- options: { buffAppliesVs?: "any" | "infantry"; extraTargets?: "use.target" | "enemy.living" } = {}
-): SimulatorConfig {
- const buffAppliesVs = options.buffAppliesVs ?? "any";
- const extraTargets = options.extraTargets ?? "use.target";
- return minimalConfig({
- FollowUp: {
- name: "FollowUp",
- skills: {
- OneUseNormalBoost: {
- trigger: { type: "battle_start" },
- effects: {
- boost: {
- type: "active.hero.attack.up",
- value: 100,
- units: { applies_to: "marksman", applies_vs: buffAppliesVs },
- duration: { type: "attack", value: 1 }
- }
- }
- },
- TriggeredExtraDamage: {
- trigger: { type: "attack", probability: 100, source: "marksman" },
- effects: {
- extra: {
- type: "extra_skill_attack",
- value: 100,
- units: { applies_to: "trigger.source", applies_vs: "any" },
- trigger_damage_jobs: [{ source: "use.source", target: extraTargets }],
- duration: { type: "attack", value: 1 }
- }
- }
- }
- }
- }
- });
-}
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -18,9 +18,10 @@
TriggerDamageJobSelector,
UnitType
} from "./types";
import { ALL_UNIT_MASK, UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";
-import { calculateDamageJob, createFastDamageScratch, type DamageScratch } from "./damage";
+import { calculateDamageJob, createFastDamageScratch, type DamageResult, type DamageScratch } from "./damage";
+import { createRecorder } from "./recorder";
import {
activateEffect,
chancePasses,
createSeededRng,
@@ -52,9 +53,8 @@
effectActivationCounts: Record<SideId, number>;
extraSkillAttackJobsByEffect: Record<string, number>;
attackControlCounts: { dodge: number; no_attack: number };
consumedEffectUseKeys: Set<string>;
- carryAttackDurationEffectsToTriggeredExtraSkillDamage: boolean;
counters: {
attacks: Record<SideId, Record<UnitType, number>>;
received: Record<SideId, Record<UnitType, number>>;
};
@@ -103,22 +103,15 @@
trace: run.trace
};
}
-export function simulateBattleScore(input: BattleInput, config: SimulatorConfig): number {
- const run = runBattle(input, config, { detail: "fast" });
- return signedRemainingScore(run.winner, run.fighters);
-}
-
function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions): BattleRun {
- const attacker = resolveFighter(input.attacker, "attacker", config, input.mechanics);
- const defender = resolveFighter(input.defender, "defender", config, input.mechanics);
+ const 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"), input.mechanics);
- const detail = options.detail ?? "full";
- const traceEnabled = detail === "full" && input.trace;
- const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;
- const attacks: AttackOutcome[] = [];
+ const 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;
triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);
addInputPassiveEffects(runtime, input.attacker.passive, "attacker");
@@ -131,69 +124,75 @@
if (winnerFor(fighters)) break;
rounds = round;
const roundStartTroops = snapshotTroops(fighters);
expireInactive(runtime, round);
- triggerRoundStartSkills(round, fighters, runtime, roundStartTroops);
+ triggerRoundStartSkills(round, runtime, roundStartTroops);
- const intents = resolveAttackIntents(round, fighters, runtime, roundStartTroops);
+ const intents = resolveAttackIntents(round, runtime, roundStartTroops);
const jobs: DamageJob[] = [];
- const cancelled: AttackOutcome[] = [];
+ const cancelled: CancelledAttack[] = [];
for (const intent of intents) {
triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);
const job = normalJob(intent, roundStartTroops);
const controls = applicableControls(job, round, runtime);
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
runtime.attackControlCounts[control.reason] += 1;
- const outcome = cancelledOutcome(intent, control.effect.id, control.reason, attackDurationEffectIdsForJob(job, round, runtime.activeEffects));
- cancelled.push(outcome);
- consumeEffects(runtime, outcome.consumedEffectIds);
+ const consumedEffectIds = attackDurationEffectIdsForJob(job, round, runtime.activeEffects);
+ cancelled.push({ intent, effectId: control.effect.id, reason: control.reason, consumedEffectIds });
+ consumeEffects(runtime, consumedEffectIds);
} else {
jobs.push(job);
jobs.push(...extraSkillJobs(job, round, runtime, roundStartTroops));
}
}
- const roundOutcomes: AttackOutcome[] = [];
+ const results: DamageJobResult[] = [];
for (const job of jobs) {
- const outcome =
- detail === "fast"
- ? calculateDamageJob(job, fighters, runtime.activeEffects, {
- detail: "fast",
- effectIndex: runtime.effectIndex,
- staticDamageProfile: runtime.staticDamageProfile,
- scratch: runtime.damageScratch
- })
- : calculateDamageJob(job, fighters, runtime.activeEffects, {
- trace: traceEnabled,
- effectIndex: runtime.effectIndex,
- staticDamageProfile: runtime.staticDamageProfile
- });
- roundOutcomes.push(outcome);
- consumeEffects(runtime, outcome.consumedEffectIds, outcome.consumedEffectUseKey, outcome.consumedEffectUseId, outcome.consumedEffectUseIds);
+ const result = calculateDamageJob(job, fighters, runtime.activeEffects, {
+ trace: recorder.capturesTrace,
+ effectIndex: runtime.effectIndex,
+ staticDamageProfile: runtime.staticDamageProfile,
+ scratch: recorder.capturesTrace ? undefined : runtime.damageScratch
+ });
+ results.push({ job, result });
+ consumeEffects(runtime, result.consumedEffectIds, result.consumedEffectUseKey, result.consumedEffectUseId, result.consumedEffectUseIds);
}
- finalizeRoundOutcomes(roundOutcomes, roundStartTroops, runtime);
- if (detail === "full") attacks.push(...cancelled, ...roundOutcomes);
- commitOutcomes(cancelled, fighters, runtime);
- commitOutcomes(roundOutcomes, fighters, runtime);
- trace?.rounds.push({ round, roundStartTroops, intents, jobs });
+
+ capRoundKills(results, roundStartTroops);
+ commitRound(cancelled, results, fighters, runtime);
+
+ for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.consumedEffectIds);
+ for (const entry of results) recorder.recordDamageJob(entry.job, entry.result);
+ recorder.recordRound(round, roundStartTroops, intents, jobs);
}
const winner = winnerFor(fighters) ?? "draw";
return {
fighters,
runtime,
winner,
rounds,
- attacks,
- trace
+ attacks: recorder.attacks,
+ trace: recorder.trace
};
}
+interface CancelledAttack {
+ intent: AttackIntent;
+ effectId: string;
+ reason: "dodge" | "no_attack";
+ consumedEffectIds: string[];
+}
+
+interface DamageJobResult {
+ job: DamageJob;
+ result: DamageResult;
+}
+
function triggerRoundStartSkills(
round: number,
- fighters: Record<SideId, ResolvedFighter>,
runtime: Runtime,
roundStartTroops: DamageJob["roundStartTroops"]
): ActiveEffect[] {
const activated: ActiveEffect[] = [];
@@ -289,9 +288,9 @@
const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);
return Number.isFinite(value) && value > 0 && value < 100;
}
-function createRuntime(fighters: ResolvedFighter[], rng: Rng, mechanics?: BattleInput["mechanics"]): Runtime {
+function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {
const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };
const skills = buildRuntimeSkills(fighters);
for (const fighter of fighters) {
for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {
@@ -321,9 +320,8 @@
effectActivationCounts: { attacker: 0, defender: 0 },
extraSkillAttackJobsByEffect: {},
attackControlCounts: { dodge: 0, no_attack: 0 },
consumedEffectUseKeys: new Set(),
- carryAttackDurationEffectsToTriggeredExtraSkillDamage: mechanics?.carryAttackDurationEffectsToTriggeredExtraSkillDamage === true,
counters: {
attacks: { attacker: emptyTroops(), defender: emptyTroops() },
received: { attacker: emptyTroops(), defender: emptyTroops() }
}
@@ -410,9 +408,8 @@
}
function resolveAttackIntents(
round: number,
- fighters: Record<SideId, ResolvedFighter>,
runtime: Runtime,
roundStartTroops: DamageJob["roundStartTroops"]
): AttackIntent[] {
const intents: AttackIntent[] = [];
@@ -514,11 +511,8 @@
runtime: Runtime,
roundStartTroops: DamageJob["roundStartTroops"]
): DamageJob[] {
const jobs: DamageJob[] = [];
- const carriedAttackDurationEffectIds = runtime.carryAttackDurationEffectsToTriggeredExtraSkillDamage
- ? attackDurationBucketEffectIdsForJob(normalAttack, round, runtime.activeEffects)
- : undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
round
);
@@ -552,9 +546,8 @@
defenderUnit: target.unit,
sourceEffectId,
sourceSkillReportKey,
sourceMultiplier: multiplier,
- carriedAttackDurationEffectIds,
consumedEffectIds,
consumedEffectUseKey,
consumedEffectUseId: effect.id,
consumedEffectUseIds: consumedEffectIds
@@ -654,77 +647,46 @@
const pct = Number(raw ?? 0);
return Number.isFinite(pct) ? pct / 100 : 0;
}
-function cancelledOutcome(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[] = [effectId]): AttackOutcome {
- return {
- jobId: `${intent.id}:cancelled`,
- kind: "normal",
- attackerSide: intent.attackerSide,
- attackerUnit: intent.attackerUnit,
- defenderSide: intent.defenderSide,
- defenderUnit: intent.defenderUnit,
- kills: 0,
- counterDeltas: [
- { side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },
- { side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }
- ],
- appliedEffectIds: [],
- appliedEffects: [],
- consumedEffectIds,
- cancelledBy: effectId,
- cancelReason: reason
- };
-}
-
-function finalizeRoundOutcomes(outcomes: AttackOutcome[], roundStartTroops: DamageJob["roundStartTroops"], runtime: Runtime): void {
- capRoundOutcomeKills(outcomes, roundStartTroops);
- attributeSkillKills(outcomes, runtime);
-}
-
-function capRoundOutcomeKills(outcomes: AttackOutcome[], roundStartTroops: DamageJob["roundStartTroops"]): void {
+// Cap each defender unit's total kills this round to the troops available at round start, applied
+// in job order. Mutates result.kills (and the trace's finalKills when present). This is
+// simulation-affecting and runs in every mode, before commit and recording.
+function capRoundKills(results: DamageJobResult[], roundStartTroops: DamageJob["roundStartTroops"]): void {
for (const side of ["attacker", "defender"] as SideId[]) {
for (const unit of UNIT_TYPES) {
- const matching = outcomes.filter((outcome) => outcome.defenderSide === side && outcome.defenderUnit === unit && outcome.kills > 0);
+ const matching = results.filter((entry) => entry.job.defenderSide === side && entry.job.defenderUnit === unit && entry.result.kills > 0);
if (matching.length === 0) continue;
const available = Math.max(0, roundStartTroops[side][unit] ?? 0);
- const totalKills = matching.reduce((sum, outcome) => sum + outcome.kills, 0);
+ const totalKills = matching.reduce((sum, entry) => sum + entry.result.kills, 0);
if (totalKills <= available) continue;
let appliedKills = 0;
let rawRemaining = available;
- for (const outcome of matching) {
- const rawKills = outcome.kills;
- outcome.kills = Math.min(rawKills, Math.max(0, available - appliedKills));
- appliedKills += outcome.kills;
+ for (const entry of matching) {
+ const rawKills = entry.result.kills;
+ entry.result.kills = Math.min(rawKills, Math.max(0, available - appliedKills));
+ appliedKills += entry.result.kills;
rawRemaining = Math.max(0, rawRemaining - rawKills);
if (rawRemaining === 0) appliedKills = available;
- if (outcome.trace) outcome.trace.finalKills = outcome.kills;
+ if (entry.result.trace) entry.result.trace.finalKills = entry.result.kills;
}
}
}
}
-function attributeSkillKills(outcomes: AttackOutcome[], runtime: Runtime): void {
- for (const outcome of outcomes) {
- if (outcome.kind !== "skill" || !outcome.sourceSkillReportKey || outcome.kills <= 0) continue;
- const report = runtime.skillReports[outcome.attackerSide].get(outcome.sourceSkillReportKey);
- if (report) report.skillKills += outcome.kills;
+// Apply the round's effects to fighter state: remove killed troops and bump attack/received
+// counters. Every declared attack (each damage job and each cancelled attack) counts as one
+// attack for its attacker unit and one received for its defender unit.
+function commitRound(cancelled: CancelledAttack[], results: DamageJobResult[], fighters: Record<SideId, ResolvedFighter>, runtime: Runtime): void {
+ for (const entry of cancelled) {
+ runtime.counters.attacks[entry.intent.attackerSide][entry.intent.attackerUnit] += 1;
+ runtime.counters.received[entry.intent.defenderSide][entry.intent.defenderUnit] += 1;
}
-}
-
-function commitOutcomes(outcomes: AttackOutcome[], fighters: Record<SideId, ResolvedFighter>, runtime: Runtime): void {
const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };
- for (const outcome of outcomes) {
- losses[outcome.defenderSide][outcome.defenderUnit] += outcome.kills;
- if (outcome.counterDeltas.length === 0) {
- runtime.counters.attacks[outcome.attackerSide][outcome.attackerUnit] += 1;
- runtime.counters.received[outcome.defenderSide][outcome.defenderUnit] += 1;
- } else {
- for (const delta of outcome.counterDeltas) {
- if (delta.counter === "attacks") runtime.counters.attacks[delta.side][delta.unit] += delta.by;
- else runtime.counters.received[delta.side][delta.unit] += delta.by;
- }
- }
+ for (const { job, result } of results) {
+ losses[job.defenderSide][job.defenderUnit] += result.kills;
+ runtime.counters.attacks[job.attackerSide][job.attackerUnit] += 1;
+ runtime.counters.received[job.defenderSide][job.defenderUnit] += 1;
}
for (const side of ["attacker", "defender"] as SideId[]) {
for (const unit of UNIT_TYPES) {
fighters[side].troops[unit] = Math.max(0, fighters[side].troops[unit] - losses[side][unit]);
@@ -754,18 +716,8 @@
})
.map((effect) => effect.id);
}
-function attackDurationBucketEffectIdsForJob(job: DamageJob, round: number, effects: ActiveEffect[]): string[] {
- return effects
- .filter((effect) => {
- if (effect.kind === "extra_attack" || effect.duration.type !== "attack" || !isEffectActive(effect, round)) return false;
- const classification = classifyEffectForJob(effect, job);
- return classification?.kind === "bucket";
- })
- .map((effect) => effect.id);
-}
-
function consumeEffects(
runtime: Runtime,
consumedEffectIds: string[],
consumedEffectUseKey?: string,
@@ -801,11 +753,13 @@
if (defenderAlive && !attackerAlive) return "defender";
return undefined;
}
-function signedRemainingScore(winner: SideId | "draw", fighters: Record<SideId, ResolvedFighter>): number {
- if (winner === "attacker") return total(ceilTroops(fighters.attacker.troops));
- if (winner === "defender") return -total(ceilTroops(fighters.defender.troops));
+// Signed battle outcome: positive = attacker survivors, negative = defender survivors, 0 = draw.
+// Replaces the former simulateBattleScore entry point; call with a "fast"-mode result.
+export function signedRemainingScore(result: BattleResult): number {
+ if (result.winner === "attacker") return total(result.remaining.attacker);
+ if (result.winner === "defender") return -total(result.remaining.defender);
return 0;
}
function total(troops: Record<UnitType, number>): number {
Index: simulator/src/staticDamageProfile.ts
===================================================================
--- simulator/src/staticDamageProfile.ts prev run
+++ simulator/src/staticDamageProfile.ts this run
@@ -1,7 +1,7 @@
import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";
import { UNIT_TYPES, unitMaskHas } from "./types";
-import type { BucketRole } from "./damageBuckets";
+import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";
import { currentEffectValuePct } from "./effects";
export interface StaticDamageProfileTerm {
raw?: number;
@@ -48,18 +48,12 @@
export type StaticPlayerBucket = (typeof STATIC_PLAYER_BUCKETS)[number];
export type StaticPassiveBucket = (typeof STATIC_PASSIVE_BUCKETS)[number];
export type StaticDamageBucket = StaticRawBucket | StaticPlayerBucket | StaticPassiveBucket;
-const STATIC_PASSIVE_BUCKET_ROLES: Record<StaticPassiveBucket, BucketRole> = {
- "passive.attack.up": "attacker",
- "passive.attack.down": "attacker",
- "passive.lethality.up": "attacker",
- "passive.lethality.down": "attacker",
- "passive.health.up": "defender",
- "passive.health.down": "defender",
- "passive.defense.up": "defender",
- "passive.defense.down": "defender"
-};
+// Roles are owned by the BUCKETS tree (single source of truth); derive, do not redeclare.
+const STATIC_PASSIVE_BUCKET_ROLES = Object.fromEntries(
+ STATIC_PASSIVE_BUCKETS.map((bucket) => [bucket, bucketDefinition(bucket)!.role])
+) as Record<StaticPassiveBucket, BucketRole>;
export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {
const profile: StaticDamageProfile = {
offense: {
@@ -192,37 +186,53 @@
}
}
}
+interface StaticFactorTerm {
+ bucket: StaticDamageBucket;
+ valueType: BucketValueType;
+ placement: BucketPlacement;
+}
+
+// The closed-pool aggregation is driven entirely by BUCKETS-tree metadata (role/valueType/
+// placement); there is no second hand-written damage equation. Iteration follows tree
+// insertion order, which fixes the numerator/denominator float association — do not reorder
+// the static families in the tree without re-validating the parity gate.
+const STATIC_FACTOR_TERMS_BY_ROLE: Record<BucketRole, StaticFactorTerm[]> = { attacker: [], defender: [] };
+for (const path of Object.keys(BUCKET_DEFINITIONS)) {
+ const definition = BUCKET_DEFINITIONS[path];
+ if (definition.phase !== "static") continue;
+ STATIC_FACTOR_TERMS_BY_ROLE[definition.role].push({
+ bucket: path as StaticDamageBucket,
+ valueType: definition.valueType,
+ placement: definition.placement
+ });
+}
+
+function roleFactor(entry: StaticDamageProfileEntry, terms: StaticFactorTerm[]): number {
+ let numerator = 1;
+ let denominator = 1;
+ for (const term of terms) {
+ const value = term.valueType === "raw" ? raw(entry, term.bucket) : pct(entry, term.bucket);
+ if (term.placement === "numerator") numerator *= value;
+ else denominator *= value;
+ }
+ return numerator / denominator;
+}
+
function offenseFactor(entry: StaticDamageProfileEntry): number {
- return (
- raw(entry, "troops.baseAttack") *
- raw(entry, "troops.baseLethality") *
- pct(entry, "player.attack") *
- pct(entry, "player.lethality") *
- pct(entry, "passive.attack.up") *
- pct(entry, "passive.lethality.up") /
- (pct(entry, "passive.attack.down") * pct(entry, "passive.lethality.down"))
- );
+ return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.attacker);
}
function defenseFactor(entry: StaticDamageProfileEntry): number {
- return (
- (pct(entry, "passive.health.down") * pct(entry, "passive.defense.down")) /
- (raw(entry, "troops.baseHealth") *
- raw(entry, "troops.baseDefense") *
- pct(entry, "player.health") *
- pct(entry, "player.defense") *
- pct(entry, "passive.health.up") *
- pct(entry, "passive.defense.up"))
- );
+ return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.defender);
}
-function raw(entry: StaticDamageProfileEntry, bucket: StaticRawBucket): number {
+function raw(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {
return Math.max(0, entry.buckets[bucket]?.raw ?? 0);
}
-function pct(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket | StaticPassiveBucket): number {
+function pct(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {
return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100;
}
function setRaw(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {
@@ -261,4 +271,14 @@
}
const STATIC_BUCKET_SET = new Set<string>([...STATIC_RAW_BUCKETS, ...STATIC_PLAYER_BUCKETS, ...STATIC_PASSIVE_BUCKETS]);
const STATIC_PASSIVE_BUCKET_SET = new Set<string>(STATIC_PASSIVE_BUCKETS);
+
+// The typed manifest above is a convenience for literal types; the BUCKETS tree is the
+// authority for which buckets are static. Fail loudly if the two ever drift.
+{
+ const manifest = [...STATIC_BUCKET_SET].sort().join(",");
+ const tree = [...STATIC_BUCKETS].sort().join(",");
+ if (manifest !== tree) {
+ throw new Error(`static bucket manifest drifted from BUCKETS tree:\n manifest=[${manifest}]\n tree=[${tree}]`);
+ }
+}
Index: simulator/src/testcases.test.ts
===================================================================
--- simulator/src/testcases.test.ts prev run
+++ simulator/src/testcases.test.ts this run
@@ -236,8 +236,17 @@
assert.equal(summary?.gameStatAdjustment?.value, 0.05);
assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);
});
+test("runTestcases skips 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));
+});
+
test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {
const config = loadSimulatorConfig();
const report = runTestcases({ matching: "1-testcases_no-heroes_t6_single-type_nc.json", repeat: 1 }, config);
const entry = report.details.find((item) => item.testcaseId === "daut_viper_9");
@@ -272,37 +281,28 @@
assert.equal(report.counts.comparedToGame, 1);
assert.equal(report.counts.comparedToBaseline, 1);
});
-test("adaptTestcaseEntry passes testcase mechanics and engagement aliases into BattleInput", () => {
+test("adaptTestcaseEntry promotes engagement_type to a top-level BattleInput key", () => {
const input = adaptTestcaseEntry({
- test_id: "mechanics_case",
+ test_id: "engagement_case",
engagement_type: "rally",
- mechanics: { weather: "clear" },
attacker: { troops: { infantry_t1: 1 } },
defender: { troops: { infantry_t1: 1 } }
});
- assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });
+ assert.equal(input.engagement_type, "rally");
});
-test("adaptTestcaseEntry merges option mechanics without replacing testcase mechanics", () => {
- const input = adaptTestcaseEntry(
- {
- test_id: "mechanics_case",
- engagement_type: "rally",
- mechanics: { weather: "clear" },
- attacker: { troops: { infantry_t1: 1 } },
- defender: { troops: { infantry_t1: 1 } }
- },
- { mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true } }
- );
-
- assert.deepEqual(input.mechanics, {
- weather: "clear",
- carryAttackDurationEffectsToTriggeredExtraSkillDamage: true,
- engagement_type: "rally"
+test("adaptTestcaseEntry reads engagement_type nested under a legacy mechanics object", () => {
+ const input = adaptTestcaseEntry({
+ test_id: "nested_engagement_case",
+ mechanics: { engagement_type: "garrison" },
+ attacker: { troops: { infantry_t1: 1 } },
+ defender: { troops: { infantry_t1: 1 } }
});
+
+ assert.equal(input.engagement_type, "garrison");
});
test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {
const metrics = compareOutcomeDistribution({
Index: simulator/src/testcases.ts
===================================================================
--- simulator/src/testcases.ts prev run
+++ simulator/src/testcases.ts this run
@@ -16,19 +16,18 @@
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;
export interface TestcaseRunOptions {
testcaseRoot?: string;
calibrationReportPath?: string;
matching?: string;
includeDisabled?: boolean;
repeat?: number;
seed?: string | number;
- trace?: boolean;
workers?: number;
- mechanics?: Record<string, unknown>;
}
export interface TestcaseCaseReport {
file: string;
@@ -207,9 +206,9 @@
const diagnostics: string[] = [];
const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);
const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };
try {
- preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace, mechanics: options.mechanics }, diagnostics);
+ preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed }, diagnostics);
preparedCase.key = snapshotKey(reportFile, index);
} catch (error) {
detail.error = errorMessage(error);
diagnostics.push(detail.error);
@@ -242,9 +241,9 @@
details: []
};
for (const preparedCase of prepared.cases) {
- const { file, reportFile, entry, testcaseId, index, detail } = preparedCase;
+ const { file, reportFile, testcaseId, index, detail } = preparedCase;
if (!preparedCase.input || !preparedCase.key) {
if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);
report.details.push(detail);
continue;
@@ -435,48 +434,83 @@
initialTroops: number;
deterministic: boolean;
thresholds?: Record<string, number>;
}): InternalStatAdjustment | undefined {
- const shouldCorrect = options.deterministic ? options.game.bias_raw !== 0 : !options.game.passes;
- if (!shouldCorrect || options.game.bias_raw === 0) return undefined;
+ if (!options.deterministic || options.game.bias_raw === 0) return undefined;
const direction = -Math.sign(options.game.bias_raw);
- const candidates = statAdjustmentCandidates(direction);
- let best: InternalStatAdjustment | undefined;
- for (const value of candidates) {
- const adjustedInput = inputWithStatAdjustment(options.input, value);
- const candidateStats = simulateAdjustedDistribution(adjustedInput, options.job, options.config);
- const adjusted = compareOutcomeDistribution({
- candidate: { n: candidateStats.n, mu: candidateStats.mu, sigma: candidateStats.sigma },
- reference: options.reference,
- initialTroops: options.initialTroops,
- deterministic: options.deterministic,
- thresholds: options.thresholds
- });
- const candidate = {
- value,
- mode: adjustmentMode(options.game, adjusted, options.deterministic),
- unadjusted: options.game,
- adjusted: adjustedForRoundingRules(adjusted, options.deterministic)
- };
- if (!best || correctionScore(candidate.adjusted, options.deterministic) < correctionScore(best.adjusted, options.deterministic)) best = candidate;
- if (options.deterministic && candidate.mode === "deterministic_exact") return candidate;
+ const maxCandidate = evaluateStatAdjustment(options, direction * STAT_ROUNDING_MAX_ADJUSTMENT);
+ let best = maxCandidate;
+ if (maxCandidate.mode === "deterministic_exact") return maxCandidate;
+
+ let low = { value: 0, bias: options.game.bias_raw };
+ let high = { value: maxCandidate.value, bias: maxCandidate.adjusted.bias_raw };
+ if (!biasesBracketZero(low.bias, high.bias)) return maxCandidate;
+
+ const tested = new Set<number>([maxCandidate.value]);
+ for (let iteration = 0; iteration < STAT_ROUNDING_INTERPOLATION_LIMIT; iteration += 1) {
+ const value = interpolatedZeroAdjustment(low, high);
+ if (value === undefined || tested.has(value)) break;
+ tested.add(value);
+
+ const candidate = evaluateStatAdjustment(options, value);
+ if (correctionScore(candidate.adjusted, options.deterministic) < correctionScore(best.adjusted, options.deterministic)) best = candidate;
+ if (candidate.mode === "deterministic_exact") return candidate;
+
+ if (Math.sign(candidate.adjusted.bias_raw) === Math.sign(low.bias)) {
+ low = { value: candidate.value, bias: candidate.adjusted.bias_raw };
+ } else {
+ high = { value: candidate.value, bias: candidate.adjusted.bias_raw };
+ }
}
- if (best && !options.deterministic && best.adjusted.passes) return { ...best, mode: "stochastic_tolerance" };
+
return best;
}
-function statAdjustmentCandidates(direction: number): number[] {
- const values: number[] = [];
- for (let step = STAT_ROUNDING_SCAN_STEPS; step >= 1; step -= 1) {
- values.push(roundStatAdjustment(direction * (STAT_ROUNDING_MAX_ADJUSTMENT * step) / STAT_ROUNDING_SCAN_STEPS));
- }
- return values;
+function evaluateStatAdjustment(options: {
+ game: ParityComparisonMetrics;
+ input: BattleInput;
+ config: SimulatorConfig;
+ job: TestcaseExecutionJob;
+ reference: { n: number; mu: number; sigma: number };
+ initialTroops: number;
+ deterministic: boolean;
+ thresholds?: Record<string, number>;
+}, value: number): InternalStatAdjustment {
+ const adjustedInput = inputWithStatAdjustment(options.input, value);
+ const candidateStats = simulateAdjustedDistribution(adjustedInput, options.job, options.config);
+ const adjusted = compareOutcomeDistribution({
+ candidate: { n: candidateStats.n, mu: candidateStats.mu, sigma: candidateStats.sigma },
+ reference: options.reference,
+ initialTroops: options.initialTroops,
+ deterministic: options.deterministic,
+ thresholds: options.thresholds
+ });
+ return {
+ value: roundStatAdjustment(value),
+ mode: adjustmentMode(adjusted, options.deterministic),
+ unadjusted: options.game,
+ adjusted: adjustedForRoundingRules(adjusted, options.deterministic)
+ };
}
+function biasesBracketZero(first: number, second: number): boolean {
+ return first === 0 || second === 0 || Math.sign(first) !== Math.sign(second);
+}
+
+function interpolatedZeroAdjustment(low: { value: number; bias: number }, high: { value: number; bias: number }): number | undefined {
+ const biasRange = high.bias - low.bias;
+ if (biasRange === 0) return undefined;
+ const value = low.value - (low.bias * (high.value - low.value)) / biasRange;
+ const min = Math.min(low.value, high.value);
+ const max = Math.max(low.value, high.value);
+ if (value < min || value > max) return undefined;
+ return roundStatAdjustment(value);
+}
+
function simulateAdjustedDistribution(input: BattleInput, job: TestcaseExecutionJob, config: SimulatorConfig): SampleStats {
const samples: number[] = [];
for (let iteration = 0; iteration < job.repeat; iteration += 1) {
- const result = simulateBattle(sampleInput(input, job.seed, job.file, job.testcaseId, job.index, iteration), config, { detail: "fast" });
+ const result = simulateBattle(sampleInput(input, job.seed, job.file, job.testcaseId, job.index, iteration), config, { mode: "fast" });
const score = battleScoreDelta(result);
if (score !== undefined) samples.push(score);
}
return sampleStats(samples);
@@ -497,9 +531,9 @@
}
}
}
-function adjustmentMode(original: ParityComparisonMetrics, adjusted: ParityComparisonMetrics, deterministic: boolean): TestcaseStatAdjustment["mode"] {
+function adjustmentMode(adjusted: ParityComparisonMetrics, deterministic: boolean): TestcaseStatAdjustment["mode"] {
if (deterministic) {
if (adjusted.bias_raw === 0) return "deterministic_exact";
if (Math.abs(adjusted.bias_raw) <= 1) return "deterministic_within_one";
return "best_effort";
@@ -615,32 +649,31 @@
}
export function adaptTestcaseEntry(
entry: unknown,
- options: { seed?: string | number; trace?: boolean; mechanics?: Record<string, unknown> } = {},
+ options: { seed?: string | number } = {},
diagnostics: string[] = []
): BattleInput {
const object = entry as {
attacker?: FighterInput;
defender?: FighterInput;
test_id?: string;
- mechanics?: Record<string, unknown>;
+ mechanics?: { engagement_type?: unknown; engagementType?: unknown };
engagement_type?: unknown;
engagementType?: unknown;
maxRounds?: unknown;
max_rounds?: unknown;
};
if (!object.attacker || !object.defender) throw new Error(`Testcase ${object.test_id ?? "(unknown)"} is missing attacker or defender`);
diagnostics.push(...diagnoseFighterShape("attacker", object.attacker), ...diagnoseFighterShape("defender", object.defender));
- const mechanics = testcaseMechanics(object, options.mechanics);
+ const engagementType = engagementTypeFromEntry(object);
const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);
return {
attacker: object.attacker,
defender: object.defender,
seed: options.seed,
- trace: options.trace,
...(maxRounds !== undefined ? { maxRounds } : {}),
- ...(mechanics ? { mechanics } : {})
+ ...(engagementType !== undefined ? { engagement_type: engagementType } : {})
};
}
export function battleScoreDelta(value: unknown): number | undefined {
@@ -706,17 +739,16 @@
if (!fighter.stats) diagnostics.push(`${side} has no stats block`);
return diagnostics;
}
-function testcaseMechanics(
- entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown },
- optionMechanics?: Record<string, unknown>
-): Record<string, unknown> | undefined {
- const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};
- if (optionMechanics && typeof optionMechanics === "object") Object.assign(mechanics, optionMechanics);
- if (entry.engagement_type !== undefined) mechanics.engagement_type = entry.engagement_type;
- if (entry.engagementType !== undefined) mechanics.engagementType = entry.engagementType;
- return Object.keys(mechanics).length > 0 ? mechanics : undefined;
+function engagementTypeFromEntry(entry: {
+ mechanics?: { engagement_type?: unknown; engagementType?: unknown };
+ engagement_type?: unknown;
+ engagementType?: unknown;
+}): string | undefined {
+ const value =
+ entry.engagement_type ?? entry.engagementType ?? entry.mechanics?.engagement_type ?? entry.mechanics?.engagementType;
+ return value === undefined ? undefined : String(value);
}
function visibilityFromResult(result: BattleResult | undefined): TestcaseCaseReport["visibility"] {
if (!result) {
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -161,16 +161,18 @@
attacker: FighterInput;
defender: FighterInput;
seed?: string | number;
maxRounds?: number;
- trace?: boolean;
- mechanics?: Record<string, unknown>;
+ // The type of battle (e.g. "rally", "garrison"); gates engagement-specific hero skills.
+ engagement_type?: string;
}
-export type SimulationDetail = "full" | "fast";
+// fast: signed-score only (no per-attack outcomes); standard: attack-by-attack
+// outcomes without per-attack damage traces; trace: full per-attack equation traces.
+export type SimulationMode = "fast" | "standard" | "trace";
export interface SimulationOptions {
- detail?: SimulationDetail;
+ mode?: SimulationMode;
}
export interface ResolvedTroopLine {
id: string;
@@ -197,9 +199,8 @@
export interface ResolvedHero {
name: string;
heroGeneration?: string;
- generationStats: StatBlock;
skillIds: string[];
instanceId?: string;
role?: "main" | "joiner";
missing?: boolean;
@@ -276,9 +277,8 @@
defenderUnit: UnitType;
sourceEffectId?: string;
sourceSkillReportKey?: string;
sourceMultiplier?: number;
- carriedAttackDurationEffectIds?: string[];
consumedEffectIds?: string[];
consumedEffectUseKey?: string;
consumedEffectUseId?: string;
consumedEffectUseIds?: string[];
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -38,6 +38,7 @@
effect: ActiveEffect;
bucket: AtomicBucket;
valuePct: number;
+ carriedInactive?: boolean;
}
interface MaxBucketCandidateGroup {
@@ -123,13 +124,17 @@
const consumedEffectIds = new Set<string>();
const candidates: BucketCandidate[] = [];
const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
+ const carriedAttackDurationEffectIds = job.carriedAttackDurationEffectIds ? new Set(job.carriedAttackDurationEffectIds) : undefined;
for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
- if (!isEffectActive(candidate.effect, job.round)) continue;
+ const active = isEffectActive(candidate.effect, job.round);
+ const carriedInactive = !active && carriedAttackDurationEffectIds?.has(candidate.effect.id) === true;
+ if (!active && !carriedInactive) continue;
handledCandidateEffectIds?.add(candidate.effect.id);
candidates.push({
effect: candidate.effect,
bucket: candidate.bucket,
- valuePct: currentEffectValuePct(candidate.effect, job.round)
+ valuePct: currentEffectValuePct(candidate.effect, job.round),
+ carriedInactive
});
}
@@ -267,7 +272,7 @@
appliedEffects.push(appliedEffect);
}
for (const candidate of candidates) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ if (candidate.effect.duration.type === "attack" && !candidate.carriedInactive) consumedEffectIds.add(candidate.effect.id);
if (detail === "full" && candidate !== selected) {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
}
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
@@ -823,6 +823,36 @@
assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });
});
+test("committed losses clamp exhausted floating point residue before next-round target selection", () => {
+ const config = loadSimulatorConfig();
+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/renee_solo_nc.json", import.meta.url));
+ const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;
+ const input = testcases.find((testcase) => testcase.test_id === "renee_solo_nc");
+ if (!input) throw new Error("missing renee_solo_nc fixture");
+
+ const adjusted = structuredClone(input);
+ for (const [fighter, adjustment] of [
+ [adjusted.attacker, 0.05],
+ [adjusted.defender, -0.05]
+ ] as const) {
+ for (const stats of Object.values(fighter.stats ?? {})) {
+ for (const key of ["attack", "defense", "lethality", "health"] as const) {
+ if (stats[key] !== undefined) stats[key] = Number((Number(stats[key]) + adjustment).toFixed(3));
+ }
+ }
+ }
+ adjusted.trace = true;
+
+ const result = simulateBattle(adjusted, config);
+ const roundSix = result.trace?.rounds.find((round) => round.round === 6);
+ const defenderTargets = roundSix?.intents
+ .filter((intent) => intent.attackerSide === "defender")
+ .map((intent) => intent.defenderUnit);
+
+ assert.equal(roundSix?.roundStartTroops.attacker.infantry, 0);
+ assert.deepEqual(defenderTargets, ["lancer", "lancer", "lancer"]);
+});
+
test("extra skill trigger damage jobs reject missing runtime selectors", () => {
assert.throws(
() =>
@@ -1323,6 +1353,87 @@
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });
});
+test("attack-duration normal damage effects do not carry to triggered extra skill damage by default", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig()
+ );
+
+ const normalBoosts = result.attacks
+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ const skillBoosts = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ assert.deepEqual(normalBoosts, [100]);
+ assert.deepEqual(skillBoosts, [0]);
+});
+
+test("mechanics flag carries consumed attack-duration effects to matching triggered extra skill damage only", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig({ buffAppliesVs: "infantry", extraTargets: "enemy.living" })
+ );
+
+ const normalBoosts = result.attacks
+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ const skillBoostsByTarget = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => `${attack.defenderUnit}:${attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0}`)
+ .sort();
+ assert.deepEqual(normalBoosts, [100]);
+ assert.deepEqual(skillBoostsByTarget, ["infantry:100", "lancer:0", "marksman:0"]);
+});
+
+test("mechanics flag carries consumed attack-duration effects with any target scope to all matching triggered extra skill damage", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig({ buffAppliesVs: "any", extraTargets: "enemy.living" })
+ );
+
+ const skillBoosts = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0)
+ .sort((left, right) => left - right);
+ assert.deepEqual(skillBoosts, [100, 100, 100]);
+});
+
test("attack-triggered source and target selectors resolve to concrete active scopes", () => {
const result = simulateBattle(
{
@@ -1801,3 +1912,40 @@
diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }
};
}
+
+function attackDurationCarryConfig(
+ options: { buffAppliesVs?: "any" | "infantry"; extraTargets?: "use.target" | "enemy.living" } = {}
+): SimulatorConfig {
+ const buffAppliesVs = options.buffAppliesVs ?? "any";
+ const extraTargets = options.extraTargets ?? "use.target";
+ return minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ OneUseNormalBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "marksman", applies_vs: buffAppliesVs },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ },
+ TriggeredExtraDamage: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ extra: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: extraTargets }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ });
+}
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -53,6 +53,7 @@
extraSkillAttackJobsByEffect: Record<string, number>;
attackControlCounts: { dodge: number; no_attack: number };
consumedEffectUseKeys: Set<string>;
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: boolean;
counters: {
attacks: Record<SideId, Record<UnitType, number>>;
received: Record<SideId, Record<UnitType, number>>;
@@ -112,7 +113,7 @@
const attacker = resolveFighter(input.attacker, "attacker", config, input.mechanics);
const defender = resolveFighter(input.defender, "defender", config, input.mechanics);
const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
- const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"));
+ const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"), input.mechanics);
const detail = options.detail ?? "full";
const traceEnabled = detail === "full" && input.trace;
const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;
@@ -289,7 +290,7 @@
return Number.isFinite(value) && value > 0 && value < 100;
}
-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {
+function createRuntime(fighters: ResolvedFighter[], rng: Rng, mechanics?: BattleInput["mechanics"]): Runtime {
const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };
const skills = buildRuntimeSkills(fighters);
for (const fighter of fighters) {
@@ -321,6 +322,7 @@
extraSkillAttackJobsByEffect: {},
attackControlCounts: { dodge: 0, no_attack: 0 },
consumedEffectUseKeys: new Set(),
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: mechanics?.carryAttackDurationEffectsToTriggeredExtraSkillDamage === true,
counters: {
attacks: { attacker: emptyTroops(), defender: emptyTroops() },
received: { attacker: emptyTroops(), defender: emptyTroops() }
@@ -513,6 +515,9 @@
roundStartTroops: DamageJob["roundStartTroops"]
): DamageJob[] {
const jobs: DamageJob[] = [];
+ const carriedAttackDurationEffectIds = runtime.carryAttackDurationEffectsToTriggeredExtraSkillDamage
+ ? attackDurationBucketEffectIdsForJob(normalAttack, round, runtime.activeEffects)
+ : undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
round
@@ -548,6 +553,7 @@
sourceEffectId,
sourceSkillReportKey,
sourceMultiplier: multiplier,
+ carriedAttackDurationEffectIds,
consumedEffectIds,
consumedEffectUseKey,
consumedEffectUseId: effect.id,
@@ -683,10 +689,14 @@
const available = Math.max(0, roundStartTroops[side][unit] ?? 0);
const totalKills = matching.reduce((sum, outcome) => sum + outcome.kills, 0);
if (totalKills <= available) continue;
- let remaining = available;
+ let appliedKills = 0;
+ let rawRemaining = available;
for (const outcome of matching) {
- outcome.kills = Math.min(outcome.kills, remaining);
- remaining -= outcome.kills;
+ const rawKills = outcome.kills;
+ outcome.kills = Math.min(rawKills, Math.max(0, available - appliedKills));
+ appliedKills += outcome.kills;
+ rawRemaining = Math.max(0, rawRemaining - rawKills);
+ if (rawRemaining === 0) appliedKills = available;
if (outcome.trace) outcome.trace.finalKills = outcome.kills;
}
}
@@ -745,6 +755,16 @@
.map((effect) => effect.id);
}
+function attackDurationBucketEffectIdsForJob(job: DamageJob, round: number, effects: ActiveEffect[]): string[] {
+ return effects
+ .filter((effect) => {
+ if (effect.kind === "extra_attack" || effect.duration.type !== "attack" || !isEffectActive(effect, round)) return false;
+ const classification = classifyEffectForJob(effect, job);
+ return classification?.kind === "bucket";
+ })
+ .map((effect) => effect.id);
+}
+
function consumeEffects(
runtime: Runtime,
consumedEffectIds: string[],
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
@@ -285,6 +285,25 @@
assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });
});
+test("adaptTestcaseEntry merges option mechanics without replacing testcase mechanics", () => {
+ const input = adaptTestcaseEntry(
+ {
+ test_id: "mechanics_case",
+ engagement_type: "rally",
+ mechanics: { weather: "clear" },
+ attacker: { troops: { infantry_t1: 1 } },
+ defender: { troops: { infantry_t1: 1 } }
+ },
+ { mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true } }
+ );
+
+ assert.deepEqual(input.mechanics, {
+ weather: "clear",
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: true,
+ engagement_type: "rally"
+ });
+});
+
test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {
const metrics = compareOutcomeDistribution({
candidate: { n: 1, mu: -186, sigma: 0 },
diff --git a/simulator/src/testcases.ts b/simulator/src/testcases.ts
--- a/simulator/src/testcases.ts
+++ b/simulator/src/testcases.ts
@@ -27,6 +27,7 @@
seed?: string | number;
trace?: boolean;
workers?: number;
+ mechanics?: Record<string, unknown>;
}
export interface TestcaseCaseReport {
@@ -207,7 +208,7 @@
const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);
const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };
try {
- preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace }, diagnostics);
+ preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace, mechanics: options.mechanics }, diagnostics);
preparedCase.key = snapshotKey(reportFile, index);
} catch (error) {
detail.error = errorMessage(error);
@@ -613,7 +614,11 @@
return summary;
}
-export function adaptTestcaseEntry(entry: unknown, options: { seed?: string | number; trace?: boolean } = {}, diagnostics: string[] = []): BattleInput {
+export function adaptTestcaseEntry(
+ entry: unknown,
+ options: { seed?: string | number; trace?: boolean; mechanics?: Record<string, unknown> } = {},
+ diagnostics: string[] = []
+): BattleInput {
const object = entry as {
attacker?: FighterInput;
defender?: FighterInput;
@@ -626,7 +631,7 @@
};
if (!object.attacker || !object.defender) throw new Error(`Testcase ${object.test_id ?? "(unknown)"} is missing attacker or defender`);
diagnostics.push(...diagnoseFighterShape("attacker", object.attacker), ...diagnoseFighterShape("defender", object.defender));
- const mechanics = testcaseMechanics(object);
+ const mechanics = testcaseMechanics(object, options.mechanics);
const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);
return {
attacker: object.attacker,
@@ -702,8 +707,12 @@
return diagnostics;
}
-function testcaseMechanics(entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown }): Record<string, unknown> | undefined {
+function testcaseMechanics(
+ entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown },
+ optionMechanics?: Record<string, unknown>
+): Record<string, unknown> | undefined {
const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};
+ if (optionMechanics && typeof optionMechanics === "object") Object.assign(mechanics, optionMechanics);
if (entry.engagement_type !== undefined) mechanics.engagement_type = entry.engagement_type;
if (entry.engagementType !== undefined) mechanics.engagementType = entry.engagementType;
return Object.keys(mechanics).length > 0 ? mechanics : undefined;
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -277,6 +277,7 @@
sourceEffectId?: string;
sourceSkillReportKey?: string;
sourceMultiplier?: number;
+ carriedAttackDurationEffectIds?: string[];
consumedEffectIds?: string[];
consumedEffectUseKey?: string;
consumedEffectUseId?: string;
Run B dirty state patch
diff --git a/simulator/config/hero_definitions/Mia.json b/simulator/config/hero_definitions/Mia.json
--- a/simulator/config/hero_definitions/Mia.json
+++ b/simulator/config/hero_definitions/Mia.json
@@ -30,7 +30,8 @@
},
"duration": {
"type": "turn",
- "value": 1
+ "value": 1,
+ "delay": 1
},
"same_effect_stacking": "max"
}