WOS Sim
← Back to Runs

Compare Runs

A (baseline)
4804e8bf-a550821673915/07/2026, 13:01:29
B (current)
5bad8215-6c81f9d181a20/07/2026, 06:32:18
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error+0.00%
Changed1
Improved0
Regressed1
Added0
Retired0

Testcase Delta

199 / 199
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/ambusher_no_marksmen.jsonambusher_no_marksmen0-0.05%1.87%1.92%+0.05%regressed
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_solo00.00%0.00%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.03%-0.03%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo1-0.26%-0.26%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2-0.19%-0.19%0.00%unchanged
testcases/emulator_verified/alonso_solo.jsonalonso_solo02.97%2.97%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201.59%1.59%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.16%-0.16%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_minxxx_attacks_nc.jsonattdef_jasser_patrick_minxxx_attacks0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_nc.jsonattdef_jasser_patrick00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_swap_nc.jsonattdef_jasser_patrick_swap00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks1-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_test_minxxx_attacks_nc.jsonattdef_test_minxxx_attacks_nc0-0.05%0.08%0.08%0.00%unchanged
testcases/emulator_verified/attdef_test_wip_attacks_nc.jsonattdef_test_wip_attacks_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc0-3.31%-3.31%0.00%unchanged
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo00.32%0.32%0.00%unchanged
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc00.04%0.04%0.00%unchanged
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal00.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped00.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo0-0.15%-0.15%0.00%unchanged
testcases/emulator_verified/flint_solo_nc.jsonflint_solo0+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/flint_zinman_combo_nc.jsonflint_zinman_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo0-1.58%-1.58%0.00%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo1-0.05%1.12%1.12%0.00%unchanged
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current00.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/greg_only_defender_current.jsongreg_only_defender_current00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current10.01%0.01%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/greg_solo.jsongreg_solo00.16%0.16%0.00%unchanged
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc00.19%0.19%0.00%unchanged
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0+0.05%-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo0-0.05%-0.05%0.00%unchanged
testcases/emulator_verified/hector_renee_wayne.jsonhector_renee_wayne0-0.45%-0.45%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.54%0.54%0.00%unchanged
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc0+0.05%0.00%0.00%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.60%-0.60%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.07%-0.07%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current00.01%0.01%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1-0.13%-0.13%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.18%-0.18%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo10.27%0.27%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.16%-0.16%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo30.09%0.09%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo40.26%0.26%0.00%unchanged
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo00.00%0.00%0.00%unchanged
testcases/emulator_verified/molly_solo.jsonmolly_solo0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0+0.05%-1.06%-1.06%0.00%unchanged
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo00.07%0.07%0.00%unchanged
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0+0.05%-0.35%-0.35%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.26%0.26%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.14%-0.14%0.00%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo10.20%0.20%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo00.20%0.20%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo10.02%0.02%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.04%1.04%0.00%unchanged
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator00.14%0.14%0.00%unchanged
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc00.00%0.00%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.58%-1.58%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_solo00.15%0.15%0.00%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1-1.81%-1.81%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.20%0.20%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.04%-0.04%0.00%unchanged
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_8400-0.85%-0.85%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_hero_nc.jsonwos444_bradley_s3_all_troops_damage_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_hero_nc.jsonwos444_edith_s11_mark_damage_taken_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.jsonwos444_edith_s12_lancer_damage_dealt_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_hero_nc.jsonwos444_edith_s12_lancer_damage_dealt_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_hero_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_hero_nc00.11%0.11%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_bucket.jsonwos451_bradley_s21_lancer_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.jsonwos451_bradley_s21_lancer_damage_up_control0-0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_bucket.jsonwos451_bradley_s22_infantry_damage_up_bucket0-0.05%0.03%0.03%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.jsonwos451_bradley_s22_infantry_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_bucket.jsonwos451_bradley_s3_all_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.jsonwos451_bradley_s3_all_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_bucket.jsonwos451_gordon_s11_lancer_damage_up_bucket0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.jsonwos451_gordon_s11_lancer_damage_up_control0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_bucket.jsonwos451_gordon_s21_lancer_damage_up_bucket0-0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.jsonwos451_gordon_s21_lancer_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo00.00%0.00%0.00%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.05%0.27%0.27%0.00%unchanged
testcases/heroes_unittests/Hector_tc.jsondaut_viper_100.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43-0.017%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_540.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_870.00%0.00%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.21%0.21%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21+0.05%-0.50%-0.50%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_32-0.21%-0.21%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43-0.29%-0.29%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54-0.10%-0.10%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.43%-0.43%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_530.01%0.01%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43+0.05%-0.04%-0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65-0.006%0.04%0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76-0.004%0.04%0.04%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10-0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_320.00%0.00%0.00%unchanged

Code / Config Changes

Commits (082167391f9d181a)

1f9d181aDocument testcase naming policypiddlyminx19/07/2026, 23:13:11

Code Changes (Run A → Run B)

Index: simulator/config/hero_definitions/Ahmose.json
===================================================================
--- simulator/config/hero_definitions/Ahmose.json prev run
+++ simulator/config/hero_definitions/Ahmose.json this run
@@ -145,9 +145,9 @@
},
"GuardiansVitality": {
"description": "Widget bonus: Increase Health by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Alonso.json
===================================================================
--- simulator/config/hero_definitions/Alonso.json prev run
+++ simulator/config/hero_definitions/Alonso.json this run
@@ -99,9 +99,8 @@
"attacks": {
"count": 1
}
},
- "same_effect_stacking": "max",
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
@@ -112,9 +111,9 @@
},
"RazorVolley": {
"description": "Widget bonus: Increase Lethality by X% for all troops when attacking",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Bradley.json
===================================================================
--- simulator/config/hero_definitions/Bradley.json prev run
+++ simulator/config/hero_definitions/Bradley.json this run
@@ -86,9 +86,9 @@
},
"SiegeInsight": {
"description": "Widget bonus: Increase Attack by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Edith.json
===================================================================
--- simulator/config/hero_definitions/Edith.json prev run
+++ simulator/config/hero_definitions/Edith.json this run
@@ -85,9 +85,9 @@
},
"Fortworks": {
"description": "Widget bonus: Increase Health by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Flint.json
===================================================================
--- simulator/config/hero_definitions/Flint.json prev run
+++ simulator/config/hero_definitions/Flint.json this run
@@ -65,9 +65,9 @@
},
"FortressSpear": {
"description": "Widget bonus: Increase Attack by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Gatot.json
===================================================================
--- simulator/config/hero_definitions/Gatot.json prev run
+++ simulator/config/hero_definitions/Gatot.json this run
@@ -33,21 +33,27 @@
"source": "infantry"
},
"effects": {
"KingsBestowal/1": {
- "type": "active.hero.damageTaken.down",
+ "type": "active.hero.shield",
"value": [
6,
12,
18,
24,
30
],
+ "value_formula": {
+ "type": "percent_of",
+ "source": "trigger.total_kills"
+ },
"units": {
- "applies_to": "trigger.source"
+ "applies_to": "trigger.source",
+ "applies_vs": "enemy.any"
},
"duration": {
"turns": {
+ "delay": 1,
"count": 1
}
},
"same_effect_stacking": "max"
@@ -77,9 +83,9 @@
},
"IndestructibleCity": {
"description": "Widget bonus: Increase Defense by X% for all troops when defending.",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Gordon.json
===================================================================
--- simulator/config/hero_definitions/Gordon.json prev run
+++ simulator/config/hero_definitions/Gordon.json this run
@@ -150,9 +150,9 @@
},
"BioAssault": {
"description": "Widget bonus: Increase Lethality by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Greg.json
===================================================================
--- simulator/config/hero_definitions/Greg.json prev run
+++ simulator/config/hero_definitions/Greg.json this run
@@ -93,9 +93,9 @@
},
"VitalSurge": {
"description": "Widget bonus: Increase Health by X% for all troops when attacking",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Gwen.json
===================================================================
--- simulator/config/hero_definitions/Gwen.json prev run
+++ simulator/config/hero_definitions/Gwen.json this run
@@ -50,9 +50,9 @@
100
],
"units": {
"applies_to": "trigger.source",
- "applies_vs":"trigger.target"
+ "applies_vs": "trigger.target"
},
"duration": {
"attacks": {
"count": 1
@@ -131,9 +131,9 @@
},
"LethalPrecision": {
"description": "Widget bonus: Increase Lethality by X% for all troops when attacking",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Hector.json
===================================================================
--- simulator/config/hero_definitions/Hector.json prev run
+++ simulator/config/hero_definitions/Hector.json this run
@@ -136,9 +136,9 @@
},
"BulwarkCommander": {
"description": "Widget bonus: Increase Attack by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Hendrik.json
===================================================================
--- simulator/config/hero_definitions/Hendrik.json prev run
+++ simulator/config/hero_definitions/Hendrik.json this run
@@ -52,37 +52,42 @@
"DragonsHeir": {
"description": "Every 3 turns, the ancient abyssal spirit's descendants will work together with Hendrik's Marksmen to launch an attack, dealing X% damage to all enemies.",
"trigger": {
"type": "turn",
- "every": 3,
- "source": "marksman"
+ "every": 3
},
"effects": {
"DragonsHeir/1": {
- "type": "active.hero.damage.up",
+ "type": "extra_skill_attack",
"value": [
8,
16,
24,
32,
40
],
"units": {
- "applies_to": "trigger.source",
- "applies_vs": "trigger.target"
+ "applies_to": "marksman",
+ "applies_vs": "any"
},
"duration": {
"attacks": {
"count": 1
}
- }
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "enemy.living"
+ }
+ ]
}
}
},
"AbyssalBlessing": {
"description": "Widget bonus: Increase Attack by X% for all troops when rallying.",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Jeronimo.json
===================================================================
--- simulator/config/hero_definitions/Jeronimo.json prev run
+++ simulator/config/hero_definitions/Jeronimo.json this run
@@ -69,9 +69,9 @@
},
"BattleStance": {
"description": "Widget bonus: Increase Attack by X% for all troops when attacking",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Ling.json
===================================================================
--- simulator/config/hero_definitions/Ling.json prev run
+++ simulator/config/hero_definitions/Ling.json this run
@@ -1,6 +1,7 @@
{
"name": "Ling",
+ "aliases": ["Ling Xue"],
"hero_generation": "SR",
"troop_type": "lancer",
"skills": {
"FearsomeAura": {
Index: simulator/config/hero_definitions/Logan.json
===================================================================
--- simulator/config/hero_definitions/Logan.json prev run
+++ simulator/config/hero_definitions/Logan.json this run
@@ -62,9 +62,9 @@
},
"IronDen": {
"description": "Widget bonus: Increase Defense by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Lumak.json
===================================================================
--- simulator/config/hero_definitions/Lumak.json prev run
+++ simulator/config/hero_definitions/Lumak.json this run
@@ -1,6 +1,7 @@
{
"name": "Lumak",
+ "aliases": ["Lumak Bokan"],
"hero_generation": "SR",
"troop_type": "lancer",
"skills": {
"TacticalDeception": {
Index: simulator/config/hero_definitions/Lynn.json
===================================================================
--- simulator/config/hero_definitions/Lynn.json prev run
+++ simulator/config/hero_definitions/Lynn.json this run
@@ -83,9 +83,9 @@
},
"SilentThreat": {
"description": "Widget bonus: Increase Lethality by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Mia.json
===================================================================
--- simulator/config/hero_definitions/Mia.json prev run
+++ simulator/config/hero_definitions/Mia.json this run
@@ -68,9 +68,8 @@
"turns": {
"count": 1
}
},
- "same_effect_stacking": "max",
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "use.target"
@@ -112,9 +111,9 @@
},
"PrecisionDrive": {
"description": "Widget bonus: Increase Attack by X% for all troops when attacking",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Molly.json
===================================================================
--- simulator/config/hero_definitions/Molly.json prev run
+++ simulator/config/hero_definitions/Molly.json this run
@@ -96,9 +96,9 @@
},
"WinterFang": {
"description": "Widget bonus: Increase Lethality by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Natalia.json
===================================================================
--- simulator/config/hero_definitions/Natalia.json prev run
+++ simulator/config/hero_definitions/Natalia.json this run
@@ -74,9 +74,9 @@
},
"GaleForce": {
"description": "Widget bonus: Increase Lethality by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Norah.json
===================================================================
--- simulator/config/hero_definitions/Norah.json prev run
+++ simulator/config/hero_definitions/Norah.json this run
@@ -77,9 +77,8 @@
"turns": {
"count": 1
}
},
- "same_effect_stacking": "max",
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "enemy.living"
@@ -135,9 +134,9 @@
},
"SteadfastGuard": {
"description": "Widget bonus: Increase Defense by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Philly.json
===================================================================
--- simulator/config/hero_definitions/Philly.json prev run
+++ simulator/config/hero_definitions/Philly.json this run
@@ -106,9 +106,9 @@
},
"GuardianBloom": {
"description": "Widget bonus: Increase Health by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/README.md
===================================================================
--- simulator/config/hero_definitions/README.md prev run
+++ simulator/config/hero_definitions/README.md this run
@@ -0,0 +1,561 @@
+# Hero definition configuration guide
+
+This directory contains the simulator's hero catalogue. Each JSON file describes one hero, the hero's skill ordering, when each skill triggers, and the effects produced by that trigger.
+
+This guide describes the native TypeScript simulator's current behaviour. It is not a transcription of the in-game skill text: the JSON controls the simulation, even when a `description` says something slightly different.
+
+## Quick example
+
+```json
+{
+ "name": "Example Hero",
+ "aliases": ["Example"],
+ "hero_generation": "S8",
+ "troop_type": "lancer",
+ "skills": {
+ "RepeatedStrike": {
+ "description": "Every second Lancer attack deals 25% extra skill damage.",
+ "trigger": {
+ "type": "attack",
+ "every": 2,
+ "source": "lancer"
+ },
+ "effects": {
+ "RepeatedStrike/1": {
+ "type": "extra_skill_attack",
+ "value": [5, 10, 15, 20, 25],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "attacks": { "count": 1 }
+ },
+ "trigger_damage_jobs": [
+ { "source": "use.source", "target": "use.target" }
+ ]
+ }
+ }
+ }
+ }
+}
+```
+
+At skill level 5, this trigger is attempted when the owning side's Lancer declares a normal attack whose `previous counter -> previous counter + 1` step crosses an even threshold. The counter is per side and unit type, and is shared by normal and skill-damage jobs. If nothing else has advanced it, the first activation is on the Lancer's second normal attack. It creates a one-use extra-attack effect, which immediately creates a skill-damage job from that Lancer to the normal attack's target at a `25 / 100 = 0.25` damage multiplier.
+
+That generated skill job also increments the Lancer's counter when the round commits. Assuming the target remains valid and one extra job runs each time, this example therefore activates on the Lancer's 2nd, 3rd, 4th, ... normal attacks—not its 2nd, 4th, 6th, ... normal attacks.
+
+The generated job does not evaluate `attack` triggers itself. If one or more generated jobs move the counter across a threshold, that threshold produces no activation and is not replayed later. A later normal attack fires only if its own `previous -> previous + 1` check crosses a threshold. Consequently, changing the number of jobs produced by an effect can change the future trigger cadence.
+
+## File and hero fields
+
+| Field | Accepted value | Meaning |
+| --- | --- | --- |
+| `name` | String | Name stored on the resolved hero and accepted when resolving battle input. It does not have to equal the filename. |
+| `aliases` | Array of strings | Additional names accepted in battle input. Matching lowercases the name and removes every non-ASCII-alphanumeric character. Aliases, filenames, and `name` values must not collide after that normalization. |
+| `hero_generation` | Key from `../hero_generation_stats.json` | Associates the hero with a generation stat block, such as `SR`, `S1`, `S1_natalia`, ..., `S8`. Different consumers apply that association differently; see below. |
+| `troop_type` | `infantry`, `lancer`, or `marksman` | Hero class used by the dashboard catalogue and ingestion tools. It does **not** restrict which troops the hero's effects can affect, and the simulator's combat resolver does not otherwise read it. |
+| `skills` | Object keyed by skill ID | The hero's skills, in skill-number order. The object key is the skill ID used in reports and may also be used when supplying a level. |
+
+### Filename, `name`, aliases, and registration
+
+The filename without `.json` is the definition's primary config key. The filename, `name`, and every entry in `aliases` can all resolve the hero after normalization. For example, `WuMing.json` can have `"name": "Wu Ming"`; both resolve to the same definition.
+
+The Node directory loader discovers JSON files automatically. The browser-safe/default config in `simulator/src/config.ts` uses explicit JSON imports, so adding a new file also requires importing it and adding it to `DEFAULT_HERO_DEFINITIONS`.
+
+### `hero_generation` is opt-in build-time data
+
+`hero_generation` is data, not an unconditional core-simulator effect. The core combat resolver copies the string into resolved output but does not add the associated stats by itself.
+
+For callers constructing a raw `BattleInput`, generation stats are added only when they explicitly use `applyHeroGenerationStats(...)` or `BattleInputBuilder.addHeroGenerationStats()`. Tournament input builders do this. The dashboard also reads `hero_generation` directly when adding or subtracting hero base stats in its form state, so dashboard inputs can already contain those stats before reaching the core simulator.
+
+When enabled:
+
+- the stat block from `hero_generation_stats.json` is added to every troop type's player stats;
+- generation stats from all **main** heroes are summed;
+- joiner heroes do not contribute generation stats;
+- skill level does not change the generation stat block.
+
+A `hero_generation` value with no matching entry in `hero_generation_stats.json` is reported in `config.diagnostics.unsupportedEffects` as `missing_hero_generation`. Config loading still succeeds; helpers that look up the missing block add zeroes.
+
+## Skill fields
+
+Each property inside `skills` is a skill definition. Its object key is significant: it becomes the resolved skill ID and is used in traces and skill reports.
+
+| Field | Accepted value | Meaning |
+| --- | --- | --- |
+| `description` | String | Human-readable documentation only. Combat code does not parse or enforce it. |
+| `requirements` | Array of requirement objects | Optional gates checked before the skill is added to the battle. Hero definitions currently support meaningful `engagement_type` gating; details are below. |
+| `trigger` | Trigger object | Required. Determines when and for which attack/round the whole skill is attempted. |
+| `effects` | Object keyed by effect ID | Effects activated together after the trigger's probability gate succeeds. An empty object is allowed for non-combat placeholder skills. |
+| `status` | Free-form string | Metadata only; currently used for Reina's `"tbd"` marker. Ignored by combat code. |
+| `notes` | Free-form string | Metadata only. Ignored by combat code. |
+
+### Skill numbering and levels
+
+Skill order is `Object.entries(...)` order. For the current descriptive, non-integer skill IDs, this is the order written in the JSON file. Battle input such as `"skill_1": 5` selects the first skill in that order, `skill_2` the second, and so on. A level may instead be supplied under the exact skill ID. `skill_N` takes precedence if both are present.
+
+Avoid integer-like skill IDs such as `"1"` and `"10"`: JavaScript enumerates integer-index object keys numerically before other keys, regardless of their textual position in the JSON. Current hero skill IDs do not have this problem.
+
+A missing level or a numeric level `<= 0` disables that skill. Positive levels enable it. Use positive integers: battle-input levels are coerced with `Number(...)` but are not fully validated, and fractional or non-numeric levels can enable a skill while resolving its level-dependent values incorrectly.
+
+Most level-scaled arrays contain five entries, but selection is generic: level 1 selects index 0, and integer levels outside a non-empty array are clamped to its first or last entry. An all-string `value` array is the special fixed-order form used by `attack_order`; it is not level-selected.
+
+Changing skill order is therefore a data migration, not cosmetic JSON reformatting.
+
+## Requirements
+
+A requirement has this shape:
+
+```json
+{ "level": 1, "type": "engagement_type", "value": "rally" }
+```
+
+| Field | Meaning |
+| --- | --- |
+| `level` | The first skill level at which this requirement applies. Below this level, the requirement is ignored. |
+| `type` | For hero definitions, the only type with gating behaviour is `engagement_type`. `tier`, `fc`, and unknown types pass automatically in the hero resolver; `tier` and `fc` are meaningful only for troop skills. |
+| `value` | Required engagement name. It is trimmed and lowercased before comparison. Current hero files use `rally` and `garrison`, but any non-empty string can match the same normalized `BattleInput.engagement_type`, subject to the rally special case below. |
+
+Engagement behaviour has one deliberate rally-role special case:
+
+- in a battle with `engagement_type: "rally"`, a `rally` requirement passes only for the attacker and a `garrison` requirement passes only for the defender;
+- in a battle explicitly labelled `garrison`, a `garrison` requirement passes for either side because only `rally` receives side-specific treatment;
+- any other requirement value passes for either side when it equals the normalized battle engagement;
+- with no matching `engagement_type`, the skill is omitted entirely, including from the skill report.
+
+All requirements on a skill must pass.
+
+## Triggers
+
+A trigger controls **when the skill is attempted**. If it matches, the simulator reaches the attempt, rolls `probability`, and proceeds only on success. Full trace mode records matching attempts, including failed probability rolls; standard mode records successful skill activations but does not retain failed-attempt counts.
+
+### `trigger.type`
+
+| Value | Behaviour |
+| --- | --- |
+| `pre_battle` | Activated once while preparing the battle, before any runtime exists. Chance-free by definition and restricted to static `passive.*` effects; these feed the static damage profile (in-game: widget passives baked into player bonus stats). |
+| `battle_start` | Is attempted once during battle setup, before round 1. `first`, `every`, `source`, and `target` do not gate this trigger type. |
+| `turn` | Is attempted at the start of matching simulator rounds. The simulator calls these rounds internally; JSON and game text commonly call them turns. Omitting `source` makes it global; defining `source` selects the separate per-unit path described below. |
+| `attack` | Is attempted when a matching **normal** attack is declared. Generated skill jobs do not evaluate it. Every intended normal attack's skill triggers are processed before any controls or damage jobs for the round. |
+
+Only these three exact strings are scheduled by the runtime. An unknown string is not rejected by a closed trigger schema; it simply leaves the skill unscheduled.
+
+**Phase gotcha for `attack`.** The simulator first resolves every side/unit's intended normal attack for the round, then activates all matching `attack` skills for all of those intents, and only afterwards evaluates controls and damage in intent order. An effect triggered by a later intent can therefore affect an earlier intent's damage in the same round. Conversely, an `attack_order` activated by an attack trigger is too late to change targets already selected for that round.
+
+### `trigger.probability`
+
+Accepts either a percentage number or a per-level array of percentages:
+
+```json
+"probability": [20, 20, 20, 20, 20]
+```
+
+Omitting it means 100%. A scalar is coerced with `Number(...)`; an array selects by skill level first. The resolved value is clamped to 0–100, and a value that cannot become a finite number resolves to 0. The probability gate is attached to the whole skill trigger, not to individual effects.
+
+An `attack` trigger rolls separately for each matching normal intent. A global `turn` trigger rolls once for the round. A per-unit `turn` trigger also rolls only once; on success, the runtime then tries to activate the effects once for each living matching source unit type. It does not roll independently for infantry, lancer, and marksman.
+
+On the per-unit turn path, the recorder counts the probability success as a skill activation before source/target intent construction finishes. A skill can therefore report one successful activation but zero effect activations when no living source/acceptable target produces a valid synthetic intent.
+
+Passive (`passive.*`) battle-start effects may not use probability: static passive factors must be deterministic.
+
+### `trigger.every` and `trigger.first`
+
+`every` defines arithmetic thresholds. What is checked against those thresholds depends on the trigger type.
+
+- for a `turn` trigger, `"every": 4` fires on rounds 4, 8, 12, ...;
+- for an `attack` trigger, `"every": 4` fires when a matching normal attack's `previous counter -> previous counter + 1` step crosses thresholds 4, 8, 12, ...; these need not be the unit's 4th, 8th, and 12th **normal** attacks;
+- `"first": 4, "every": 5` uses thresholds 4, 9, 14, ... on the applicable round number or attack counter;
+- omitting `every` means every otherwise-matching turn or attack;
+- `first` is only valid when `every` is also present and must be at least 1.
+
+Use positive integer intervals. The loader validates `first` but currently does not fully validate `every`, so malformed `every` values should not be treated as supported merely because they pass loading.
+
+**Gotcha — counter updates and trigger evaluation are different events.** A normal declaration evaluates `attack` triggers using `previousAttackCount` and `projectedAttackCount = previousAttackCount + 1`. At the end of the round, the counter is advanced once for every normal damage job, cancelled normal attack, and generated skill-damage job that actually ran. Generated jobs update the counter but never evaluate `attack` triggers. Thresholds reached or crossed by those jobs are therefore skipped, not queued.
+
+An intended normal attack is not guaranteed to advance the counter. All intents and their `attack` triggers are created from round-start troop counts, but before each intent is calculated the simulator checks whether its selected target has already been exhausted by earlier same-round jobs. If so, that intent is dropped: its trigger has already been attempted, but it produces no job or cancellation record and advances neither attack nor received-attack counters. This can leave the same frequency threshold available to the unit's next normal declaration.
+
+Concrete current examples:
+
+- Sonya's `BountyTemptation` emits one skill job when its every-2 trigger succeeds. With a valid target, its Lancer counter advances by two on each successful round, so the skill fires on normal attacks 2, 3, 4, ... after the first activation.
+- Wayne's `RoundaboutHit` can emit two jobs, one against Lancers and one against Marksmen. When both targets exist, the Marksman counter advances by three on a successful round. Its every-2 trigger then fires on normal attacks 2, 4, 6, ... because the generated jobs themselves pass alternate thresholds without evaluating the trigger.
+
+### `trigger.source` and `trigger.target`
+
+These fields filter the attack shape that can trigger the skill.
+
+- `source` means the attacking/dealing troop line. Omitted means `self.any`.
+- `target` means the attacked/taking troop line. Omitted means `enemy.any`.
+
+Supported selector forms are:
+
+| Form | Example | Meaning |
+| --- | --- | --- |
+| Unqualified unit | `"lancer"` | That unit on the field's default relation: self for `source`, enemy for `target`. |
+| Unit array | `["infantry", "marksman"]` | Any listed unit on the default relation. |
+| Relation only | `"self"`, `"enemy"` | All unit types on that relation. |
+| Relation plus all | `"self.any"`, `"self.all"`, `"enemy.any"`, `"enemy.all"` | All unit types on that relation. `any` and `all` are equivalent here. |
+| Relation plus unit | `"self.lancer"`, `"enemy.infantry"` | One unit type on the selected relation. |
+| Unqualified all | `"any"`, `"all"` | All units on the default relation. |
+
+Canonical unit names are `infantry`, `lancer`, and `marksman`. The unit normalizer also recognizes common forms such as `inf`, `lancers`, `marksmen`, and `marks`, but canonical names make definitions clearer.
+
+There is no closed selector validator here. Empty arrays resolve to an empty unit mask, and malformed unit strings generally fail later when triggers are compiled. The parser also treats `friendly` as “all units on this field's default relation”; for `target`, that default is the enemy, so `friendly` is particularly misleading and should not be used.
+
+For an `attack` trigger, `source` and `target` are matched directly against each intended normal attack. Reina demonstrates the reverse-side form: `source: "enemy.any"` and `target: "self.any"` listens for an enemy attack against Reina's side.
+
+For a `turn` trigger:
+
+- with no `source`, the skill is global and activates once on each matching round; because no attack-shaped intent is constructed, `trigger.target` does not filter this path either;
+- with `source`, it is per-unit: after the single probability roll succeeds, the simulator considers each living matching source troop type and builds a synthetic attack-shaped intent for it;
+- source and target should resolve to opposing sides. A same-side source/target pairing cannot match the synthetic attack intent.
+
+**Per-unit target-selection gotcha.** The runtime does not search for a troop satisfying `trigger.target`. It first chooses one opposing target using the current `attack_order` or the default order `infantry`, `lancer`, `marksman`, then checks that chosen target against the trigger selector. If the chosen target fails the selector, that source produces no effects; the runtime does not try the next living target. Thus `target: "enemy.lancer"` can produce no effect while enemy Infantry is alive, even when enemy Lancers are also alive.
+
+`pre_battle` and `battle_start` never have an attack-shaped intent. Its `source` and `target` fields are compiled but never used for matching; use effect scopes, not trigger selectors, to scope a battle-start effect.
+
+## Effects
+
+For `battle_start`, global `turn`, and `attack` triggers, every entry in `effects` is activated together when the trigger's probability gate succeeds. For a per-unit `turn` trigger, every entry is activated once for each source unit type that produces a valid synthetic source/target intent; a successful probability roll alone does not guarantee an effect activation.
+
+The entry's object key becomes the effect ID used in traces, applied-effect summaries, and extra-skill job attribution. Keep effect IDs unique across the catalogue: aggregate fields such as `extraSkillAttackJobsByEffect` are keyed by this ID alone. The current `SkillId/number` convention does that while keeping the source readable. The ID labels the effect but does not decide stacking identity; `same_effect_stacking` groups by the originating definition object and resolved scope.
+
+Effect entries retain object enumeration order. That order is mechanically significant in some special-effect paths: extra-attack effects and their job definitions run sequentially, the first ready `attack_order` wins, and the last matching control of a given type is reported as the winner. Use descriptive non-integer IDs and treat reordering effects or `trigger_damage_jobs` as a behavioural change, not merely formatting.
+
+| Field | Accepted value | Meaning |
+| --- | --- | --- |
+| `type` | One supported modifier or special-effect string | Chooses the mechanic and, for modifiers, the damage-equation bucket. |
+| `value` | Usually a number or per-level numeric array | Percentage magnitude for modifiers; damage multiplier percentage for `extra_skill_attack`; fixed unit order for `attack_order`. |
+| `units` | `{ applies_to?, applies_vs? }` | Resolves which troop lines may receive/use the effect and which opposing troop lines it applies against. |
+| `duration` | `{ turns?, attacks? }` | Optional round window and/or use limit. Omitted means the effect is permanent. |
+| `same_effect_stacking` | `add` or `max` | Controls overlap between live activations of the same modifier definition and scope. Omitted means `add`. |
+| `value_evolution` | Evolution object | Optionally changes the effect value as rounds or uses advance. |
+| `trigger_damage_jobs` | Non-empty array of job definitions | Required for `extra_skill_attack`; describes the actual skill-damage jobs it emits. |
+
+### `value` and level selection
+
+For percentage-bucket modifiers, every configured scalar/array entry must be a finite, non-negative JSON number. Missing `value` is permitted and resolves to 0. Use a positive magnitude with a `.down` effect type; negative values are rejected rather than treated as the opposite direction.
+
+```json
+"value": [5, 10, 15, 20, 25]
+```
+
+At level 3 this resolves to 15. A scalar number gives the same value at every level. Numeric arrays are selected by level before effects reach the runtime.
+
+`dodge` and `no_attack` ignore `value`; their probability belongs on the skill trigger. `extra_skill_attack` is not covered by the percentage-bucket value validator: its resolved `value` is converted to a number, and a missing, non-finite, zero, or negative value produces no damage jobs.
+
+## Modifier effect types
+
+Modifier names identify a damage-equation bucket. The general forms are:
+
+| Form | Accepted middle values | Meaning |
+| --- | --- | --- |
+| `passive.<stat>.<direction>` | `stat`: `attack`, `lethality`, `health`, `defense`; `direction`: `up`, `down` | Permanent deterministic battle-start stat modifiers folded into the static damage profile. |
+| `active.hero.<property>.<direction>` | `property`: `attack`, `lethality`, `health`, `defense`, `damage`, `damageTaken`; `direction`: `up`, `down` | Runtime modifier in a hero-labelled bucket. All combinations are supported even if the current hero files use only a subset. |
+| `active.troop.<property>.<direction>` | Same values as `active.hero` | Runtime modifier in a separate troop-labelled bucket. Supported by the engine but not currently used in hero definitions. A hero effect placed here is still reported as hero-sourced; only its equation bucket changes. |
+| `type.all.damage.<direction>` | `up`, `down` | Modifier applied to both normal and skill damage. |
+| `type.normal.damage.<direction>` | `up`, `down` | Dealer-side modifier for normal attacks only. |
+| `type.normal.damageTaken.<direction>` | `up`, `down` | Taker-side modifier for normal attacks only. |
+| `type.skill.damage.<direction>` | `up`, `down` | Dealer-side modifier for generated skill attacks only. |
+| `type.skill.damageTaken.<direction>` | `up`, `down` | Taker-side modifier for generated skill attacks only. |
+
+These effects do not mutate a fighter's stored Attack, Defense, Health, or Lethality. When an applicable damage job is calculated, they contribute factors to that job's equation. Expiry therefore affects future jobs only; it does not require undoing a stat mutation.
+
+The current hero JSON files use only the subset listed by the inventory at the end of this guide. The additional combinations above are nevertheless registered native buckets. `active.hero.*` and `active.troop.*` are not source-validated: a hero definition can write to either family. The families are separate factors, so identical hero- and troop-labelled bonuses multiply rather than add together.
+
+The four stat/property roles are important:
+
+- `attack`, `lethality`, and `damage` describe the **damage dealer**;
+- `health`, `defense`, and `damageTaken` describe the **damage taker**;
+- `.up` factors are placed in the damage numerator;
+- `.down` factors are placed in the denominator.
+
+For example, `active.hero.damage.down` with `applies_to: "enemy.any"` reduces damage dealt by enemy troops. `active.hero.damageTaken.up` with `applies_to: "trigger.target"` increases damage received by the attacked troop.
+
+**Gotcha — `.down` is denominator-based, not subtractive.** A 20% `.down` contribution divides damage by `1.20`; it does not multiply damage by `0.80`. Likewise, reducing a taker's health or defense by 20% contributes a `1.20` numerator factor. This makes equal `.up` and `.down` magnitudes mathematical opposites in factor placement, but not ordinary `+20%`/`-20%` percentage arithmetic.
+
+Percentages in one ordinary bucket add: two live `active.hero.damage.up` contributions of 10 and 20 produce that bucket's `1 + 0.10 + 0.20 = 1.30` factor. Distinct buckets multiply in the final equation. `type.all.damage.up/down` is the exception: its contributions multiply as individual percentage factors rather than adding into one percentage total.
+
+### Passive restrictions
+
+`passive.*` effects are static profile inputs and must satisfy all of these rules:
+
+- trigger type is `pre_battle`;
+- no trigger probability (`pre_battle` is chance-free by definition);
+- no `duration`;
+- no `value_evolution`;
+- no `same_effect_stacking: "max"` (passives add);
+- only `units.applies_to` affects passive scope; `applies_vs` is not used when the static contribution is built.
+
+## `units`: effect scope and usage gates
+
+`units.applies_to` identifies the troop line described by the effect. `units.applies_vs` is the counterpart-line gate for uses of that effect. Most valid combat jobs are cross-side, but the selector itself is resolved independently and can be misconfigured to the same side.
+
+The defaults are:
+
+- omitted `applies_to`: all unit types on the skill owner's side;
+- omitted `applies_vs`: all unit types on the side opposite the resolved `applies_to` side.
+
+Unqualified unit strings and arrays use those same default sides. `self.*` and `enemy.*` are always relative to the **skill owner**, not relative to the normal attack that may be using the effect. Trigger-relative selectors are the forms that follow the concrete dealer/taker intent.
+
+This means `applies_to` is not always “the attacker” or always “the target.” The effect type's dealer/taker role determines how the scope is matched to a damage job.
+
+Supported values for either field include:
+
+- a canonical unit string, such as `"lancer"`;
+- a non-empty unit array, such as `["infantry", "marksman"]`;
+- `"any"` or, for `applies_to` only, `"all"`;
+- `"self"`, `"self.any"`, `"self.all"`, `"self.<unit>"`;
+- `"enemy"`, `"enemy.any"`, `"enemy.all"`, `"enemy.<unit>"`;
+- `"trigger.source"` or its short alias `"trigger"`;
+- `"trigger.target"` or its short alias `"target"`.
+
+`applies_vs: "all"` is deliberately rejected; use `"any"`. `units.side` is also rejected. Side belongs in relation-qualified selectors such as `enemy.any`.
+
+`trigger.source`/`trigger` resolve to the intent's dealer side and unit. `trigger.target`/`target` resolve to its taker side and unit. They are meaningful for `attack` triggers and valid per-unit `turn` intents. They are rejected on `pre_battle` and `battle_start` effects because no such intent exists.
+
+**Gotcha — global turn triggers have no concrete source or target.** On a `turn` trigger with no `trigger.source`, the activation has no attack intent. Trigger-relative effect selectors consequently retain their precompiled fallback: all units on that field's default side. The loader records an `ambiguousTurnTriggerSelectors` diagnostic but does not reject the definition.
+
+The current `Sonya.json` `TorrentialImpact/2` definition is affected. Its global turn trigger gives `applies_to: "trigger.target"` no target to resolve, so it becomes all troops on Sonya's own side. Because the effect is `no_attack`, it cancels Sonya's side's normal attacks on the trigger round. A trace with only this skill shows the round-5 Lancer attack cancelled and `TorrentialImpact/1`'s one-use extra-skill effect surviving until it is used on round 6. This is the runtime behaviour of the current JSON, despite the description saying the enemy target is stunned.
+
+The parser also accepts `"friendly"`, but only as “all units on this field's default side.” It is not an owner-relative synonym in every context. No current hero definition uses it; use `self.any`, `enemy.any`, or `any` instead.
+
+## Duration
+
+`duration` may contain `turns`, `attacks`, or both. Each axis accepts:
+
+```json
+{
+ "duration": {
+ "turns": { "delay": 1, "count": 2 },
+ "attacks": { "delay": 1, "count": 3 }
+ }
+}
+```
+
+`count` must be an integer at least 1. `delay` must be an integer at least 0. If both axes are present, they are independent constraints on the same instance. Turn expiry or exhaustion of the attack-use budget removes the effect, whichever happens first.
+
+### `turns`
+
+`turns` are 1-based simulator rounds and are processed at round boundaries.
+
+- An effect created during round `R >= 1` with no turn delay starts immediately in `R`.
+- For an effect created during round `R >= 1`, `delay: 1` starts it at the beginning of round `R + 1`.
+- `count: 1` expires it at the beginning of the round after its first active round.
+- A delay without a count postpones activation and then leaves the effect permanent.
+
+For example, an attack-triggered effect created during round 3 with `{ "turns": { "delay": 1, "count": 2 } }` is active in rounds 4 and 5 and expires at the start of round 6.
+
+An attack-triggered effect with `{ "turns": { "count": 1 } }` is available for later jobs in the same round, but expires before the next round begins. It does not mean “the next full turn.”
+
+**Battle-start delay gotcha.** Battle-start effects are created at setup round 0, while the earliest active round is clamped to 1. Consequently, both `turns.delay: 0` and `turns.delay: 1` start in round 1. A battle-start effect needs `delay: 2` to begin in round 2. No current hero definition combines `battle_start` with a turn delay, but the distinction matters when authoring one.
+
+### `attacks`
+
+`attacks.count` is a limit on the number of times that particular effect instance participates in the relevant mechanic. “Attack” here is not uniformly synonymous with “normal attack”:
+
+- a modifier use is an applicable normal **or skill** damage job to which it contributes a non-zero value;
+- an extra-skill use is one eligible normal attack that emits at least one job, regardless of how many target jobs it emits;
+- a control use is an attack it cancels;
+- an attack-order use is an attack whose target order it chooses.
+
+`attacks.delay` skips that many otherwise-eligible mechanic uses before the effect can apply. For a modifier this means applicable damage jobs; for extra attacks, controls, and attack order it means applicable normal attacks. The last skipped use is not also the first active use. Delay decrements only when the effect's side/unit/job-kind gates otherwise match.
+
+For an effect created by an `attack` trigger, the triggering normal intent is eligible to be its first use because attack skills activate before controls and damage. Thus `attacks.delay: 1` normally skips that current matching intent and begins with the next eligible one; it does not mean “wait one complete round.” The all-intents trigger phase can make the first eligible use a different, earlier intent in the same round when the effect has broad scope.
+
+Cancelled attacks normally still consume applicable attack-limited modifiers and controls because the attack was declared even though it did not land. Permanent modifiers without an `attacks` duration are not use-charged through this cancellation path merely because they have `value_evolution.step: "attack"`. Simulation options can disable cancellation charging separately for dodge or no-attack, but those options are outside the hero JSON.
+
+## `same_effect_stacking`
+
+| Value | Behaviour |
+| --- | --- |
+| `add` | Every live activation contributes. This is the default. |
+| `max` | Only the live activation with the greatest current value contributes for an overlapping job. |
+
+This setting is implemented only for dynamic percentage modifiers. The group identity is the **same originating config effect object on the same owner side plus the same resolved `applies_to`/`applies_vs` side-and-unit masks**. Repeated activations and duplicate main/joiner instances of the same hero definition can therefore share a group. Different config effects do not share a max group merely because their IDs or `type` strings match.
+
+Only the exact string `"max"` selects max behaviour. `"add"` is the documented alternative and default, but the loader currently does not reject other values; every value other than exact `"max"` silently normalizes to `add`. Treat that fallback as weak validation, not as support for additional modes.
+
+For `max`, the greatest **current** value is chosen on each applicable job, so value evolution can change the winner. If the selected value is non-zero, all eligible sibling activations are use-charged, including suppressed siblings. If the selected value is zero, none is charged. Charging the whole eligible group prevents a weaker suppressed copy from waiting untouched and taking over later with a fresh attack budget.
+
+Passive effects reject `max` and remain additive. `extra_skill_attack` rejects the field entirely because every applicable extra-attack effect independently emits its own jobs. The loader does not reject the field on `dodge`, `no_attack`, or `attack_order`, but those runtime paths do not implement add/max grouping, so setting it there has no mechanical effect.
+
+## `value_evolution`
+
+The runtime-recognized shape is:
+
+```json
+{
+ "value_evolution": {
+ "type": "pct_decay",
+ "step": "attack",
+ "value": 15
+ }
+}
+```
+
+| Field | Accepted runtime values | Meaning |
+| --- | --- | --- |
+| `type` | `pct_decay`, `fixed_decay` | `pct_decay` multiplies by `max(0, 1 - value/100)` per step. `fixed_decay` subtracts `value` percentage points per step, floored at zero. |
+| `step` | `attack`, `round`, `turn` | `attack` uses the effect's mechanical-use count. `round` and `turn` are synonyms and use elapsed active rounds. |
+| `value` | Number | Decay amount. |
+
+For `step: "attack"`, the step count is the effect instance's `uses` counter, with “use” defined by the mechanic in the duration section. The current use reads the value before it is charged, so a 100% effect with 15% attack decay produces 100%, 85%, 72.25%, ... on consecutive uses. Skipped attack delays do not increment `uses`; a zero-valued modifier is not charged; a max-suppressed sibling is charged alongside the selected non-zero member.
+
+For `step: "round"`/`"turn"`, the step count is `current round - first active round`. It advances with time even if the effect has not participated in a damage job. The first active round uses the initial value.
+
+`value_evolution` does not have complete value validation. Unknown types or steps leave the value unchanged; negative decay amounts can increase a value; `pct_decay` amounts of 100 or more clamp the retained factor to zero. Treat only non-negative values with the listed types/steps as supported authoring input. Passive effects reject evolution entirely.
+
+## Special effect types
+
+### `extra_skill_attack`
+
+This creates an active effect that can be used by eligible normal attacks. `units.applies_to`/`applies_vs` gate the parent normal attack; they do not by themselves define the generated jobs. After the parent normal job is calculated, each `trigger_damage_jobs` entry expands into zero or more immediate `kind: "skill"` jobs.
+
+`value` becomes the generated job's source multiplier: `100` means `1.0`, `25` means `0.25`, and `200` means `2.0`. The result is a **new full damage calculation**, not that percentage of the parent normal job's final kills or raw damage. It uses the generated source/target pair, round-start troop counts, the ordinary static factors, general active modifiers, and skill-only type modifiers. Normal-only type modifiers do not apply.
+
+Requirements:
+
+- `trigger_damage_jobs` must be a non-empty array;
+- every job must have `source` and `target`;
+- `same_effect_stacking` is not allowed;
+- generated jobs do not trigger `attack` skills recursively;
+- every generated job that actually runs advances its dealer's attack counter and taker's received counter when the round commits;
+- each live applicable extra-attack effect runs independently; there is no max/add suppression between them.
+
+Each job has this shape:
+
+```json
+{
+ "source": "use.source",
+ "target": "enemy.living"
+}
+```
+
+Supported `source` and `target` selectors are:
+
+| Selector | Meaning |
+| --- | --- |
+| `use.source` | Dealer of the normal attack that is using the effect. |
+| `use.target` | Taker of that normal attack. |
+| `effect.applies_to` | Every unit type in the effect's resolved `applies_to` scope. |
+| `effect.applies_vs` | Every unit type in the effect's resolved `applies_vs` scope. For a target selector, `applies_vs` must be concrete: a unit/list or trigger target, not unrestricted `any`. |
+| `self.living` | Every troop type with a positive round-start count on the normal dealer's side. |
+| `enemy.living` | Every troop type with a positive round-start count on the normal target's side. |
+| A canonical unit string | `infantry`, `lancer`, or `marksman` on the normal dealer's side when used as `source`, or normal target's side when used as `target`. Unlike general unit scopes, trigger-job validation requires these exact strings. |
+| A canonical unit array | Every listed canonical unit on the same role-dependent side. The array must be non-empty. |
+
+If selectors produce multiple sources and targets, the simulator considers their Cartesian product. Sources or targets with zero round-start troops are skipped. In normal PvP mode, a target already exhausted by earlier same-round jobs is also skipped before calculation. For example, one source and `enemy.living` can produce up to three skill jobs, but fewer may actually run.
+
+The job selector determines the actual side as well as the unit. The runtime does not enforce that a generated source and target are opponents, so scopes misconfigured onto the same side can generate friendly-fire or same-side jobs.
+
+The extra-attack effect is charged once for the parent normal attack if at least one of its jobs runs. If its `value` is non-positive, every job lacks living source/target troops, or every job is skipped because its target is already exhausted, the effect is not charged and can remain available.
+
+All jobs emitted by one use read the parent extra-attack effect's same current `value`; its use/evolution is charged only after those jobs finish. Other attack-limited **modifier** effects are different: each generated skill job is a separate modifier use, and modifiers are charged after each job, so a one-job modifier can expire before the second target in the same extra attack is calculated.
+
+### `dodge`
+
+Cancels a normal attack based on the **taker**:
+
+- `applies_to` is the troop line dodging/being targeted;
+- `applies_vs` is the attacking troop line;
+- chance belongs on the skill's `trigger.probability`; it decides whether the control effect is created, not a fresh dodge roll on each later attack;
+- `value` is ignored.
+
+### `no_attack`
+
+Cancels a normal attack based on the **dealer**:
+
+- `applies_to` is the troop line prevented from attacking;
+- `applies_vs` is the would-be target;
+- chance belongs on the skill's `trigger.probability` when needed; it decides whether the control effect is created;
+- `value` is ignored.
+
+Attack-triggered controls are activated during the all-triggers phase and can cancel the same normal intent that triggered them. All matching attack skills have already attempted before the cancellation is applied.
+
+If both a no-attack and dodge control apply to the same normal attack, no-attack wins. If several live controls of the same type match, the last one encountered is reported as the winning control; matching attack-limited controls can still be charged together. `same_effect_stacking` does not select among controls.
+
+**One-use control gotcha.** Control applicability is determined for every normal intent in the round before any of those intents is processed and charges a use. A broad control with `attacks.count: 1` can therefore already be attached to several same-round intents and cancel all of them, even though its first cancellation expires it from the live index. The current one-use Reina dodge is narrowed to its concrete triggering dealer/target pair, but the generic duration shape does not itself guarantee only one cancellation per round.
+
+A cancelled normal attack emits no normal damage job and no extra-skill jobs, but it still advances the dealer's normal attack counter and the target's received counter once when the round commits.
+
+### `attack_order`
+
+Overrides target preference for matching normal attacks. Although no current hero definition uses it, it is a supported native effect type.
+
+```json
+{
+ "type": "attack_order",
+ "value": ["marksman", "lancer", "infantry"],
+ "units": { "applies_to": "self.any" }
+}
+```
+
+`value` must be a non-empty all-string array of valid unit names. Because all-string arrays are preserved by level resolution, this is one fixed order for every skill level, not a per-level value array. The simulator chooses the first unit in the array with positive round-start troops. Unlisted unit types are not appended as fallbacks; if none of the listed types is alive, no normal intent is created for that dealer.
+
+Only `units.applies_to` gates which dealer troop lines use the order. `applies_vs` is not consulted by attack-order target selection, even though the current troop-skill `Ambusher` definition supplies it.
+
+Target order is resolved before attack triggers for the round. Orders activated at battle start or round start can affect that round; an order activated by an `attack` trigger cannot affect the already-created intents until a later round. When multiple live orders match a dealer, the first ready effect in runtime insertion order wins and later orders are not consulted. A per-unit turn trigger may use an active order to select its synthetic target, but doing so does not consume an order use. Duration and attack delay/count otherwise work as described above.
+
+## Values currently present in hero definitions
+
+The engine accepts some values not currently used by a hero. This inventory distinguishes the data that exists in this directory from the wider native effect vocabulary documented above.
+
+| Field | Values currently present |
+| --- | --- |
+| `hero_generation` | `SR`, `S1`, `S1_natalia`, `S1_jeronimo`, `S2` through `S8` |
+| `troop_type` | `infantry`, `lancer`, `marksman` |
+| `requirements[].type/value` | `engagement_type` with `rally` or `garrison`, always beginning at level 1 |
+| `trigger.type` | `pre_battle`, `battle_start`, `turn`, `attack` |
+| `trigger.source` | omitted, `infantry`, `lancer`, `marksman`, `self.any`, `self.all`, `enemy.any` |
+| `trigger.target` | omitted or `self.any` |
+| `trigger.probability` | omitted or a five-level numeric percentage array |
+| `trigger.first` | omitted, 4, or 5 |
+| `trigger.every` | omitted, 2, 3, 4, 5, or 6 |
+| `units.applies_to` | omitted; unit strings/arrays; `all`; `enemy.any`, `enemy.infantry`, `enemy.marksman`; `target`; `trigger.source`; `trigger.target` |
+| `units.applies_vs` | omitted; `any`; unit strings/arrays; `self.any`; `trigger.source`; `trigger.target` |
+| `duration.turns` | `count` 1–3; optional `delay: 1`; or `delay: 1` with no count |
+| `duration.attacks` | `count: 1` or `count: 10`; no hero currently uses `attacks.delay` |
+| `same_effect_stacking` | omitted, `add`, or `max` |
+| `value_evolution` | only `{ "type": "pct_decay", "step": "attack", "value": 15 }` |
+| `trigger_damage_jobs[].source` | `use.source` |
+| `trigger_damage_jobs[].target` | `use.target`, `effect.applies_vs`, `enemy.living`, `["lancer"]`, or `["marksman"]` |
+| metadata | `status`/`notes` occur only on Reina's `SwiftJive`; aliases occur only for Ling and Lumak |
+
+Current hero modifier `type` values are:
+
+- passive: `passive.attack.up`, `passive.defense.up`, `passive.health.up`, `passive.lethality.up`;
+- active stat: `active.hero.attack.up`, `active.hero.attack.down`, `active.hero.defense.up`, `active.hero.defense.down`, `active.hero.health.up`, `active.hero.health.down`, `active.hero.lethality.up`;
+- active damage: `active.hero.damage.up`, `active.hero.damage.down`, `active.hero.damageTaken.up`, `active.hero.damageTaken.down`;
+- damage-kind-specific: `type.normal.damage.up`, `type.normal.damageTaken.down`, `type.skill.damage.up`, `type.skill.damageTaken.down`;
+- special: `dodge`, `no_attack`, `extra_skill_attack`.
+
+No hero currently uses `active.troop.*`, `type.all.*`, the other registered `type.normal.*`/`type.skill.*` directions, or `attack_order`; those are supported by the shared native engine and used or tested outside this directory.
+
+## Validation and ignored data
+
+The loader performs focused validation, but the JSON is cast into TypeScript interfaces rather than validated by a closed JSON schema. Consequences:
+
+- legacy `legacy`, `effect_op`, and `effect_type` fields are rejected;
+- `trigger.first`, static-passive restrictions, bucket values, duration shapes, attack-order values, and extra-skill job shapes receive focused validation;
+- `trigger.every`, trigger types/selectors, evolution fields, and many top-level/skill-level fields do not receive equivalent closed validation;
+- malformed duration and `trigger_damage_jobs` keys are rejected;
+- unknown effect types are added to `config.diagnostics.unsupportedEffects`, but loading does not throw solely for that reason. The skill can still report effect activation even though the unknown effect is not indexed and changes no combat mechanic;
+- some unknown or misspelled fields outside tightly validated objects can be silently ignored;
+- `description`, `notes`, and `status` never override runtime behaviour.
+
+The current 33 hero files load with zero unsupported-effect diagnostics and one ambiguity diagnostic: Sonya `TorrentialImpact/2`, described in the units section.
+
+After editing definitions, at minimum load the config and inspect both diagnostics arrays, then run the simulator tests and a focused trace that exercises the relevant timing/scope interaction. A definition being valid JSON—or a description sounding plausible—is not evidence that the mechanic behaves as intended.
+
+## Implementation reference
+
+The behaviour described here is owned primarily by:
+
+- `simulator/src/config.ts` — loading, aliases, diagnostics, and config validation;
+- `simulator/src/resolve.ts` — hero lookup, skill ordering/levels, requirements, and generation-stat baking;
+- `simulator/src/effects.ts` — trigger matching, probability, selectors, durations, stacking defaults, and value evolution;
+- `simulator/src/runtimeSkills.ts` — trigger scheduling and prepared modifier groups;
+- `simulator/src/simulator.ts` — round/attack phases, controls, extra-skill jobs, counters, and use charging;
+- `simulator/src/damageBuckets.ts` and `simulator/src/damage.ts` — effect-type buckets and damage arithmetic;
+- `simulator/src/staticDamageProfile.ts` — static/passive contribution selection.
Index: simulator/config/hero_definitions/Reina.json
===================================================================
--- simulator/config/hero_definitions/Reina.json prev run
+++ simulator/config/hero_definitions/Reina.json this run
@@ -89,9 +89,8 @@
"turns": {
"count": 1
}
},
- "same_effect_stacking": "max",
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
@@ -102,9 +101,9 @@
},
"LancersEdge": {
"description": "Widget bonus: Increase Lethality by X% for all troops when attacking",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Renee.json
===================================================================
--- simulator/config/hero_definitions/Renee.json prev run
+++ simulator/config/hero_definitions/Renee.json this run
@@ -11,19 +11,19 @@
"source": "lancer"
},
"effects": {
"NightmareTrace/1": {
- "type": "active.hero.defense.down",
+ "type": "extra_skill_attack",
"value": [
40,
80,
120,
160,
200
],
"units": {
- "applies_to": "target",
- "applies_vs": "lancer"
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
},
"duration": {
"turns": {
"count": 1,
@@ -32,9 +32,14 @@
"attacks": {
"count": 1
}
},
- "same_effect_stacking": "max"
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
}
}
},
"Dreamcatcher": {
@@ -45,27 +50,32 @@
"source": "lancer"
},
"effects": {
"Dreamcatcher/1": {
- "type": "active.hero.defense.down",
+ "type": "extra_skill_attack",
"value": [
30,
60,
90,
120,
150
],
"units": {
- "applies_to": "target",
- "applies_vs": "lancer"
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
},
"duration": {
"turns": {
"count": 1,
"delay": 1
}
},
- "same_effect_stacking": "max"
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
}
}
},
"Dreamslice": {
@@ -76,34 +86,39 @@
"source": "lancer"
},
"effects": {
"Dreamslice/1": {
- "type": "active.hero.defense.down",
+ "type": "extra_skill_attack",
"value": [
15,
30,
45,
60,
75
],
"units": {
- "applies_to": "target",
- "applies_vs": "any"
+ "applies_to": "all",
+ "applies_vs": "trigger.target"
},
"duration": {
"turns": {
"count": 1,
"delay": 1
}
},
- "same_effect_stacking": "max"
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
}
}
},
"DreamfangRally": {
"description": "Widget bonus: Increase Lethality by X% for all troops when rallying",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Sonya.json
===================================================================
--- simulator/config/hero_definitions/Sonya.json prev run
+++ simulator/config/hero_definitions/Sonya.json this run
@@ -29,9 +29,9 @@
"source": "lancer"
},
"effects": {
"BountyTemptation/1": {
- "type": "active.hero.damage.up",
+ "type": "extra_skill_attack",
"value": [
15,
30,
45,
@@ -45,9 +45,15 @@
"duration": {
"attacks": {
"count": 1
}
- }
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
},
"BountyTemptation/2": {
"type": "active.hero.attack.up",
"value": [
@@ -74,9 +80,9 @@
"source": "lancer"
},
"effects": {
"TorrentialImpact/1": {
- "type": "active.hero.damage.up",
+ "type": "extra_skill_attack",
"value": [
50,
100,
150,
@@ -90,9 +96,15 @@
"duration": {
"attacks": {
"count": 1
}
- }
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
},
"TorrentialImpact/2": {
"type": "no_attack",
"units": {
@@ -109,9 +121,9 @@
},
"VortexTurret": {
"description": "Widget bonus: Increase Lethality by X% for all troops when defending.",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Wayne.json
===================================================================
--- simulator/config/hero_definitions/Wayne.json prev run
+++ simulator/config/hero_definitions/Wayne.json this run
@@ -28,9 +28,8 @@
"attacks": {
"count": 1
}
},
- "same_effect_stacking": "max",
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "use.target"
@@ -145,9 +144,9 @@
},
"TacticalEdge": {
"description": "Widget bonus: Increase Lethality by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/WuMing.json
===================================================================
--- simulator/config/hero_definitions/WuMing.json prev run
+++ simulator/config/hero_definitions/WuMing.json this run
@@ -79,9 +79,9 @@
},
"IronBastion": {
"description": "Widget bonus: Increase Defense by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/hero_definitions/Zinman.json
===================================================================
--- simulator/config/hero_definitions/Zinman.json prev run
+++ simulator/config/hero_definitions/Zinman.json this run
@@ -58,9 +58,9 @@
},
"Woodpecker": {
"description": "Widget bonus: Increase Attack by X% for all troops when defending",
"trigger": {
- "type": "battle_start"
+ "type": "pre_battle"
},
"requirements": [
{
"level": 1,
Index: simulator/config/troop_skills.json
===================================================================
--- simulator/config/troop_skills.json prev run
+++ simulator/config/troop_skills.json this run
@@ -61,9 +61,9 @@
}
}
},
"CrystalShield": {
- "description": "Infantry has a chance to reduce damage when under attack.",
+ "description": "The fire crystal energy attached to the surface makes the shield impregnable and grants it X% chance of offsetting 36 damage",
"troop_type": "infantry",
"requirements": [
{
"level": 1,
Index: simulator/src/battleInputBuilder.ts
===================================================================
--- simulator/src/battleInputBuilder.ts prev run
+++ simulator/src/battleInputBuilder.ts this run
@@ -1,15 +1,15 @@
import type { BattleInput, FighterInput, SideId, SimulatorConfig } from "./types";
-import { applyHeroGenerationStats } from "./resolve";
+import { applyHeroGenerationStats } from "./fighterResolution";
/**
* 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.
+ * each main hero's generation stats into that hero's troop-type 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;
Index: simulator/src/calibration.ts
===================================================================
--- simulator/src/calibration.ts prev run
+++ simulator/src/calibration.ts this run
@@ -1,267 +0,0 @@
-import { readdirSync, readFileSync, statSync } from "node:fs";
-import { dirname, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-
-export interface CalibrationCaseComparison {
- file: string;
- testcaseId: string;
- idx: number;
- nSim?: number;
- muSim?: number;
- sigmaSim?: number;
- nGame?: number;
- muGame?: number;
- sigmaGame?: number;
- biasRaw?: number;
- biasPct?: number;
- sem?: number;
- p?: number | null;
- q?: number | null;
- statType?: string;
- passes?: boolean;
-}
-
-export interface SampleStats {
- n: number;
- mu: number;
- sigma: number;
- sem: number;
- samples?: number[];
-}
-
-export interface DistributionCompatibility {
- biasRaw?: number;
- biasPct?: number;
- z?: number;
- passes?: boolean;
- statType: "deterministic" | "distribution" | "unmatched";
-}
-
-export interface CalibrationComparison {
- calibrationAvailable: boolean;
- reportPath?: string;
- startedAt?: string;
- finishedAt?: string;
- gitSha?: string;
- totalReferenceCases: number;
- referencePassedCases: number;
- referencePassRate: number;
- thresholds?: Record<string, number>;
- cases: CalibrationCaseComparison[];
- table: CalibrationComparisonRow[];
-}
-
-export interface CalibrationComparisonRow extends CalibrationCaseComparison {
- matched: boolean;
- referencePasses?: boolean;
- referenceBiasPct?: number;
- simulatorScoreDelta?: number;
- simulator?: SampleStats;
- simulatorVsBaseline?: DistributionCompatibility;
- simulatorVsGame?: DistributionCompatibility;
- simulatorVsGameRaw?: number;
- simulatorVsGamePct?: number;
- simulatorPasses?: boolean;
- simulatorN?: number;
- simulatorMu?: number;
- simulatorSigma?: number;
- simulatorSem?: number;
- simulatorVsBaselineBiasRaw?: number;
- simulatorVsBaselineBiasPct?: number;
- simulatorVsBaselineZ?: number;
- simulatorVsBaselinePasses?: boolean;
- simulatorVsGameBiasRaw?: number;
- simulatorVsGameBiasPct?: number;
- simulatorVsGameZ?: number;
- simulatorVsGamePasses?: boolean;
-}
-
-export function defaultCalibrationDir(): string {
- return resolve(dirname(fileURLToPath(import.meta.url)), "..", "testcase_results");
-}
-
-export function loadCalibrationComparison(path?: string): CalibrationComparison {
- const reportPath = path ? resolve(path) : latestCalibrationReportPath(defaultCalibrationDir());
- if (!reportPath || !fileExists(reportPath)) return emptyCalibrationComparison();
-
- const parsed = JSON.parse(readFileSync(reportPath, "utf8")) as CalibrationReportJson;
- const cases = Object.values(parsed.testcases ?? {}).map(normalizeCalibrationCase).filter((entry): entry is CalibrationCaseComparison => !!entry);
- const referencePassedCases = cases.filter((entry) => entry.passes).length;
- return {
- calibrationAvailable: true,
- reportPath,
- startedAt: stringOrUndefined(parsed.started_at),
- finishedAt: stringOrUndefined(parsed.finished_at),
- gitSha: stringOrUndefined(parsed.git_sha),
- totalReferenceCases: cases.length,
- referencePassedCases,
- referencePassRate: cases.length > 0 ? referencePassedCases / cases.length : 0,
- thresholds: normalizeThresholds(parsed.thresholds),
- cases,
- table: []
- };
-}
-
-export function readCalibrationCase(
- comparison: CalibrationComparison,
- testcaseFile: string,
- testcaseId: string,
- options: { index?: number } = {}
-): CalibrationCaseComparison | undefined {
- if (!comparison.calibrationAvailable) return undefined;
- const files = new Set(testcaseFileLookupVariants(testcaseFile));
- return comparison.cases.find((entry) => {
- if (entry.testcaseId !== testcaseId) return false;
- if (options.index !== undefined && entry.idx !== options.index) return false;
- return testcaseFileLookupVariants(entry.file).some((variant) => files.has(variant));
- });
-}
-
-export function testcaseFileLookupVariants(path: string): string[] {
- const normalized = normalizePath(path);
- const variants = new Set<string>([normalized]);
- // Current parity reports may embed "simulator/testcases/"; both that form and
- // the root "testcases/" form normalize to the canonical testcase id.
- for (const prefix of ["simulator/testcases/"]) {
- const idx = normalized.indexOf(prefix);
- if (idx >= 0) variants.add(`testcases/${normalized.slice(idx + prefix.length)}`);
- }
- const testcaseIndex = normalized.indexOf("testcases/");
- if (testcaseIndex >= 0) {
- const testcasePath = normalized.slice(testcaseIndex);
- variants.add(testcasePath);
- variants.add(`simulator/${testcasePath}`);
- }
- return [...variants];
-}
-
-export function sampleStats(samples: number[], options: { includeSamples?: boolean } = {}): SampleStats {
- const n = samples.length;
- const mu = n > 0 ? samples.reduce((sum, value) => sum + value, 0) / n : 0;
- const variance = n > 1 ? samples.reduce((sum, value) => sum + (value - mu) ** 2, 0) / (n - 1) : 0;
- const sigma = Math.sqrt(variance);
- const sem = n > 0 ? sigma / Math.sqrt(n) : 0;
- return {
- n,
- mu,
- sigma,
- sem,
- ...(options.includeSamples ? { samples: [...samples] } : {})
- };
-}
-
-function latestCalibrationReportPath(dir: string): string | undefined {
- if (!directoryExists(dir)) return undefined;
- const candidates = readdirSync(dir)
- .filter((name) => name.endsWith(".json"))
- .map((name) => resolve(dir, name))
- .filter((path) => statSync(path).isFile())
- .map((path) => ({ path, finishedAt: calibrationFinishedAt(path) }))
- .sort((a, b) => b.finishedAt - a.finishedAt || a.path.localeCompare(b.path));
- return candidates[0]?.path;
-}
-
-function fileExists(path: string): boolean {
- try {
- return statSync(path).isFile();
- } catch {
- return false;
- }
-}
-
-function directoryExists(path: string): boolean {
- try {
- return statSync(path).isDirectory();
- } catch {
- return false;
- }
-}
-
-function calibrationFinishedAt(path: string): number {
- try {
- const parsed = JSON.parse(readFileSync(path, "utf8")) as { finished_at?: unknown };
- const time = Date.parse(String(parsed.finished_at ?? ""));
- return Number.isFinite(time) ? time : 0;
- } catch {
- return 0;
- }
-}
-
-function normalizeCalibrationCase(value: unknown): CalibrationCaseComparison | undefined {
- if (!value || typeof value !== "object") return undefined;
- const object = value as Record<string, unknown>;
- const file = stringOrUndefined(object.file);
- const testcaseId = stringOrUndefined(object.testcase_id ?? object.testcaseId);
- if (!file || !testcaseId) return undefined;
- return {
- file: normalizePath(file),
- testcaseId,
- idx: numberOrDefault(object.idx, 0),
- nSim: numberOrUndefined(object.n_sim),
- muSim: numberOrUndefined(object.mu_sim),
- sigmaSim: numberOrUndefined(object.sigma_sim),
- nGame: numberOrUndefined(object.n_game),
- muGame: numberOrUndefined(object.mu_game),
- sigmaGame: numberOrUndefined(object.sigma_game),
- biasRaw: numberOrUndefined(object.bias_raw),
- biasPct: numberOrUndefined(object.bias_pct),
- sem: numberOrUndefined(object.sem),
- p: nullableNumber(object.p),
- q: nullableNumber(object.q),
- statType: stringOrUndefined(object.stat_type),
- passes: booleanOrUndefined(object.passes)
- };
-}
-
-function emptyCalibrationComparison(): CalibrationComparison {
- return {
- calibrationAvailable: false,
- totalReferenceCases: 0,
- referencePassedCases: 0,
- referencePassRate: 0,
- cases: [],
- table: []
- };
-}
-
-function normalizeThresholds(value: unknown): Record<string, number> | undefined {
- if (!value || typeof value !== "object") return undefined;
- const entries = Object.entries(value as Record<string, unknown>)
- .map(([key, raw]) => [key, numberOrUndefined(raw)] as const)
- .filter((entry): entry is readonly [string, number] => entry[1] !== undefined);
- return entries.length > 0 ? Object.fromEntries(entries) : undefined;
-}
-
-function normalizePath(path: string): string {
- return path.replaceAll("\\", "/");
-}
-
-function stringOrUndefined(value: unknown): string | undefined {
- return typeof value === "string" && value.length > 0 ? value : undefined;
-}
-
-function numberOrUndefined(value: unknown): number | undefined {
- const number = Number(value);
- return Number.isFinite(number) ? number : undefined;
-}
-
-function nullableNumber(value: unknown): number | null | undefined {
- if (value === null) return null;
- return numberOrUndefined(value);
-}
-
-function numberOrDefault(value: unknown, fallback: number): number {
- return numberOrUndefined(value) ?? fallback;
-}
-
-function booleanOrUndefined(value: unknown): boolean | undefined {
- return typeof value === "boolean" ? value : undefined;
-}
-
-interface CalibrationReportJson {
- started_at?: unknown;
- finished_at?: unknown;
- git_sha?: unknown;
- thresholds?: unknown;
- testcases?: Record<string, unknown>;
-}
Index: simulator/src/classifier.ts
===================================================================
--- simulator/src/classifier.ts prev run
+++ simulator/src/classifier.ts this run
@@ -1,67 +0,0 @@
-import type { ActiveEffect, DamageJob, SideId, UnitType } from "./types";
-import { unitMaskHas } from "./types";
-import { bucketDefinition, passiveBucketRole, type BucketName, type PassiveBucket } from "./damageBuckets";
-
-export interface Classification {
- kind: "bucket" | "control" | "extra_skill_attack" | "battle_order" | "report_only";
- bucket?: BucketName | PassiveBucket;
- control?: "dodge" | "no_attack";
- reason?: string;
-}
-
-export function classifyEffectForJob(effect: ActiveEffect, job: DamageJob): Classification | undefined {
- const type = effect.intent.type;
- if (type === "dodge" || type === "no_attack") {
- if (!controlEffectApplies(effect, job, type)) return { kind: "report_only", reason: "not_applicable_to_job" };
- return { kind: "control", control: type };
- }
-
- if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };
- if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };
- if (type === "attack_order") return { kind: "battle_order" };
-
- const definition = bucketDefinition(type);
- const passiveRole = passiveBucketRole(type);
- if ((!definition || definition.valueType !== "pct") && !passiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };
- if (passiveRole) {
- if (passiveRole === "dealer" && effect.appliesTo.side === job.dealerSide) return { kind: "bucket", bucket: type as PassiveBucket };
- if (passiveRole === "taker" && effect.appliesTo.side === job.takerSide) return { kind: "bucket", bucket: type as PassiveBucket };
- return { kind: "report_only", reason: unsupportedReason(effect, job) };
- }
- if (!definition) return { kind: "report_only", reason: unsupportedReason(effect, job) };
- if (definition.phase !== "dynamic") return { kind: "report_only", reason: unsupportedReason(effect, job) };
- if (definition.appliesTo !== undefined && definition.appliesTo !== job.kind) return { kind: "report_only", reason: "not_applicable_to_job_kind" };
- if (definition.role === "dealer" && effect.appliesTo.side === job.dealerSide) return { kind: "bucket", bucket: definition.path };
- if (definition.role === "taker" && effect.appliesTo.side === job.takerSide) return { kind: "bucket", bucket: definition.path };
- return { kind: "report_only", reason: unsupportedReason(effect, job) };
-}
-
-export function basicEffectApplies(effect: ActiveEffect, job: DamageJob): boolean {
- const affectedUnit = unitForSide(effect.appliesTo.side, job);
- if (!affectedUnit || !unitMaskHas(effect.appliesTo.units, affectedUnit)) return false;
- const opposingUnit = unitForSide(effect.appliesVs.side, job);
- if (!opposingUnit || !unitMaskHas(effect.appliesVs.units, opposingUnit)) return false;
- return true;
-}
-
-function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {
- const appliesToSide = control === "no_attack" ? job.dealerSide : job.takerSide;
- const appliesToUnit = control === "no_attack" ? job.dealerUnit : job.takerUnit;
- if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;
-
- const appliesVsSide = control === "no_attack" ? job.takerSide : job.dealerSide;
- const appliesVsUnit = control === "no_attack" ? job.takerUnit : job.dealerUnit;
- return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);
-}
-
-function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {
- if (effect.appliesTo.side === job.dealerSide) return "unsupported_dealer_effect";
- if (effect.appliesTo.side === job.takerSide) return "unsupported_taker_effect";
- return "wrong_side";
-}
-
-function unitForSide(side: SideId, job: DamageJob): UnitType | undefined {
- if (side === job.dealerSide) return job.dealerUnit;
- if (side === job.takerSide) return job.takerUnit;
- return undefined;
-}
Index: simulator/src/classifierDamage.test.ts
===================================================================
--- simulator/src/classifierDamage.test.ts prev run
+++ simulator/src/classifierDamage.test.ts this run
@@ -1,831 +0,0 @@
-import assert from "node:assert/strict";
-import { test } from "node:test";
-
-import { classifyEffectForJob } from "./classifier";
-import { calculateDamageJob, createDamageScratch, evaluateDamageExpression } from "./damage";
-import { ATOMIC_BUCKETS, DYNAMIC_BUCKETS, PASSIVE_BUCKETS, STATIC_BUCKETS, type BucketPlacement, type BucketRole } from "./damageBuckets";
-import { createEffectIndex, damageShapeSlotsForEffect, indexEffect } from "./effectIndex";
-import { activateEffect, evolvingActiveEffectValuePct, resolvedEffectScopeKey } from "./effects";
-import { buildStaticDamageBucketFactors, buildStaticDamageProfile } from "./staticDamageProfile";
-import { createRecorder, type BattleRecorder } from "./recorder";
-import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";
-import { ALL_UNIT_MASK, unitMask } from "./types";
-
-const job: DamageJob = {
- id: "job-1",
- round: 1,
- kind: "normal",
- sourceIntentId: "intent-1",
- roundStartTroops: {
- attacker: { infantry: 1000, lancer: 0, marksman: 0 },
- defender: { infantry: 0, lancer: 1000, marksman: 0 }
- },
- dealerSide: "attacker",
- dealerUnit: "infantry",
- takerSide: "defender",
- takerUnit: "lancer",
- sourceMultiplier: 1
-};
-
-test("resolved effect scope matches concrete side and unit masks", () => {
- const active: ActiveEffect = {
- id: "resolved-scope",
- source: { kind: "hero_skill", side: "attacker", heroName: "Example", skillId: "Scope", effectId: "scope/1" },
- intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },
- ownerSide: "attacker",
- kind: "modifier",
- bucketIndex: -1,
- initialValuePct: 25,
- getCurrentValuePct() { return this.initialValuePct; },
- appliesTo: { side: "attacker", units: unitMask(["infantry"]) },
- appliesVs: { side: "defender", units: unitMask(["lancer"]) },
- createdRound: 1,
- startRound: 1,
- duration: {},
- remainingAttackDelay: 0,
- uses: 0,
- sameEffectStacking: "add"
- };
-
- assert.equal(classifyEffectForJob(active, job)?.bucket, "active.hero.lethality.up");
- assert.equal(
- classifyEffectForJob(active, {
- ...job,
- id: "job-attacker-marksman",
- dealerUnit: "marksman"
- })?.reason,
- "not_applicable_to_job"
- );
- assert.equal(
- classifyEffectForJob(active, {
- ...job,
- id: "job-defender-marksman",
- takerUnit: "marksman"
- })?.reason,
- "not_applicable_to_job"
- );
-});
-
-function effect(
- type: string,
- ownerSide: "attacker" | "defender",
- valuePct = 25,
- sourceKind: ActiveEffect["source"]["kind"] = "hero_skill"
-): ActiveEffect {
- const source =
- sourceKind === "troop_skill"
- ? { kind: sourceKind, side: ownerSide, troopType: "infantry" as const, skillId: "Skill", effectId: `${type}/1` }
- : { kind: sourceKind, side: ownerSide, heroName: "Example", skillId: "Skill", effectId: `${type}/1` };
- return {
- id: `${type}-1`,
- source,
- intent: { id: `${type}/1`, type, value: [valuePct] },
- ownerSide,
- kind: "modifier",
- bucketIndex: -1,
- initialValuePct: valuePct,
- getCurrentValuePct() { return this.initialValuePct; },
- appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },
- appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },
- createdRound: 1,
- startRound: 1,
- duration: {},
- remainingAttackDelay: 0,
- uses: 0,
- sameEffectStacking: "add"
- };
-}
-
-function calculateIndexedDamageJob(
- damageJob: DamageJob,
- fighters: Record<"attacker" | "defender", ResolvedFighter>,
- effects: ActiveEffect[],
- options: Omit<Partial<Parameters<typeof calculateDamageJob>[2]>, "recorder"> & { recorder?: BattleRecorder; trace?: boolean } = {}
-) {
- const effectIndex = options.effectIndex ?? preparedEffectIndex(effects);
- if (!options.effectIndex) {
- for (const activeEffect of effects) {
- if (activeEffect.damageIndexGroup) indexEffect(effectIndex, activeEffect);
- }
- }
- const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
- const usedEffects = options.usedEffects ?? [];
- const recorder = options.recorder ?? createRecorder(options.trace === true ? "trace" : "standard", [], () => {
- throw new Error("damage-only test recorder has no battle resolution");
- });
- recorder.recordStaticProfile(fighters, effects);
- const { trace: _trace, ...damageOptions } = options;
- const result = calculateDamageJob(damageJob, fighters, { ...damageOptions, recorder, effectIndex, staticDamageProfile, usedEffects });
- return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };
-}
-
-function preparedEffectIndex(effects: ActiveEffect[]): ReturnType<typeof createEffectIndex> {
- const groups: NonNullable<ActiveEffect["damageIndexGroup"]>[] = [];
- const byShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][] = Array.from({ length: 72 }, () => []);
- const byResolvedGroup = new Map<string, NonNullable<ActiveEffect["damageIndexGroup"]>>();
- for (const activeEffect of effects) {
- const slots = damageShapeSlotsForEffect(activeEffect);
- if (slots.length === 0) continue;
- const key = `${activeEffect.stackingKey ?? activeEffect.id}:${resolvedEffectScopeKey(activeEffect.appliesTo, activeEffect.appliesVs)}`;
- let group = byResolvedGroup.get(key);
- if (!group) {
- group = {
- effects: [],
- bucketIndex: ATOMIC_BUCKETS.indexOf(activeEffect.intent.type as never),
- sameEffectStacking: activeEffect.sameEffectStacking
- };
- byResolvedGroup.set(key, group);
- groups.push(group);
- for (const slot of slots) byShape[slot].push(group);
- }
- activeEffect.damageIndexGroup = group;
- }
- return createEffectIndex(groups, byShape);
-}
-
-test("classifier routes up/down effects into neutral atomic buckets", () => {
- assert.equal(classifyEffectForJob(effect("active.hero.health.up", "defender"), job)?.bucket, "active.hero.health.up");
- assert.equal(classifyEffectForJob(effect("active.hero.health.down", "defender"), job)?.bucket, "active.hero.health.down");
- assert.equal(classifyEffectForJob(effect("active.hero.attack.up", "attacker"), job)?.bucket, "active.hero.attack.up");
- assert.equal(classifyEffectForJob(effect("active.hero.attack.down", "attacker"), job)?.bucket, "active.hero.attack.down");
-});
-
-test("classifier keeps hero and troop active effects in separate atomic buckets", () => {
- assert.equal(classifyEffectForJob(effect("active.hero.lethality.up", "attacker", 20, "hero_skill"), job)?.bucket, "active.hero.lethality.up");
- assert.equal(classifyEffectForJob(effect("active.troop.lethality.up", "attacker", 20, "troop_skill"), job)?.bucket, "active.troop.lethality.up");
- assert.equal(classifyEffectForJob(effect("active.hero.lethality.up", "attacker", 10, "hero_skill"), job)?.bucket, "active.hero.lethality.up");
- assert.equal(classifyEffectForJob(effect("active.troop.lethality.up", "attacker", 10, "troop_skill"), job)?.bucket, "active.troop.lethality.up");
-});
-
-test("classifier routes the complete native bucket policy into atomic buckets", () => {
- const atomicBuckets = new Set<string>(ATOMIC_BUCKETS);
- const expected = new Map([
- ["active.hero.lethality.up", "active.hero.lethality.up"],
- ["active.hero.lethality.down", "active.hero.lethality.down"],
- ["active.hero.attack.up", "active.hero.attack.up"],
- ["active.hero.attack.down", "active.hero.attack.down"],
- ["active.hero.lethality.up", "active.hero.lethality.up"],
- ["active.hero.lethality.down", "active.hero.lethality.down"],
- ["active.hero.damage.up", "active.hero.damage.up"],
- ["active.hero.damage.down", "active.hero.damage.down"],
- ["active.troop.damage.up", "active.troop.damage.up"],
- ["active.troop.damage.down", "active.troop.damage.down"],
- ["type.normal.damage.up", "type.normal.damage.up"],
- ["type.normal.damage.down", "type.normal.damage.down"],
- ["type.skill.damage.up", "type.skill.damage.up"],
- ["type.skill.damage.down", "type.skill.damage.down"],
- ["type.all.damage.up", "type.all.damage.up"],
- ["type.all.damage.down", "type.all.damage.down"],
- ["active.hero.defense.up", "active.hero.defense.up"],
- ["active.hero.defense.down", "active.hero.defense.down"],
- ["active.hero.health.up", "active.hero.health.up"],
- ["active.hero.health.down", "active.hero.health.down"],
- ["active.hero.damageTaken.up", "active.hero.damageTaken.up"],
- ["active.hero.damageTaken.down", "active.hero.damageTaken.down"],
- ["active.troop.damageTaken.up", "active.troop.damageTaken.up"],
- ["active.troop.damageTaken.down", "active.troop.damageTaken.down"],
- ["type.normal.damageTaken.up", "type.normal.damageTaken.up"],
- ["type.normal.damageTaken.down", "type.normal.damageTaken.down"],
- ["type.skill.damageTaken.up", "type.skill.damageTaken.up"],
- ["type.skill.damageTaken.down", "type.skill.damageTaken.down"]
- ]);
- const defenderEffectTypes = new Set([
- "active.hero.defense.up",
- "active.hero.defense.down",
- "active.hero.health.up",
- "active.hero.health.down",
- "active.hero.damageTaken.up",
- "active.hero.damageTaken.down",
- "active.troop.damageTaken.up",
- "active.troop.damageTaken.down",
- "type.normal.damageTaken.up",
- "type.normal.damageTaken.down",
- "type.skill.damageTaken.up",
- "type.skill.damageTaken.down"
- ]);
-
- for (const [type, expectedBucket] of expected) {
- const ownerSide = defenderEffectTypes.has(type) ? "defender" : "attacker";
- const classifierJob = type.startsWith("type.skill.") ? { ...job, id: "job-skill-route", kind: "skill" as const } : job;
- const bucket = classifyEffectForJob(effect(type, ownerSide), classifierJob)?.bucket;
- assert.equal(bucket, expectedBucket, type);
- assert.ok(bucket !== undefined && atomicBuckets.has(bucket), `${type} route ${bucket} is not an atomic bucket`);
- }
-
- const passiveExpected = new Map([
- ["passive.attack.up", "passive.attack.up"],
- ["passive.lethality.up", "passive.lethality.up"],
- ["passive.health.up", "passive.health.up"],
- ["passive.defense.up", "passive.defense.up"]
- ]);
-
- for (const [type, expectedBucket] of passiveExpected) {
- const ownerSide = type.includes(".attack.") || type.includes(".lethality.") ? "attacker" : "defender";
- const bucket = classifyEffectForJob(effect(type, ownerSide), job)?.bucket;
- assert.equal(bucket, expectedBucket, type);
- assert.ok(bucket !== undefined && (PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a passive bucket`);
- }
-
- assert.equal(classifyEffectForJob(effect("passive.attack.down", "attacker", 5), job)?.bucket, "passive.attack.down");
-});
-
-test("passive stat bonuses only apply in the damage role that consumes that stat", () => {
- const reversedJob: DamageJob = {
- ...job,
- id: "job-reversed",
- dealerSide: "defender",
- dealerUnit: "lancer",
- takerSide: "attacker",
- takerUnit: "infantry"
- };
- const passiveAttack = effect("passive.attack.up", "attacker", 10);
- const passiveHealth = effect("passive.health.up", "defender", 10);
-
- assert.equal(classifyEffectForJob(passiveAttack, job)?.bucket, "passive.attack.up");
- assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_taker_effect");
- assert.equal(classifyEffectForJob(passiveHealth, job)?.bucket, "passive.health.up");
- assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_dealer_effect");
-});
-
-test("damage calculator requires indexed effect candidates", () => {
- assert.throws(() => calculateDamageJob(job, simpleFighters(), { trace: true } as never), /effectIndex/i);
-});
-
-test("damage calculator uses centralized bucket definitions for player stat routing", () => {
- const attacker: ResolvedFighter = {
- side: "attacker",
- name: "A",
- troops: { infantry: 1000, lancer: 0, marksman: 0 },
- initialTroops: { infantry: 1000, lancer: 0, marksman: 0 },
- troopDetails: {
- infantry: { id: "infantry_t1", type: "infantry", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
- },
- statBonuses: {
- infantry: { attack: 100, lethality: 0, defense: 0, health: 0 },
- lancer: { attack: 0, lethality: 0, defense: 0, health: 0 },
- marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
- },
- heroes: [],
- troopSkills: [],
- diagnostics: []
- };
- const defender: ResolvedFighter = {
- ...attacker,
- side: "defender",
- name: "D",
- troops: { infantry: 0, lancer: 1000, marksman: 0 },
- initialTroops: { infantry: 0, lancer: 1000, marksman: 0 },
- troopDetails: {
- lancer: { id: "lancer_t1", type: "lancer", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
- },
- statBonuses: {
- infantry: { attack: 0, lethality: 0, defense: 0, health: 0 },
- lancer: { attack: 0, lethality: 0, defense: 0, health: 100 },
- marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
- }
- };
-
- const outcome = calculateIndexedDamageJob(job, { attacker, defender }, [], { trace: true });
-
- assert.equal(outcome.trace?.atomicBuckets["player.attack"].totalPct, 100);
- assert.equal(outcome.trace?.atomicBuckets["player.health"].totalPct, 100);
- assert.ok(outcome.kills > 0);
- assert.ok(outcome.kills < 1000);
-});
-
-test("active stat-up effects multiply separately from player stat bonuses", () => {
- const fighters = simpleFighters();
- fighters.attacker.statBonuses.infantry.attack = 100;
-
- const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
- const withRuntimeAttackUp = calculateIndexedDamageJob(job, fighters, [effect("active.hero.attack.up", "attacker", 100)], { trace: true });
-
- assert.equal(baseline.trace?.atomicBuckets["player.attack"].totalPct, 100);
- assert.equal(baseline.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 0);
- assert.equal(withRuntimeAttackUp.trace?.atomicBuckets["player.attack"].totalPct, 100);
- assert.equal(withRuntimeAttackUp.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
- assert.equal(withRuntimeAttackUp.kills, baseline.kills * 2);
-});
-
-test("traced damage uses the effect index for applied bucket candidates", () => {
- const indexedEffect = effect("active.hero.attack.up", "attacker", 100);
- const index = preparedEffectIndex([indexedEffect]);
- indexEffect(index, indexedEffect);
-
- const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true, effectIndex: index });
-
- assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
- assert.equal(outcome.kills, baseline.kills * 2);
-});
-
-test("passive stat bonuses aggregate as up sum over down sum on top of player stats", () => {
- const fighters = simpleFighters();
- fighters.attacker.statBonuses.infantry.attack = 2015.2;
- const passiveAttack = (value: number): ActiveEffect => ({
- ...effect(value < 0 ? "passive.attack.down" : "passive.attack.up", "attacker", Math.abs(value)),
- id: `passive-attack-${value}`,
- intent: { id: `passive/attack/${value}`, type: value < 0 ? "passive.attack.down" : "passive.attack.up", value: Math.abs(value) }
- });
-
- const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
- const outcome = calculateIndexedDamageJob(job, fighters, [passiveAttack(20), passiveAttack(10), passiveAttack(-5)], { trace: true });
-
- assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);
- assert.equal(outcome.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
- assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.3);
- assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.down"].factor, 1.05);
- assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), Number((1.3 / 1.05).toFixed(6)));
-});
-
-test("static profile factoring is identical to evaluating its buckets unfactored", () => {
- const fighters = simpleFighters();
- fighters.attacker.statBonuses.infantry.attack = 50;
- fighters.attacker.statBonuses.infantry.lethality = 25;
- fighters.defender.statBonuses.lancer.health = 40;
- fighters.defender.statBonuses.lancer.defense = 10;
-
- const passives = [
- effect("passive.attack.up", "attacker", 20),
- effect("passive.attack.down", "attacker", 5),
- effect("passive.lethality.up", "attacker", 10),
- effect("passive.health.up", "defender", 15),
- effect("passive.health.down", "defender", 30),
- effect("passive.defense.up", "defender", 5)
- ];
- const runtimeEffects = [
- effect("active.hero.attack.up", "attacker", 15),
- effect("active.troop.damage.down", "attacker", 12),
- effect("active.hero.health.down", "defender", 8),
- effect("type.normal.damageTaken.up", "defender", 7)
- ];
- const allEffects = [...passives, ...runtimeEffects];
- const bucketFactors = buildStaticDamageBucketFactors(fighters, allEffects);
- const profile = buildStaticDamageProfile(fighters, allEffects);
- const scratch = createDamageScratch();
- const profiled = calculateIndexedDamageJob(job, fighters, allEffects, {
- trace: true,
- staticDamageProfile: profile,
- scratch,
- capToTakerTroops: false
- });
- const dealerBuckets = bucketFactors[job.dealerSide][job.dealerUnit];
- const takerBuckets = bucketFactors[job.takerSide][job.takerUnit];
- const dealer = profile[job.dealerSide][job.dealerUnit];
- const taker = profile[job.takerSide][job.takerUnit];
-
- assert.equal(
- dealer.dealerFactor.factors[0],
- unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") /
- unfactoredStaticProduct(dealerBuckets, "dealer", "denominator")
- );
- assert.equal(
- taker.takerFactor.factors[0],
- unfactoredStaticProduct(takerBuckets, "taker", "numerator") /
- unfactoredStaticProduct(takerBuckets, "taker", "denominator")
- );
- assert.equal(
- evaluateDamageExpression(dealer.dealerFactor, taker.takerFactor).factors[0],
- dealer.dealerFactor.factors[0] * taker.takerFactor.factors[0]
- );
- const unfactoredStaticNumerator =
- unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") *
- unfactoredStaticProduct(takerBuckets, "taker", "numerator");
- const unfactoredStaticDenominator =
- unfactoredStaticProduct(dealerBuckets, "dealer", "denominator") *
- unfactoredStaticProduct(takerBuckets, "taker", "denominator");
- const unfactoredDamage =
- unfactoredDynamicProduct(scratch.factors, job.kind, "numerator") * unfactoredStaticNumerator /
- (100 * unfactoredDynamicProduct(scratch.factors, job.kind, "denominator") * unfactoredStaticDenominator);
-
- assert.ok(Math.abs(profiled.kills - unfactoredDamage) < 1e-12);
- assert.equal(profiled.trace?.atomicBuckets["player.attack"].totalPct, 50);
- assert.equal(profiled.trace?.atomicBuckets["passive.attack.up"].totalPct, 20);
- assert.equal(profiled.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
- assert.equal(profiled.trace?.atomicBuckets["passive.health.down"].totalPct, 30);
- assert.equal(profiled.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 15);
- assert.equal(profiled.trace?.atomicBuckets["type.normal.damageTaken.up"].totalPct, 7);
- assert.equal(profiled.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.2);
-});
-
-function unfactoredStaticProduct(
- factors: Float64Array,
- role: BucketRole,
- placement: BucketPlacement
-): number {
- return STATIC_BUCKETS.reduce(
- (product, definition, index) =>
- definition.role === role && definition.placement === placement ? product * factors[index] : product,
- 1
- );
-}
-
-function unfactoredDynamicProduct(
- factors: Float64Array,
- kind: DamageJob["kind"],
- placement: BucketPlacement
-): number {
- return DYNAMIC_BUCKETS.reduce(
- (product, definition, index) => {
- const appliesTo = "appliesTo" in definition ? definition.appliesTo : undefined;
- return definition.placement === placement && (!appliesTo || appliesTo === kind)
- ? product * factors[index]
- : product;
- },
- 1
- );
-}
-
-test("static damage profile preserves max stacking for duplicate passive effects", () => {
- const fighters = simpleFighters();
- const weaker = { ...effect("passive.attack.up", "attacker", 20), id: "weak", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };
- const stronger = { ...effect("passive.attack.up", "attacker", 50), id: "strong", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };
- const profile = buildStaticDamageProfile(fighters, [weaker, stronger]);
- const outcome = calculateIndexedDamageJob(job, fighters, [weaker, stronger], { trace: true, staticDamageProfile: profile });
-
- assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 50);
- assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].contributors.length, 1);
- assert.equal(outcome.kills, calculateIndexedDamageJob(job, fighters, [], { trace: true }).kills * 1.5);
-});
-
-test("default aggregation multiplies hero and troop active damage buckets", () => {
- const fighters = simpleFighters();
- const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
- const combined = calculateIndexedDamageJob(
- job,
- fighters,
- [effect("active.hero.lethality.up", "attacker", 20, "hero_skill"), effect("active.troop.lethality.up", "attacker", 10, "troop_skill")],
- { trace: true }
- );
-
- assert.equal(combined.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 20);
- assert.equal(combined.trace?.atomicBuckets["active.troop.lethality.up"].totalPct, 10);
- assert.equal(combined.trace?.aggregationGroups["active.hero.lethality.up"].factor, 1.2);
- assert.equal(combined.trace?.aggregationGroups["active.troop.lethality.up"].factor, 1.1);
- assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.1) < 1e-12);
-});
-
-test("multiplicative all-damage buckets compound instead of adding", () => {
- const fighters = simpleFighters();
- const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
- const combined = calculateIndexedDamageJob(
- job,
- fighters,
- [effect("type.all.damage.up", "attacker", 20), effect("type.all.damage.up", "attacker", 25)],
- { trace: true }
- );
-
- assert.equal(combined.trace?.atomicBuckets["type.all.damage.up"].totalPct, 50);
- assert.equal(combined.trace?.atomicBuckets["type.all.damage.up"].contributors.length, 2);
- assert.equal(combined.trace?.aggregationGroups["type.all.damage.up"].factor, 1.5);
- assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.25) < 1e-12);
-});
-
-test("damage-taken buckets use the expected damage direction", () => {
- const fighters = simpleFighters();
- const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
- const damageTakenUp = calculateIndexedDamageJob(job, fighters, [effect("active.hero.damageTaken.up", "defender", 25)], { trace: true });
- const damageTakenDown = calculateIndexedDamageJob(job, fighters, [effect("active.hero.damageTaken.down", "defender", 25)], { trace: true });
-
- assert.equal(damageTakenUp.trace?.aggregationGroups["active.hero.damageTaken.up"].placement, "numerator");
- assert.equal(damageTakenDown.trace?.aggregationGroups["active.hero.damageTaken.down"].placement, "denominator");
- assert.ok(Math.abs(damageTakenUp.kills - baseline.kills * 1.25) < 1e-12);
- assert.ok(Math.abs(damageTakenDown.kills - baseline.kills / 1.25) < 1e-12);
-});
-
-test("negative passive stat bonuses route to down buckets with positive factors", () => {
- const passiveHealthDown = {
- ...effect("passive.health.down", "defender", 105),
- intent: { id: "passive-health-down", type: "passive.health.down", value: 105 }
- };
- const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [passiveHealthDown], { trace: true });
-
- assert.equal(outcome.trace?.atomicBuckets["passive.health.down"].totalPct, 105);
- assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].factor, 2.05);
- assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].placement, "numerator");
- assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);
-});
-
-test("pass-specific buckets only apply to matching damage job kind", () => {
- const normalEffect = effect("type.normal.damage.up", "attacker", 100);
- const skillEffect = effect("type.skill.damage.up", "attacker", 100);
- const fighters = simpleFighters();
- const normalOutcome = calculateIndexedDamageJob(job, fighters, [normalEffect, skillEffect], { trace: true });
- const skillOutcome = calculateIndexedDamageJob({ ...job, id: "job-skill", kind: "skill", sourceMultiplier: 1 }, fighters, [normalEffect, skillEffect], {
- trace: true
- });
-
- assert.equal(normalOutcome.trace?.atomicBuckets["type.normal.damage.up"].totalPct, 100);
- assert.equal(normalOutcome.trace?.aggregationGroups["type.skill.damage.up"], undefined);
- assert.equal(skillOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);
- assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);
-});
-
-test("attack-duration bucket effects are charged by the applicable attack job", () => {
- const oneAttackEffect = {
- ...effect("active.hero.attack.up", "attacker", 100),
- id: "attack-up-active",
- duration: { attacks: { count: 1 } }
- };
-
- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
-
- assert.ok(outcome.usedEffectIds.includes("attack-up-active"));
- assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
-});
-
-test("turn-duration bucket effects are charged and visible on the attack outcome", () => {
- const turnEffect = {
- ...effect("active.hero.defense.down", "defender", 30),
- id: "bad-luck-like",
- source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },
- duration: { turns: { count: 1 } }
- };
-
- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });
-
- assert.ok(outcome.usedEffectIds.includes("bad-luck-like"));
- assert.deepEqual(outcome.appliedEffects, [
- {
- kind: "modifier",
- activeEffectId: "bad-luck-like",
- effectId: "BadLuckStreak/1",
- bucket: "active.hero.defense.down",
- valuePct: 30,
- source: "Example/Skill/BadLuckStreak/1",
- sourceSide: "defender",
- sameEffectStacking: "add"
- }
- ]);
-});
-
-test("effects are only charged when they participate in the calculation", () => {
- const defenderOutgoingBuff = {
- ...effect("active.hero.lethality.up", "defender", 100),
- id: "defender-outgoing-buff",
- duration: { attacks: { count: 1 } }
- };
-
- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
-
- assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);
- assert.equal(outcome.usedEffectIds.includes("defender-outgoing-buff"), false);
-});
-
-test("pct attack value evolution uses the effect use count for the current bucket value", () => {
- const decayedAttackUp = {
- ...effect("active.hero.attack.up", "attacker", 100),
- id: "decayed-attack-up",
- intent: {
- id: "active.hero.attack.up/decay",
- type: "active.hero.attack.up",
- value: 100,
- value_evolution: { type: "pct_decay", step: "attack", value: 15 }
- },
- duration: { attacks: { count: 10 } },
- valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },
- getCurrentValuePct: evolvingActiveEffectValuePct,
- uses: 2
- };
-
- const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [decayedAttackUp], { trace: true });
-
- assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);
- assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);
- assert.ok(outcome.usedEffectIds.includes("decayed-attack-up"));
-});
-
-test("pct turn value evolution starts decaying after the first active turn", () => {
- const turnDecayAttackUp = {
- ...effect("active.hero.attack.up", "attacker", 100),
- id: "turn-decay-attack-up",
- intent: {
- id: "active.hero.attack.up/turn_decay",
- type: "active.hero.attack.up",
- value: 100,
- value_evolution: { type: "pct_decay", step: "turn", value: 15 }
- },
- createdRound: 0,
- startRound: 0,
- valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },
- getCurrentValuePct: evolvingActiveEffectValuePct,
- duration: { attacks: { count: 10 } }
- };
-
- const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
- const secondTurn = calculateIndexedDamageJob({ ...job, round: 2 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
-
- assert.equal(firstTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
- assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);
-});
-
-test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {
- const weaker = {
- ...effect("active.hero.lethality.up", "attacker", 50),
- id: "max-weaker",
- duration: { attacks: { count: 3 } },
- stackingKey: "same-max-group",
- sameEffectStacking: "max" as const
- };
- const strongerButDecayed = {
- ...effect("active.hero.lethality.up", "attacker", 100),
- id: "max-stronger-decayed",
- intent: {
- id: "damage_up/decay",
- type: "active.hero.lethality.up",
- value: 100,
- value_evolution: { type: "pct_decay", step: "attack", value: 50 }
- },
- duration: { attacks: { count: 3 } },
- valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },
- getCurrentValuePct: evolvingActiveEffectValuePct,
- uses: 1,
- stackingKey: "same-max-group",
- sameEffectStacking: "max" as const
- };
-
- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [weaker, strongerButDecayed], { trace: true });
-
- assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);
- assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
- assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
-});
-
-test("max-stacked activations with disjoint resolved unit scopes apply independently", () => {
- const infantry = {
- ...effect("active.hero.lethality.up", "attacker", 40),
- id: "max-infantry",
- appliesTo: { side: "attacker" as const, units: unitMask("infantry") },
- stackingKey: "same-resolved-effect",
- sameEffectStacking: "max" as const
- };
- const lancer = {
- ...effect("active.hero.lethality.up", "attacker", 70),
- id: "max-lancer",
- appliesTo: { side: "attacker" as const, units: unitMask("lancer") },
- stackingKey: "same-resolved-effect",
- sameEffectStacking: "max" as const
- };
- const fighters = simpleFighters();
- const infantryOutcome = calculateIndexedDamageJob(job, fighters, [infantry, lancer], { trace: true });
- const lancerOutcome = calculateIndexedDamageJob({ ...job, id: "lancer-job", dealerUnit: "lancer" }, fighters, [infantry, lancer], { trace: true });
-
- assert.equal(infantryOutcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 40);
- assert.deepEqual(infantryOutcome.usedEffectIds, ["max-infantry"]);
- assert.equal(lancerOutcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 70);
- assert.deepEqual(lancerOutcome.usedEffectIds, ["max-lancer"]);
-});
-
-test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {
- const active = activateEffect(
- {
- id: "TargetSkill",
- name: "TargetSkill",
- sourceKind: "hero_skill",
- side: "defender",
- heroName: "Targeter",
- level: 1,
- trigger: { type: "attack", source: "enemy.any", target: "self.any" },
- effects: []
- },
- {
- id: "targeted-defense",
- type: "active.hero.defense.up",
- value: 50,
- sourceDefinition: { type: "active.hero.defense.up" },
- units: { applies_to: "trigger.target", applies_vs: "trigger.source" }
- },
- 1,
- {
- id: "intent",
- round: 1,
- source: "normal",
- dealerSide: "attacker",
- dealerUnit: "infantry",
- takerSide: "defender",
- takerUnit: "lancer",
- orderIndex: 0,
- previousAttackCount: 0,
- projectedAttackCount: 1,
- previousReceivedAttackCount: 0,
- projectedReceivedAttackCount: 1
- }
- );
-
- assert.deepEqual(active.appliesTo, { side: "defender", units: unitMask("lancer") });
- assert.deepEqual(active.appliesVs, { side: "attacker", units: unitMask("infantry") });
- assert.equal(classifyEffectForJob(active, job)?.bucket, "active.hero.defense.up");
-});
-
-test('applies_vs "target" resolves to the trigger target', () => {
- const active = activateEffect(
- {
- id: "TargetSkill",
- name: "TargetSkill",
- sourceKind: "hero_skill",
- side: "attacker",
- heroName: "Targeter",
- level: 1,
- trigger: { type: "attack", source: "self.any", target: "enemy.any" },
- effects: []
- },
- {
- id: "targeted-offense",
- type: "active.hero.lethality.up",
- value: 50,
- sourceDefinition: { type: "active.hero.lethality.up" },
- units: { applies_to: "trigger.source", applies_vs: "target" }
- },
- 1,
- {
- id: "intent",
- round: 1,
- source: "normal",
- dealerSide: "attacker",
- dealerUnit: "infantry",
- takerSide: "defender",
- takerUnit: "lancer",
- orderIndex: 0,
- previousAttackCount: 0,
- projectedAttackCount: 1,
- previousReceivedAttackCount: 0,
- projectedReceivedAttackCount: 1
- }
- );
-
- assert.deepEqual(active.appliesTo, { side: "attacker", units: unitMask("infantry") });
- assert.deepEqual(active.appliesVs, { side: "defender", units: unitMask("lancer") });
-});
-
-test('applies_vs "target" still resolves to the trigger target for defensive effects', () => {
- const active = activateEffect(
- {
- id: "TargetSkill",
- name: "TargetSkill",
- sourceKind: "hero_skill",
- side: "defender",
- heroName: "Targeter",
- level: 1,
- trigger: { type: "attack", source: "enemy.any", target: "self.any" },
- effects: []
- },
- {
- id: "targeted-defense",
- type: "active.hero.defense.up",
- value: 50,
- sourceDefinition: { type: "active.hero.defense.up" },
- units: { applies_to: "trigger.target", applies_vs: "target" }
- },
- 1,
- {
- id: "intent",
- round: 1,
- source: "normal",
- dealerSide: "attacker",
- dealerUnit: "infantry",
- takerSide: "defender",
- takerUnit: "lancer",
- orderIndex: 0,
- previousAttackCount: 0,
- projectedAttackCount: 1,
- previousReceivedAttackCount: 0,
- projectedReceivedAttackCount: 1
- }
- );
-
- assert.deepEqual(active.appliesTo, { side: "defender", units: unitMask("lancer") });
- assert.deepEqual(active.appliesVs, { side: "defender", units: unitMask("lancer") });
-});
-
-function simpleFighters(): Record<"attacker" | "defender", ResolvedFighter> {
- const attacker: ResolvedFighter = {
- side: "attacker",
- name: "A",
- troops: { infantry: 1000, lancer: 0, marksman: 0 },
- initialTroops: { infantry: 1000, lancer: 0, marksman: 0 },
- troopDetails: {
- infantry: { id: "infantry_t1", type: "infantry", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
- },
- statBonuses: {
- infantry: { attack: 0, lethality: 0, defense: 0, health: 0 },
- lancer: { attack: 0, lethality: 0, defense: 0, health: 0 },
- marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
- },
- heroes: [],
- troopSkills: [],
- diagnostics: []
- };
- const defender: ResolvedFighter = {
- ...attacker,
- side: "defender",
- name: "D",
- troops: { infantry: 0, lancer: 1000, marksman: 0 },
- initialTroops: { infantry: 0, lancer: 1000, marksman: 0 },
- troopDetails: {
- lancer: { id: "lancer_t1", type: "lancer", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
- }
- };
- return { attacker, defender };
-}
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -176,8 +176,18 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /negative.*active\.hero\.attack\.down.*value/i);
});
+test("loadSimulatorConfig rejects invalid attack_order unit names", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "attack_order",
+ value: ["marksman", "invalid-unit", "lancer"],
+ units: { applies_to: "lancer", applies_vs: "marksman" }
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /attack_order.*unsupported unit.*invalid-unit/i);
+});
+
test("loadSimulatorConfig rejects effects targeting static buckets unless they are fully static", () => {
const turnRoot = writeConfigWithTroopEffect({
type: "passive.attack.up",
value: 10
@@ -187,39 +197,64 @@
type: "passive.attack.up",
value: 10,
duration: { turns: { count: 1 } }
},
- { type: "battle_start" }
+ { type: "pre_battle" }
);
const evolvingRoot = writeConfigWithTroopEffect(
{
type: "passive.attack.up",
value: 10,
value_evolution: { type: "fixed_decay", step: "round", value: 1 }
},
- { type: "battle_start" }
+ { type: "pre_battle" }
);
const emptyDurationRoot = writeConfigWithTroopEffect(
{
type: "passive.attack.up",
value: 10,
duration: {}
},
- { type: "battle_start" }
+ { type: "pre_battle" }
);
const probabilityRoot = writeConfigWithTroopEffect(
{
type: "passive.attack.up",
value: 10
},
- { type: "battle_start", probability: 50 }
+ { type: "pre_battle", probability: 50 }
);
+ const maxStackingRoot = writeConfigWithTroopEffect(
+ {
+ type: "passive.attack.up",
+ value: 10,
+ same_effect_stacking: "max"
+ },
+ { type: "pre_battle" }
+ );
+ const battleStartRoot = writeConfigWithTroopEffect(
+ {
+ type: "passive.attack.up",
+ value: 10
+ },
+ { type: "battle_start" }
+ );
+ const runtimeEffectRoot = writeConfigWithTroopEffect(
+ {
+ type: "active.hero.attack.up",
+ value: 10
+ },
+ { type: "pre_battle" }
+ );
- assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /static bucket passive\.attack\.up.*battle_start/i);
+ assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /static bucket passive\.attack\.up.*pre_battle/i);
assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /static bucket passive\.attack\.up.*duration/i);
assert.throws(() => loadSimulatorConfigFromDir(emptyDurationRoot), /static bucket passive\.attack\.up.*duration/i);
assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /static bucket passive\.attack\.up.*value_evolution/i);
- assert.throws(() => loadSimulatorConfigFromDir(probabilityRoot), /static bucket passive\.attack\.up.*probability/i);
+ assert.throws(() => loadSimulatorConfigFromDir(probabilityRoot), /pre_battle skill.*probability/i);
+ assert.throws(() => loadSimulatorConfigFromDir(maxStackingRoot), /static bucket passive\.attack\.up.*cannot use max stacking/i);
+ assert.throws(() => loadSimulatorConfigFromDir(battleStartRoot), /static bucket passive\.attack\.up.*pre_battle/i);
+ assert.throws(() => loadSimulatorConfigFromDir(runtimeEffectRoot), /pre_battle skill may only contain static passive-bucket effects/i);
});
test("loadSimulatorConfig rejects skill effects targeting input-derived static buckets", () => {
const playerRoot = writeConfigWithTroopEffect(
@@ -245,8 +280,20 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /invalid trigger_damage_jobs target selector.*effect\.applies_v/i);
});
+test("loadSimulatorConfig rejects stacking metadata on extra skill attacks", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ same_effect_stacking: "max",
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /extra_skill_attack does not support same_effect_stacking/i);
+});
+
test("loadSimulatorConfig rejects trigger_damage_jobs with typoed keys", () => {
const root = writeConfigWithTroopEffect({
type: "extra_skill_attack",
value: 100,
@@ -256,8 +303,19 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /unknown trigger_damage_jobs key soruce/i);
});
+test("loadSimulatorConfig rejects per-job extra skill damage multipliers", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", multiplier: 50 }]
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /unknown trigger_damage_jobs key multiplier/i);
+});
+
test("loadSimulatorConfig requires trigger_damage_jobs source and target", () => {
const missingSourceRoot = writeConfigWithTroopEffect({
type: "extra_skill_attack",
value: 100,
@@ -303,8 +361,55 @@
assert.throws(() => loadSimulatorConfigFromDir(omittedRoot), /effect\.applies_vs.*concrete applies_vs/i);
assert.throws(() => loadSimulatorConfigFromDir(allRoot), /applies_vs.*all/i);
});
+test("loadSimulatorConfig accepts the deferred shield value formula schema", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.shield",
+ value: 30,
+ value_formula: { type: "percent_of", source: "trigger.total_kills" },
+ units: { applies_to: "trigger.source", applies_vs: "enemy.any" },
+ duration: { turns: { delay: 1, count: 1 } },
+ same_effect_stacking: "max"
+ }, { type: "attack", source: "infantry" });
+
+ assert.doesNotThrow(() => loadSimulatorConfigFromDir(root));
+});
+
+test("loadSimulatorConfig rejects malformed or non-attack value formulas", () => {
+ const badSource = writeConfigWithTroopEffect({
+ type: "active.hero.shield",
+ value: 30,
+ value_formula: { type: "percent_of", source: "trigger.damage" }
+ }, { type: "attack", source: "infantry" });
+ const typoedKey = writeConfigWithTroopEffect({
+ type: "active.hero.shield",
+ value: 30,
+ value_formula: { type: "percent_of", source: "trigger.total_kills", scale: 2 }
+ }, { type: "attack", source: "infantry" });
+ const wrongTrigger = writeConfigWithTroopEffect({
+ type: "active.hero.shield",
+ value: 30,
+ value_formula: { type: "percent_of", source: "trigger.total_kills" }
+ }, { type: "turn" });
+ const evolving = writeConfigWithTroopEffect({
+ type: "active.hero.shield",
+ value: 30,
+ value_formula: { type: "percent_of", source: "trigger.total_kills" },
+ value_evolution: { type: "fixed_decay", step: "turn", value: 1 }
+ }, { type: "attack", source: "infantry" });
+ const missingCoefficient = writeConfigWithTroopEffect({
+ type: "active.hero.shield",
+ value_formula: { type: "percent_of", source: "trigger.total_kills" }
+ }, { type: "attack", source: "infantry" });
+
+ assert.throws(() => loadSimulatorConfigFromDir(badSource), /invalid value_formula source.*trigger\.damage/i);
+ assert.throws(() => loadSimulatorConfigFromDir(typoedKey), /unknown value_formula key scale/i);
+ assert.throws(() => loadSimulatorConfigFromDir(wrongTrigger), /value_formula requires an attack trigger/i);
+ assert.throws(() => loadSimulatorConfigFromDir(evolving), /cannot combine value_formula with value_evolution/i);
+ assert.throws(() => loadSimulatorConfigFromDir(missingCoefficient), /requires a numeric percentage coefficient/i);
+});
+
function writeConfigWithTroopEffect(effect: Record<string, unknown>, trigger: Record<string, unknown> = { type: "turn" }): string {
const root = join(tmpdir(), `wos-simulator-config-trigger-jobs-${Date.now()}-${Math.random().toString(16).slice(2)}`);
mkdirSync(join(root, "hero_definitions"), { recursive: true });
writeFileSync(
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -36,13 +36,20 @@
import Zinman from "../config/hero_definitions/Zinman.json" with { type: "json" };
import { UNIT_TYPES } from "./types";
import type { ConfigDiagnostics, EffectIntentDefinition, SimulatorConfig, SkillFile, TriggerDamageJobDefinition } from "./types";
-import { bucketDefinition, DYNAMIC_EFFECT_BUCKETS, isPassiveBucket, PASSIVE_BUCKETS } from "./damageBuckets";
+import {
+ DYNAMIC_EFFECT_BUCKETS,
+ STATIC_EFFECT_BUCKETS,
+ dynamicBucketDefinition,
+ isPassiveBucket,
+ staticBucketDefinition
+} from "./damageBuckets";
+import { normalizeUnitType } from "./normalize";
const KNOWN_EFFECT_TYPES = new Set([
...DYNAMIC_EFFECT_BUCKETS,
- ...PASSIVE_BUCKETS,
+ ...STATIC_EFFECT_BUCKETS,
"extra_skill_attack",
"dodge",
"no_attack",
"attack_order"
@@ -170,15 +177,18 @@
function collectEffectDiagnostics(skillFile: SkillFile, file: string, diagnostics: ConfigDiagnostics): void {
for (const [skillId, skill] of Object.entries(skillFile.skills ?? {})) {
validateTriggerDefinition(skill.trigger, file, skillId);
+ if (skill.trigger.type === "pre_battle") validatePreBattleSkill(skill, file, skillId);
for (const [effectId, effect] of Object.entries(skill.effects ?? {})) {
const type = String((effect as { type?: unknown }).type ?? "");
diagnostics.effectTypes[type] = (diagnostics.effectTypes[type] ?? 0) + 1;
collectAmbiguousTurnTriggerSelectorDiagnostics(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId, diagnostics);
validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);
+ validateEffectValueFormula(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
+ if (type === "attack_order") validateAttackOrderEffect(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);
validateStaticBucketEffect(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);
if (!KNOWN_EFFECT_TYPES.has(type)) {
@@ -186,9 +196,9 @@
file,
skillId,
effectId,
type,
- reason: "Effect type is not in the initial simulator classifier policy"
+ reason: "Effect type is not supported by the native simulator"
});
}
}
}
@@ -238,18 +248,35 @@
file: string,
skillId: string,
effectId: string
): void {
- if (trigger.type !== "battle_start") return;
+ if (trigger.type !== "battle_start" && trigger.type !== "pre_battle") return;
for (const field of ["applies_to", "applies_vs"] as const) {
const selector = effect.units?.[field];
if (!isTriggerRelativeUnitSelector(selector)) continue;
throw new Error(
- `battle_start effect cannot use trigger-relative units.${field} selector ${JSON.stringify(selector)} at ${file}:${skillId}.${effectId}; use a concrete selector such as self.any`
+ `${trigger.type} effect cannot use trigger-relative units.${field} selector ${JSON.stringify(selector)} at ${file}:${skillId}.${effectId}; use a concrete selector such as self.any`
);
}
}
+// The pre_battle phase feeds the static damage profile before any runtime exists, so by
+// definition it is chance-free and carries only static passive-bucket effects.
+function validatePreBattleSkill(skill: SkillFile["skills"][string], file: string, skillId: string): void {
+ const path = `${file}:${skillId}`;
+ if (skill.trigger.probability !== undefined) {
+ throw new Error(`pre_battle skill cannot define trigger probability at ${path}; pre_battle is chance-free by definition`);
+ }
+ for (const [effectId, effect] of Object.entries(skill.effects ?? {})) {
+ const type = String((effect as { type?: unknown }).type ?? "");
+ if (!isPassiveBucket(type)) {
+ throw new Error(
+ `pre_battle skill may only contain static passive-bucket effects; effect ${type} at ${path}.${effectId} must use a runtime trigger such as battle_start`
+ );
+ }
+ }
+}
+
function validateNativeEffectUnits(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
const path = `${file}:${skillId}.${effectId}`;
if (effect.units && "side" in effect.units) {
throw new Error(`native effect units.side is not supported at ${path}; use relation-qualified applies_to selectors such as enemy.any or trigger.target`);
@@ -259,43 +286,43 @@
`native effect units.applies_vs cannot be "all" at ${path}; use "any" for an unrestricted usage gate or trigger_damage_jobs target selectors for multi-target damage`
);
}
-// Static buckets are aggregated once per battle into the static damage profile, so any effect
-// that feeds one must itself be known at battle start, deterministic, immutable, and permanent.
-// Static buckets outside the passive family (troops/player) are input-derived and cannot be
-// produced by a skill at all.
+// Static buckets are aggregated once per battle into the static damage profile before any
+// runtime exists, so skill effects that feed them live in the pre_battle phase and must be
+// deterministic, immutable, and permanent. Static buckets outside the passive family
+// (troops/player) are input-derived and cannot be produced by a skill at all.
function validateStaticBucketEffect(
trigger: SkillFile["skills"][string]["trigger"],
effect: EffectIntentDefinition,
file: string,
skillId: string,
effectId: string
): void {
- const definition = bucketDefinition(effect.type);
- if (definition?.phase !== "static") return;
+ const definition = staticBucketDefinition(effect.type);
+ if (!definition) return;
const path = `${file}:${skillId}.${effectId}`;
if (!isPassiveBucket(effect.type)) {
throw new Error(`effect type ${effect.type} targets an input-derived static bucket and cannot be defined by a skill at ${path}`);
}
- if (trigger.type !== "battle_start") {
- throw new Error(`effect targeting static bucket ${effect.type} must use a battle_start trigger at ${path}`);
+ if (trigger.type !== "pre_battle") {
+ throw new Error(`effect targeting static bucket ${effect.type} must use a pre_battle trigger at ${path}`);
}
- if (trigger.probability !== undefined) {
- throw new Error(`effect targeting static bucket ${effect.type} cannot define trigger probability at ${path}`);
- }
if (effect.value_evolution !== undefined) {
throw new Error(`effect targeting static bucket ${effect.type} cannot define value_evolution at ${path}`);
}
if (effect.duration !== undefined) {
throw new Error(`effect targeting static bucket ${effect.type} cannot define duration at ${path}`);
}
+ if (effect.same_effect_stacking === "max") {
+ throw new Error(`effect targeting static bucket ${effect.type} cannot use max stacking at ${path}; passive effects are additive`);
+ }
}
function validateNativeEffectValue(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
- const definition = bucketDefinition(effect.type);
- if ((!definition || definition.valueType !== "pct") && !isPassiveBucket(effect.type)) return;
+ const definition = dynamicBucketDefinition(effect.type) ?? staticBucketDefinition(effect.type);
+ if (definition?.effectBucket !== true) return;
const values = Array.isArray(effect.value) ? effect.value : [effect.value];
for (const value of values) {
if (value === undefined) continue;
if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -306,8 +333,59 @@
}
}
}
+function validateEffectValueFormula(
+ trigger: SkillFile["skills"][string]["trigger"],
+ effect: EffectIntentDefinition,
+ file: string,
+ skillId: string,
+ effectId: string
+): void {
+ if (effect.value_formula === undefined) return;
+ const path = `${file}:${skillId}.${effectId}.value_formula`;
+ if (trigger.type !== "attack") {
+ throw new Error(`effect value_formula requires an attack trigger at ${path}`);
+ }
+ if (effect.value_evolution !== undefined) {
+ throw new Error(`effect cannot combine value_formula with value_evolution at ${path}`);
+ }
+ if (effect.value === undefined) {
+ throw new Error(`effect value_formula requires a numeric percentage coefficient in value at ${path}`);
+ }
+ const formula = effect.value_formula as unknown;
+ if (!formula || typeof formula !== "object" || Array.isArray(formula)) {
+ throw new Error(`effect value_formula must be an object at ${path}`);
+ }
+ const record = formula as Record<string, unknown>;
+ for (const key of Object.keys(record)) {
+ if (key !== "type" && key !== "source") throw new Error(`unknown value_formula key ${key} at ${path}`);
+ }
+ if (record.type !== "percent_of") {
+ throw new Error(`effect value_formula type must be "percent_of" at ${path}`);
+ }
+ if (!new Set(["trigger.normal_kills", "trigger.skill_kills", "trigger.total_kills"]).has(String(record.source))) {
+ throw new Error(`invalid value_formula source ${JSON.stringify(record.source)} at ${path}`);
+ }
+}
+
+function validateAttackOrderEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
+ const path = `${file}:${skillId}.${effectId}`;
+ if (!Array.isArray(effect.value) || effect.value.length === 0) {
+ throw new Error(`attack_order value must be a non-empty unit array at ${path}`);
+ }
+ for (const value of effect.value) {
+ if (typeof value !== "string") {
+ throw new Error(`attack_order value must contain only unit names at ${path}`);
+ }
+ try {
+ normalizeUnitType(value);
+ } catch {
+ throw new Error(`attack_order value contains unsupported unit ${JSON.stringify(value)} at ${path}`);
+ }
+ }
+}
+
function validateNativeEffectDuration(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
if (effect.duration === undefined) return;
const path = `${file}:${skillId}.${effectId}.duration`;
const duration = effect.duration as Record<string, unknown>;
@@ -347,8 +425,11 @@
}
function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
const path = `${file}:${skillId}.${effectId}`;
+ if (effect.same_effect_stacking !== undefined) {
+ throw new Error(`extra_skill_attack does not support same_effect_stacking at ${path}; every applicable effect emits all configured damage jobs`);
+ }
if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {
throw new Error(`extra_skill_attack requires non-empty trigger_damage_jobs at ${path}`);
}
effect.trigger_damage_jobs.forEach((job, index) => {
@@ -359,19 +440,16 @@
throw new Error(
`trigger_damage_jobs target effect.applies_vs requires a concrete applies_vs, not ${JSON.stringify(effect.units?.applies_vs)}, at ${path}.trigger_damage_jobs[${index}]`
);
}
- if (job.multiplier !== undefined && typeof job.multiplier !== "number") {
- throw new Error(`trigger_damage_jobs multiplier must be a number at ${path}.trigger_damage_jobs[${index}]`);
- }
});
}
function validateTriggerDamageJobShape(job: unknown, path: string, jobIndex: number): asserts job is TriggerDamageJobDefinition {
if (!job || typeof job !== "object" || Array.isArray(job)) {
throw new Error(`trigger_damage_jobs entry must be an object at ${path}.trigger_damage_jobs[${jobIndex}]`);
}
- const allowedKeys = new Set(["source", "target", "multiplier"]);
+ const allowedKeys = new Set(["source", "target"]);
for (const key of Object.keys(job)) {
if (!allowedKeys.has(key)) {
throw new Error(`unknown trigger_damage_jobs key ${key} at ${path}.trigger_damage_jobs[${jobIndex}]`);
}
@@ -413,8 +491,9 @@
const index: Record<string, string> = {};
for (const [key, definition] of Object.entries(heroDefinitions)) {
addHeroAlias(index, key, key);
if (definition.name) addHeroAlias(index, definition.name, key);
+ for (const alias of definition.aliases ?? []) addHeroAlias(index, alias, key);
}
return index;
}
Index: simulator/src/damage.test.ts
===================================================================
--- simulator/src/damage.test.ts prev run
+++ simulator/src/damage.test.ts this run
@@ -0,0 +1,744 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { calculateDamageJob, createDamageScratch, evaluateDamageExpression } from "./damage";
+import { DYNAMIC_BUCKETS, STATIC_BUCKETS, type BucketJobSide, type BucketPlacement } from "./damageBuckets";
+import { createEffectIndex, damageShapeSlotsForEffect, DAMAGE_JOB_SHAPE_SLOTS, indexEffect } from "./effectIndex";
+import { activateEffect, evolvingActiveEffectValue, resolvedEffectScopeKey } from "./effects";
+import { buildStaticDamageBucketFactors, buildStaticDamageProfile } from "./staticDamageProfile";
+import { createRecorder, type BattleRecorder } from "./recorder";
+import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";
+import { ALL_UNIT_MASK, unitMask } from "./types";
+
+const job: DamageJob = {
+ round: 1,
+ kind: "normal",
+ roundStartTroops: {
+ attacker: { infantry: 1000, lancer: 0, marksman: 0 },
+ defender: { infantry: 0, lancer: 1000, marksman: 0 }
+ },
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
+ sourceMultiplier: 1
+};
+
+function effect(
+ type: string,
+ ownerSide: "attacker" | "defender",
+ valuePct = 25,
+ sourceKind: ActiveEffect["source"]["kind"] = "hero_skill"
+): ActiveEffect {
+ const source =
+ sourceKind === "troop_skill"
+ ? { kind: sourceKind, side: ownerSide, troopType: "infantry" as const, skillId: "Skill", effectId: `${type}/1` }
+ : { kind: sourceKind, side: ownerSide, heroName: "Example", skillId: "Skill", effectId: `${type}/1` };
+ return {
+ source,
+ intent: { id: `${type}/1`, type, value: [valuePct] },
+ ownerSide,
+ kind: "modifier",
+ bucketIndex: -1,
+ initialValue: valuePct,
+ getCurrentValue() { return this.initialValue; },
+ appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },
+ appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },
+ createdRound: 1,
+ startRound: 1,
+ duration: {},
+ remainingAttackDelay: 0,
+ uses: 0,
+ sameEffectStacking: "add"
+ };
+}
+
+function calculateIndexedDamageJob(
+ damageJob: DamageJob,
+ fighters: Record<"attacker" | "defender", ResolvedFighter>,
+ effects: ActiveEffect[],
+ options: Omit<Partial<Parameters<typeof calculateDamageJob>[2]>, "recorder"> & { recorder?: BattleRecorder; trace?: boolean } = {}
+) {
+ const effectIndex = options.effectIndex ?? preparedEffectIndex(effects);
+ if (!options.effectIndex) {
+ for (const activeEffect of effects) {
+ if (activeEffect.effectGroup) indexEffect(effectIndex, activeEffect);
+ }
+ }
+ const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
+ const usedEffects = options.usedEffects ?? [];
+ const recorder = options.recorder ?? createRecorder(options.trace === true ? "trace" : "standard", [], () => {
+ throw new Error("damage-only test recorder has no battle resolution");
+ });
+ recorder.recordStaticProfile(fighters, effects);
+ const { trace: _trace, ...damageOptions } = options;
+ const result = calculateDamageJob(damageJob, fighters, { ...damageOptions, recorder, effectIndex, staticDamageProfile, usedEffects });
+ return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.intent.id) };
+}
+
+function preparedEffectIndex(effects: ActiveEffect[]): ReturnType<typeof createEffectIndex> {
+ const groups: NonNullable<ActiveEffect["effectGroup"]>[] = [];
+ const byShape: NonNullable<ActiveEffect["effectGroup"]>[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => []);
+ const byResolvedGroup = new Map<string, NonNullable<ActiveEffect["effectGroup"]>>();
+ for (const activeEffect of effects) {
+ const slots = damageShapeSlotsForEffect(activeEffect);
+ if (slots.length === 0) continue;
+ const key = `${activeEffect.source.effectId}:${resolvedEffectScopeKey(activeEffect.appliesTo, activeEffect.appliesVs)}`;
+ let group = byResolvedGroup.get(key);
+ if (!group) {
+ group = {
+ ordinal: groups.length,
+ bucketIndex: DYNAMIC_BUCKETS.findIndex((definition) => definition.name === activeEffect.intent.type),
+ sameEffectStacking: activeEffect.sameEffectStacking
+ };
+ byResolvedGroup.set(key, group);
+ groups.push(group);
+ for (const slot of slots) byShape[slot].push(group);
+ }
+ activeEffect.effectGroup = group;
+ }
+ return createEffectIndex(groups, byShape);
+}
+
+test("damage calculator requires indexed effect candidates", () => {
+ assert.throws(() => calculateDamageJob(job, simpleFighters(), { trace: true } as never), /effectIndex/i);
+});
+
+test("damage calculator uses centralized bucket definitions for player stat routing", () => {
+ const attacker: ResolvedFighter = {
+ side: "attacker",
+ name: "A",
+ initialTroops: { infantry: 1000, lancer: 0, marksman: 0 },
+ troopDetails: {
+ infantry: { id: "infantry_t1", type: "infantry", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ },
+ statBonuses: {
+ infantry: { attack: 100, lethality: 0, defense: 0, health: 0 },
+ lancer: { attack: 0, lethality: 0, defense: 0, health: 0 },
+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
+ },
+ heroes: [],
+ troopSkills: [],
+ diagnostics: []
+ };
+ const defender: ResolvedFighter = {
+ ...attacker,
+ side: "defender",
+ name: "D",
+ initialTroops: { infantry: 0, lancer: 1000, marksman: 0 },
+ troopDetails: {
+ lancer: { id: "lancer_t1", type: "lancer", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ },
+ statBonuses: {
+ infantry: { attack: 0, lethality: 0, defense: 0, health: 0 },
+ lancer: { attack: 0, lethality: 0, defense: 0, health: 100 },
+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
+ }
+ };
+
+ const outcome = calculateIndexedDamageJob(job, { attacker, defender }, [], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["player.attack"].totalPct, 100);
+ assert.equal(outcome.trace?.atomicBuckets["player.health"].totalPct, 100);
+ assert.ok(outcome.kills > 0);
+ assert.ok(outcome.kills < 1000);
+});
+
+test("static damage profile rejects live units without resolved troop details", () => {
+ const fighters = simpleFighters();
+ delete fighters.attacker.troopDetails.infantry;
+
+ assert.throws(
+ () => buildStaticDamageProfile(fighters, []),
+ /attacker has 1000 infantry troops but no troop details/
+ );
+});
+
+test("static damage profile rejects live units without resolved stat bonuses", () => {
+ const fighters = simpleFighters();
+ delete (fighters.attacker.statBonuses as Partial<typeof fighters.attacker.statBonuses>).infantry;
+
+ assert.throws(
+ () => buildStaticDamageProfile(fighters, []),
+ /attacker has 1000 infantry troops but no stat bonuses/
+ );
+});
+
+test("active stat-up effects multiply separately from player stat bonuses", () => {
+ const fighters = simpleFighters();
+ fighters.attacker.statBonuses.infantry.attack = 100;
+
+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const withRuntimeAttackUp = calculateIndexedDamageJob(job, fighters, [effect("active.hero.attack.up", "attacker", 100)], { trace: true });
+
+ assert.equal(baseline.trace?.atomicBuckets["player.attack"].totalPct, 100);
+ assert.equal(baseline.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 0);
+ assert.equal(withRuntimeAttackUp.trace?.atomicBuckets["player.attack"].totalPct, 100);
+ assert.equal(withRuntimeAttackUp.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
+ assert.equal(withRuntimeAttackUp.kills, baseline.kills * 2);
+});
+
+test("traced damage uses the effect index for applied bucket candidates", () => {
+ const indexedEffect = effect("active.hero.attack.up", "attacker", 100);
+ const index = preparedEffectIndex([indexedEffect]);
+ indexEffect(index, indexedEffect);
+
+ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true, effectIndex: index });
+
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
+ assert.equal(outcome.kills, baseline.kills * 2);
+});
+
+test("passive stat bonuses aggregate as up sum over down sum on top of player stats", () => {
+ const fighters = simpleFighters();
+ fighters.attacker.statBonuses.infantry.attack = 2015.2;
+ const passiveAttack = (value: number): ActiveEffect => ({
+ ...effect(value < 0 ? "passive.attack.down" : "passive.attack.up", "attacker", Math.abs(value)),
+ intent: { id: `passive/attack/${value}`, type: value < 0 ? "passive.attack.down" : "passive.attack.up", value: Math.abs(value) }
+ });
+
+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const outcome = calculateIndexedDamageJob(job, fighters, [passiveAttack(20), passiveAttack(10), passiveAttack(-5)], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);
+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
+ assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.3);
+ assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.down"].factor, 1.05);
+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), Number((1.3 / 1.05).toFixed(6)));
+});
+
+test("static profile factoring is identical to evaluating its buckets unfactored", () => {
+ const fighters = simpleFighters();
+ fighters.attacker.statBonuses.infantry.attack = 50;
+ fighters.attacker.statBonuses.infantry.lethality = 25;
+ fighters.defender.statBonuses.lancer.health = 40;
+ fighters.defender.statBonuses.lancer.defense = 10;
+
+ const passives = [
+ effect("passive.attack.up", "attacker", 20),
+ effect("passive.attack.down", "attacker", 5),
+ effect("passive.lethality.up", "attacker", 10),
+ effect("passive.health.up", "defender", 15),
+ effect("passive.health.down", "defender", 30),
+ effect("passive.defense.up", "defender", 5)
+ ];
+ const runtimeEffects = [
+ effect("active.hero.attack.up", "attacker", 15),
+ effect("active.troop.damage.down", "attacker", 12),
+ effect("active.hero.health.down", "defender", 8),
+ effect("type.normal.damageTaken.up", "defender", 7)
+ ];
+ const allEffects = [...passives, ...runtimeEffects];
+ const bucketFactors = buildStaticDamageBucketFactors(fighters, allEffects);
+ const profile = buildStaticDamageProfile(fighters, allEffects);
+ const scratch = createDamageScratch();
+ const profiled = calculateIndexedDamageJob(job, fighters, allEffects, {
+ trace: true,
+ staticDamageProfile: profile,
+ scratch,
+ capToTakerTroops: false
+ });
+ const dealerBuckets = bucketFactors[job.dealerSide][job.dealerUnit];
+ const takerBuckets = bucketFactors[job.takerSide][job.takerUnit];
+ const dealer = profile[job.dealerSide][job.dealerUnit];
+ const taker = profile[job.takerSide][job.takerUnit];
+
+ assert.equal(
+ dealer.dealerFactor.factors[0],
+ unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") /
+ unfactoredStaticProduct(dealerBuckets, "dealer", "denominator")
+ );
+ assert.equal(
+ taker.takerFactor.factors[0],
+ unfactoredStaticProduct(takerBuckets, "taker", "numerator") /
+ unfactoredStaticProduct(takerBuckets, "taker", "denominator")
+ );
+ assert.equal(
+ evaluateDamageExpression(dealer.dealerFactor, taker.takerFactor).factors[0],
+ dealer.dealerFactor.factors[0] * taker.takerFactor.factors[0]
+ );
+ const unfactoredStaticNumerator =
+ unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") *
+ unfactoredStaticProduct(takerBuckets, "taker", "numerator");
+ const unfactoredStaticDenominator =
+ unfactoredStaticProduct(dealerBuckets, "dealer", "denominator") *
+ unfactoredStaticProduct(takerBuckets, "taker", "denominator");
+ const unfactoredDamage =
+ unfactoredDynamicProduct(scratch.factors, job.kind, "numerator") * unfactoredStaticNumerator /
+ (100 * unfactoredDynamicProduct(scratch.factors, job.kind, "denominator") * unfactoredStaticDenominator);
+
+ assert.ok(Math.abs(profiled.kills - unfactoredDamage) < 1e-12);
+ assert.equal(profiled.trace?.atomicBuckets["player.attack"].totalPct, 50);
+ assert.equal(profiled.trace?.atomicBuckets["passive.attack.up"].totalPct, 20);
+ assert.equal(profiled.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
+ assert.equal(profiled.trace?.atomicBuckets["passive.health.down"].totalPct, 30);
+ assert.equal(profiled.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 15);
+ assert.equal(profiled.trace?.atomicBuckets["type.normal.damageTaken.up"].totalPct, 7);
+ assert.equal(profiled.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.2);
+});
+
+function unfactoredStaticProduct(
+ factors: Float64Array,
+ jobSide: BucketJobSide,
+ placement: BucketPlacement
+): number {
+ return STATIC_BUCKETS.reduce(
+ (product, definition, index) =>
+ definition.jobSide === jobSide && definition.placement === placement ? product * factors[index] : product,
+ 1
+ );
+}
+
+function unfactoredDynamicProduct(
+ factors: Float64Array,
+ kind: DamageJob["kind"],
+ placement: BucketPlacement
+): number {
+ return DYNAMIC_BUCKETS.reduce(
+ (product, definition, index) => {
+ const damageKind = "damageKind" in definition ? definition.damageKind : undefined;
+ return definition.placement === placement && (!damageKind || damageKind === kind)
+ ? product * factors[index]
+ : product;
+ },
+ 1
+ );
+}
+
+test("static damage profile adds duplicate passive effects without effect groups", () => {
+ const fighters = simpleFighters();
+ const weaker = effect("passive.attack.up", "attacker", 20);
+ const stronger = effect("passive.attack.up", "attacker", 50);
+ const profile = buildStaticDamageProfile(fighters, [weaker, stronger]);
+ const outcome = calculateIndexedDamageJob(job, fighters, [weaker, stronger], { trace: true, staticDamageProfile: profile });
+
+ assert.equal(weaker.effectGroup, undefined);
+ assert.equal(stronger.effectGroup, undefined);
+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 70);
+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].contributors.length, 2);
+ assert.equal(outcome.kills, calculateIndexedDamageJob(job, fighters, [], { trace: true }).kills * 1.7);
+});
+
+test("default aggregation multiplies hero and troop active damage buckets", () => {
+ const fighters = simpleFighters();
+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const combined = calculateIndexedDamageJob(
+ job,
+ fighters,
+ [effect("active.hero.lethality.up", "attacker", 20, "hero_skill"), effect("active.troop.lethality.up", "attacker", 10, "troop_skill")],
+ { trace: true }
+ );
+
+ assert.equal(combined.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 20);
+ assert.equal(combined.trace?.atomicBuckets["active.troop.lethality.up"].totalPct, 10);
+ assert.equal(combined.trace?.aggregationGroups["active.hero.lethality.up"].factor, 1.2);
+ assert.equal(combined.trace?.aggregationGroups["active.troop.lethality.up"].factor, 1.1);
+ assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.1) < 1e-12);
+});
+
+test("multiplicative all-damage buckets compound instead of adding", () => {
+ const fighters = simpleFighters();
+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const combined = calculateIndexedDamageJob(
+ job,
+ fighters,
+ [effect("type.all.damage.up", "attacker", 20), effect("type.all.damage.up", "attacker", 25)],
+ { trace: true }
+ );
+
+ assert.equal(combined.trace?.atomicBuckets["type.all.damage.up"].totalPct, 50);
+ assert.equal(combined.trace?.atomicBuckets["type.all.damage.up"].contributors.length, 2);
+ assert.equal(combined.trace?.aggregationGroups["type.all.damage.up"].factor, 1.5);
+ assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.25) < 1e-12);
+});
+
+test("damage-taken buckets use the expected damage direction", () => {
+ const fighters = simpleFighters();
+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const damageTakenUp = calculateIndexedDamageJob(job, fighters, [effect("active.hero.damageTaken.up", "defender", 25)], { trace: true });
+ const damageTakenDown = calculateIndexedDamageJob(job, fighters, [effect("active.hero.damageTaken.down", "defender", 25)], { trace: true });
+
+ assert.equal(damageTakenUp.trace?.aggregationGroups["active.hero.damageTaken.up"].placement, "numerator");
+ assert.equal(damageTakenDown.trace?.aggregationGroups["active.hero.damageTaken.down"].placement, "denominator");
+ assert.ok(Math.abs(damageTakenUp.kills - baseline.kills * 1.25) < 1e-12);
+ assert.ok(Math.abs(damageTakenDown.kills - baseline.kills / 1.25) < 1e-12);
+});
+
+test("a shield subtracts its full raw value from every normal and skill damage job", () => {
+ const fighters = simpleFighters();
+ const shield = {
+ ...effect("active.hero.shield", "defender", 25),
+ kind: "shield" as const,
+ intent: { id: "shield", type: "active.hero.shield", value: 25 },
+ sameEffectStacking: "max" as const
+ };
+ const normalBaseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const firstNormal = calculateIndexedDamageJob(job, fighters, [shield], { trace: true });
+ const secondNormal = calculateIndexedDamageJob(job, fighters, [shield], { trace: true });
+ const skillJob = { ...job, kind: "skill" as const, sourceMultiplier: 1 };
+ const skillBaseline = calculateIndexedDamageJob(skillJob, fighters, [], { trace: true });
+ const skill = calculateIndexedDamageJob(skillJob, fighters, [shield], { trace: true });
+
+ assert.equal(firstNormal.kills, Math.max(0, normalBaseline.kills - 25));
+ assert.equal(secondNormal.kills, firstNormal.kills);
+ assert.equal(skill.kills, Math.max(0, skillBaseline.kills - 25));
+ assert.equal(firstNormal.trace?.damageBeforeOffsets, normalBaseline.kills);
+ assert.equal(firstNormal.trace?.offsetDamage, 25);
+ assert.equal(firstNormal.trace?.aggregationGroups["active.hero.shield"].placement, "post_subtract");
+ assert.deepEqual(firstNormal.appliedEffects, [{
+ kind: "shield",
+ effectId: "active.hero.shield/1",
+ bucket: "active.hero.shield",
+ value: 25,
+ source: "Example/Skill/active.hero.shield/1",
+ sourceSide: "defender",
+ sameEffectStacking: "max"
+ }]);
+});
+
+test("max-stacked shields use the largest resolved absolute value", () => {
+ const fighters = simpleFighters();
+ const smaller = {
+ ...effect("active.hero.shield", "defender", 10),
+ kind: "shield" as const,
+ sameEffectStacking: "max" as const
+ };
+ const larger = {
+ ...effect("active.hero.shield", "defender", 40),
+ kind: "shield" as const,
+ sameEffectStacking: "max" as const,
+ source: { ...smaller.source, effectId: smaller.source.effectId }
+ };
+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const shielded = calculateIndexedDamageJob(job, fighters, [smaller, larger], { trace: true });
+
+ assert.equal(shielded.kills, Math.max(0, baseline.kills - 40));
+ assert.equal(shielded.trace?.offsetDamage, 40);
+ assert.equal(shielded.trace?.appliedEffects.length, 1);
+ assert.equal(shielded.trace?.appliedEffects[0]?.kind, "shield");
+ assert.equal(shielded.trace?.appliedEffects[0] && "value" in shielded.trace.appliedEffects[0] ? shielded.trace.appliedEffects[0].value : undefined, 40);
+});
+
+test("negative passive stat bonuses route to down buckets with positive factors", () => {
+ const passiveHealthDown = {
+ ...effect("passive.health.down", "defender", 105),
+ intent: { id: "passive-health-down", type: "passive.health.down", value: 105 }
+ };
+ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [passiveHealthDown], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["passive.health.down"].totalPct, 105);
+ assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].factor, 2.05);
+ assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].placement, "numerator");
+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);
+});
+
+test("pass-specific buckets only apply to matching damage job kind", () => {
+ const normalEffect = effect("type.normal.damage.up", "attacker", 100);
+ const skillEffect = effect("type.skill.damage.up", "attacker", 100);
+ const fighters = simpleFighters();
+ const normalOutcome = calculateIndexedDamageJob(job, fighters, [normalEffect, skillEffect], { trace: true });
+ const skillOutcome = calculateIndexedDamageJob({ ...job, kind: "skill", sourceMultiplier: 1 }, fighters, [normalEffect, skillEffect], {
+ trace: true
+ });
+
+ assert.equal(normalOutcome.trace?.atomicBuckets["type.normal.damage.up"].totalPct, 100);
+ assert.equal(normalOutcome.trace?.aggregationGroups["type.skill.damage.up"], undefined);
+ assert.equal(skillOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);
+ assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);
+});
+
+test("attack-duration bucket effects are charged by the applicable attack job", () => {
+ const oneAttackEffect = {
+ ...effect("active.hero.attack.up", "attacker", 100),
+ intent: { id: "attack-up-active", type: "active.hero.attack.up", value: 100 },
+ duration: { attacks: { count: 1 } }
+ };
+
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
+
+ assert.ok(outcome.usedEffectIds.includes("attack-up-active"));
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
+});
+
+test("turn-duration bucket effects are charged and visible on the attack outcome", () => {
+ const turnEffect = {
+ ...effect("active.hero.defense.down", "defender", 30),
+ intent: { id: "bad-luck-like", type: "active.hero.defense.down", value: 30 },
+ source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },
+ duration: { turns: { count: 1 } }
+ };
+
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });
+
+ assert.ok(outcome.usedEffectIds.includes("bad-luck-like"));
+ assert.deepEqual(outcome.appliedEffects, [
+ {
+ kind: "modifier",
+ effectId: "BadLuckStreak/1",
+ bucket: "active.hero.defense.down",
+ valuePct: 30,
+ source: "Example/Skill/BadLuckStreak/1",
+ sourceSide: "defender",
+ sameEffectStacking: "add"
+ }
+ ]);
+});
+
+test("effects are only charged when they participate in the calculation", () => {
+ const defenderOutgoingBuff = {
+ ...effect("active.hero.lethality.up", "defender", 100),
+ intent: { id: "defender-outgoing-buff", type: "active.hero.lethality.up", value: 100 },
+ duration: { attacks: { count: 1 } }
+ };
+
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);
+ assert.equal(outcome.usedEffectIds.includes("defender-outgoing-buff"), false);
+});
+
+test("pct attack value evolution uses the effect use count for the current bucket value", () => {
+ const decayedAttackUp = {
+ ...effect("active.hero.attack.up", "attacker", 100),
+ intent: {
+ id: "decayed-attack-up",
+ type: "active.hero.attack.up",
+ value: 100,
+ value_evolution: { type: "pct_decay", step: "attack", value: 15 }
+ },
+ duration: { attacks: { count: 10 } },
+ valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },
+ getCurrentValue: evolvingActiveEffectValue,
+ uses: 2
+ };
+
+ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [decayedAttackUp], { trace: true });
+
+ assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);
+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);
+ assert.ok(outcome.usedEffectIds.includes("decayed-attack-up"));
+});
+
+test("pct turn value evolution starts decaying after the first active turn", () => {
+ const turnDecayAttackUp = {
+ ...effect("active.hero.attack.up", "attacker", 100),
+ intent: {
+ id: "active.hero.attack.up/turn_decay",
+ type: "active.hero.attack.up",
+ value: 100,
+ value_evolution: { type: "pct_decay", step: "turn", value: 15 }
+ },
+ createdRound: 0,
+ startRound: 0,
+ valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },
+ getCurrentValue: evolvingActiveEffectValue,
+ duration: { attacks: { count: 10 } }
+ };
+
+ const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
+ const secondTurn = calculateIndexedDamageJob({ ...job, round: 2 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
+
+ assert.equal(firstTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
+ assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);
+});
+
+test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {
+ const weaker = {
+ ...effect("active.hero.lethality.up", "attacker", 50),
+ intent: { id: "max-weaker", type: "active.hero.lethality.up", value: 50 },
+ duration: { attacks: { count: 3 } },
+ sameEffectStacking: "max" as const
+ };
+ const strongerButDecayed = {
+ ...effect("active.hero.lethality.up", "attacker", 100),
+ intent: {
+ id: "max-stronger-decayed",
+ type: "active.hero.lethality.up",
+ value: 100,
+ value_evolution: { type: "pct_decay", step: "attack", value: 50 }
+ },
+ duration: { attacks: { count: 3 } },
+ valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },
+ getCurrentValue: evolvingActiveEffectValue,
+ uses: 1,
+ sameEffectStacking: "max" as const
+ };
+
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [weaker, strongerButDecayed], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
+ assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
+});
+
+test("max-stacked activations with disjoint resolved unit scopes apply independently", () => {
+ const infantry = {
+ ...effect("active.hero.lethality.up", "attacker", 40),
+ intent: { id: "max-infantry", type: "active.hero.lethality.up", value: 40 },
+ appliesTo: { side: "attacker" as const, units: unitMask("infantry") },
+ sameEffectStacking: "max" as const
+ };
+ const lancer = {
+ ...effect("active.hero.lethality.up", "attacker", 70),
+ intent: { id: "max-lancer", type: "active.hero.lethality.up", value: 70 },
+ appliesTo: { side: "attacker" as const, units: unitMask("lancer") },
+ sameEffectStacking: "max" as const
+ };
+ const fighters = simpleFighters();
+ const infantryOutcome = calculateIndexedDamageJob(job, fighters, [infantry, lancer], { trace: true });
+ const lancerOutcome = calculateIndexedDamageJob({ ...job, dealerUnit: "lancer" }, fighters, [infantry, lancer], { trace: true });
+
+ assert.equal(infantryOutcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 40);
+ assert.deepEqual(infantryOutcome.usedEffectIds, ["max-infantry"]);
+ assert.equal(lancerOutcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 70);
+ assert.deepEqual(lancerOutcome.usedEffectIds, ["max-lancer"]);
+});
+
+test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {
+ const active = activateEffect(
+ {
+ id: "TargetSkill",
+ name: "TargetSkill",
+ sourceKind: "hero_skill",
+ side: "defender",
+ heroName: "Targeter",
+ level: 1,
+ trigger: { type: "attack", source: "enemy.any", target: "self.any" },
+ effects: []
+ },
+ {
+ id: "targeted-defense",
+ type: "active.hero.defense.up",
+ value: 50,
+ sourceDefinition: { type: "active.hero.defense.up" },
+ units: { applies_to: "trigger.target", applies_vs: "trigger.source" }
+ },
+ 1,
+ {
+ round: 1,
+ source: "normal",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 1,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 1
+ }
+ );
+
+ assert.deepEqual(active.appliesTo, { side: "defender", units: unitMask("lancer") });
+ assert.deepEqual(active.appliesVs, { side: "attacker", units: unitMask("infantry") });
+});
+
+test('applies_vs "target" resolves to the trigger target', () => {
+ const active = activateEffect(
+ {
+ id: "TargetSkill",
+ name: "TargetSkill",
+ sourceKind: "hero_skill",
+ side: "attacker",
+ heroName: "Targeter",
+ level: 1,
+ trigger: { type: "attack", source: "self.any", target: "enemy.any" },
+ effects: []
+ },
+ {
+ id: "targeted-offense",
+ type: "active.hero.lethality.up",
+ value: 50,
+ sourceDefinition: { type: "active.hero.lethality.up" },
+ units: { applies_to: "trigger.source", applies_vs: "target" }
+ },
+ 1,
+ {
+ round: 1,
+ source: "normal",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 1,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 1
+ }
+ );
+
+ assert.deepEqual(active.appliesTo, { side: "attacker", units: unitMask("infantry") });
+ assert.deepEqual(active.appliesVs, { side: "defender", units: unitMask("lancer") });
+});
+
+test('applies_vs "target" still resolves to the trigger target for defensive effects', () => {
+ const active = activateEffect(
+ {
+ id: "TargetSkill",
+ name: "TargetSkill",
+ sourceKind: "hero_skill",
+ side: "defender",
+ heroName: "Targeter",
+ level: 1,
+ trigger: { type: "attack", source: "enemy.any", target: "self.any" },
+ effects: []
+ },
+ {
+ id: "targeted-defense",
+ type: "active.hero.defense.up",
+ value: 50,
+ sourceDefinition: { type: "active.hero.defense.up" },
+ units: { applies_to: "trigger.target", applies_vs: "target" }
+ },
+ 1,
+ {
+ round: 1,
+ source: "normal",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 1,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 1
+ }
+ );
+
+ assert.deepEqual(active.appliesTo, { side: "defender", units: unitMask("lancer") });
+ assert.deepEqual(active.appliesVs, { side: "defender", units: unitMask("lancer") });
+});
+
+function simpleFighters(): Record<"attacker" | "defender", ResolvedFighter> {
+ const attacker: ResolvedFighter = {
+ side: "attacker",
+ name: "A",
+ initialTroops: { infantry: 1000, lancer: 0, marksman: 0 },
+ troopDetails: {
+ infantry: { id: "infantry_t1", type: "infantry", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ },
+ statBonuses: {
+ infantry: { attack: 0, lethality: 0, defense: 0, health: 0 },
+ lancer: { attack: 0, lethality: 0, defense: 0, health: 0 },
+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
+ },
+ heroes: [],
+ troopSkills: [],
+ diagnostics: []
+ };
+ const defender: ResolvedFighter = {
+ ...attacker,
+ side: "defender",
+ name: "D",
+ initialTroops: { infantry: 0, lancer: 1000, marksman: 0 },
+ troopDetails: {
+ lancer: { id: "lancer_t1", type: "lancer", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ }
+ };
+ return { attacker, defender };
+}
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -1,27 +1,28 @@
import type {
ActiveEffect,
ActiveEffectGroup,
+ AppliedEffect,
DamageEquationTrace,
DamageJob,
ResolvedFighter,
SideId,
UnitType
} from "./types";
import { UNIT_TYPES } from "./types";
import {
- ATOMIC_BUCKETS,
+ DYNAMIC_BUCKET_INDEX,
DYNAMIC_BUCKETS,
- EVALUATED_DAMAGE_BUCKET,
STATIC_BUCKETS,
+ STATIC_BUCKET_INDEX,
+ bucketNeutralValue,
pctBucketDelta,
pctBucketFactor,
rawBucketFactor,
- type AtomicBucket,
- type AggregateBucketSpec,
- type BucketRole,
+ type BucketJobSide,
type BucketSpec,
- type DamageAggregationBucketSpec,
+ type BucketUpdate,
+ type DynamicDamageBucket,
type StaticDamageBucket
} from "./damageBuckets";
import { advanceEffectAttackDelay } from "./effects";
import { damageJobShapeSlot, damageJobSlot, type EffectIndex } from "./effectIndex";
@@ -29,11 +30,11 @@
type NumericBucketId = number;
interface DamageFactorTerm {
bucket: NumericBucketId;
- role?: BucketRole;
- placement: "numerator" | "denominator";
- appliesTo?: DamageJob["kind"];
+ jobSide: BucketJobSide;
+ placement: "numerator" | "denominator" | "post_subtract";
+ damageKind?: DamageJob["kind"];
}
interface NumericDamageBuckets {
factors: Float64Array;
@@ -41,8 +42,9 @@
interface CompiledDamageExpression {
numeratorSlots: Int32Array;
denominatorSlots: Int32Array;
+ postSubtractSlots: Int32Array;
}
export interface DamageBucketSet {
factors: Float64Array;
@@ -65,74 +67,94 @@
// Usage charging happens via options.usedEffects (every mode); the heavy per-attack
// AttackOutcome is assembled by the recorder, not here.
export interface DamageResult {
kills: number;
- appliedEffects?: DamageEquationTrace["appliedEffects"];
+ appliedEffects?: AppliedEffect[];
trace?: DamageEquationTrace;
}
-const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;
-const STATIC_BUCKET_IDS = Object.fromEntries(STATIC_BUCKETS.map((bucket, index) => [bucket.path, index])) as Record<StaticDamageBucket, NumericBucketId>;
const DYNAMIC_FACTOR_TERMS = compileDamageTerms(DYNAMIC_BUCKETS);
const STATIC_FACTOR_TERMS = compileDamageTerms(STATIC_BUCKETS);
const DYNAMIC_EXPRESSIONS = {
normal: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "normal" }),
skill: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "skill" })
};
const STATIC_EXPRESSIONS = {
- dealer: compileDamageExpression(STATIC_FACTOR_TERMS, { role: "dealer" }),
- taker: compileDamageExpression(STATIC_FACTOR_TERMS, { role: "taker" })
+ dealer: compileDamageExpression(STATIC_FACTOR_TERMS, { jobSide: "dealer" }),
+ taker: compileDamageExpression(STATIC_FACTOR_TERMS, { jobSide: "taker" })
};
-const EVALUATED_EXPRESSION = compileDamageExpression(compileDamageTerms([EVALUATED_DAMAGE_BUCKET]), {});
+const EVALUATED_EXPRESSION: CompiledDamageExpression = {
+ numeratorSlots: Int32Array.of(0),
+ denominatorSlots: new Int32Array(),
+ postSubtractSlots: new Int32Array()
+};
const BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(DYNAMIC_BUCKETS);
const STATIC_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(STATIC_BUCKETS);
-const EVALUATED_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates([EVALUATED_DAMAGE_BUCKET]);
+const DYNAMIC_NEUTRAL_VALUES = Float64Array.from(DYNAMIC_BUCKETS.map(({ update }) => bucketNeutralValue(update)));
+const STATIC_NEUTRAL_VALUES = Float64Array.from(STATIC_BUCKETS.map(({ update }) => bucketNeutralValue(update)));
const DAMAGE_SCALE_BUCKET = createEvaluatedDamageBucket(1 / 100);
+/** sqrt of the smaller side's initial army; battle-invariant, so callers running many jobs should compute it once. */
+export function sqrtMinInitialArmy(fighters: Record<SideId, ResolvedFighter>): number {
+ return Math.sqrt(Math.max(1, Math.min(totalTroops(fighters.attacker.initialTroops), totalTroops(fighters.defender.initialTroops))));
+}
+
+export interface DamageJobOptions {
+ recorder: BattleRecorder;
+ effectIndex: EffectIndex;
+ staticDamageProfile: StaticDamageProfile;
+ scratch?: DamageScratch;
+ capToTakerTroops?: boolean;
+ usedEffects?: ActiveEffect[];
+ sqrtMinInitialArmy?: number;
+}
+
export function calculateDamageJob(
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
- options: { recorder: BattleRecorder; effectIndex: EffectIndex; staticDamageProfile: StaticDamageProfile; scratch?: DamageScratch; capToTakerTroops?: boolean; usedEffects?: ActiveEffect[] }
+ options: DamageJobOptions
): DamageResult {
if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
if (!options.recorder) throw new Error("calculateDamageJob requires a recorder");
if (!options.staticDamageProfile) throw new Error("calculateDamageJob requires a staticDamageProfile");
const recording = options.recorder.startDamageJob();
const staticProfile = options.staticDamageProfile;
- const dealer = fighters[job.dealerSide];
- const taker = fighters[job.takerSide];
const dealerTroops = job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0;
const takerTroops = job.roundStartTroops[job.takerSide][job.takerUnit] ?? 0;
- const minInitialArmy = Math.max(1, Math.min(totalTroops(dealer.initialTroops), totalTroops(taker.initialTroops)));
- const armyTerm = Math.ceil(Math.sqrt(Math.max(0, dealerTroops)) * Math.sqrt(minInitialArmy));
+ const sqrtMinArmy = options.sqrtMinInitialArmy ?? sqrtMinInitialArmy(fighters);
+ const armyTerm = Math.ceil(Math.sqrt(Math.max(0, dealerTroops)) * sqrtMinArmy);
const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();
applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);
applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
const usedEffects = options.usedEffects ?? [];
applyBucketEffects(
options.effectIndex.damageGroupsByJobShape[damageJobSlot(job)],
+ options.effectIndex.liveEffectsByGroup,
job.round,
buckets,
recording,
usedEffects
);
- const rawDamage = evaluateDamageExpressionForJob(job, buckets, staticProfile);
- const uncappedKills = Math.max(0, rawDamage);
+ const damageBeforeOffsets = evaluateDamageExpressionForJob(job, buckets, staticProfile);
+ const offsetDamage = sumSlots(buckets.factors, DYNAMIC_EXPRESSIONS[job.kind].postSubtractSlots);
+ const rawDamage = Math.max(0, damageBeforeOffsets - offsetDamage);
+ const uncappedKills = rawDamage;
const kills = options.capToTakerTroops === false ? uncappedKills : Math.min(takerTroops, uncappedKills);
- return recording.finish({ job, factors: buckets.factors, armyTerm, rawDamage, kills });
+ return recording.finish({ job, factors: buckets.factors, armyTerm, damageBeforeOffsets, offsetDamage, rawDamage, kills });
}
function applyBucketEffects(
groups: ActiveEffectGroup[],
+ liveEffectsByGroup: ActiveEffect[][],
round: number,
buckets: NumericDamageBuckets,
recording: DamageJobRecorder,
usedEffects: ActiveEffect[]
): void {
for (const group of groups) {
- const effects = group.effects;
+ const effects = liveEffectsByGroup[group.ordinal];
if (effects.length === 0) continue;
if (group.sameEffectStacking !== "max") {
for (const effect of effects) {
if (!advanceEffectAttackDelay(effect)) continue;
@@ -151,29 +173,29 @@
const eligible: ActiveEffect[] = [];
for (const effect of effects) {
if (!advanceEffectAttackDelay(effect)) continue;
eligible.push(effect);
- if (!selected || effect.getCurrentValuePct(round) > selected.getCurrentValuePct(round)) selected = effect;
+ if (!selected || effect.getCurrentValue(round) > selected.getCurrentValue(round)) selected = effect;
}
if (selected) {
applyBucketEffectGroup(selected, eligible, round, buckets, recording, usedEffects);
}
}
}
// Apply the selected candidate's value to its bucket and emit the observation to the recorder;
-// returns the applied percentage. Shared by the single-candidate and max-group paths.
+// returns the applied value. Shared by the single-candidate and max-group paths.
function applySelectedBucket(
selected: ActiveEffect,
round: number,
buckets: NumericDamageBuckets,
recording: DamageJobRecorder
): number {
- const appliedValuePct = selected.getCurrentValuePct(round);
+ const appliedValue = selected.getCurrentValue(round);
const bucketIndex = selected.bucketIndex;
- applyBucketValue(buckets.factors, bucketIndex, appliedValuePct, BUCKET_UPDATE_BY_INDEX);
- recording.recordModifier(selected, appliedValuePct);
- return appliedValuePct;
+ applyBucketValue(buckets.factors, bucketIndex, appliedValue, BUCKET_UPDATE_BY_INDEX);
+ recording.recordEffect(selected, appliedValue);
+ return appliedValue;
}
// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]
// array and no suppressed-sibling bookkeeping.
@@ -183,10 +205,10 @@
buckets: NumericDamageBuckets,
recording: DamageJobRecorder,
usedEffects: ActiveEffect[]
): void {
- const appliedValuePct = applySelectedBucket(effect, round, buckets, recording);
- if (appliedValuePct !== 0) {
+ const appliedValue = applySelectedBucket(effect, round, buckets, recording);
+ if (appliedValue !== 0) {
usedEffects.push(effect);
} else {
recording.recordRejected(effect, "same_effect_max_superseded");
}
@@ -199,10 +221,10 @@
buckets: NumericDamageBuckets,
recording: DamageJobRecorder,
usedEffects: ActiveEffect[]
): void {
- const appliedValuePct = applySelectedBucket(selected, round, buckets, recording);
- if (appliedValuePct !== 0) {
+ const appliedValue = applySelectedBucket(selected, round, buckets, recording);
+ if (appliedValue !== 0) {
// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
for (const effect of effects) {
usedEffects.push(effect);
if (effect !== selected) recording.recordRejected(effect, "same_effect_max_suppressed");
@@ -211,51 +233,50 @@
for (const effect of effects) recording.recordRejected(effect, "same_effect_max_superseded");
}
}
-function compileDamageTerms(definitions: readonly (BucketSpec | AggregateBucketSpec)[]): DamageFactorTerm[] {
+function compileDamageTerms(definitions: readonly BucketSpec[]): DamageFactorTerm[] {
return definitions.map((definition, bucket) => ({
bucket,
- role: "role" in definition ? definition.role : undefined,
+ jobSide: definition.jobSide,
placement: definition.placement,
- appliesTo: definition.appliesTo
+ damageKind: definition.damageKind
}));
}
function compileDamageExpression(
terms: DamageFactorTerm[],
selection: {
- role?: BucketRole;
+ jobSide?: BucketJobSide;
kind?: DamageJob["kind"];
}
): CompiledDamageExpression {
const selected = terms
- .filter((term) => selection.role === undefined || term.role === selection.role)
- .filter((term) => selection.kind === undefined || !term.appliesTo || term.appliesTo === selection.kind);
+ .filter((term) => selection.jobSide === undefined || term.jobSide === selection.jobSide)
+ .filter((term) => selection.kind === undefined || !term.damageKind || term.damageKind === selection.kind);
return {
numeratorSlots: Int32Array.from(selected.filter((term) => term.placement === "numerator").map((term) => term.bucket)),
- denominatorSlots: Int32Array.from(selected.filter((term) => term.placement === "denominator").map((term) => term.bucket))
+ denominatorSlots: Int32Array.from(selected.filter((term) => term.placement === "denominator").map((term) => term.bucket)),
+ postSubtractSlots: Int32Array.from(selected.filter((term) => term.placement === "post_subtract").map((term) => term.bucket))
};
}
-function compileBucketUpdates(definitions: readonly DamageAggregationBucketSpec[]): Uint8Array {
+function compileBucketUpdates(definitions: readonly { update: BucketUpdate }[]): Uint8Array {
return Uint8Array.from(
- definitions.map(({ update, valueType }) =>
- valueType === "factor" ? 3 : update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2
- )
+ definitions.map(({ update }) => update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : update === "add_pct_factor" ? 2 : 3)
);
}
function applyBucketValue(factors: Float64Array, bucket: number, value: number, updates: Uint8Array): void {
const update = updates[bucket];
if (update === 0) factors[bucket] = rawBucketFactor(value);
else if (update === 1) factors[bucket] *= pctBucketFactor(value);
else if (update === 2) factors[bucket] += pctBucketDelta(value);
- else factors[bucket] = value;
+ else factors[bucket] += value;
}
-function applyDynamicDamageBucketValue(buckets: NumericDamageBuckets, bucket: AtomicBucket, value: number): number {
- const bucketIndex = BUCKET_IDS[bucket];
+function applyDynamicDamageBucketValue(buckets: NumericDamageBuckets, bucket: DynamicDamageBucket, value: number): number {
+ const bucketIndex = DYNAMIC_BUCKET_INDEX[bucket];
applyBucketValue(buckets.factors, bucketIndex, value, BUCKET_UPDATE_BY_INDEX);
return buckets.factors[bucketIndex];
}
@@ -264,11 +285,17 @@
for (const slot of slots) product *= factors[slot];
return product;
}
+function sumSlots(values: Float64Array, slots: Int32Array): number {
+ let sum = 0;
+ for (const slot of slots) sum += values[slot];
+ return sum;
+}
+
function createNumericDamageBuckets(): NumericDamageBuckets {
- const factors = new Float64Array(ATOMIC_BUCKETS.length);
- factors.fill(1);
+ const factors = new Float64Array(DYNAMIC_BUCKETS.length);
+ factors.set(DYNAMIC_NEUTRAL_VALUES);
return { factors };
}
export function createDamageScratch(): DamageScratch {
@@ -277,9 +304,9 @@
/** Creates neutral factors for the closed/static bucket pool. */
export function createStaticDamageBucketFactors(): StaticDamageBucketFactors {
const factors = new Float64Array(STATIC_BUCKETS.length);
- factors.fill(1);
+ factors.set(STATIC_NEUTRAL_VALUES);
return factors;
}
/** Populates a static bucket with exactly the same metadata-driven update rule as a live bucket. */
@@ -287,53 +314,53 @@
factors: StaticDamageBucketFactors,
bucket: StaticDamageBucket,
value: number
): number {
- const bucketIndex = STATIC_BUCKET_IDS[bucket];
+ const bucketIndex = STATIC_BUCKET_INDEX[bucket];
applyBucketValue(factors, bucketIndex, value, STATIC_BUCKET_UPDATE_BY_INDEX);
return factors[bucketIndex];
}
-/** Selects one role's static buckets as an ordinary input to the standard evaluator. */
-export function staticDamageBucketSet(factors: StaticDamageBucketFactors, role: BucketRole): DamageBucketSet {
- return { factors, expression: STATIC_EXPRESSIONS[role] };
+/** Selects one job side's static buckets as an ordinary input to the standard evaluator. */
+export function staticDamageBucketSet(factors: StaticDamageBucketFactors, jobSide: BucketJobSide): DamageBucketSet {
+ return { factors, expression: STATIC_EXPRESSIONS[jobSide] };
}
function resetDamageScratch(buckets: DamageScratch): DamageScratch {
- buckets.factors.fill(1);
+ buckets.factors.set(DYNAMIC_NEUTRAL_VALUES);
return buckets;
}
/** Pure, composable reduction of bucket sets to an ordinary one-value bucket set. */
export function evaluateDamageExpression(...inputs: DamageBucketSet[]): DamageBucketSet {
let factor = 1;
- for (const input of inputs) {
- const numerator = multiplySlots(1, input.factors, input.expression.numeratorSlots);
- const denominator = multiplySlots(1, input.factors, input.expression.denominatorSlots);
- factor *= numerator / denominator;
- }
+ for (const input of inputs) factor *= bucketSetValue(input);
return createEvaluatedDamageBucket(factor);
}
+function bucketSetValue(input: DamageBucketSet): number {
+ return multiplySlots(1, input.factors, input.expression.numeratorSlots) / multiplySlots(1, input.factors, input.expression.denominatorSlots);
+}
+
function createEvaluatedDamageBucket(factor: number): DamageBucketSet {
- const factors = new Float64Array(1);
- applyBucketValue(factors, 0, factor, EVALUATED_BUCKET_UPDATE_BY_INDEX);
- return { factors, expression: EVALUATED_EXPRESSION };
+ return { factors: Float64Array.of(factor), expression: EVALUATED_EXPRESSION };
}
+// Same reduction as evaluateDamageExpression over (dynamic, dealer static, taker static, scale),
+// kept scalar so the per-job hot path allocates no evaluated bucket set.
function evaluateDamageExpressionForJob(
job: DamageJob,
buckets: NumericDamageBuckets,
staticProfile: StaticDamageProfile
): number {
- const dealerFactor = staticProfile[job.dealerSide][job.dealerUnit].dealerFactor;
- const takerFactor = staticProfile[job.takerSide][job.takerUnit].takerFactor;
- return evaluateDamageExpression(
- { factors: buckets.factors, expression: DYNAMIC_EXPRESSIONS[job.kind] },
- dealerFactor,
- takerFactor,
- DAMAGE_SCALE_BUCKET
- ).factors[0];
+ const expression = DYNAMIC_EXPRESSIONS[job.kind];
+ const dynamicFactor = multiplySlots(1, buckets.factors, expression.numeratorSlots) / multiplySlots(1, buckets.factors, expression.denominatorSlots);
+ return (
+ dynamicFactor *
+ bucketSetValue(staticProfile[job.dealerSide][job.dealerUnit].dealerFactor) *
+ bucketSetValue(staticProfile[job.takerSide][job.takerUnit].takerFactor) *
+ bucketSetValue(DAMAGE_SCALE_BUCKET)
+ );
}
function totalTroops(troops: Record<UnitType, number>): number {
return UNIT_TYPES.reduce((sum, unit) => sum + (troops[unit] ?? 0), 0);
Index: simulator/src/damageBuckets.test.ts
===================================================================
--- simulator/src/damageBuckets.test.ts prev run
+++ simulator/src/damageBuckets.test.ts this run
@@ -1,9 +1,9 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createDamageScratch } from "./damage";
-import { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";
+import { DYNAMIC_BUCKETS, bucketNeutralValue, dynamicBucketDefinition } from "./damageBuckets";
test("active hero and troop damage aliases are supported all-damage buckets", () => {
for (const bucket of [
"active.hero.damage.up",
@@ -14,27 +14,27 @@
"active.troop.damage.down",
"active.troop.damageTaken.up",
"active.troop.damageTaken.down"
] as const) {
- assert.equal(ATOMIC_BUCKETS.includes(bucket), true, bucket);
- assert.equal(bucketDefinition(bucket)?.appliesTo, undefined, bucket);
+ assert.equal(DYNAMIC_BUCKETS.some((definition) => definition.name === bucket), true, bucket);
+ assert.equal(dynamicBucketDefinition(bucket)?.damageKind, undefined, bucket);
}
- assert.equal(bucketDefinition("active.hero.damage.up")?.placement, "numerator");
- assert.equal(bucketDefinition("active.hero.damage.down")?.placement, "denominator");
- assert.equal(bucketDefinition("active.hero.damageTaken.up")?.placement, "numerator");
- assert.equal(bucketDefinition("active.hero.damageTaken.down")?.placement, "denominator");
+ assert.equal(dynamicBucketDefinition("active.hero.damage.up")?.placement, "numerator");
+ assert.equal(dynamicBucketDefinition("active.hero.damage.down")?.placement, "denominator");
+ assert.equal(dynamicBucketDefinition("active.hero.damageTaken.up")?.placement, "numerator");
+ assert.equal(dynamicBucketDefinition("active.hero.damageTaken.down")?.placement, "denominator");
});
test("type all damage buckets multiply percentage factors", () => {
- assert.equal(bucketDefinition("type.all.damage.up")?.update, "multiply_pct_factor");
- assert.equal(bucketDefinition("type.all.damage.up")?.placement, "numerator");
- assert.equal(bucketDefinition("type.all.damage.up")?.appliesTo, undefined);
- assert.equal(bucketDefinition("type.all.damage.down")?.update, "multiply_pct_factor");
- assert.equal(bucketDefinition("type.all.damage.down")?.placement, "denominator");
- assert.equal(bucketDefinition("type.all.damage.down")?.appliesTo, undefined);
+ assert.equal(dynamicBucketDefinition("type.all.damage.up")?.update, "multiply_pct_factor");
+ assert.equal(dynamicBucketDefinition("type.all.damage.up")?.placement, "numerator");
+ assert.equal(dynamicBucketDefinition("type.all.damage.up")?.damageKind, undefined);
+ assert.equal(dynamicBucketDefinition("type.all.damage.down")?.update, "multiply_pct_factor");
+ assert.equal(dynamicBucketDefinition("type.all.damage.down")?.placement, "denominator");
+ assert.equal(dynamicBucketDefinition("type.all.damage.down")?.damageKind, undefined);
});
-test("damage scratch stores one factor value per atomic bucket", () => {
+test("damage scratch stores one numeric value per dynamic bucket with metadata-defined neutral values", () => {
const scratch = createDamageScratch() as unknown as {
factors?: Float64Array;
raw?: unknown;
pct?: unknown;
@@ -42,10 +42,18 @@
};
assert.ok(scratch.factors instanceof Float64Array);
const factors = scratch.factors as Float64Array;
- assert.equal(factors.length, ATOMIC_BUCKETS.length);
- assert.equal(factors.every((factor) => factor === 1), true);
+ assert.equal(factors.length, DYNAMIC_BUCKETS.length);
+ assert.deepEqual([...factors], DYNAMIC_BUCKETS.map(({ update }) => bucketNeutralValue(update)));
assert.equal("raw" in scratch, false);
assert.equal("pct" in scratch, false);
assert.equal("rawSet" in scratch, false);
});
+
+test("hero shields are raw post-subtract offsets applying to normal and skill damage", () => {
+ const shield = dynamicBucketDefinition("active.hero.shield");
+ assert.equal(shield?.jobSide, "taker");
+ assert.equal(shield?.update, "add_raw");
+ assert.equal(shield?.placement, "post_subtract");
+ assert.equal(shield?.damageKind, undefined);
+});
Index: simulator/src/damageBuckets.ts
===================================================================
--- simulator/src/damageBuckets.ts prev run
+++ simulator/src/damageBuckets.ts this run
@@ -1,157 +1,121 @@
import type { DamageKind } from "./types";
-export type BucketRole = "dealer" | "taker";
-export type BucketValueType = "raw" | "pct" | "factor";
-export type BucketUpdate = "assign_factor" | "add_pct_factor" | "multiply_pct_factor";
-export type BucketPlacement = "numerator" | "denominator";
-type BucketFamily = "troops" | "player" | "passive" | "active" | "type" | "source" | "aggregate";
+export type BucketJobSide = "dealer" | "taker";
+export type BucketUpdate = "assign_factor" | "add_pct_factor" | "multiply_pct_factor" | "add_raw";
+export type BucketPlacement = "numerator" | "denominator" | "post_subtract";
-export interface DamageAggregationBucketSpec {
- path: string;
- valueType: BucketValueType;
+export interface BucketSpec {
+ name: string;
+ jobSide: BucketJobSide;
update: BucketUpdate;
placement: BucketPlacement;
- appliesTo?: DamageKind;
+ damageKind?: DamageKind;
+ /** This bucket may be named by an EffectIntentDefinition. */
+ effectBucket?: true;
}
-export interface BucketSpec extends DamageAggregationBucketSpec {
- family: Exclude<BucketFamily, "aggregate">;
- role: BucketRole;
-}
-
-export interface AggregateBucketSpec extends DamageAggregationBucketSpec {
- family: "aggregate";
-}
-
-// The result of evaluating any bucket set is represented as this ordinary one-value bucket.
-// It has no dealer/taker role because its input set has already resolved that distinction.
-export const EVALUATED_DAMAGE_BUCKET = {
- path: "aggregate.factor",
- family: "aggregate",
- valueType: "factor",
- update: "assign_factor",
- placement: "numerator"
-} as const satisfies AggregateBucketSpec;
-
// Closed pools: every contributor is known while preparing the battle, so these
// buckets are aggregated once into StaticDamageProfile factors.
export const STATIC_BUCKETS = [
- { path: "troops.baseAttack", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },
- { path: "troops.baseLethality", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },
- { path: "troops.baseHealth", family: "troops", role: "taker", valueType: "raw", update: "assign_factor", placement: "denominator" },
- { path: "troops.baseDefense", family: "troops", role: "taker", valueType: "raw", update: "assign_factor", placement: "denominator" },
- { path: "player.attack", family: "player", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "player.lethality", family: "player", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "player.health", family: "player", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "player.defense", family: "player", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.attack.up", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "passive.attack.down", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.lethality.up", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "passive.lethality.down", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.health.up", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.health.down", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "passive.defense.up", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.defense.down", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
+ { name: "troops.baseAttack", jobSide: "dealer", update: "assign_factor", placement: "numerator" },
+ { name: "troops.baseLethality", jobSide: "dealer", update: "assign_factor", placement: "numerator" },
+ { name: "troops.baseHealth", jobSide: "taker", update: "assign_factor", placement: "denominator" },
+ { name: "troops.baseDefense", jobSide: "taker", update: "assign_factor", placement: "denominator" },
+ { name: "player.attack", jobSide: "dealer", update: "add_pct_factor", placement: "numerator" },
+ { name: "player.lethality", jobSide: "dealer", update: "add_pct_factor", placement: "numerator" },
+ { name: "player.health", jobSide: "taker", update: "add_pct_factor", placement: "denominator" },
+ { name: "player.defense", jobSide: "taker", update: "add_pct_factor", placement: "denominator" },
+ { name: "passive.attack.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "passive.attack.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "passive.lethality.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "passive.lethality.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "passive.health.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "passive.health.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "passive.defense.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "passive.defense.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true }
] as const satisfies readonly BucketSpec[];
// Open pools: runtime effects can feed these buckets while the battle is running.
// Array order is the numeric slot order used by the fast damage scratch.
export const DYNAMIC_BUCKETS = [
- { path: "active.hero.attack.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.attack.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.damage.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.damage.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.damageTaken.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.damageTaken.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.defense.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.defense.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.health.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.health.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.lethality.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.lethality.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.attack.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.attack.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.damage.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.damage.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.damageTaken.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.damageTaken.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.defense.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.defense.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.health.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.health.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.lethality.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.lethality.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "source.extraSkill", family: "source", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" },
- { path: "troops.count", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },
- { path: "type.all.damage.down", family: "type", role: "dealer", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" },
- { path: "type.all.damage.up", family: "type", role: "dealer", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },
- { path: "type.normal.damage.down", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },
- { path: "type.normal.damage.up", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
- { path: "type.normal.damageTaken.down", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },
- { path: "type.normal.damageTaken.up", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
- { path: "type.skill.damage.down", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },
- { path: "type.skill.damage.up", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },
- { path: "type.skill.damageTaken.down", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },
- { path: "type.skill.damageTaken.up", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" }
+ { name: "active.hero.attack.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.hero.attack.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.hero.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.hero.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.hero.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.hero.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.hero.defense.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.hero.defense.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.hero.health.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.hero.health.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.hero.lethality.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.hero.lethality.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.hero.shield", jobSide: "taker", update: "add_raw", placement: "post_subtract", effectBucket: true },
+ { name: "active.troop.attack.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.troop.attack.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.troop.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.troop.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.troop.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.troop.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.troop.defense.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.troop.defense.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.troop.health.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "active.troop.health.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.troop.lethality.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "active.troop.lethality.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "source.extraSkill", jobSide: "dealer", update: "assign_factor", placement: "numerator", damageKind: "skill" },
+ { name: "troops.count", jobSide: "dealer", update: "assign_factor", placement: "numerator" },
+ { name: "type.all.damage.down", jobSide: "dealer", update: "multiply_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "type.all.damage.up", jobSide: "dealer", update: "multiply_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "type.normal.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", damageKind: "normal", effectBucket: true },
+ { name: "type.normal.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", damageKind: "normal", effectBucket: true },
+ { name: "type.normal.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", damageKind: "normal", effectBucket: true },
+ { name: "type.normal.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", damageKind: "normal", effectBucket: true },
+ { name: "type.skill.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", damageKind: "skill", effectBucket: true },
+ { name: "type.skill.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", damageKind: "skill", effectBucket: true },
+ { name: "type.skill.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", damageKind: "skill", effectBucket: true },
+ { name: "type.skill.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", damageKind: "skill", effectBucket: true }
] as const satisfies readonly BucketSpec[];
-export type StaticBucketSpec = (typeof STATIC_BUCKETS)[number];
-export type DynamicBucketSpec = (typeof DYNAMIC_BUCKETS)[number];
-export type StaticDamageBucket = StaticBucketSpec["path"];
-export type AtomicBucket = DynamicBucketSpec["path"];
-export type BucketName = AtomicBucket;
-export type StaticRawBucket = Extract<StaticBucketSpec, { valueType: "raw" }>["path"];
-export type StaticPlayerBucket = Extract<StaticBucketSpec, { family: "player" }>["path"];
-export type PassiveBucket = Extract<StaticBucketSpec, { family: "passive" }>["path"];
-export type BucketPath = StaticDamageBucket | AtomicBucket;
+export type StaticBucketSpec = (typeof STATIC_BUCKETS)[number] & BucketSpec;
+export type DynamicBucketSpec = (typeof DYNAMIC_BUCKETS)[number] & BucketSpec;
+export type StaticDamageBucket = StaticBucketSpec["name"];
+export type DynamicDamageBucket = DynamicBucketSpec["name"];
+export type StaticRawBucket = Extract<StaticBucketSpec, { update: "assign_factor" }>["name"];
+export type PassiveBucket = Extract<StaticBucketSpec, { effectBucket: true }>["name"];
+export type StaticPlayerBucket = Exclude<StaticDamageBucket, StaticRawBucket | PassiveBucket>;
+export type DynamicEffectBucket = Extract<DynamicBucketSpec, { effectBucket: true }>["name"];
-type NativeBucketSpec = StaticBucketSpec | DynamicBucketSpec;
-type BucketDefinitionFields = {
- family: NativeBucketSpec["family"];
- role: NativeBucketSpec["role"];
- valueType: NativeBucketSpec["valueType"];
- update: NativeBucketSpec["update"];
- placement: NativeBucketSpec["placement"];
- appliesTo?: DamageKind;
-};
-export type BucketDefinition =
- | (BucketDefinitionFields & { path: StaticDamageBucket; phase: "static" })
- | (BucketDefinitionFields & { path: AtomicBucket; phase: "dynamic" });
+export const STATIC_BUCKET_INDEX = Object.fromEntries(
+ STATIC_BUCKETS.map((bucket, index) => [bucket.name, index])
+) as Readonly<Record<StaticDamageBucket, number>>;
+export const DYNAMIC_BUCKET_INDEX = Object.fromEntries(
+ DYNAMIC_BUCKETS.map((bucket, index) => [bucket.name, index])
+) as Readonly<Record<DynamicDamageBucket, number>>;
-export const ATOMIC_BUCKETS = DYNAMIC_BUCKETS.map((bucket) => bucket.path) as AtomicBucket[];
+export const STATIC_EFFECT_BUCKETS = STATIC_BUCKETS
+ .filter((bucket): bucket is Extract<StaticBucketSpec, { effectBucket: true }> => "effectBucket" in bucket)
+ .map((bucket) => bucket.name) as PassiveBucket[];
export const DYNAMIC_EFFECT_BUCKETS = DYNAMIC_BUCKETS
- .filter((bucket) => bucket.family === "active" || bucket.family === "type")
- .map((bucket) => bucket.path) as AtomicBucket[];
-export const PASSIVE_BUCKETS = STATIC_BUCKETS
- .filter((bucket): bucket is Extract<StaticBucketSpec, { family: "passive" }> => bucket.family === "passive")
- .map((bucket) => bucket.path) as PassiveBucket[];
+ .filter((bucket): bucket is Extract<DynamicBucketSpec, { effectBucket: true }> => "effectBucket" in bucket)
+ .map((bucket) => bucket.name) as DynamicEffectBucket[];
-const ALL_BUCKET_DEFINITIONS: BucketDefinition[] = [
- ...STATIC_BUCKETS.map((bucket) => ({ ...bucket, phase: "static" as const })),
- ...DYNAMIC_BUCKETS.map((bucket) => ({ ...bucket, phase: "dynamic" as const }))
-];
-
-export const BUCKET_DEFINITIONS = Object.fromEntries(
- ALL_BUCKET_DEFINITIONS.map((definition) => [definition.path, definition])
-) as Record<BucketPath, BucketDefinition>;
-
-export function bucketDefinition(path: string): BucketDefinition | undefined {
- return BUCKET_DEFINITIONS[path as BucketPath];
+export function staticBucketDefinition(name: string): StaticBucketSpec | undefined {
+ const index = STATIC_BUCKET_INDEX[name as StaticDamageBucket];
+ return index === undefined ? undefined : STATIC_BUCKETS[index];
}
-export function isPassiveBucket(bucket: string): bucket is PassiveBucket {
- const definition = BUCKET_DEFINITIONS[bucket as PassiveBucket];
- return definition?.phase === "static" && definition.family === "passive";
+export function dynamicBucketDefinition(name: string): DynamicBucketSpec | undefined {
+ const index = DYNAMIC_BUCKET_INDEX[name as DynamicDamageBucket];
+ return index === undefined ? undefined : DYNAMIC_BUCKETS[index];
}
-export function passiveBucketRole(bucket: string): BucketRole | undefined {
- return isPassiveBucket(bucket) ? BUCKET_DEFINITIONS[bucket].role : undefined;
+export function isPassiveBucket(name: string): name is PassiveBucket {
+ return staticBucketDefinition(name)?.effectBucket === true;
}
-// The one and only bucket-value conversion. A raw bucket contributes its value clamped
-// non-negative; a pct bucket contributes its percentage delta or the corresponding factor.
-// Every consumer (static profile, dynamic scratch, recorders) must use these.
+// Bucket updates encode both their input representation and their aggregation rule.
export function rawBucketFactor(raw: number): number {
return Math.max(0, raw);
}
@@ -161,4 +125,8 @@
export function pctBucketFactor(totalPct: number): number {
return 1 + pctBucketDelta(totalPct);
}
+
+export function bucketNeutralValue(update: BucketUpdate): number {
+ return update === "add_raw" ? 0 : 1;
+}
Index: simulator/src/effectIndex.test.ts
===================================================================
--- simulator/src/effectIndex.test.ts prev run
+++ simulator/src/effectIndex.test.ts this run
@@ -7,34 +7,30 @@
import type { ActiveEffect, DamageJob } from "./types";
test("effect index returns bucket-tagged candidates from a direct job-shape lookup", () => {
const effect: ActiveEffect = {
- id: "effect-1",
source: { kind: "hero_skill", side: "attacker", effectId: "boost" },
intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
bucketIndex: -1,
- initialValuePct: 25,
- getCurrentValuePct() { return this.initialValuePct; },
+ initialValue: 25,
+ getCurrentValue() { return this.initialValue; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
createdRound: 0,
startRound: 0,
duration: {},
remainingAttackDelay: 0,
uses: 0,
- stackingKey: "stack",
sameEffectStacking: "add"
};
const index = preparedIndex([effect]);
indexEffect(index, effect);
const job: DamageJob = {
- id: "job-1",
round: 1,
kind: "normal",
- sourceIntentId: "intent-1",
roundStartTroops: {
attacker: { infantry: 100, lancer: 0, marksman: 0 },
defender: { infantry: 0, lancer: 100, marksman: 0 }
},
@@ -43,88 +39,85 @@
takerSide: "defender",
takerUnit: "lancer"
};
- assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job)].flatMap((group) => group.effects), [effect]);
+ assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job)].flatMap((group) => index.liveEffectsByGroup[group.ordinal]), [effect]);
});
test("static-profile bucket effects are not prepared into the runtime effect index", () => {
const passive = effect("passive.attack.up");
const active = effect("active.hero.lethality.up");
const index = preparedIndex([passive, active]);
indexEffect(index, active);
- assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job())].flatMap((group) => group.effects), [active]);
+ assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job())].flatMap((group) => index.liveEffectsByGroup[group.ordinal]), [active]);
});
test("expiring a modifier swap-removes it once from its shared job-shape group", () => {
const first = effect("active.hero.lethality.up");
- const second = { ...effect("active.hero.lethality.up"), id: "second" };
+ const second = effect("active.hero.lethality.up");
+ second.intent = first.intent;
const index = preparedIndex([first, second]);
indexEffect(index, first);
indexEffect(index, second);
const group = index.damageGroupsByJobShape[damageJobSlot(job())][0];
- assert.deepEqual(group.effects, [first, second]);
+ assert.deepEqual(index.liveEffectsByGroup[group.ordinal], [first, second]);
expireEffectIndex(index, first);
- assert.deepEqual(group.effects, [second]);
- assert.equal(second.damageIndexPosition, 0);
- assert.equal(first.damageIndexGroup, undefined);
+ assert.deepEqual(index.liveEffectsByGroup[group.ordinal], [second]);
+ assert.equal(second.effectGroupPosition, 0);
+ assert.equal(first.effectGroup, undefined);
});
function effect(type: string): ActiveEffect {
return {
- id: type,
source: { kind: "hero_skill", side: "attacker", effectId: type },
intent: { id: type, type, value: 25 },
ownerSide: "attacker",
kind: "modifier",
bucketIndex: -1,
- initialValuePct: 25,
- getCurrentValuePct() { return this.initialValuePct; },
+ initialValue: 25,
+ getCurrentValue() { return this.initialValue; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
createdRound: 0,
startRound: 0,
duration: {},
remainingAttackDelay: 0,
uses: 0,
- stackingKey: "stack",
sameEffectStacking: "add"
};
}
function preparedIndex(effects: ActiveEffect[]): ReturnType<typeof createEffectIndex> {
- const groups: NonNullable<ActiveEffect["damageIndexGroup"]>[] = [];
- const byShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][] = Array.from({ length: 72 }, () => []);
- const byResolvedGroup = new Map<string, NonNullable<ActiveEffect["damageIndexGroup"]>>();
+ const groups: NonNullable<ActiveEffect["effectGroup"]>[] = [];
+ const byShape: NonNullable<ActiveEffect["effectGroup"]>[][] = Array.from({ length: 72 }, () => []);
+ const byResolvedGroup = new Map<string, NonNullable<ActiveEffect["effectGroup"]>>();
for (const effect of effects) {
const slots = damageShapeSlotsForEffect(effect);
if (slots.length === 0) continue;
- const key = `${effect.stackingKey ?? effect.id}:${resolvedEffectScopeKey(effect.appliesTo, effect.appliesVs)}`;
+ const key = `${effect.intent.id}:${resolvedEffectScopeKey(effect.appliesTo, effect.appliesVs)}`;
let group = byResolvedGroup.get(key);
if (!group) {
group = {
- effects: [],
+ ordinal: groups.length,
bucketIndex: 0,
sameEffectStacking: effect.sameEffectStacking
};
byResolvedGroup.set(key, group);
groups.push(group);
for (const slot of slots) byShape[slot].push(group);
}
- effect.damageIndexGroup = group;
+ effect.effectGroup = group;
}
return createEffectIndex(groups, byShape);
}
function job(): DamageJob {
return {
- id: "job-1",
round: 1,
kind: "normal",
- sourceIntentId: "intent-1",
roundStartTroops: {
attacker: { infantry: 100, lancer: 0, marksman: 0 },
defender: { infantry: 0, lancer: 100, marksman: 0 }
},
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -1,55 +1,59 @@
import type { ActiveEffect, ActiveEffectGroup, DamageJob, DamageKind, SideId, UnitType } from "./types";
import { unitsFromMask } from "./types";
-import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";
+import { DYNAMIC_BUCKET_INDEX, dynamicBucketDefinition, type DynamicDamageBucket } from "./damageBuckets";
+/**
+ * Runtime lookup structure for live effects, designed around the damage hot path.
+ *
+ * Damage modifiers use a prepared group index instead of per-job scanning:
+ * - A job shape is (kind, dealerSide, dealerUnit, takerSide, takerUnit) — one of the
+ * DAMAGE_JOB_SHAPE_SLOTS slots computed by damageJobShapeSlot.
+ * - Battle preparation creates one stable ActiveEffectGroup per (config definition,
+ * resolved unit scope) and registers it in damageGroupsByJobShape under every slot its
+ * scope can affect. Calculating a job then walks only its slot's groups; empty groups
+ * cost one length check, and no per-job classification happens at all.
+ * - Activations push into the runtime's liveEffectsByGroup[group.ordinal] list and record
+ * their dense position, so expiry is a single swap-remove regardless of how many slots
+ * reference the group.
+ * - A group is also the unit of same-effect max stacking: the max is selected among the
+ * group's live effects, and preparation asserts that a max-stacking definition's
+ * differently-scoped groups never overlap on a slot.
+ *
+ * Control, extra-attack, and battle-order effects are rare and cheap; they stay in flat
+ * lists matched per use. This split was benchmarked, not assumed — the group index beat a
+ * per-job classifier scan by ~3x on long battles while staying byte-identical on parity.
+ *
+ * Group descriptors and the two group arrays belong to the prepared battle and are
+ * immutable; every run allocates its own liveEffectsByGroup lists, so a CompiledBattle
+ * carries no per-run state.
+ */
export interface EffectIndex {
- // Prepared stable graph. Repeated synchronous runs reuse these group references and
- // reset only their live activation arrays.
damageGroupsByJobShape: ActiveEffectGroup[][];
- damageGroups: ActiveEffectGroup[];
+ effectGroups: ActiveEffectGroup[];
+ // This run's live activations, parallel to effectGroups (indexed by group.ordinal).
+ liveEffectsByGroup: ActiveEffect[][];
controls: ActiveEffect[];
extraAttacks: ActiveEffect[];
battleOrder: ActiveEffect[];
}
export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;
export function createEffectIndex(
- damageGroups: ActiveEffectGroup[] = [],
+ effectGroups: ActiveEffectGroup[] = [],
damageGroupsByJobShape: ActiveEffectGroup[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => [])
): EffectIndex {
- for (const group of damageGroups) group.effects.length = 0;
return {
damageGroupsByJobShape,
- damageGroups,
+ effectGroups,
+ liveEffectsByGroup: effectGroups.map(() => []),
controls: [],
extraAttacks: [],
battleOrder: []
};
}
-export function cloneEffectIndex(
- index: EffectIndex,
- preparedEffects: ActiveEffect[],
- cloneEffect: (effect: ActiveEffect) => ActiveEffect
-): EffectIndex {
- for (const group of index.damageGroups) group.effects.length = 0;
- const clone: EffectIndex = {
- damageGroupsByJobShape: index.damageGroupsByJobShape,
- damageGroups: index.damageGroups,
- controls: index.controls.map(cloneEffect),
- extraAttacks: index.extraAttacks.map(cloneEffect),
- battleOrder: index.battleOrder.map(cloneEffect)
- };
- for (const effect of preparedEffects) {
- const group = effect.damageIndexGroup;
- if (!group || effect.damageIndexPosition === undefined) continue;
- effect.damageIndexPosition = group.effects.length;
- group.effects.push(effect);
- }
- return clone;
-}
export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {
if (effect.kind === "control") {
index.controls.push(effect);
@@ -63,27 +67,27 @@
index.battleOrder.push(effect);
return;
}
- const group = effect.damageIndexGroup;
- if (!group) throw new Error(`Runtime modifier ${effect.id} has no prepared damage group`);
+ const group = effect.effectGroup;
+ if (!group) throw new Error(`Runtime modifier ${effect.intent.id} has no prepared effect group`);
+ const live = index.liveEffectsByGroup[group.ordinal];
+ effect.effectGroupPosition = live.length;
+ live.push(effect);
effect.bucketIndex = group.bucketIndex;
- effect.damageIndexPosition = group.effects.length;
- group.effects.push(effect);
}
export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {
if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;
- const definition = bucketDefinition(effect.intent.type);
- return definition !== undefined && definition.valueType === "pct" && definition.phase !== "static";
+ return dynamicBucketDefinition(effect.intent.type)?.effectBucket === true;
}
export function expireEffectIndex(index: EffectIndex, effect: ActiveEffect): void {
effect.expired = true;
if (effect.kind === "control") removeStable(index.controls, effect);
else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);
else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);
- else removeDamageEffect(effect);
+ removeEffectGroupEntry(index, effect);
}
export function damageJobSlot(job: DamageJob): number {
return damageJobShapeSlot(job.kind, job.dealerSide, job.dealerUnit, job.takerSide, job.takerUnit);
@@ -99,23 +103,21 @@
return (((kindIndex(jobKind) * 2 + sideIndex(dealerSide)) * 3 + unitIndex(dealerUnit)) * 2 + sideIndex(takerSide)) * 3 + unitIndex(takerUnit);
}
const JOB_SHAPE_CACHE = new Map<number, Uint8Array>();
-const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));
-
-export function damageBucketIndex(bucket: AtomicBucket): number {
- return BUCKET_INDEX.get(bucket) ?? -1;
+export function damageBucketIndex(bucket: DynamicDamageBucket): number {
+ return DYNAMIC_BUCKET_INDEX[bucket];
}
-export function damageShapeSlotsForEffect(effect: ActiveEffect, bucketOverride?: AtomicBucket): Uint8Array {
- const runtimeDefinition = bucketOverride === undefined ? bucketDefinition(effect.intent.type) : undefined;
- if (!bucketOverride && (!runtimeDefinition || runtimeDefinition.valueType !== "pct" || runtimeDefinition.phase === "static")) {
+export function damageShapeSlotsForEffect(effect: ActiveEffect, bucketOverride?: DynamicDamageBucket): Uint8Array {
+ const runtimeDefinition = bucketOverride === undefined ? dynamicBucketDefinition(effect.intent.type) : undefined;
+ if (!bucketOverride && runtimeDefinition?.effectBucket !== true) {
return EMPTY_JOB_SHAPE_SLOTS;
}
- const bucket = (bucketOverride ?? runtimeDefinition?.path) as AtomicBucket | undefined;
+ const bucket = (bucketOverride ?? runtimeDefinition?.name) as DynamicDamageBucket | undefined;
if (!bucket) return EMPTY_JOB_SHAPE_SLOTS;
const key =
- ((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 +
+ (((DYNAMIC_BUCKET_INDEX[bucket] * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 +
(effect.appliesVs.units & 7);
const cached = JOB_SHAPE_CACHE.get(key);
if (cached) return cached;
const slots = buildShapeSlots(effect, bucket);
@@ -124,17 +126,17 @@
}
const EMPTY_JOB_SHAPE_SLOTS = new Uint8Array();
-function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
- const definition = bucketDefinition(bucket)!;
+function buildShapeSlots(effect: ActiveEffect, bucket: DynamicDamageBucket): Uint8Array {
+ const definition = dynamicBucketDefinition(bucket)!;
const slots: number[] = [];
- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
+ const jobKinds: DamageKind[] = definition.damageKind ? [definition.damageKind] : ["normal", "skill"];
for (const jobKind of jobKinds) {
for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {
for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {
slots.push(
- definition.role === "dealer"
+ definition.jobSide === "dealer"
? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
: damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)
);
}
@@ -155,18 +157,19 @@
const index = effects.indexOf(effect);
if (index >= 0) effects.splice(index, 1);
}
-function removeDamageEffect(effect: ActiveEffect): void {
- const group = effect.damageIndexGroup;
- const position = effect.damageIndexPosition;
+function removeEffectGroupEntry(index: EffectIndex, effect: ActiveEffect): void {
+ const group = effect.effectGroup;
+ const position = effect.effectGroupPosition;
if (!group || position === undefined) return;
- const lastPosition = group.effects.length - 1;
- const moved = group.effects[lastPosition];
+ const live = index.liveEffectsByGroup[group.ordinal];
+ const lastPosition = live.length - 1;
+ const moved = live[lastPosition];
if (position !== lastPosition) {
- group.effects[position] = moved;
- moved.damageIndexPosition = position;
+ live[position] = moved;
+ moved.effectGroupPosition = position;
}
- group.effects.pop();
- effect.damageIndexGroup = undefined;
- effect.damageIndexPosition = undefined;
+ live.pop();
+ effect.effectGroup = undefined;
+ effect.effectGroupPosition = undefined;
}
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -16,20 +16,20 @@
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
interface CompiledActivation {
- idPrefix: string;
source: ActiveEffect["source"];
+ sourceSkill: ResolvedSkill;
ownerSide: SideId;
kind: ActiveEffectKind;
- initialValuePct: number;
- getCurrentValuePct: ActiveEffect["getCurrentValuePct"];
+ initialValue: number;
+ getCurrentValue: ActiveEffect["getCurrentValue"];
valueEvolution?: EvolvingActiveEffect["valueEvolution"];
triggerDamageJobs: ActiveEffect["triggerDamageJobs"];
+ attackOrder?: readonly UnitType[];
duration: EffectDuration;
turnDelay: number;
attackDelay: number;
- stackingKey: string;
sameEffectStacking: SameEffectStacking;
staticAppliesTo: ResolvedUnitScope;
staticAppliesVs: ResolvedUnitScope;
intentScoped: boolean;
@@ -105,9 +105,9 @@
return state / 0x100000000;
};
}
-export function crossedFrequency(previous: number, current: number, frequency: number, first = frequency): boolean {
+function crossedFrequency(previous: number, current: number, frequency: number, first = frequency): boolean {
if (current < first) return false;
if (previous < first) return true;
return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);
}
@@ -123,12 +123,10 @@
const effectKind = kindForIntent(intent);
if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {
throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);
}
- const sourceKey = skillActivationSourceKey(skill);
const evolution = intent.value_evolution;
const compiled: CompiledActivation = {
- idPrefix: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r`,
source: {
kind: skill.sourceKind,
side: skill.side,
heroName: skill.heroName,
@@ -137,18 +135,22 @@
skillId: skill.id,
skillName: skill.name,
effectId: intent.id
},
+ sourceSkill: skill,
ownerSide,
kind: effectKind,
- initialValuePct: finiteNumberOrZero(intent.value),
- getCurrentValuePct: evolution ? evolvingActiveEffectValuePct : constantActiveEffectValuePct,
+ initialValue: finiteNumberOrZero(intent.value),
+ getCurrentValue: evolution ? evolvingActiveEffectValue : constantActiveEffectValue,
valueEvolution: evolution ? { type: evolution.type, step: evolution.step, amount: finiteNumberOrZero(evolution.value) } : undefined,
triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,
+ attackOrder:
+ effectKind === "battle_order" && Array.isArray(intent.value)
+ ? intent.value.map((value) => normalizeUnitType(String(value)))
+ : undefined,
duration,
turnDelay: Math.max(0, duration.turns?.delay ?? 0),
attackDelay: duration.attacks?.delay ?? 0,
- stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,
sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking),
staticAppliesTo,
staticAppliesVs,
intentScoped:
@@ -158,9 +160,15 @@
ACTIVATION_CACHE.set(intent, compiled);
return compiled;
}
-export function activateEffect(skill: ResolvedSkill, intent: ResolvedEffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {
+export function activateEffect(
+ skill: ResolvedSkill,
+ intent: ResolvedEffectIntentDefinition,
+ round: number,
+ attackIntent?: AttackIntent,
+ resolvedValue?: number
+): ActiveEffect {
const compiled = compiledActivation(skill, intent);
let appliesTo = compiled.staticAppliesTo;
let appliesVs = compiled.staticAppliesVs;
if (compiled.intentScoped && attackIntent) {
@@ -168,31 +176,29 @@
appliesTo = resolveUnitScope(units.applies_to, compiled.ownerSide, "applies_to", attackIntent, compiled.ownerSide);
appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, compiled.ownerSide);
}
const effect: ActiveEffect = {
- id: compiled.idPrefix + round + ":" + (attackIntent?.id ?? "global"),
expired: false,
source: compiled.source,
+ sourceSkill: compiled.sourceSkill,
intent,
ownerSide: compiled.ownerSide,
kind: compiled.kind,
bucketIndex: -1,
- initialValuePct: compiled.initialValuePct,
- getCurrentValuePct: compiled.getCurrentValuePct,
+ initialValue: resolvedValue ?? compiled.initialValue,
+ getCurrentValue: compiled.getCurrentValue,
appliesTo,
appliesVs,
triggerDamageJobs: compiled.triggerDamageJobs,
+ attackOrder: compiled.attackOrder,
createdRound: round,
startRound: Math.max(1, round + compiled.turnDelay),
duration: compiled.duration,
remainingAttackDelay: compiled.attackDelay,
uses: 0,
- stackingKey: compiled.stackingKey,
sameEffectStacking: compiled.sameEffectStacking,
- damageIndexGroup: compiled.kind === "modifier"
- ? intent.damageGroup ?? intent.damageGroupsByScopeKey?.[resolvedEffectScopeKey(appliesTo, appliesVs)]
- : undefined,
- damageIndexPosition: undefined
+ effectGroup: intent.effectGroupsByScopeKey?.[resolvedEffectScopeKey(appliesTo, appliesVs)],
+ effectGroupPosition: undefined
};
if (!compiled.valueEvolution) return effect;
return { ...effect, valueEvolution: compiled.valueEvolution } as EvolvingActiveEffect;
}
@@ -206,16 +212,8 @@
function sideIndex(side: SideId): number {
return side === "attacker" ? 0 : 1;
}
-function skillActivationSourceKey(skill: ResolvedSkill): string {
- return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";
-}
-
-function skillStackingSourceKey(skill: ResolvedSkill): string {
- return skill.heroName ?? skill.troopType ?? "global";
-}
-
export function sourceLabel(effect: ActiveEffect): string {
return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
}
@@ -249,23 +247,23 @@
effect.remainingAttackDelay = remaining - 1;
return false;
}
-export function constantActiveEffectValuePct(this: ActiveEffect, _round: number): number {
- return this.initialValuePct;
+export function constantActiveEffectValue(this: ActiveEffect, _round: number): number {
+ return this.initialValue;
}
-export function evolvingActiveEffectValuePct(this: EvolvingActiveEffect, round: number): number {
+export function evolvingActiveEffectValue(this: EvolvingActiveEffect, round: number): number {
const firstActiveRound = Math.max(1, this.startRound);
const evolution = this.valueEvolution;
const stepCount = evolution.step === "attack" ? this.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;
- if (stepCount <= 0) return this.initialValuePct;
+ if (stepCount <= 0) return this.initialValue;
if (evolution.type === "pct_decay") {
const factor = Math.max(0, 1 - evolution.amount / 100);
- return this.initialValuePct * factor ** stepCount;
+ return this.initialValue * factor ** stepCount;
}
- if (evolution.type === "fixed_decay") return Math.max(0, this.initialValuePct - stepCount * evolution.amount);
- return this.initialValuePct;
+ if (evolution.type === "fixed_decay") return Math.max(0, this.initialValue - stepCount * evolution.amount);
+ return this.initialValue;
}
function finiteNumberOrZero(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
@@ -277,9 +275,9 @@
relation: TriggerSelectorRelation;
units?: UnitType[];
}
-export function parseTriggerSelector(value: unknown, defaultRelation: TriggerSelectorRelation): ParsedTriggerSelector {
+function parseTriggerSelector(value: unknown, defaultRelation: TriggerSelectorRelation): ParsedTriggerSelector {
if (typeof value === "string") {
const [maybeRelation, maybeUnit, ...extra] = value.split(".");
if (extra.length === 0 && (maybeRelation === "self" || maybeRelation === "enemy")) {
if (maybeUnit === undefined || maybeUnit === "any" || maybeUnit === "all") return { relation: maybeRelation };
@@ -289,9 +287,9 @@
}
return { relation: defaultRelation, units: normalizeUnitList(value) };
}
-export function sideForTriggerRelation(skillSide: SideId, relation: TriggerSelectorRelation): SideId {
+function sideForTriggerRelation(skillSide: SideId, relation: TriggerSelectorRelation): SideId {
return relation === "self" ? skillSide : oppositeSide(skillSide);
}
function triggerScopeMatches(scope: ResolvedUnitScope, actualSide: SideId, actualUnit: UnitType): boolean {
@@ -339,8 +337,9 @@
return typeof value === "string" && (value === "self" || value === "enemy" || value.startsWith("self.") || value.startsWith("enemy."));
}
function kindForIntent(intent: EffectIntentDefinition): ActiveEffectKind {
+ if (intent.type === "active.hero.shield") return "shield";
if (intent.type === "extra_skill_attack") return "extra_attack";
if (intent.type === "dodge" || intent.type === "no_attack") return "control";
if (intent.type === "attack_order") return "battle_order";
return "modifier";
Index: simulator/src/extraAttacks.ts
===================================================================
--- simulator/src/extraAttacks.ts prev run
+++ simulator/src/extraAttacks.ts this run
@@ -0,0 +1,147 @@
+import type {
+ ActiveEffect,
+ AttackIntent,
+ DamageJob,
+ ResolvedFighter,
+ SideId,
+ TriggerDamageJobSelector,
+ UnitType
+} from "./types";
+import { UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";
+import { advanceEffectAttackDelay } from "./effects";
+import { calculateDamageJob, type DamageJobOptions } from "./damage";
+import { normalizeUnitType } from "./normalize";
+import {
+ capJobToRemainingTarget,
+ chargeEffectUse,
+ chargeUsedEffects,
+ targetExhausted,
+ type DamageJobResult,
+ type RunLoopOptions,
+ type Runtime
+} from "./runtime";
+
+// Spawn, calculate, and charge the extra_skill_attack attacks riding on one normal attack.
+// Returns the finalized kills from every generated job in this normal attack's
+// cluster. Each applicable effect expands its trigger_damage_jobs into jobs that run immediately, in
+// place; the effect is charged one use only when at least one of its own jobs actually ran.
+// Spawning reads only round-start state and the effect's own gates, so running earlier
+// effects' jobs first cannot change what later effects spawn.
+export function processExtraSkillAttacks(
+ normalAttack: DamageJob,
+ intent: AttackIntent,
+ runtime: Runtime,
+ fighters: Record<SideId, ResolvedFighter>,
+ damageJobOptions: DamageJobOptions,
+ roundTargetDamage: Record<SideId, Record<UnitType, number>>,
+ loopOptions: RunLoopOptions,
+ results: DamageJobResult[]
+): number {
+ if (runtime.effectIndex.extraAttacks.length === 0) return 0;
+ const { round, roundStartTroops } = normalAttack;
+ const recorder = damageJobOptions.recorder;
+ let triggeredKills = 0;
+ // Snapshot the applicable effects: charging an effect below may expire it out of the live index.
+ const effects = runtime.effectIndex.extraAttacks.filter(
+ (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)
+ );
+ for (const effect of effects) {
+ const sourceEffectId = effect.source.effectId ?? effect.intent.id;
+ let processedJobCount = 0;
+ for (const definition of effect.triggerDamageJobs ?? []) {
+ const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);
+ const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);
+ const multiplier = effect.getCurrentValue(round) / 100;
+ if (multiplier <= 0) continue;
+ for (const source of sources) {
+ if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;
+ for (const target of targets) {
+ if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;
+ const job: DamageJob = {
+ round,
+ kind: "skill",
+ roundStartTroops,
+ dealerSide: source.side,
+ dealerUnit: source.unit,
+ takerSide: target.side,
+ takerUnit: target.unit,
+ sourceEffectId,
+ sourceMultiplier: multiplier
+ };
+ recorder.recordSkillDamageJob(job, effect);
+ if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;
+ recorder.recordScheduledDamageJob(job);
+ const result = calculateDamageJob(job, fighters, damageJobOptions);
+ if (loopOptions.capRoundKills) capJobToRemainingTarget(result, job, roundStartTroops, roundTargetDamage, recorder);
+ processedJobCount += 1;
+ runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;
+ results.push({ job, result, intent });
+ triggeredKills += result.kills;
+ chargeUsedEffects(runtime);
+ }
+ }
+ }
+ if (processedJobCount > 0) {
+ chargeEffectUse(runtime, effect);
+ recorder.recordExtraAttack(normalAttack, effect, processedJobCount);
+ }
+ }
+ return triggeredKills;
+}
+
+function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {
+ return (
+ effect.appliesTo.side === normalAttack.dealerSide &&
+ unitMaskHas(effect.appliesTo.units, normalAttack.dealerUnit) &&
+ effect.appliesVs.side === normalAttack.takerSide &&
+ unitMaskHas(effect.appliesVs.units, normalAttack.takerUnit)
+ );
+}
+
+interface TriggerJobUnit {
+ side: SideId;
+ unit: UnitType;
+}
+
+function resolveTriggerJobSelector(
+ selector: TriggerDamageJobSelector,
+ role: "source" | "target",
+ effect: ActiveEffect,
+ normalAttack: DamageJob,
+ roundStartTroops: DamageJob["roundStartTroops"]
+): TriggerJobUnit[] {
+ if (selector === "use.source") return [{ side: normalAttack.dealerSide, unit: normalAttack.dealerUnit }];
+ if (selector === "use.target") return [{ side: normalAttack.takerSide, unit: normalAttack.takerUnit }];
+ if (selector === "effect.applies_to") return unitsFromMask(effect.appliesTo.units).map((unit) => ({ side: effect.appliesTo.side, unit }));
+ if (selector === "effect.applies_vs") return unitsFromMask(effect.appliesVs.units).map((unit) => ({ side: effect.appliesVs.side, unit }));
+ if (selector === "enemy.living") return livingUnits(normalAttack.takerSide, roundStartTroops);
+ if (selector === "self.living") return livingUnits(normalAttack.dealerSide, roundStartTroops);
+ const units = unitListFromSelector(selector);
+ if (!units) {
+ throw new Error(`trigger_damage_jobs ${role} selector is required and must be a supported selector, got ${JSON.stringify(selector)}`);
+ }
+ const fallbackSide = role === "source" ? normalAttack.dealerSide : normalAttack.takerSide;
+ return units.map((unit) => ({ side: fallbackSide, unit }));
+}
+
+function livingUnits(side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): TriggerJobUnit[] {
+ return UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0).map((unit) => ({ side, unit }));
+}
+
+function unitListFromSelector(selector: TriggerDamageJobSelector): UnitType[] | undefined {
+ if (Array.isArray(selector)) {
+ try {
+ return selector.map((entry) => normalizeUnitType(String(entry)));
+ } catch {
+ return undefined;
+ }
+ }
+ if (typeof selector === "string") {
+ try {
+ return [normalizeUnitType(selector)];
+ } catch {
+ return undefined;
+ }
+ }
+ return undefined;
+}
Index: simulator/src/fighterResolution.ts
===================================================================
--- simulator/src/fighterResolution.ts prev run
+++ simulator/src/fighterResolution.ts this run
@@ -0,0 +1,321 @@
+import type {
+ EffectIntentDefinition,
+ FighterInput,
+ HeroInputCollection,
+ ResolvedFighter,
+ ResolvedHero,
+ ResolvedEffectIntentDefinition,
+ ResolvedSkill,
+ ResolvedTroopLine,
+ SideId,
+ SimulatorConfig,
+ SkillDefinition,
+ SkillRequirement,
+ StatBlock,
+ UnitType
+} from "./types";
+import { UNIT_TYPES } from "./types";
+import { normalizeEngagementType } from "./effects";
+import { addStats, normalizeStatBlock, normalizeUnitType, valueAtLevel, zeroStats } from "./normalize";
+
+export function resolveFighter(input: FighterInput, side: SideId, config: SimulatorConfig, engagementType?: string): ResolvedFighter {
+ const diagnostics: string[] = [];
+ const initialTroops = emptyTroops();
+ const troopDetails: Partial<Record<UnitType, ResolvedTroopLine>> = {};
+ const weightedStats: Partial<Record<UnitType, { count: number; stats: StatBlock; tier: number; fc: number; ids: string[] }>> = {};
+
+ for (const [troopId, rawCount] of Object.entries(input.troops ?? {})) {
+ const count = Number(rawCount) || 0;
+ if (count <= 0) continue;
+ const record = config.troopStats[troopId];
+ if (!record) {
+ diagnostics.push(`Unsupported troop id ${troopId}`);
+ continue;
+ }
+ let type: UnitType;
+ try {
+ type = normalizeUnitType(String(record.type));
+ } catch {
+ diagnostics.push(`Unsupported troop type ${record.type} for ${troopId}`);
+ continue;
+ }
+ const stats = normalizeStatBlock(record.stats);
+ initialTroops[type] += count;
+ const existing = weightedStats[type] ?? { count: 0, stats: zeroStats(), tier: 0, fc: 0, ids: [] };
+ existing.count += count;
+ existing.stats = addStats(existing.stats, {
+ attack: stats.attack * count,
+ defense: stats.defense * count,
+ lethality: stats.lethality * count,
+ health: stats.health * count
+ });
+ existing.tier = Math.max(existing.tier, Number(record.tier) || 0);
+ existing.fc = Math.max(existing.fc, Number(record.fc) || 0);
+ existing.ids.push(troopId);
+ weightedStats[type] = existing;
+ }
+
+ for (const [type, aggregate] of Object.entries(weightedStats) as Array<[UnitType, NonNullable<typeof weightedStats[UnitType]>]>) {
+ troopDetails[type] = {
+ id: aggregate.ids.join("+"),
+ type,
+ tier: aggregate.tier,
+ fc: aggregate.fc,
+ count: aggregate.count,
+ stats: {
+ attack: aggregate.stats.attack / aggregate.count,
+ defense: aggregate.stats.defense / aggregate.count,
+ lethality: aggregate.stats.lethality / aggregate.count,
+ health: aggregate.stats.health / aggregate.count
+ }
+ };
+ }
+
+ const statBonuses = resolveInputStatBonuses(input.stats);
+ const { heroes, heroSkills } = resolveHeroesAndSkills(input, side, config, diagnostics, engagementType);
+ const troopSkills = resolveTroopSkills(side, troopDetails, config, engagementType);
+
+ return {
+ side,
+ name: input.name ?? side,
+ initialTroops,
+ troopDetails,
+ statBonuses,
+ heroes,
+ heroSkills,
+ troopSkills,
+ diagnostics
+ };
+}
+
+export function emptyTroops(): Record<UnitType, number> {
+ return { infantry: 0, lancer: 0, marksman: 0 };
+}
+
+function resolveInputStatBonuses(stats: FighterInput["stats"]): Record<UnitType, StatBlock> {
+ const byUnit: Record<UnitType, StatBlock> = {
+ infantry: zeroStats(),
+ lancer: zeroStats(),
+ marksman: zeroStats()
+ };
+ for (const [key, block] of Object.entries(stats ?? {})) {
+ try {
+ byUnit[normalizeUnitType(key)] = normalizeStatBlock(block as Record<string, unknown>);
+ } catch {
+ // Unknown testcase stat aliases are non-fatal because troop ids still determine active units.
+ }
+ }
+ return byUnit;
+}
+
+// Build-time scaffolding: fold each MAIN hero's generation-stat block into the stat block for
+// that hero's troop type, so the simulator can treat FighterInput.stats as authoritative.
+export function applyHeroGenerationStats(input: FighterInput, config: SimulatorConfig): FighterInput {
+ const stats = resolveInputStatBonuses(input.stats);
+ 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;
+ const generation = definition.hero_generation;
+ if (!generation) continue;
+ const generationStats = config.heroGenerationStats[generation];
+ if (!generationStats) continue;
+ if (!definition.troop_type) {
+ throw new Error(`Hero ${definition.name ?? instance.name} has hero_generation ${generation} but no troop_type`);
+ }
+ const unit = normalizeUnitType(String(definition.troop_type));
+ stats[unit] = addStats(stats[unit], normalizeStatBlock(generationStats as Record<string, unknown>));
+ }
+ return { ...input, stats };
+}
+
+interface HeroInputInstance {
+ name: string;
+ levels: Record<string, number>;
+ role: "main" | "joiner";
+ instanceId: string;
+}
+
+function heroInputInstances(input: FighterInput): HeroInputInstance[] {
+ return [...heroCollectionInstances(input.heroes, "main"), ...heroCollectionInstances(input.joiner_heroes, "joiner")];
+}
+
+function heroCollectionInstances(collection: HeroInputCollection | undefined, role: "main" | "joiner"): HeroInputInstance[] {
+ if (!collection) return [];
+ const entries = Array.isArray(collection)
+ ? collection.map((entry) => ({ name: entry.name, levels: entry.levels ?? {} }))
+ : Object.entries(collection).map(([name, levels]) => ({ name, levels }));
+ const counts = new Map<string, number>();
+ return entries.map((entry) => {
+ const key = `${role}:${normalizeHeroName(entry.name)}`;
+ const index = counts.get(key) ?? 0;
+ counts.set(key, index + 1);
+ return {
+ name: entry.name,
+ levels: entry.levels,
+ role,
+ instanceId: `${role}:${normalizeHeroName(entry.name)}:${index}`
+ };
+ });
+}
+
+// One walk resolves both the hero roster and the hydrated skills, so a skill can never
+// appear in a hero's skillIds without also being hydrated (and vice versa).
+function resolveHeroesAndSkills(
+ input: FighterInput,
+ side: SideId,
+ config: SimulatorConfig,
+ diagnostics: string[],
+ engagementType?: string
+): { heroes: ResolvedHero[]; heroSkills: ResolvedSkill[] } {
+ const heroes: ResolvedHero[] = [];
+ const heroSkills: ResolvedSkill[] = [];
+ for (const instance of heroInputInstances(input)) {
+ const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
+ const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;
+ if (!definition) {
+ diagnostics.push(`Missing hero definition for ${instance.name}`);
+ heroes.push({
+ name: instance.name,
+ skillIds: [],
+ instanceId: instance.instanceId,
+ role: instance.role,
+ missing: true
+ });
+ continue;
+ }
+ const skillIds: string[] = [];
+ let index = 0;
+ for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {
+ index += 1;
+ // Joiners contribute exactly their first expedition skill. Treat any later levels in
+ // low-level input as inert rather than rejecting an otherwise valid battle request.
+ if (instance.role === "joiner" && index > 1) continue;
+ const level = Number(instance.levels[`skill_${index}`] ?? instance.levels[skillId] ?? 0);
+ if (level <= 0) continue;
+ if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, engagementType)) continue;
+ skillIds.push(skillId);
+ heroSkills.push(hydrateSkill(skillId, rawSkill, side, level, "hero_skill", definition.name ?? resolvedHeroName, undefined, instance.instanceId, instance.role));
+ }
+ heroes.push({
+ name: definition.name ?? instance.name,
+ heroGeneration: definition.hero_generation,
+ skillIds,
+ instanceId: instance.instanceId,
+ role: instance.role
+ });
+ }
+ return { heroes, heroSkills };
+}
+
+function resolveTroopSkills(
+ side: SideId,
+ troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>,
+ config: SimulatorConfig,
+ engagementType?: string
+): ResolvedSkill[] {
+ const skills: ResolvedSkill[] = [];
+ for (const [skillId, rawSkill] of Object.entries(config.troopSkills.skills ?? {})) {
+ let troopType: UnitType;
+ try {
+ troopType = normalizeUnitType(String((rawSkill as { troop_type?: unknown }).troop_type));
+ } catch {
+ continue;
+ }
+ const troop = troopDetails[troopType];
+ if (!troop) continue;
+ const requirements = (((rawSkill as { requirements?: unknown }).requirements ?? []) as SkillRequirement[]);
+ const troopRequirements = requirements.filter((req) => req.type === "tier" || req.type === "fc");
+ const satisfied = troopRequirements
+ .filter((req) => (req.type === "tier" ? troop.tier >= Number(req.value) : req.type === "fc" ? troop.fc >= Number(req.value) : false))
+ .sort((a, b) => b.level - a.level)[0];
+ if (!satisfied) continue;
+ if (!battleRequirementsSatisfied(requirements, satisfied.level, 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, engagementType?: string): boolean {
+ return (requirements ?? []).every((requirement) => {
+ if (requirement.type !== "engagement_type") return battleRequirementsSatisfied([requirement], level, engagementType);
+ if (level < Number(requirement.level ?? 1)) return true;
+ return heroEngagementRequirementSatisfied(requirement, side, engagementType);
+ });
+}
+
+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) === normalizeEngagementType(engagementType);
+ });
+}
+
+function heroEngagementRequirementSatisfied(requirement: SkillRequirement, side: SideId, engagementType?: string): boolean {
+ const required = normalizeEngagementType(requirement.value);
+ 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 hydrateSkill(
+ skillId: string,
+ rawSkill: Omit<SkillDefinition, "id" | "name">,
+ side: SideId,
+ level: number,
+ sourceKind: "hero_skill" | "troop_skill",
+ heroName?: string,
+ troopType?: UnitType,
+ heroInstanceId?: string,
+ heroRole?: "main" | "joiner"
+): ResolvedSkill {
+ const effects: ResolvedEffectIntentDefinition[] = [];
+ for (const [effectId, effect] of Object.entries(rawSkill.effects ?? {})) {
+ effects.push({
+ id: effectId,
+ ...effect,
+ value: levelSelectPreservingOrder(effect.value, level),
+ sourceDefinition: effect
+ });
+ }
+ return {
+ id: skillId,
+ name: skillId,
+ sourceKind,
+ side,
+ heroName,
+ heroInstanceId,
+ heroRole,
+ troopType,
+ level,
+ trigger: rawSkill.trigger,
+ effects
+ };
+}
+
+function levelSelectPreservingOrder(value: unknown, level: number): unknown {
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value;
+ return valueAtLevel(value, level);
+}
+
+function resolveHeroDefinitionKey(heroName: string, config: SimulatorConfig): string | undefined {
+ if (config.heroDefinitions[heroName]) return heroName;
+ const normalized = normalizeHeroName(heroName);
+ const indexed = config.heroAliasIndex?.[normalized];
+ if (indexed && config.heroDefinitions[indexed]) return indexed;
+ for (const [key, definition] of Object.entries(config.heroDefinitions)) {
+ if (normalizeHeroName(key) === normalized || normalizeHeroName(definition.name ?? "") === normalized) return key;
+ }
+ return undefined;
+}
+
+function normalizeHeroName(name: string): string {
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
+}
Index: simulator/src/gen8Heroes.test.ts
===================================================================
--- simulator/src/gen8Heroes.test.ts prev run
+++ simulator/src/gen8Heroes.test.ts this run
@@ -1,9 +1,9 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { loadSimulatorConfig } from "./config";
-import { resolveFighter } from "./resolve";
+import { resolveFighter } from "./fighterResolution";
test("Gen8 heroes are registered with the shared S8 expedition stats", () => {
const config = loadSimulatorConfig();
@@ -68,22 +68,39 @@
assert.deepEqual(gatot.GoldenGuard.effects["GoldenGuard/1"].value, [6, 12, 18, 24, 30]);
assert.equal(gatot.GoldenGuard.effects["GoldenGuard/1"].type, "active.hero.defense.up");
assert.deepEqual(gatot.KingsBestowal.effects["KingsBestowal/1"].value, [6, 12, 18, 24, 30]);
- assert.equal(gatot.KingsBestowal.effects["KingsBestowal/1"].type, "active.hero.damageTaken.down");
+ assert.deepEqual(gatot.KingsBestowal.effects["KingsBestowal/1"], {
+ type: "active.hero.shield",
+ value: [6, 12, 18, 24, 30],
+ value_formula: { type: "percent_of", source: "trigger.total_kills" },
+ units: { applies_to: "trigger.source", applies_vs: "enemy.any" },
+ duration: { turns: { delay: 1, count: 1 } },
+ same_effect_stacking: "max"
+ });
assert.deepEqual(gatot.RoyalLegion.effects["RoyalLegion/1"].value, [5, 10, 15, 20, 25]);
assert.equal(gatot.RoyalLegion.effects["RoyalLegion/1"].type, "active.hero.attack.down");
assert.deepEqual(sonya.TreasureHunter.effects["TreasureHunter/1"].value, [4, 8, 12, 16, 20]);
- assert.equal(sonya.BountyTemptation.effects["BountyTemptation/1"].type, "active.hero.damage.up");
+ assert.equal(sonya.BountyTemptation.effects["BountyTemptation/1"].type, "extra_skill_attack");
assert.deepEqual(sonya.BountyTemptation.effects["BountyTemptation/1"].value, [15, 30, 45, 60, 75]);
+ assert.deepEqual(sonya.BountyTemptation.effects["BountyTemptation/1"].trigger_damage_jobs, [
+ { source: "use.source", target: "use.target" }
+ ]);
assert.equal(sonya.BountyTemptation.effects["BountyTemptation/2"].type, "active.hero.attack.up");
assert.deepEqual(sonya.TorrentialImpact.effects["TorrentialImpact/1"].value, [50, 100, 150, 200, 250]);
+ assert.equal(sonya.TorrentialImpact.effects["TorrentialImpact/1"].type, "extra_skill_attack");
+ assert.deepEqual(sonya.TorrentialImpact.effects["TorrentialImpact/1"].trigger_damage_jobs, [
+ { source: "use.source", target: "use.target" }
+ ]);
assert.equal(sonya.TorrentialImpact.effects["TorrentialImpact/2"].type, "no_attack");
assert.deepEqual(hendrik.WormsRavage.effects["WormsRavage/1"].value, [5, 10, 15, 20, 25]);
assert.equal(hendrik.WormsRavage.effects["WormsRavage/1"].type, "active.hero.defense.down");
assert.deepEqual(hendrik.ArmorOfBarnacles.effects["ArmorOfBarnacles/1"].value, [6, 12, 18, 24, 30]);
assert.equal(hendrik.ArmorOfBarnacles.effects["ArmorOfBarnacles/1"].type, "active.hero.defense.up");
assert.deepEqual(hendrik.DragonsHeir.effects["DragonsHeir/1"].value, [8, 16, 24, 32, 40]);
- assert.equal(hendrik.DragonsHeir.effects["DragonsHeir/1"].type, "active.hero.damage.up");
+ assert.equal(hendrik.DragonsHeir.effects["DragonsHeir/1"].type, "extra_skill_attack");
+ assert.deepEqual(hendrik.DragonsHeir.effects["DragonsHeir/1"].trigger_damage_jobs, [
+ { source: "use.source", target: "enemy.living" }
+ ]);
});
Index: simulator/src/index.ts
===================================================================
--- simulator/src/index.ts prev run
+++ simulator/src/index.ts this run
@@ -1,11 +1,8 @@
export { buildSimulatorConfig, loadSimulatorConfig } from "./config";
export type { RawSimulatorConfig } from "./config";
-export { prepareBattle, runPrepared, simulateBattles } from "./simulator";
-export type { CompiledBattle } from "./simulator";
+export { prepareBattle } from "./prepare";
+export type { CompiledBattle } from "./prepare";
+export { runPrepared, simulateBattles } 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 type { CalibrationCaseComparison, CalibrationComparison, CalibrationComparisonRow } from "./calibration";
+export { applyHeroGenerationStats } from "./fighterResolution";
export type * from "./types";
Index: simulator/src/node-shims.d.ts
===================================================================
--- simulator/src/node-shims.d.ts prev run
+++ simulator/src/node-shims.d.ts this run
@@ -1,42 +0,0 @@
-declare const process: {
- cwd(): string;
-};
-
-declare module "node:assert/strict" {
- const assert: any;
- export default assert;
-}
-
-declare module "node:fs" {
- export function copyFileSync(source: string, destination: string): void;
- export function existsSync(path: string): boolean;
- export function mkdtempSync(prefix: string): string;
- export function mkdirSync(path: string, options?: Record<string, unknown>): unknown;
- export function readdirSync(path: string): string[];
- export function readFileSync(path: string, encoding: string): string;
- export function rmSync(path: string, options?: Record<string, unknown>): void;
- export function statSync(path: string): { isDirectory(): boolean; isFile(): boolean };
- export function writeFileSync(path: string, data: string): void;
-}
-
-declare module "node:os" {
- export function cpus(): unknown[];
- export function tmpdir(): string;
-}
-
-declare module "node:path" {
- export function basename(path: string): string;
- export function dirname(path: string): string;
- export function join(...parts: string[]): string;
- export function relative(from: string, to: string): string;
- export function resolve(...parts: string[]): string;
-}
-
-declare module "node:test" {
- export function test(name: string, fn: () => void): void;
-}
-
-declare module "node:url" {
- export function fileURLToPath(url: string | URL): string;
- export function pathToFileURL(path: string): URL;
-}
Index: simulator/src/normalize.ts
===================================================================
--- simulator/src/normalize.ts prev run
+++ simulator/src/normalize.ts this run
@@ -31,9 +31,9 @@
health: a.health + (b.health ?? 0)
};
}
-export function numberField(input: StatBlockInput | undefined, ...keys: Array<string | number>): number {
+function numberField(input: StatBlockInput | undefined, ...keys: Array<string | number>): number {
if (!input) return 0;
for (const key of keys) {
const value = Array.isArray(input) ? (typeof key === "number" ? input[key] : undefined) : valueForObjectKey(input as Record<string, unknown>, key);
if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -53,7 +53,6 @@
if (Array.isArray(selected)) return selected.map(String);
return Number(selected) || 0;
}
if (typeof value === "number") return value;
- if (Array.isArray(value)) return value.map(String);
return Number(value) || 0;
}
Index: simulator/src/parityMetrics.ts
===================================================================
--- simulator/src/parityMetrics.ts prev run
+++ simulator/src/parityMetrics.ts this run
@@ -1,117 +0,0 @@
-export interface OutcomeDistribution {
- n: number;
- mu: number;
- sigma: number;
-}
-
-export interface ParityThresholds {
- z_threshold?: number;
- min_bias_pct?: number;
- max_diff_ratio?: number;
- max_diff_ratio_deterministic?: number;
-}
-
-export interface ParityComparisonMetrics {
- n_candidate: number;
- mu_candidate: number;
- sigma_candidate: number;
- n_reference: number;
- mu_reference: number;
- sigma_reference: number;
- bias_raw: number;
- bias_pct: number;
- sem: number;
- stat_type: "deterministic" | "zero_var" | "single_obs" | "t";
- stat: number | null;
- p: number | null;
- q: number | null;
- passes: boolean;
-}
-
-export function compareOutcomeDistribution(options: {
- candidate: OutcomeDistribution;
- reference: OutcomeDistribution;
- initialTroops: number;
- deterministic: boolean;
- thresholds?: ParityThresholds;
-}): ParityComparisonMetrics {
- const thresholds = options.thresholds ?? {};
- const initialTroops = options.initialTroops || 1;
- const biasRaw = round(options.candidate.mu - options.reference.mu, 2);
- const biasPct = round((biasRaw / initialTroops) * 100, 2);
- const zThreshold = thresholds.z_threshold ?? 2;
- const minBiasPct = thresholds.min_bias_pct ?? 0.5;
- const deterministicLimit = (thresholds.max_diff_ratio_deterministic ?? thresholds.max_diff_ratio ?? 0.01) * 100;
-
- let sem = 0;
- let stat: number | null = null;
- let p: number | null = null;
- let statType: ParityComparisonMetrics["stat_type"];
- let passes: boolean;
-
- if (options.deterministic) {
- statType = "deterministic";
- passes = Math.abs(biasPct) <= deterministicLimit;
- } else if (options.candidate.sigma === 0) {
- statType = "zero_var";
- passes = Math.abs(biasPct) <= deterministicLimit;
- } else if (options.reference.n <= 1) {
- statType = "single_obs";
- sem = options.candidate.sigma;
- stat = sem === 0 ? null : round(biasRaw / sem, 4);
- p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
- passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
- } else {
- statType = "t";
- sem = options.candidate.sigma * Math.sqrt(1 / Math.max(options.candidate.n, 1) + 1 / options.reference.n);
- stat = sem === 0 ? null : round(biasRaw / sem, 4);
- p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
- passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
- }
-
- return {
- n_candidate: options.candidate.n,
- mu_candidate: round(options.candidate.mu, 2),
- sigma_candidate: round(options.candidate.sigma, 2),
- n_reference: options.reference.n,
- mu_reference: round(options.reference.mu, 2),
- sigma_reference: round(options.reference.sigma, 2),
- bias_raw: biasRaw,
- bias_pct: biasPct,
- sem: round(sem, 2),
- stat_type: statType,
- stat,
- p,
- q: null,
- passes
- };
-}
-
-export function applyBenjaminiHochberg(rows: Array<{ p: number | null; q: number | null }>): void {
- const ranked = rows
- .filter((row): row is { p: number; q: number | null } => row.p !== null)
- .sort((a, b) => a.p - b.p);
- const m = ranked.length;
- let runningMin = 1;
- for (let index = m - 1; index >= 0; index -= 1) {
- const rawQ = (ranked[index].p * m) / (index + 1);
- runningMin = Math.min(runningMin, rawQ);
- ranked[index].q = round(Math.min(1, runningMin), 6);
- }
-}
-
-function normalCdf(x: number): number {
- return 0.5 * (1 + erf(x / Math.SQRT2));
-}
-
-function erf(x: number): number {
- const sign = x < 0 ? -1 : 1;
- const ax = Math.abs(x);
- const t = 1 / (1 + 0.3275911 * ax);
- const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
- return sign * y;
-}
-
-function round(value: number, digits: number): number {
- return Number(value.toFixed(digits));
-}
Index: simulator/src/prepare.ts
===================================================================
--- simulator/src/prepare.ts prev run
+++ simulator/src/prepare.ts this run
@@ -0,0 +1,117 @@
+import type {
+ ActiveEffect,
+ BattleInput,
+ BattleResult,
+ FighterInput,
+ ResolvedFighter,
+ SideId,
+ SimulatorConfig
+} from "./types";
+import { ALL_UNIT_MASK } from "./types";
+import { constantActiveEffectValue, activateEffect, oppositeSide } from "./effects";
+import type { StaticDamageProfile } from "./damage";
+import { buildStaticDamageProfile } from "./staticDamageProfile";
+import { buildRuntimeSkills, type RuntimeSkills } from "./runtimeSkills";
+import { resolveFighter } from "./fighterResolution";
+
+/**
+ * A resolved battle ready to run many times with different seeds. Fighters, skills, the effect-group
+ * graph, and the static damage profile are built once and reused; each run fires battle_start with
+ * its own seed and builds a fresh Runtime. (Benchmarked 2026-07-17: a cloned battle-start template
+ * was no faster than re-firing setup per run, so the simpler always-runtime path is used.)
+ * A CompiledBattle is immutable: live effect lists, troop counts, and all other run state live on
+ * each run's Runtime. (Sole post-prepare write: the idempotent per-intent activation cache in
+ * effects.ts, populated on first activation.)
+ */
+export interface CompiledBattle {
+ input: BattleInput;
+ config: SimulatorConfig;
+ fighters: Record<SideId, ResolvedFighter>;
+ // Pre-battle skill activations plus input passives: the inputs to the static damage
+ // profile, kept so each run's recorder can describe the profile's contributors.
+ preBattleEffects: ActiveEffect[];
+ staticProfile: StaticDamageProfile;
+ runtimeSkills: RuntimeSkills;
+ // Run-invariant result payload shared by reference across every run of this compiled battle.
+ resolved: BattleResult["resolved"];
+}
+
+export function prepareBattle(input: BattleInput, config: SimulatorConfig): CompiledBattle {
+ const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);
+ const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);
+ const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
+ const runtimeSkills = buildRuntimeSkills([attacker, defender]);
+ const resolved = buildResolved(attacker, defender);
+ const preBattleEffects = activatePreBattleEffects(runtimeSkills, input);
+ const staticProfile = buildStaticDamageProfile(fighters, preBattleEffects);
+ return { input, config, fighters, preBattleEffects, staticProfile, runtimeSkills, resolved };
+}
+
+// The pre_battle phase: activate every chance-free static-passive skill effect plus the
+// input passives. Needs no runtime, RNG, or effect index — these effects only ever feed
+// the static damage profile and the recorders' description of it.
+export function activatePreBattleEffects(runtimeSkills: RuntimeSkills, input: BattleInput): ActiveEffect[] {
+ const effects: ActiveEffect[] = [];
+ for (const skill of runtimeSkills.preBattle) {
+ for (const intent of skill.effects) effects.push(activateEffect(skill, intent, 0));
+ }
+ effects.push(...createInputPassiveEffects(input.attacker.passive, "attacker"));
+ effects.push(...createInputPassiveEffects(input.defender.passive, "defender"));
+ return effects;
+}
+
+function createInputPassiveEffects(passive: FighterInput["passive"], side: SideId): ActiveEffect[] {
+ const effects: ActiveEffect[] = [];
+ if (!passive) return effects;
+ for (const stat of ["attack", "defense", "lethality", "health"] as const) {
+ for (const direction of ["up", "down"] as const) {
+ const valuePct = Number(passive[stat]?.[direction] ?? 0);
+ if (!Number.isFinite(valuePct) || valuePct <= 0) continue;
+ const bucket = `passive.${stat}.${direction}`;
+ const effect: ActiveEffect = {
+ source: {
+ kind: "input_stat",
+ side,
+ effectId: `input:${bucket}`
+ },
+ intent: {
+ id: `input:${bucket}`,
+ type: bucket,
+ value: valuePct
+ },
+ ownerSide: side,
+ kind: "modifier",
+ bucketIndex: -1,
+ initialValue: valuePct,
+ getCurrentValue: constantActiveEffectValue,
+ appliesTo: { side, units: ALL_UNIT_MASK },
+ appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },
+ createdRound: 0,
+ startRound: 0,
+ duration: {},
+ remainingAttackDelay: 0,
+ uses: 0,
+ sameEffectStacking: "add"
+ };
+ effects.push(effect);
+ }
+ }
+ return effects;
+}
+
+export function buildResolved(attacker: ResolvedFighter, defender: ResolvedFighter): BattleResult["resolved"] {
+ return {
+ attacker: {
+ troops: { ...attacker.initialTroops },
+ heroes: attacker.heroes,
+ troopSkillIds: attacker.troopSkills.map((skill) => skill.id),
+ diagnostics: attacker.diagnostics
+ },
+ defender: {
+ troops: { ...defender.initialTroops },
+ heroes: defender.heroes,
+ troopSkillIds: defender.troopSkills.map((skill) => skill.id),
+ diagnostics: defender.diagnostics
+ }
+ };
+}
Index: simulator/src/recorder.ts
===================================================================
--- simulator/src/recorder.ts prev run
+++ simulator/src/recorder.ts this run
@@ -1,12 +1,24 @@
import type { DamageResult } from "./damage";
-import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, pctBucketFactor, rawBucketFactor, type AtomicBucket, type StaticDamageBucket, type StaticPlayerBucket, type StaticRawBucket } from "./damageBuckets";
+import {
+ DYNAMIC_BUCKET_INDEX,
+ DYNAMIC_BUCKETS,
+ dynamicBucketDefinition,
+ pctBucketFactor,
+ rawBucketFactor,
+ type DynamicDamageBucket,
+ type StaticDamageBucket,
+ type StaticPlayerBucket,
+ type StaticRawBucket
+} from "./damageBuckets";
import { sourceLabel } from "./effects";
import { selectPassiveContributions, unitBaseStats, unitPlayerBonuses, type PassiveContribution } from "./staticDamageProfile";
import { UNIT_TYPES } from "./types";
import type {
ActiveEffect,
AppliedEffect,
+ AppliedModifierEffect,
+ AppliedShieldEffect,
AttackIntent,
AttackOutcome,
BattleTrace,
DamageAggregationGroupTrace,
@@ -21,30 +33,32 @@
UnitType
} from "./types";
type RejectedEffectReason = "same_effect_max_suppressed" | "same_effect_max_superseded";
-type GroupPlacement = "numerator" | "denominator";
+type GroupPlacement = "numerator" | "denominator" | "post_subtract";
type NumericBucketId = number;
interface DamageFactorTerm {
id: string;
bucket: NumericBucketId;
- bucketName: AtomicBucket;
+ bucketName: DynamicDamageBucket;
placement: GroupPlacement;
- appliesTo?: DamageJob["kind"];
+ damageKind?: DamageJob["kind"];
}
export interface DamageRecordingResult {
job: DamageJob;
factors: Float64Array;
armyTerm: number;
+ damageBeforeOffsets: number;
+ offsetDamage: number;
rawDamage: number;
kills: number;
}
/** Per-damage-job observation sink selected by the enclosing battle recorder. */
export interface DamageJobRecorder {
- recordModifier(effect: ActiveEffect, valuePct: number): void;
+ recordEffect(effect: ActiveEffect, value: number): void;
recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): void;
finish(result: DamageRecordingResult): DamageResult;
}
@@ -69,10 +83,10 @@
// Per-(side, unit) applied-effect fragments prebuilt from the static profile description; the
// per-job recorders splice them into each damage event instead of re-walking the description.
interface StaticAppliedIndex {
- dealer: Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;
- taker: Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;
+ dealer: Record<SideId, Record<UnitType, AppliedEffect[]>>;
+ taker: Record<SideId, Record<UnitType, AppliedEffect[]>>;
}
/**
* Observes the battle without deciding combat behavior. The concrete recorder is selected once,
@@ -89,13 +103,14 @@
recordSkillTriggerAttempt(skill: ResolvedSkill): void;
recordSkillTriggered(skill: ResolvedSkill): void;
recordSkillEffectActivated(skill: ResolvedSkill): void;
recordSkillDamageJob(job: DamageJob, effect: ActiveEffect): void;
- recordBattleOrder(intentId: string, effect: ActiveEffect, chosenTarget: UnitType): void;
+ recordBattleOrder(intent: AttackIntent, effect: ActiveEffect, chosenTarget: UnitType): void;
recordScheduledDamageJob(job: DamageJob): void;
- recordExtraAttack(normalJob: DamageJob, effect: ActiveEffect, jobs: DamageJob[], firstJobIndex: number, jobCount: number, processedJobIds: Set<string>): void;
+ /** One extra_skill_attack firing on a normal attack; spawnedJobCount is the number of its jobs that ran (always > 0). */
+ recordExtraAttack(normalJob: DamageJob, effect: ActiveEffect, spawnedJobCount: number): void;
recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "dodge" | "no_attack"): void;
- recordDamageJob(job: DamageJob, result: DamageResult): void;
+ recordDamageJob(job: DamageJob, result: DamageResult, intent?: AttackIntent): void;
recordFinalKills(result: DamageResult): void;
recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[]): void;
readonly attacks: AttackOutcome[];
readonly skillReport: Record<SideId, SkillReportEntry[]>;
@@ -104,12 +119,11 @@
const NO_ATTACKS: AttackOutcome[] = [];
const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
const EMPTY_SKILL_REPORT: Record<SideId, SkillReportEntry[]> = { attacker: [], defender: [] };
-const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();
const NULL_DAMAGE_JOB_RECORDER: DamageJobRecorder = {
- recordModifier() {},
+ recordEffect() {},
recordRejected() {},
finish({ kills }) {
return { kills };
}
@@ -151,21 +165,22 @@
}
export class BasicInfoRecorder implements BattleRecorder {
readonly attacks: AttackOutcome[] = [];
+ protected readonly detailedAttackEffects: boolean = false;
protected staticApplied?: StaticAppliedIndex;
- protected readonly orderEvents = new Map<string, AppliedEffect>();
- protected readonly extraAttackEvents = new Map<string, AppliedEffect[]>();
- protected readonly skillReports: Record<SideId, Map<string, SkillReportEntry>> = {
+ protected readonly orderEvents = new Map<AttackIntent, AppliedEffect>();
+ protected readonly extraAttackEvents = new Map<DamageJob, AppliedEffect[]>();
+ protected readonly skillReports: Record<SideId, Map<ResolvedSkill, SkillReportEntry>> = {
attacker: new Map(),
defender: new Map()
};
- protected readonly skillDamageKeys = new Map<string, string>();
+ protected readonly skillDamageReports = new Map<DamageJob, SkillReportEntry>();
constructor(fighters: ResolvedFighter[] = []) {
for (const fighter of fighters) {
for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {
- this.skillReports[fighter.side].set(skillReportKey(skill), {
+ this.skillReports[fighter.side].set(skill, {
sourceKind: skill.sourceKind,
heroName: skill.heroName,
troopType: skill.troopType,
skillId: skill.id,
@@ -192,15 +207,15 @@
};
}
recordStaticProfile(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): void {
- this.applyStaticDescription(buildStaticProfileDescription(fighters, setupEffects));
+ this.applyStaticDescription(buildStaticProfileDescription(fighters, setupEffects, this.detailedAttackEffects));
}
protected applyStaticDescription(description: StaticProfileDescription): void {
this.staticApplied = {
- dealer: staticAppliedFragments(description.dealer),
- taker: staticAppliedFragments(description.taker)
+ dealer: staticAppliedFragments(description.dealer, this.detailedAttackEffects),
+ taker: staticAppliedFragments(description.taker, this.detailedAttackEffects)
};
}
startDamageJob(): DamageJobRecorder {
@@ -217,112 +232,99 @@
this.incrementSkillReport(skill, "effectActivations");
}
recordSkillDamageJob(job: DamageJob, effect: ActiveEffect): void {
- const key = skillReportKeyForEffect(effect);
- if (key) this.skillDamageKeys.set(job.id, key);
+ const skill = effect.sourceSkill;
+ const report = skill && this.skillReports[skill.side].get(skill);
+ if (report) this.skillDamageReports.set(job, report);
}
- recordBattleOrder(intentId: string, effect: ActiveEffect, chosenTarget: UnitType): void {
- this.orderEvents.set(intentId, {
- kind: "battle_order",
- ...appliedEffectBase(effect),
- chosenTarget
- });
+ recordBattleOrder(intent: AttackIntent, effect: ActiveEffect, chosenTarget: UnitType): void {
+ this.orderEvents.set(intent, this.effectEvent(effect, intent.round, { kind: "battle_order", chosenTarget }));
}
recordScheduledDamageJob(_job: DamageJob): void {}
- recordExtraAttack(
- normalJob: DamageJob,
- effect: ActiveEffect,
- jobs: DamageJob[],
- firstJobIndex: number,
- jobCount: number,
- processedJobIds: Set<string>
- ): void {
- const spawnedJobIds: string[] = [];
- const end = firstJobIndex + jobCount;
- for (let index = firstJobIndex; index < end; index += 1) {
- const jobId = jobs[index]?.id;
- if (jobId && processedJobIds.has(jobId)) spawnedJobIds.push(jobId);
- }
- if (spawnedJobIds.length === 0) return;
- const event: AppliedEffect = {
- kind: "extra_attack",
- ...appliedEffectBase(effect),
- spawnedJobIds
- };
- const events = this.extraAttackEvents.get(normalJob.id);
+ recordExtraAttack(normalJob: DamageJob, effect: ActiveEffect, spawnedJobCount: number): void {
+ const event = this.effectEvent(effect, normalJob.round, { kind: "extra_attack", spawnedJobCount });
+ const events = this.extraAttackEvents.get(normalJob);
if (events) events.push(event);
- else this.extraAttackEvents.set(normalJob.id, [event]);
+ else this.extraAttackEvents.set(normalJob, [event]);
}
recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "dodge" | "no_attack"): void {
- const order = this.orderEvents.get(intent.id);
- this.orderEvents.delete(intent.id);
+ const order = this.orderEvents.get(intent);
+ this.orderEvents.delete(intent);
+ const control = this.effectEvent(effect, intent.round, { kind: "control", reason });
+ const appliedEffects = order ? [order, control] : [control];
this.attacks.push({
- jobId: `${intent.id}:cancelled`,
round: intent.round,
kind: "normal",
dealerSide: intent.dealerSide,
dealerUnit: intent.dealerUnit,
takerSide: intent.takerSide,
takerUnit: intent.takerUnit,
kills: 0,
- counterDeltas: counterDeltas(intent, "normal_attack"),
- appliedEffects: order
- ? [order, { kind: "control", ...appliedEffectBase(effect), reason }]
- : [{ kind: "control", ...appliedEffectBase(effect), reason }],
- cancelledBy: effect.id,
+ ...(this.detailedAttackEffects ? { counterDeltas: counterDeltas(intent, "normal_attack") } : {}),
+ appliedEffects,
cancelReason: reason
});
}
- recordDamageJob(job: DamageJob, result: DamageResult): void {
- const sourceSkillReportKey = this.skillDamageKeys.get(job.id);
- this.skillDamageKeys.delete(job.id);
- if (job.kind === "skill" && sourceSkillReportKey && result.kills > 0) {
- const report = this.skillReports[job.dealerSide].get(sourceSkillReportKey);
- if (report) report.skillKills += result.kills;
+ recordDamageJob(job: DamageJob, result: DamageResult, intent?: AttackIntent): void {
+ const sourceSkillReport = this.skillDamageReports.get(job);
+ this.skillDamageReports.delete(job);
+ if (job.kind === "skill" && sourceSkillReport && result.kills > 0) {
+ sourceSkillReport.skillKills += result.kills;
}
+ const order = intent && job.kind === "normal" ? this.orderEvents.get(intent) : undefined;
+ if (intent && job.kind === "normal") this.orderEvents.delete(intent);
+ const extras = this.extraAttackEvents.get(job);
+ this.extraAttackEvents.delete(job);
+ const appliedEffects = mergeAppliedEffects(result.appliedEffects, order, extras);
const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";
- const order = job.sourceIntentId ? this.orderEvents.get(job.sourceIntentId) : undefined;
- if (job.sourceIntentId) this.orderEvents.delete(job.sourceIntentId);
- const extras = this.extraAttackEvents.get(job.id);
- this.extraAttackEvents.delete(job.id);
this.attacks.push({
- jobId: job.id,
round: job.round,
kind: job.kind,
sourceEffectId: job.sourceEffectId,
- sourceSkillReportKey,
dealerSide: job.dealerSide,
dealerUnit: job.dealerUnit,
takerSide: job.takerSide,
takerUnit: job.takerUnit,
kills: result.kills,
- counterDeltas: counterDeltas(job, cause),
- appliedEffects: mergeAppliedEffects(result.appliedEffects, order, extras),
+ ...(this.detailedAttackEffects ? { counterDeltas: counterDeltas(job, cause) } : {}),
+ ...(appliedEffects.length ? { appliedEffects } : {}),
trace: result.trace
});
}
+ protected effectEvent(
+ effect: ActiveEffect,
+ round: number,
+ detail: { kind: "control"; reason: "dodge" | "no_attack" } | { kind: "battle_order"; chosenTarget: UnitType } | { kind: "extra_attack"; spawnedJobCount: number }
+ ): AppliedEffect {
+ const summary = appliedEffectSummary(effect, effect.getCurrentValue(round));
+ return this.detailedAttackEffects
+ ? { ...summary, source: sourceLabel(effect), ...detail }
+ : summary;
+ }
+
recordFinalKills(_result: DamageResult): void {}
recordRound(_round: number, _roundStartTroops: DamageJob["roundStartTroops"], _intents: AttackIntent[]): void {
this.orderEvents.clear();
this.extraAttackEvents.clear();
- this.skillDamageKeys.clear();
+ this.skillDamageReports.clear();
}
protected incrementSkillReport(skill: ResolvedSkill, field: "triggersSeen" | "skillActivations" | "effectActivations"): void {
- const report = this.skillReports[skill.side].get(skillReportKey(skill));
+ const report = this.skillReports[skill.side].get(skill);
if (report) report[field] += 1;
}
}
export class FullTraceRecorder extends BasicInfoRecorder {
+ protected override readonly detailedAttackEffects: boolean = true;
private readonly rounds: BattleTrace["rounds"] = [];
private readonly roundJobs: DamageJob[] = [];
private staticDescription?: StaticProfileDescription;
@@ -344,10 +346,9 @@
this.incrementSkillReport(skill, "triggersSeen");
}
override recordScheduledDamageJob(job: DamageJob): void {
- const sourceSkillReportKey = this.skillDamageKeys.get(job.id);
- this.roundJobs.push(sourceSkillReportKey ? { ...job, sourceSkillReportKey } : job);
+ this.roundJobs.push(job);
}
override recordFinalKills(result: DamageResult): void {
if (result.trace) result.trace.finalKills = result.kills;
@@ -363,117 +364,121 @@
}
}
class BasicDamageJobRecorder implements DamageJobRecorder {
- protected readonly appliedEffects: DamageEquationTrace["appliedEffects"] = [];
+ private readonly appliedEffects: AppliedEffect[] = [];
constructor(private readonly staticApplied?: StaticAppliedIndex) {}
- recordModifier(effect: ActiveEffect, valuePct: number): void {
- if (valuePct === 0) return;
- const applied = {
- kind: "modifier" as const,
- activeEffectId: effect.id,
- effectId: effect.source.effectId ?? effect.id,
- bucket: effect.intent.type,
- valuePct,
- source: sourceLabel(effect),
- sourceSide: effect.ownerSide,
- sameEffectStacking: effect.sameEffectStacking,
- ...(effect.stackingKey === undefined ? {} : { stackingKey: effect.stackingKey })
- };
- this.appliedEffects.push(applied);
+ recordEffect(effect: ActiveEffect, value: number): void {
+ if (value !== 0) this.appliedEffects.push(appliedEffectSummary(effect, value));
}
-
recordRejected(_effect: ActiveEffect, _reason: RejectedEffectReason): void {}
finish({ job, kills }: DamageRecordingResult): DamageResult {
- return { kills, appliedEffects: this.appliedEffectsFor(job) };
- }
-
- // Dynamic modifiers first, then the prebuilt static passive fragments for the job's
- // participants — the same order the per-job walk used to produce.
- protected appliedEffectsFor(job: DamageJob): DamageEquationTrace["appliedEffects"] {
const statics = this.staticApplied;
- if (!statics) return this.appliedEffects;
+ if (!statics) return { kills, appliedEffects: this.appliedEffects };
const dealer = statics.dealer[job.dealerSide][job.dealerUnit];
const taker = statics.taker[job.takerSide][job.takerUnit];
- if (dealer.length === 0 && taker.length === 0) return this.appliedEffects;
- return [...this.appliedEffects, ...dealer, ...taker];
+ return { kills, appliedEffects: [...this.appliedEffects, ...dealer, ...taker] };
}
}
-class FullDamageJobRecorder extends BasicDamageJobRecorder {
+class FullDamageJobRecorder implements DamageJobRecorder {
+ private readonly appliedEffects: DamageEquationTrace["appliedEffects"] = [];
private readonly contributors: DamageBucketTrace["contributors"][] = Array.from(
- { length: ATOMIC_BUCKETS.length },
+ { length: DYNAMIC_BUCKETS.length },
() => []
);
private readonly rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];
constructor(
- staticApplied?: StaticAppliedIndex,
+ private readonly staticApplied?: StaticAppliedIndex,
private readonly staticDescription?: StaticProfileDescription
- ) { super(staticApplied); }
+ ) {}
- override recordModifier(effect: ActiveEffect, valuePct: number): void {
- this.contributors[effect.bucketIndex]?.push({
- effectId: effect.source.effectId ?? effect.id,
+ recordEffect(effect: ActiveEffect, value: number): void {
+ if (value === 0) return;
+ const identity = {
+ effectId: effectId(effect),
source: sourceLabel(effect),
sourceSide: effect.ownerSide,
- valuePct,
bucket: effect.intent.type,
- stackingKey: effect.stackingKey,
sameEffectStacking: effect.sameEffectStacking
- });
- super.recordModifier(effect, valuePct);
+ };
+ if (effect.kind === "shield") {
+ this.contributors[effect.bucketIndex]?.push({ ...identity, value });
+ this.appliedEffects.push({ kind: "shield", ...identity, value });
+ } else {
+ this.contributors[effect.bucketIndex]?.push({ ...identity, valuePct: value });
+ this.appliedEffects.push({ kind: "modifier", ...identity, valuePct: value });
+ }
}
- override recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): void {
- this.rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason });
+ recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): void {
+ this.rejectedEffects.push({ effectId: effectId(effect), reason });
}
- override finish(result: DamageRecordingResult): DamageResult {
- const basic = super.finish(result);
+ finish(result: DamageRecordingResult): DamageResult {
const job = result.job;
+ const appliedEffects = this.appliedEffectsFor(job);
const staticEntries = this.staticDescription
? [this.staticDescription.dealer[job.dealerSide][job.dealerUnit], this.staticDescription.taker[job.takerSide][job.takerUnit]]
: [];
return {
- ...basic,
+ kills: result.kills,
+ appliedEffects,
trace: {
roundStartTroops: {
attacker: { ...job.roundStartTroops.attacker },
defender: { ...job.roundStartTroops.defender }
},
armyTerm: result.armyTerm,
atomicBuckets: toTraceBuckets(result.factors, this.contributors, staticEntries),
aggregationGroups: buildAggregationGroups(job, result.factors, this.contributors, staticEntries),
- appliedEffects: basic.appliedEffects ?? [],
+ appliedEffects,
rejectedEffects: this.rejectedEffects,
+ damageBeforeOffsets: result.damageBeforeOffsets,
+ offsetDamage: result.offsetDamage,
rawDamage: result.rawDamage,
finalKills: result.kills
}
};
}
+
+ // Dynamic modifiers first, then the prebuilt static passive fragments for the job's
+ // participants — the same order the per-job walk used to produce.
+ private appliedEffectsFor(job: DamageJob): Array<AppliedModifierEffect | AppliedShieldEffect> {
+ const statics = this.staticApplied;
+ if (!statics) return this.appliedEffects;
+ const dealer = statics.dealer[job.dealerSide][job.dealerUnit];
+ const taker = statics.taker[job.takerSide][job.takerUnit];
+ if (dealer.length === 0 && taker.length === 0) return this.appliedEffects;
+ return [...this.appliedEffects, ...dealer, ...taker] as Array<AppliedModifierEffect | AppliedShieldEffect>;
+ }
}
-const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;
-const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));
+const DEFAULT_FACTOR_TERMS = DYNAMIC_BUCKETS.map((definition) => factorTerm(definition.name));
const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");
const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");
+const DEFAULT_OFFSET_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "post_subtract");
-function factorTerm(bucket: AtomicBucket): DamageFactorTerm {
- const definition = BUCKET_DEFINITIONS[bucket];
+function factorTerm(bucket: DynamicDamageBucket): DamageFactorTerm {
+ const definition = dynamicBucketDefinition(bucket)!;
return {
id: bucket,
- bucket: BUCKET_IDS[bucket],
+ bucket: DYNAMIC_BUCKET_INDEX[bucket],
bucketName: bucket,
placement: definition.placement,
- appliesTo: definition.appliesTo
+ damageKind: "damageKind" in definition ? definition.damageKind : undefined
};
}
-function buildStaticProfileDescription(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): StaticProfileDescription {
+function buildStaticProfileDescription(
+ fighters: Record<SideId, ResolvedFighter>,
+ setupEffects: ActiveEffect[],
+ detailed: boolean
+): StaticProfileDescription {
const description: StaticProfileDescription = {
dealer: {
attacker: buildSideEntries(fighters.attacker, "dealer"),
defender: buildSideEntries(fighters.defender, "dealer")
@@ -483,9 +488,9 @@
defender: buildSideEntries(fighters.defender, "taker")
}
};
for (const contribution of selectPassiveContributions(setupEffects)) {
- addPassiveTerm(description[contribution.role][contribution.side][contribution.unit], contribution);
+ addPassiveTerm(description[contribution.jobSide][contribution.side][contribution.unit], contribution, detailed);
}
return description;
}
@@ -493,8 +498,9 @@
return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildStaticEntry(fighter, unit, role)])) as Record<UnitType, StaticProfileEntry>;
}
function buildStaticEntry(fighter: ResolvedFighter, unit: UnitType, role: "dealer" | "taker"): StaticProfileEntry {
+ if (fighter.initialTroops[unit] <= 0) return { buckets: {} };
const stats = unitBaseStats(fighter, unit);
const bonuses = unitPlayerBonuses(fighter, unit);
const buckets: StaticProfileEntry["buckets"] = {};
if (role === "dealer") {
@@ -522,18 +528,17 @@
contributors: [{ effectId: `input:${bucket.slice("player.".length)}`, source: "input_stats", valuePct, bucket }]
};
}
-function addPassiveTerm(entry: StaticProfileEntry, contribution: PassiveContribution): void {
+function addPassiveTerm(entry: StaticProfileEntry, contribution: PassiveContribution, detailed: boolean): void {
const { bucket, effect, valuePct } = contribution;
const contributor = {
- effectId: effect.source.effectId ?? effect.id,
- source: sourceLabel(effect),
+ effectId: effectId(effect),
+ source: detailed ? sourceLabel(effect) : "",
sourceSide: effect.ownerSide,
valuePct,
bucket,
- stackingKey: effect.stackingKey,
- sameEffectStacking: effect.sameEffectStacking
+ ...(detailed ? { sameEffectStacking: effect.sameEffectStacking } : {})
};
const existing = entry.buckets[bucket];
if (existing) {
existing.totalPct = (existing.totalPct ?? 0) + valuePct;
@@ -544,36 +549,40 @@
}
}
function staticAppliedFragments(
- entries: Record<SideId, Record<UnitType, StaticProfileEntry>>
-): Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>> {
+ entries: Record<SideId, Record<UnitType, StaticProfileEntry>>,
+ detailed: boolean
+): Record<SideId, Record<UnitType, AppliedEffect[]>> {
return Object.fromEntries(
(Object.entries(entries) as Array<[SideId, Record<UnitType, StaticProfileEntry>]>).map(([side, units]) => [
side,
Object.fromEntries(
- (Object.entries(units) as Array<[UnitType, StaticProfileEntry]>).map(([unit, entry]) => [unit, passiveAppliedEffects(entry)])
+ (Object.entries(units) as Array<[UnitType, StaticProfileEntry]>).map(([unit, entry]) => [unit, passiveAppliedEffects(entry, detailed)])
)
])
- ) as Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;
+ ) as Record<SideId, Record<UnitType, AppliedEffect[]>>;
}
-function passiveAppliedEffects(entry: StaticProfileEntry): DamageEquationTrace["appliedEffects"] {
- const applied: DamageEquationTrace["appliedEffects"] = [];
+function passiveAppliedEffects(entry: StaticProfileEntry, detailed: boolean): AppliedEffect[] {
+ const applied: AppliedEffect[] = [];
for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticProfileEntry["buckets"][StaticDamageBucket]]>) {
if (!bucket.startsWith("passive.") || !term) continue;
for (const contributor of term.contributors) {
- applied.push({
- kind: "modifier",
- activeEffectId: contributor.effectId,
+ const summary = {
effectId: contributor.effectId,
bucket,
- valuePct: contributor.valuePct,
- source: contributor.source,
- sourceSide: contributor.sourceSide,
- stackingKey: contributor.stackingKey,
- sameEffectStacking: contributor.sameEffectStacking
- });
+ valuePct: contributor.valuePct!,
+ sourceSide: contributor.sourceSide!
+ };
+ applied.push(detailed
+ ? {
+ ...summary,
+ kind: "modifier",
+ source: contributor.source,
+ sameEffectStacking: contributor.sameEffectStacking ?? "add"
+ }
+ : summary);
}
}
return applied;
}
@@ -585,15 +594,25 @@
staticEntries: StaticProfileEntry[]
): Record<string, DamageAggregationGroupTrace> {
const groups: Record<string, DamageAggregationGroupTrace> = {};
addStaticAggregationGroups(groups, staticEntries);
- for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS]) {
- if (term.appliesTo && term.appliesTo !== job.kind) continue;
- const definition = BUCKET_DEFINITIONS[term.bucketName];
+ for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS, ...DEFAULT_OFFSET_TERMS]) {
+ if (term.damageKind && term.damageKind !== job.kind) continue;
+ const definition = dynamicBucketDefinition(term.bucketName)!;
const factor = factors[term.bucket];
- groups[term.id] = definition.valueType === "raw"
+ groups[term.id] = definition.update === "add_raw"
? {
id: term.id,
+ mode: "sum_raw",
+ placement: term.placement,
+ inputBuckets: [term.bucketName],
+ raw: factor,
+ factor: 1,
+ contributors: contributors[term.bucket] ?? []
+ }
+ : definition.update === "assign_factor"
+ ? {
+ id: term.id,
mode: "raw",
placement: term.placement,
inputBuckets: [term.bucketName],
factor,
@@ -679,13 +698,15 @@
contributors: DamageBucketTrace["contributors"][],
staticEntries: StaticProfileEntry[]
): Record<string, DamageBucketTrace> {
const traced = Object.fromEntries(
- ATOMIC_BUCKETS.map((bucket, index) => {
- const definition = BUCKET_DEFINITIONS[bucket];
+ DYNAMIC_BUCKETS.map((definition, index) => {
+ const bucket = definition.name;
const factor = factors[index];
const bucketContributors = contributors[index] ?? [];
- return definition.valueType === "raw"
+ return definition.update === "add_raw"
+ ? [bucket, { raw: factor, factor: 1, contributors: [...bucketContributors] }]
+ : definition.update === "assign_factor"
? [bucket, { raw: factor, factor, contributors: [...bucketContributors] }]
: [bucket, { totalPct: pctFromFactor(factor), factor, contributors: [...bucketContributors] }];
})
) as Record<string, DamageBucketTrace>;
@@ -699,57 +720,31 @@
}
return traced;
}
-function appliedEffectBase(effect: ActiveEffect): { activeEffectId: string; effectId: string; source: string; sourceSide: SideId } {
- return {
- activeEffectId: effect.id,
- effectId: effect.source.effectId ?? effect.id,
- source: sourceLabel(effect),
- sourceSide: effect.ownerSide
+function effectId(effect: ActiveEffect): string {
+ return effect.source.effectId ?? effect.intent.id;
+}
+
+function appliedEffectSummary(effect: ActiveEffect, value: number): AppliedEffect {
+ const identity = {
+ effectId: effectId(effect),
+ sourceSide: effect.ownerSide,
+ bucket: effect.intent.type
};
+ return effect.kind === "shield" ? { ...identity, value } : { ...identity, valuePct: value };
}
function counterDeltas(
attack: Pick<AttackIntent | DamageJob, "dealerSide" | "dealerUnit" | "takerSide" | "takerUnit">,
cause: "normal_attack" | "extra_skill_attack"
-): AttackOutcome["counterDeltas"] {
+): NonNullable<AttackOutcome["counterDeltas"]> {
return [
{ side: attack.dealerSide, unit: attack.dealerUnit, counter: "attacks", by: 1, cause },
{ side: attack.takerSide, unit: attack.takerUnit, counter: "received_attacks", by: 1, cause }
];
}
-function skillReportKey(skill: ResolvedSkill): string {
- const cached = REPORT_KEY_CACHE.get(skill);
- if (cached) return cached;
- const key = skillReportKeyFromParts(
- skill.sourceKind,
- skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "",
- skill.id
- );
- REPORT_KEY_CACHE.set(skill, key);
- return key;
-}
-
-function skillReportKeyForEffect(effect: ActiveEffect): string | undefined {
- const source = effect.source;
- if ((source.kind !== "hero_skill" && source.kind !== "troop_skill") || !source.skillId) return undefined;
- return skillReportKeyFromParts(
- source.kind,
- source.heroInstanceId ?? source.heroName ?? source.troopType ?? "",
- source.skillId
- );
-}
-
-function skillReportKeyFromParts(
- sourceKind: SkillReportEntry["sourceKind"],
- sourceKey: string,
- skillId: string
-): string {
- return `${sourceKind}:${sourceKey}:${skillId}`;
-}
-
function mergeAppliedEffects(
applied: AppliedEffect[] | undefined,
order: AppliedEffect | undefined,
extras: AppliedEffect[] | undefined
Index: simulator/src/resolve.ts
===================================================================
--- simulator/src/resolve.ts prev run
+++ simulator/src/resolve.ts this run
@@ -1,351 +0,0 @@
-import type {
- EffectIntentDefinition,
- FighterInput,
- HeroInputCollection,
- ResolvedFighter,
- ResolvedHero,
- ResolvedEffectIntentDefinition,
- ResolvedSkill,
- ResolvedTroopLine,
- SideId,
- SimulatorConfig,
- SkillDefinition,
- SkillFile,
- SkillRequirement,
- StatBlock,
- UnitType
-} from "./types";
-import { UNIT_TYPES } from "./types";
-import { normalizeEngagementType } from "./effects";
-import { addStats, normalizeStatBlock, normalizeUnitType, valueAtLevel, zeroStats } from "./normalize";
-
-export function resolveFighter(input: FighterInput, side: SideId, config: SimulatorConfig, engagementType?: string): ResolvedFighter {
- const diagnostics: string[] = [];
- const troops = emptyTroops();
- const initialTroops = emptyTroops();
- const troopDetails: Partial<Record<UnitType, ResolvedTroopLine>> = {};
- const weightedStats: Partial<Record<UnitType, { count: number; stats: StatBlock; tier: number; fc: number; ids: string[] }>> = {};
-
- for (const [troopId, rawCount] of Object.entries(input.troops ?? {})) {
- const count = Number(rawCount) || 0;
- if (count <= 0) continue;
- const record = config.troopStats[troopId];
- if (!record) {
- diagnostics.push(`Unsupported troop id ${troopId}`);
- continue;
- }
- let type: UnitType;
- try {
- type = normalizeUnitType(String(record.type));
- } catch {
- diagnostics.push(`Unsupported troop type ${record.type} for ${troopId}`);
- continue;
- }
- const stats = normalizeStatBlock(record.stats);
- troops[type] += count;
- initialTroops[type] += count;
- const existing = weightedStats[type] ?? { count: 0, stats: zeroStats(), tier: 0, fc: 0, ids: [] };
- existing.count += count;
- existing.stats = addStats(existing.stats, {
- attack: stats.attack * count,
- defense: stats.defense * count,
- lethality: stats.lethality * count,
- health: stats.health * count
- });
- existing.tier = Math.max(existing.tier, Number(record.tier) || 0);
- existing.fc = Math.max(existing.fc, Number(record.fc) || 0);
- existing.ids.push(troopId);
- weightedStats[type] = existing;
- }
-
- for (const [type, aggregate] of Object.entries(weightedStats) as Array<[UnitType, NonNullable<typeof weightedStats[UnitType]>]>) {
- troopDetails[type] = {
- id: aggregate.ids.join("+"),
- type,
- tier: aggregate.tier,
- fc: aggregate.fc,
- count: aggregate.count,
- stats: {
- attack: aggregate.stats.attack / aggregate.count,
- defense: aggregate.stats.defense / aggregate.count,
- lethality: aggregate.stats.lethality / aggregate.count,
- health: aggregate.stats.health / aggregate.count
- }
- };
- }
-
- const statBonuses = resolveInputStatBonuses(input.stats);
- const heroes = resolveHeroes(input, side, config, diagnostics, engagementType);
- const heroSkills = resolveHeroSkills(input, side, config, diagnostics, engagementType);
- const troopSkills = resolveTroopSkills(side, troopDetails, config, engagementType);
-
- return {
- side,
- name: input.name ?? side,
- troops,
- initialTroops,
- troopDetails,
- statBonuses,
- heroes,
- heroSkills,
- troopSkills,
- diagnostics
- };
-}
-
-export function emptyTroops(): Record<UnitType, number> {
- return { infantry: 0, lancer: 0, marksman: 0 };
-}
-
-function resolveInputStatBonuses(stats: FighterInput["stats"]): Record<UnitType, StatBlock> {
- const byUnit: Record<UnitType, StatBlock> = {
- infantry: zeroStats(),
- lancer: zeroStats(),
- marksman: zeroStats()
- };
- for (const [key, block] of Object.entries(stats ?? {})) {
- try {
- byUnit[normalizeUnitType(key)] = normalizeStatBlock(block as Record<string, unknown>);
- } catch {
- // Unknown testcase stat aliases are non-fatal because troop ids still determine active units.
- }
- }
- return byUnit;
-}
-
-// 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";
- instanceId: string;
-}
-
-function heroInputInstances(input: FighterInput): HeroInputInstance[] {
- return [...heroCollectionInstances(input.heroes, "main"), ...heroCollectionInstances(input.joiner_heroes, "joiner")];
-}
-
-function heroCollectionInstances(collection: HeroInputCollection | undefined, role: "main" | "joiner"): HeroInputInstance[] {
- if (!collection) return [];
- const entries = Array.isArray(collection)
- ? collection.map((entry) => ({ name: entry.name, levels: entry.levels ?? {} }))
- : Object.entries(collection).map(([name, levels]) => ({ name, levels }));
- const counts = new Map<string, number>();
- return entries.map((entry) => {
- const key = `${role}:${normalizeHeroName(entry.name)}`;
- const index = counts.get(key) ?? 0;
- counts.set(key, index + 1);
- return {
- name: entry.name,
- levels: entry.levels,
- role,
- instanceId: `${role}:${normalizeHeroName(entry.name)}:${index}`
- };
- });
-}
-
-function resolveHeroes(
- input: FighterInput,
- side: SideId,
- config: SimulatorConfig,
- diagnostics: string[],
- engagementType?: string
-): ResolvedHero[] {
- const heroes: ResolvedHero[] = [];
- for (const instance of heroInputInstances(input)) {
- const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
- const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;
- if (!definition) {
- diagnostics.push(`Missing hero definition for ${instance.name}`);
- heroes.push({
- name: instance.name,
- skillIds: [],
- instanceId: instance.instanceId,
- role: instance.role,
- missing: true
- });
- continue;
- }
- heroes.push({
- name: definition.name ?? instance.name,
- heroGeneration: definition.hero_generation,
- skillIds: resolveHeroSkillIds(definition, instance.levels, side, engagementType),
- instanceId: instance.instanceId,
- role: instance.role
- });
- }
- void side;
- return heroes;
-}
-
-function resolveHeroSkills(
- input: FighterInput,
- side: SideId,
- config: SimulatorConfig,
- diagnostics: string[],
- engagementType?: string
-): ResolvedSkill[] {
- const skills: ResolvedSkill[] = [];
- for (const instance of heroInputInstances(input)) {
- const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
- const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;
- if (!definition) continue;
- let index = 0;
- for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {
- index += 1;
- const level = Number(instance.levels[`skill_${index}`] ?? instance.levels[skillId] ?? 0);
- if (level <= 0) continue;
- if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, 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, 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, engagementType)) ids.push(skillId);
- }
- return ids;
-}
-
-function resolveTroopSkills(
- side: SideId,
- troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>,
- config: SimulatorConfig,
- engagementType?: string
-): ResolvedSkill[] {
- const skills: ResolvedSkill[] = [];
- for (const [skillId, rawSkill] of Object.entries(config.troopSkills.skills ?? {})) {
- let troopType: UnitType;
- try {
- troopType = normalizeUnitType(String((rawSkill as { troop_type?: unknown }).troop_type));
- } catch {
- continue;
- }
- const troop = troopDetails[troopType];
- if (!troop) continue;
- const requirements = (((rawSkill as { requirements?: unknown }).requirements ?? []) as SkillRequirement[]);
- const troopRequirements = requirements.filter((req) => req.type === "tier" || req.type === "fc");
- const satisfied = troopRequirements
- .filter((req) => (req.type === "tier" ? troop.tier >= Number(req.value) : req.type === "fc" ? troop.fc >= Number(req.value) : false))
- .sort((a, b) => b.level - a.level)[0];
- if (!satisfied) continue;
- if (!battleRequirementsSatisfied(requirements, satisfied.level, 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, engagementType?: string): boolean {
- return (requirements ?? []).every((requirement) => {
- if (requirement.type !== "engagement_type") return battleRequirementsSatisfied([requirement], level, engagementType);
- if (level < Number(requirement.level ?? 1)) return true;
- return heroEngagementRequirementSatisfied(requirement, side, engagementType);
- });
-}
-
-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) === normalizeEngagementType(engagementType);
- });
-}
-
-function heroEngagementRequirementSatisfied(requirement: SkillRequirement, side: SideId, engagementType?: string): boolean {
- const required = normalizeEngagementType(requirement.value);
- 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 hydrateSkill(
- skillId: string,
- rawSkill: Omit<SkillDefinition, "id" | "name">,
- side: SideId,
- level: number,
- sourceKind: "hero_skill" | "troop_skill",
- heroName?: string,
- troopType?: UnitType,
- heroInstanceId?: string,
- heroRole?: "main" | "joiner"
-): ResolvedSkill {
- const effects: ResolvedEffectIntentDefinition[] = [];
- for (const [effectId, effect] of Object.entries(rawSkill.effects ?? {})) {
- effects.push({
- id: effectId,
- ...effect,
- value: levelSelectPreservingOrder(effect.value, level),
- sourceDefinition: effect
- });
- }
- return {
- id: skillId,
- name: skillId,
- sourceKind,
- side,
- heroName,
- heroInstanceId,
- heroRole,
- troopType,
- level,
- trigger: rawSkill.trigger,
- effects
- };
-}
-
-function levelSelectPreservingOrder(value: unknown, level: number): unknown {
- if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value;
- return valueAtLevel(value, level);
-}
-
-function resolveHeroDefinitionKey(heroName: string, config: SimulatorConfig): string | undefined {
- if (config.heroDefinitions[heroName]) return heroName;
- const normalized = normalizeHeroName(heroName);
- const indexed = config.heroAliasIndex?.[normalized];
- if (indexed && config.heroDefinitions[indexed]) return indexed;
- const manualAliases: Record<string, string> = {
- lingxue: "Ling",
- lumakbokan: "Lumak",
- wuming: "WuMing"
- };
- const manual = manualAliases[normalized];
- if (manual && config.heroDefinitions[manual]) return manual;
- for (const [key, definition] of Object.entries(config.heroDefinitions)) {
- if (normalizeHeroName(key) === normalized || normalizeHeroName(definition.name ?? "") === normalized) return key;
- }
- return undefined;
-}
-
-function normalizeHeroName(name: string): string {
- return name.toLowerCase().replace(/[^a-z0-9]/g, "");
-}
Index: simulator/src/runtime.ts
===================================================================
--- simulator/src/runtime.ts prev run
+++ simulator/src/runtime.ts this run
@@ -0,0 +1,264 @@
+import type {
+ ActiveEffect,
+ AttackIntent,
+ DamageJob,
+ ResolvedFighter,
+ ResolvedSkill,
+ SideId,
+ UnitType
+} from "./types";
+import { UNIT_TYPES } from "./types";
+import {
+ activateEffect,
+ chancePasses,
+ effectAttackUseLimit,
+ effectRoundWindow,
+ skillMatchesTrigger,
+ type Rng
+} from "./effects";
+import { createEffectIndex, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";
+import { createDamageScratch, type DamageResult, type DamageScratch, type StaticDamageProfile } from "./damage";
+import type { DeferredEffectPlan, PreparedAttackSkill, RuntimeSkills } from "./runtimeSkills";
+import type { BattleRecorder } from "./recorder";
+import { emptyTroops } from "./fighterResolution";
+
+export interface Runtime {
+ effectIndex: EffectIndex;
+ // Live troop counts, initialized from the fighters' immutable initialTroops.
+ troops: Record<SideId, Record<UnitType, number>>;
+ activateEffectsByRound: Array<ActiveEffect[] | undefined>;
+ expireEffectsByRound: Array<ActiveEffect[] | undefined>;
+ // Per-job scratch: effects that affected the job being calculated; drained by
+ // chargeUsedEffects (uses += 1 each) after every job in every mode.
+ usedEffects: ActiveEffect[];
+ staticDamageProfile: StaticDamageProfile;
+ damageScratch: DamageScratch;
+ rng: Rng;
+ skills: RuntimeSkills;
+ effectActivationCounts: Record<SideId, number>;
+ extraSkillAttackJobsByEffect: Record<string, number>;
+ attackControlCounts: { dodge: number; no_attack: number };
+ counters: {
+ attacks: Record<SideId, Record<UnitType, number>>;
+ received: Record<SideId, Record<UnitType, number>>;
+ };
+}
+
+export interface RunLoopOptions {
+ capRoundKills: boolean;
+ capJobKills: boolean;
+ commitLosses: boolean;
+ scoreSide?: {
+ dealerSide: SideId;
+ takerSide: SideId;
+ };
+}
+
+export interface DamageJobResult {
+ job: DamageJob;
+ result: DamageResult;
+ intent?: AttackIntent;
+}
+
+export function createRuntime(fighters: Record<SideId, ResolvedFighter>, rng: Rng, skills: RuntimeSkills, staticDamageProfile: StaticDamageProfile): Runtime {
+ return {
+ troops: {
+ attacker: { ...fighters.attacker.initialTroops },
+ defender: { ...fighters.defender.initialTroops }
+ },
+ effectIndex: createEffectIndex(
+ skills.effectGroups,
+ skills.damageGroupsByJobShape
+ ),
+ activateEffectsByRound: [],
+ expireEffectsByRound: [],
+ usedEffects: [],
+ staticDamageProfile,
+ damageScratch: createDamageScratch(),
+ rng,
+ skills,
+ effectActivationCounts: { attacker: 0, defender: 0 },
+ extraSkillAttackJobsByEffect: {},
+ attackControlCounts: { dodge: 0, no_attack: 0 },
+ counters: {
+ attacks: { attacker: emptyTroops(), defender: emptyTroops() },
+ received: { attacker: emptyTroops(), defender: emptyTroops() }
+ }
+ };
+}
+
+export function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
+ if (!isRuntimeIndexableEffect(effect)) return;
+ const window = effectRoundWindow(effect);
+ if (!window) {
+ indexEffect(runtime.effectIndex, effect);
+ return;
+ }
+ if (window.expirationRound !== undefined) {
+ if (window.expirationRound <= window.activationRound) {
+ effect.expired = true;
+ return;
+ }
+ scheduleEffect(runtime.expireEffectsByRound, window.expirationRound, effect);
+ }
+ if (window.activationRound <= effect.createdRound) indexEffect(runtime.effectIndex, effect);
+ else scheduleEffect(runtime.activateEffectsByRound, window.activationRound, effect);
+}
+
+function scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {
+ const effects = schedule[round];
+ if (effects) effects.push(effect);
+ else schedule[round] = [effect];
+}
+
+export function processEffectSchedule(runtime: Runtime, round: number): void {
+ const expiring = runtime.expireEffectsByRound[round];
+ if (expiring) {
+ for (const effect of expiring) expireActiveEffect(runtime, effect);
+ runtime.expireEffectsByRound[round] = undefined;
+ }
+ const activating = runtime.activateEffectsByRound[round];
+ if (activating) {
+ for (const effect of activating) {
+ if (!effect.expired) indexEffect(runtime.effectIndex, effect);
+ }
+ runtime.activateEffectsByRound[round] = undefined;
+ }
+}
+
+export function triggerSkills(
+ triggerType: "battle_start" | "round_start" | "attack_declared",
+ round: number,
+ skills: ResolvedSkill[],
+ runtime: Runtime,
+ recorder: BattleRecorder,
+ intent?: AttackIntent
+): ActiveEffect[] {
+ const activated: ActiveEffect[] = [];
+ for (const skill of skills) {
+ if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;
+ recorder.recordSkillTriggerAttempt(skill);
+ if (!chancePasses(skill, runtime.rng)) continue;
+ recorder.recordSkillTriggered(skill);
+ for (const effectIntent of skill.effects) {
+ const effect = activateEffect(skill, effectIntent, round, intent);
+ addActiveEffect(runtime, effect);
+ activated.push(effect);
+ runtime.effectActivationCounts[skill.side] += 1;
+ recorder.recordSkillEffectActivated(skill);
+ }
+ }
+ return activated;
+}
+
+// Attack-triggered effects with a value_formula are gated here with the rest of
+// their skill, but materialized only after the normal attack and its immediate
+// extra-skill jobs have produced finalized kill counts.
+export function triggerAttackSkills(
+ round: number,
+ preparedSkills: PreparedAttackSkill[],
+ runtime: Runtime,
+ recorder: BattleRecorder,
+ intent: AttackIntent
+): DeferredEffectPlan[] | undefined {
+ let deferred: DeferredEffectPlan[] | undefined;
+ for (const prepared of preparedSkills) {
+ const { skill } = prepared;
+ if (!skillMatchesTrigger(skill, "attack_declared", round, intent)) continue;
+ recorder.recordSkillTriggerAttempt(skill);
+ if (!chancePasses(skill, runtime.rng)) continue;
+ recorder.recordSkillTriggered(skill);
+ for (const effectIntent of prepared.immediateEffects) {
+ const effect = activateEffect(skill, effectIntent, round, intent);
+ addActiveEffect(runtime, effect);
+ runtime.effectActivationCounts[skill.side] += 1;
+ recorder.recordSkillEffectActivated(skill);
+ }
+ if (prepared.deferredEffects) {
+ if (deferred) deferred.push(...prepared.deferredEffects);
+ else deferred = prepared.deferredEffects.slice();
+ }
+ }
+ return deferred;
+}
+
+export function materializeDeferredEffects(
+ plans: DeferredEffectPlan[] | undefined,
+ round: number,
+ intent: AttackIntent,
+ normalKills: number,
+ skillKills: number,
+ runtime: Runtime,
+ recorder: BattleRecorder
+): void {
+ if (!plans) return;
+ for (const { skill, intent: effectIntent } of plans) {
+ const formula = effectIntent.value_formula!;
+ const basis = formula.source === "trigger.normal_kills"
+ ? normalKills
+ : formula.source === "trigger.skill_kills"
+ ? skillKills
+ : normalKills + skillKills;
+ const coefficientPct = Number(effectIntent.value);
+ const resolvedValue = basis * coefficientPct / 100;
+ if (!Number.isFinite(resolvedValue) || resolvedValue <= 0) continue;
+ const effect = activateEffect(skill, effectIntent, round, intent, resolvedValue);
+ addActiveEffect(runtime, effect);
+ runtime.effectActivationCounts[skill.side] += 1;
+ recorder.recordSkillEffectActivated(skill);
+ }
+}
+
+export function emptyRoundTargetDamage(): Record<SideId, Record<UnitType, number>> {
+ return { attacker: emptyTroops(), defender: emptyTroops() };
+}
+
+export function targetExhausted(
+ job: DamageJob,
+ roundStartTroops: DamageJob["roundStartTroops"],
+ roundTargetDamage: Record<SideId, Record<UnitType, number>>
+): boolean {
+ const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);
+ return available <= 0 || roundTargetDamage[job.takerSide][job.takerUnit] >= available;
+}
+
+export function capJobToRemainingTarget(
+ result: DamageResult,
+ job: DamageJob,
+ roundStartTroops: DamageJob["roundStartTroops"],
+ roundTargetDamage: Record<SideId, Record<UnitType, number>>,
+ recorder: BattleRecorder
+): void {
+ const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);
+ const alreadyDamaged = roundTargetDamage[job.takerSide][job.takerUnit];
+ const remaining = Math.max(0, available - alreadyDamaged);
+ result.kills = Math.min(result.kills, remaining);
+ roundTargetDamage[job.takerSide][job.takerUnit] += result.kills;
+ recorder.recordFinalKills(result);
+}
+
+// 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.
+
+// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in
+// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.
+export function chargeUsedEffects(runtime: Runtime): void {
+ if (runtime.usedEffects.length === 0) return;
+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);
+ runtime.usedEffects.length = 0;
+}
+
+export function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {
+ effect.uses += 1;
+ const limit = effectAttackUseLimit(effect);
+ if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);
+}
+
+export function expireActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
+ if (effect.expired) return;
+ expireEffectIndex(runtime.effectIndex, effect);
+}
+
+// A cancelled attack still charges the attacker's attack-constrained effects (the attack
+// happened, it just didn't land) plus the control that cancelled it, whatever its duration.
Index: simulator/src/runtimeSkills.ts
===================================================================
--- simulator/src/runtimeSkills.ts prev run
+++ simulator/src/runtimeSkills.ts this run
@@ -0,0 +1,188 @@
+import type {
+ ActiveEffectGroup,
+ AttackIntent,
+ BattleRandomness,
+ ResolvedEffectIntentDefinition,
+ ResolvedFighter,
+ ResolvedSkill,
+ SideId
+} from "./types";
+import { unitsFromMask } from "./types";
+import { activateEffect, compiledTriggerForSkill, oppositeSide, resolvedEffectScopeKey } from "./effects";
+import { damageBucketIndex, damageJobShapeSlot, damageShapeSlotsForEffect, DAMAGE_JOB_SHAPE_SLOTS } from "./effectIndex";
+import { dynamicBucketDefinition } from "./damageBuckets";
+
+/**
+ * Battle-preparation product: every skill bucketed by trigger phase, the prepared
+ * effect-group graph for runtime damage modifiers (see the EffectIndex design notes),
+ * and run-invariant metadata. Built once per battle — or once per CompiledBattle and
+ * shared across all of its runs.
+ */
+export interface RuntimeSkills {
+ // Chance-free static-passive skills activated once at prepare time, before any runtime exists.
+ preBattle: ResolvedSkill[];
+ battleStart: ResolvedSkill[];
+ roundStartGlobal: ResolvedSkill[];
+ roundStartPerUnit: ResolvedSkill[];
+ attackDeclaredByJobShape: Array<PreparedAttackSkill[] | undefined>;
+ effectGroups: ActiveEffectGroup[];
+ damageGroupsByJobShape: ActiveEffectGroup[][];
+ randomness: BattleRandomness;
+}
+
+export interface DeferredEffectPlan {
+ skill: ResolvedSkill;
+ intent: ResolvedEffectIntentDefinition;
+}
+
+export interface PreparedAttackSkill {
+ skill: ResolvedSkill;
+ immediateEffects: ResolvedEffectIntentDefinition[];
+ deferredEffects?: DeferredEffectPlan[];
+}
+
+export function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {
+ const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);
+ const preBattle: ResolvedSkill[] = [];
+ const battleStart: ResolvedSkill[] = [];
+ const roundStartGlobal: ResolvedSkill[] = [];
+ const roundStartPerUnit: ResolvedSkill[] = [];
+ const attackDeclaredByJobShape: Array<PreparedAttackSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });
+ const chanceSkillIds: Record<SideId, string[]> = { attacker: [], defender: [] };
+ const effectGroups: ActiveEffectGroup[] = [];
+ const damageGroupsByJobShape: ActiveEffectGroup[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => []);
+ // One scope-key table per (side, config definition); duplicate main/joiner copies of the
+ // same config effect share it, so their activations land in the same groups.
+ const groupTablesByDefinition: Record<SideId, Map<object, Array<ActiveEffectGroup | undefined>>> = {
+ attacker: new Map(),
+ defender: new Map()
+ };
+
+ for (const skill of all) {
+ if (skill.trigger.type === "pre_battle") {
+ preBattle.push(skill);
+ continue;
+ }
+
+ const trigger = compiledTriggerForSkill(skill);
+ if (trigger.probabilityPct > 0 && trigger.probabilityPct < 100) chanceSkillIds[skill.side].push(skill.id);
+
+ if (skill.trigger.type === "battle_start") {
+ battleStart.push(skill);
+ } else if (skill.trigger.type === "turn") {
+ (hasPerUnitRoundTrigger(skill) ? roundStartPerUnit : roundStartGlobal).push(skill);
+ } else if (skill.trigger.type === "attack") {
+ const immediateEffects = skill.effects.filter((intent) => intent.value_formula === undefined);
+ const deferredIntents = skill.effects.filter((intent) => intent.value_formula !== undefined);
+ const prepared: PreparedAttackSkill = {
+ skill,
+ immediateEffects,
+ ...(deferredIntents.length > 0
+ ? { deferredEffects: deferredIntents.map((intent) => ({ skill, intent })) }
+ : {})
+ };
+ for (const dealerUnit of unitsFromMask(trigger.source.units)) {
+ for (const takerUnit of unitsFromMask(trigger.target.units)) {
+ const slot = damageJobShapeSlot("normal", trigger.source.side, dealerUnit, trigger.target.side, takerUnit);
+ const matching = attackDeclaredByJobShape[slot];
+ if (matching) matching.push(prepared);
+ else attackDeclaredByJobShape[slot] = [prepared];
+ }
+ }
+ }
+
+ for (const intent of skill.effects) {
+ const definition = dynamicBucketDefinition(intent.type);
+ if (definition?.effectBucket !== true) continue;
+ const existingTable = groupTablesByDefinition[skill.side].get(intent.sourceDefinition);
+ if (existingTable) {
+ intent.effectGroupsByScopeKey = existingTable;
+ continue;
+ }
+ const groupsByScopeKey: Array<ActiveEffectGroup | undefined> = [];
+ const groupsForDefinition: ActiveEffectGroup[] = [];
+ const slotsForDefinition: Uint8Array[] = [];
+ for (const attackIntent of potentialActivationIntents(skill)) {
+ const candidate = activateEffect(skill, intent, 1, attackIntent);
+ const scopeKey = resolvedEffectScopeKey(candidate.appliesTo, candidate.appliesVs);
+ if (groupsByScopeKey[scopeKey]) continue;
+ const slots = damageShapeSlotsForEffect(candidate, definition.name);
+ if (slots.length === 0) continue;
+ if (candidate.sameEffectStacking === "max") assertDisjointResolvedGroupSlots(intent.id, slots, slotsForDefinition);
+ const group: ActiveEffectGroup = {
+ ordinal: effectGroups.length,
+ bucketIndex: damageBucketIndex(definition.name),
+ sameEffectStacking: candidate.sameEffectStacking
+ };
+ groupsByScopeKey[scopeKey] = group;
+ groupsForDefinition.push(group);
+ slotsForDefinition.push(slots);
+ effectGroups.push(group);
+ for (const slot of slots) damageGroupsByJobShape[slot].push(group);
+ }
+ if (groupsForDefinition.length === 0) throw new Error(`Runtime effect ${intent.id} has no resolvable effect group`);
+ groupTablesByDefinition[skill.side].set(intent.sourceDefinition, groupsByScopeKey);
+ intent.effectGroupsByScopeKey = groupsByScopeKey;
+ }
+ }
+ return {
+ preBattle,
+ battleStart,
+ roundStartGlobal,
+ roundStartPerUnit,
+ attackDeclaredByJobShape,
+ effectGroups,
+ damageGroupsByJobShape,
+ randomness: {
+ deterministic: chanceSkillIds.attacker.length === 0 && chanceSkillIds.defender.length === 0,
+ chanceSkillIds
+ }
+ };
+}
+
+function hasPerUnitRoundTrigger(skill: ResolvedSkill): boolean {
+ return skill.trigger.type === "turn" && skill.trigger.source !== undefined;
+}
+
+function assertDisjointResolvedGroupSlots(effectId: string, slots: Uint8Array, existingGroups: Iterable<Uint8Array>): void {
+ for (const existing of existingGroups) {
+ for (const slot of slots) {
+ if (existing.includes(slot)) {
+ throw new Error(`Resolved scopes for max-stacking effect ${effectId} overlap at damage job slot ${slot}`);
+ }
+ }
+ }
+}
+
+// Enumerate the attack-intent shapes this skill's trigger can ever activate with, so
+// preparation can pre-create an effect group for every reachable resolved scope. The
+// activations built from these intents are throwaway scope probes, never indexed.
+function potentialActivationIntents(skill: ResolvedSkill): Array<AttackIntent | undefined> {
+ if (skill.trigger.type === "battle_start" || (skill.trigger.type === "turn" && skill.trigger.source === undefined)) return [undefined];
+ const trigger = compiledTriggerForSkill(skill);
+ const dealerSide = trigger.source.side;
+ let takerSide = trigger.target.side;
+ if (skill.trigger.type === "turn" && takerSide === dealerSide) takerSide = oppositeSide(dealerSide);
+ if (takerSide === dealerSide) return [];
+ const dealerUnits = unitsFromMask(trigger.source.units);
+ const takerUnits = unitsFromMask(trigger.target.units);
+ const intents: AttackIntent[] = [];
+ for (const dealerUnit of dealerUnits) {
+ for (const takerUnit of takerUnits) {
+ intents.push({
+ round: 1,
+ source: "normal",
+ dealerSide,
+ dealerUnit,
+ takerSide,
+ takerUnit,
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 1,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 1
+ });
+ }
+ }
+ return intents;
+}
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -2,14 +2,19 @@
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
+import { BattleInputBuilder } from "./battleInputBuilder";
import { loadSimulatorConfig } from "./config";
import { createSeededRng, chancePasses } from "./effects";
-import { applyHeroGenerationStats, resolveFighter } from "./resolve";
+import { applyHeroGenerationStats, resolveFighter } from "./fighterResolution";
import { prepareBattle, runPrepared, simulateBattles, simulateBearBattle, signedRemainingScore } from "./simulator";
-import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";
+import type { AppliedEffect, BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";
+function hasEffectKind(effect: AppliedEffect, kind: string): boolean {
+ return "kind" in effect && effect.kind === kind;
+}
+
function runOnce(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}) {
return runPrepared(prepareBattle(input, config), undefined, options);
}
@@ -83,9 +88,9 @@
assert.deepEqual(result.resolved.attacker.heroes, []);
assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));
});
-test("standard battle outcomes include round and applied effects without full traces", () => {
+test("standard battle outcomes keep lean applied-effect summaries without trace-only detail", () => {
const result = runOnce(
{
maxRounds: 1,
attacker: {
@@ -118,9 +123,15 @@
);
const attack = result.attacks.find((entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry");
assert.equal(attack?.round, 1);
- assert.equal(attack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);
+ assert.deepEqual(attack?.appliedEffects, [{
+ effectId: "boost",
+ sourceSide: "attacker",
+ bucket: "active.hero.attack.up",
+ valuePct: 25
+ }]);
+ assert.deepEqual(Object.keys(attack!.appliedEffects![0]).sort(), ["bucket", "effectId", "sourceSide", "valuePct"]);
assert.equal(attack?.trace, undefined);
});
test("fast, standard, and trace recorders expose their complete output contracts", () => {
@@ -166,14 +177,22 @@
assert.equal(fast.trace, undefined);
assert.ok(standard.attacks.length > 0);
assert.equal(standard.attacks.every((attack) => attack.trace === undefined), true);
- assert.equal(standard.attacks.some((attack) => attack.appliedEffects.some((effect) => effect.effectId === "boost")), true);
+ assert.equal(standard.attacks.every((attack) => attack.counterDeltas === undefined), true);
+ assert.equal(standard.attacks.some((attack) => (attack.appliedEffects ?? []).some((effect) => effect.effectId === "boost")), true);
+ assert.equal(standard.attacks.flatMap((attack) => attack.appliedEffects ?? []).every((effect) =>
+ Object.keys(effect).sort().join(",") === "bucket,effectId,sourceSide,valuePct"
+ ), true);
assert.equal(standard.trace, undefined);
- const traceAttacksWithoutEquations = trace.attacks.map(({ trace: _equation, ...attack }) => attack);
- const standardAttacksWithoutEquations = standard.attacks.map(({ trace: _equation, ...attack }) => attack);
+ const traceAttacksWithoutEquations = trace.attacks.map(({ appliedEffects: _effects, counterDeltas: _deltas, trace: _equation, ...attack }) => attack);
+ const standardAttacksWithoutEquations = standard.attacks.map(({ appliedEffects: _effects, counterDeltas: _deltas, trace: _equation, ...attack }) => attack);
assert.deepEqual(traceAttacksWithoutEquations, standardAttacksWithoutEquations);
+ assert.equal(trace.attacks.some((attack) => (attack.appliedEffects ?? []).some((effect) => effect.effectId === "boost")), true);
+ assert.equal(JSON.stringify(trace).includes("activeEffectId"), false);
+ assert.equal(JSON.stringify(trace).includes("stackingKey"), false);
+ assert.equal(JSON.stringify(trace).includes("jobId"), false);
const standardSkill = standard.skillReport.attacker.find((entry) => entry.skillId === "RecordedBoost");
const traceSkill = trace.skillReport.attacker.find((entry) => entry.skillId === "RecordedBoost");
assert.deepEqual(traceSkill && { ...traceSkill, triggersSeen: 0 }, standardSkill);
assert.equal(standardSkill?.triggersSeen, 0);
@@ -183,10 +202,33 @@
assert.equal(trace.trace.rounds.length, trace.rounds);
assert.equal(trace.trace.rounds[0]?.jobs.length, trace.attacks.length);
assert.equal(trace.attacks.every((attack) => attack.trace?.atomicBuckets !== undefined), true);
assert.equal(trace.attacks.every((attack) => attack.trace?.aggregationGroups !== undefined), true);
+ assert.equal(trace.attacks.every((attack) => attack.counterDeltas?.length === 2), true);
});
+test("standard output stays compact for a multi-unit battle", () => {
+ const result = runOnce(
+ {
+ maxRounds: 10,
+ seed: "standard-output-size",
+ attacker: {
+ troops: { infantry_t11_fc10: 1000, lancer_t11_fc10: 1000, marksman_t11_fc10: 1000 },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t11_fc10: 1000, lancer_t11_fc10: 1000, marksman_t11_fc10: 1000 },
+ heroes: {}
+ }
+ },
+ loadSimulatorConfig(),
+ { mode: "standard" }
+ );
+
+ assert.ok(result.attacks.length > 50);
+ assert.ok(Buffer.byteLength(JSON.stringify(result)) < 75_000);
+});
+
test("simulateBearBattle runs exactly 10 rounds and leaves the bear army unchanged", () => {
const player: FighterInput = {
name: "Player",
troops: { infantry_t1: 100 },
@@ -210,9 +252,9 @@
assert.ok(result.score > 5000);
});
test("simulateBearBattle always resolves the player army as a rally attacker", () => {
- const config = sameEffectStackingConfig("RallyHero", "add", "active.hero.lethality.up");
+ const config = sameEffectStackingConfig("RallyHero", "add");
config.heroDefinitions.RallyHero.skills.Overlap.requirements = [
{ level: 1, type: "engagement_type", value: "rally" }
];
const player: FighterInput = {
@@ -354,14 +396,14 @@
const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");
assert.deepEqual(
- normalAttacks.map((attack) => attack.jobId),
- ["r1:attacker:infantry:0:normal", "r1:defender:infantry:0:normal"]
+ normalAttacks.map((attack) => [attack.round, attack.dealerSide, attack.dealerUnit]),
+ [[1, "attacker", "infantry"], [1, "defender", "infantry"]]
);
assert.equal(normalAttacks[0]?.kills, 1);
assert.equal(normalAttacks[1]?.kills, 100);
- assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.id === "r1:attacker:lancer:1:normal"), false);
+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.dealerSide === "attacker" && job.dealerUnit === "lancer"), false);
assert.equal(result.remaining.attacker.infantry, 0);
assert.equal(result.remaining.defender.infantry, 0);
});
@@ -405,9 +447,9 @@
assert.equal(result.attacks.some((attack) => attack.kind === "skill"), false);
assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.kind === "skill"), false);
assert.deepEqual(result.extraSkillAttackJobsByEffect, {});
- assert.equal(normalAttack?.appliedEffects.some((effect) => effect.kind === "extra_attack"), false);
+ assert.equal((normalAttack?.appliedEffects ?? []).some((effect) => hasEffectKind(effect, "extra_attack")), false);
assert.equal(result.remaining.defender.lancer, 0);
});
test("runPrepared carries fractional casualties between rounds and ceils final survivors", () => {
@@ -604,13 +646,13 @@
{ mode: "trace" }
);
const lancerAttacks = result.attacks.filter((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "lancer");
- assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.takerUnit, "marksman");
- assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.takerUnit, "infantry");
- const round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));
+ assert.equal(lancerAttacks.find((attack) => attack.round === 2)?.takerUnit, "marksman");
+ assert.equal(lancerAttacks.find((attack) => attack.round === 3)?.takerUnit, "infantry");
+ const round4LancerAttack = lancerAttacks.find((attack) => attack.round === 4);
assert.equal(round4LancerAttack?.takerUnit, "marksman");
- assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);
+ assert.equal((round4LancerAttack?.appliedEffects ?? []).some((effect) => effect.effectId === "mark"), false);
});
test("attack-duration effects with a round cap expire after one applicable attack", () => {
const result = runOnce(
@@ -646,12 +688,12 @@
}),
{ mode: "trace" }
);
- const roundOneAttack = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry") && attack.kind === "normal");
- const roundTwoAttack = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
- assert.equal(roundOneAttack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);
- assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "boost"), false);
+ const roundOneAttack = result.attacks.find((attack) => attack.round === 1 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry" && attack.kind === "normal");
+ const roundTwoAttack = result.attacks.find((attack) => attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry" && attack.kind === "normal");
+ assert.equal((roundOneAttack?.appliedEffects ?? []).some((effect) => effect.effectId === "boost"), true);
+ assert.equal((roundTwoAttack?.appliedEffects ?? []).some((effect) => effect.effectId === "boost"), false);
});
test("attack-duration effects with a round delay are not charged before the delay is over", () => {
const result = runOnce(
@@ -687,12 +729,12 @@
}),
{ mode: "trace" }
);
- const roundOneAttack = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry") && attack.kind === "normal");
- const roundTwoAttack = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
- assert.equal(roundOneAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);
- assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+ const roundOneAttack = result.attacks.find((attack) => attack.round === 1 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry" && attack.kind === "normal");
+ const roundTwoAttack = result.attacks.find((attack) => attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry" && attack.kind === "normal");
+ assert.equal((roundOneAttack?.appliedEffects ?? []).some((effect) => effect.effectId === "delayedBoost"), false);
+ assert.equal((roundTwoAttack?.appliedEffects ?? []).some((effect) => effect.effectId === "delayedBoost"), true);
});
test("cancelled attacks do not charge attack-limited effects before their turn delay elapses", () => {
const result = runOnce(
@@ -738,12 +780,12 @@
}),
{ mode: "trace" }
);
- const roundOne = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry"));
- const roundTwo = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
+ const roundOne = result.attacks.find((attack) => attack.round === 1 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");
+ const roundTwo = result.attacks.find((attack) => attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry" && attack.kind === "normal");
assert.equal(roundOne?.cancelReason, "no_attack");
- assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+ assert.equal((roundTwo?.appliedEffects ?? []).some((effect) => effect.effectId === "delayedBoost"), true);
});
test("attack delay skips eligible attacks before the effect can apply", () => {
const result = runOnce(
@@ -781,9 +823,9 @@
);
const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.dealerSide === "attacker");
assert.equal(skillAttacks.length, 1);
- assert.ok(skillAttacks[0].jobId.startsWith("r2:"));
+ assert.equal(skillAttacks[0].round, 2);
});
test("attack delay skips eligible damage jobs before a modifier can apply", () => {
@@ -823,10 +865,10 @@
const normalAttacks = result.attacks.filter(
(attack) => attack.kind === "normal" && attack.dealerSide === "attacker"
);
- assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);
- assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+ assert.equal((normalAttacks[0].appliedEffects ?? []).some((effect) => effect.effectId === "delayedBoost"), false);
+ assert.equal((normalAttacks[1].appliedEffects ?? []).some((effect) => effect.effectId === "delayedBoost"), true);
});
test("runPrepared reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
@@ -883,28 +925,58 @@
assert.equal(fighter.heroes.some((hero) => hero.missing), false);
assert.equal(fighter.diagnostics.some((line) => line.includes("Missing hero definition")), false);
});
-test("applyHeroGenerationStats bakes main hero generation stats into authoritative input stats", () => {
+test("applyHeroGenerationStats applies each main hero only to its troop type", () => {
const config = minimalConfig({
- Example: {
- name: "Example",
+ InfantryHero: {
+ name: "InfantryHero",
hero_generation: "S1",
+ troop_type: "infantry",
skills: {}
+ },
+ LancerHero: {
+ name: "LancerHero",
+ hero_generation: "S2",
+ troop_type: "lancer",
+ skills: {}
+ },
+ MarksmanHero: {
+ name: "MarksmanHero",
+ hero_generation: "S3",
+ troop_type: "marksman",
+ skills: {}
}
});
config.heroGenerationStats.S1 = { attack: 50, defense: 40, lethality: 30, health: 20 };
+ config.heroGenerationStats.S2 = { attack: 15, defense: 14, lethality: 13, health: 12 };
+ config.heroGenerationStats.S3 = { attack: 25, defense: 24, lethality: 23, health: 22 };
const input = {
- troops: { infantry_t1: 10 },
- stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },
- heroes: { Example: { skill_1: 1 } }
+ troops: { infantry_t1: 10, lancer_t1: 10, marksman_t1: 10 },
+ stats: {
+ inf: { attack: 1, defense: 2, lethality: 3, health: 4 },
+ lanc: { attack: 5, defense: 6, lethality: 7, health: 8 },
+ mark: { attack: 9, defense: 10, lethality: 11, health: 12 }
+ },
+ heroes: {
+ InfantryHero: { skill_1: 1 },
+ LancerHero: { skill_1: 1 },
+ MarksmanHero: { skill_1: 1 }
+ }
};
const defaultFighter = resolveFighter(input, "attacker", config);
- const bakedFighter = resolveFighter(applyHeroGenerationStats(input, config), "attacker", config);
+ const built = new BattleInputBuilder(config)
+ .fighter("attacker", input)
+ .fighter("defender", input)
+ .addHeroGenerationStats()
+ .build();
+ const bakedFighter = resolveFighter(built.attacker, "attacker", config);
assert.deepEqual(defaultFighter.statBonuses.infantry, { attack: 1, defense: 2, lethality: 3, health: 4 });
assert.deepEqual(bakedFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });
+ assert.deepEqual(bakedFighter.statBonuses.lancer, { attack: 20, defense: 20, lethality: 20, health: 20 });
+ assert.deepEqual(bakedFighter.statBonuses.marksman, { attack: 34, defense: 34, lethality: 34, health: 34 });
});
test("array joiner heroes preserve duplicate skill instances", () => {
const result = runOnce(
@@ -951,18 +1023,76 @@
[1, 1]
);
});
+test("joiner heroes contribute only their first skill when later levels are supplied", () => {
+ const config = minimalConfig({
+ Joiner: {
+ name: "Joiner",
+ skills: {
+ FirstSkill: {
+ trigger: { type: "pre_battle" },
+ effects: { first: { type: "passive.attack.up", value: 10 } }
+ },
+ SecondSkill: {
+ trigger: { type: "pre_battle" },
+ effects: { second: { type: "passive.attack.up", value: 100 } }
+ },
+ ThirdSkill: {
+ trigger: { type: "pre_battle" },
+ effects: { third: { type: "passive.lethality.up", value: 100 } }
+ },
+ FourthSkill: {
+ trigger: { type: "pre_battle" },
+ effects: { fourth: { type: "passive.lethality.up", value: 100 } }
+ }
+ }
+ }
+ });
+ const battle = (levels: Record<string, number>): BattleInput => ({
+ maxRounds: 1,
+ seed: "joiner-first-skill-only",
+ attacker: {
+ troops: { infantry_t1: 100 },
+ joiner_heroes: [{ name: "Joiner", levels }]
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ });
+
+ const firstOnly = runOnce(battle({ skill_1: 1 }), config, { mode: "trace" });
+ const laterLevelsPresent = runOnce(
+ battle({ skill_1: 1, skill_2: 1, skill_3: 1, skill_4: 1 }),
+ config,
+ { mode: "trace" }
+ );
+
+ assert.deepEqual(semanticBattleSummary(laterLevelsPresent), semanticBattleSummary(firstOnly));
+ assert.deepEqual(laterLevelsPresent.resolved.attacker.heroes[0]?.skillIds, ["FirstSkill"]);
+ assert.deepEqual(
+ laterLevelsPresent.skillReport.attacker.map((entry) => entry.skillId),
+ ["FirstSkill"]
+ );
+ const attack = laterLevelsPresent.attacks.find(
+ (entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry"
+ );
+ assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 10);
+});
+
test("joiner hero generation stats are not applied when main hero stats are enabled", () => {
const config = minimalConfig({
Main: {
name: "Main",
hero_generation: "S1",
+ troop_type: "infantry",
skills: {}
},
Joiner: {
name: "Joiner",
hero_generation: "S2",
+ troop_type: "infantry",
skills: {
JoinerBuff: {
trigger: { type: "battle_start" },
effects: {}
@@ -1032,17 +1162,23 @@
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
const cancelledAttack = cancelled!;
- assert.equal(cancelledAttack.appliedEffects.length, 1);
- const controlEvent = cancelledAttack.appliedEffects[0];
- assert.equal(controlEvent.kind, "control");
- assert.equal(controlEvent.kind === "control" && controlEvent.reason, "no_attack");
- assert.ok(controlEvent.activeEffectId.includes(":cancel:"));
- assert.deepEqual(cancelledAttack.counterDeltas, [
- { side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },
- { side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }
- ]);
+ assert.equal(cancelledAttack.appliedEffects?.length, 1);
+ const controlEvent = cancelledAttack.appliedEffects![0];
+ assert.equal(hasEffectKind(controlEvent, "control"), true);
+ assert.equal("kind" in controlEvent && controlEvent.kind === "control" && controlEvent.reason, "no_attack");
+ assert.deepEqual({
+ effectId: controlEvent.effectId,
+ sourceSide: controlEvent.sourceSide,
+ bucket: controlEvent.bucket,
+ valuePct: "valuePct" in controlEvent ? controlEvent.valuePct : undefined
+ }, {
+ effectId: "cancel",
+ sourceSide: "attacker",
+ bucket: "no_attack",
+ valuePct: 100
+ });
});
test("no_attack applies_to cancels that unit attacking, not attacks targeting that unit", () => {
const result = runOnce(
@@ -1076,10 +1212,10 @@
})
);
assert.deepEqual(
- result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
- ["r1:attacker:infantry:0:cancelled"]
+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => [attack.round, attack.dealerSide, attack.dealerUnit]),
+ [[1, "attacker", "infantry"]]
);
assert.equal(
result.attacks.some((attack) => attack.dealerSide === "defender" && attack.takerSide === "attacker" && attack.takerUnit === "infantry" && attack.cancelReason === "no_attack"),
false
@@ -1118,10 +1254,10 @@
})
);
assert.deepEqual(
- result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),
- ["r1:attacker:infantry:0:cancelled"]
+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => [attack.round, attack.dealerSide, attack.dealerUnit]),
+ [[1, "attacker", "infantry"]]
);
assert.equal(
result.attacks.some((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "infantry" && attack.cancelReason === "dodge"),
false
@@ -1160,10 +1296,10 @@
})
);
assert.deepEqual(
- result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),
- ["r1:attacker:infantry:0:cancelled"]
+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => [attack.round, attack.dealerSide, attack.dealerUnit]),
+ [[1, "attacker", "infantry"]]
);
});
test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {
@@ -1208,9 +1344,9 @@
});
const roundTwoKills = (options: SimulationOptions): number => {
const result = runOnce(input, config, options);
- const attack = result.attacks.find((entry) => entry.kind === "normal" && entry.jobId.startsWith("r2:attacker:infantry"));
+ const attack = result.attacks.find((entry) => entry.kind === "normal" && entry.round === 2 && entry.dealerSide === "attacker" && entry.dealerUnit === "infantry");
assert.notEqual(attack, undefined);
return attack!.kills;
};
@@ -1251,10 +1387,10 @@
})
);
assert.deepEqual(
- result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
- ["r2:attacker:infantry:0:cancelled", "r4:attacker:infantry:0:cancelled"]
+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.round),
+ [2, 4]
);
});
test("attack frequency triggers can start at a different first threshold", () => {
@@ -1289,19 +1425,19 @@
})
);
assert.deepEqual(
- result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
- ["r4:attacker:infantry:0:cancelled", "r9:attacker:infantry:0:cancelled", "r14:attacker:infantry:0:cancelled"]
+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.round),
+ [4, 9, 14]
);
});
test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {
- const maxResult = runOnce(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });
- const addResult = runOnce(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });
+ const maxResult = runOnce(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max"), { mode: "trace" });
+ const addResult = runOnce(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add"), { 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");
+ const maxRoundTwo = maxResult.attacks.find((attack) => attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry" && attack.kind === "normal");
+ const addRoundTwo = addResult.attacks.find((attack) => attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry" && attack.kind === "normal");
assert.equal(maxRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 100);
assert.equal(maxRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
assert.equal(addRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 200);
@@ -1321,32 +1457,27 @@
troops: { infantry_t1: 100 },
heroes: {}
}
},
- sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up"),
+ sameEffectStackingConfig("Repeat", "max"),
{ mode: "trace" }
);
- const attack = result.attacks.find((entry) => entry.jobId.startsWith("r1:attacker:infantry") && entry.kind === "normal");
+ const attack = result.attacks.find((entry) => entry.round === 1 && entry.dealerSide === "attacker" && entry.dealerUnit === "infantry" && entry.kind === "normal");
assert.equal(result.skillReport.attacker.filter((entry) => entry.heroName === "Repeat" && entry.skillId === "Overlap").length, 2);
assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 100);
assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
});
-test("same_effect_stacking max caps overlapping extra skill attacks while add keeps all activations", () => {
- const maxResult = runOnce(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"), { mode: "trace" });
- const addResult = runOnce(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"), { mode: "trace" });
+test("overlapping extra skill attack effects each emit their configured damage jobs", () => {
+ const result = runOnce(sameEffectStackingInput("OverlappingExtra"), overlappingExtraAttackConfig("OverlappingExtra"), { mode: "trace" });
+ const roundTwoSkillJobs = result.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
- const maxRoundTwoSkillJobs = maxResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
- const addRoundTwoSkillJobs = addResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
-
- assert.equal(maxRoundTwoSkillJobs.length, 1);
- assert.equal(addRoundTwoSkillJobs.length, 2);
- assert.deepEqual(maxResult.extraSkillAttackJobsByEffect, { stacking: 2 });
- assert.deepEqual(addResult.extraSkillAttackJobsByEffect, { stacking: 3 });
+ assert.equal(roundTwoSkillJobs.length, 2);
+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { stacking: 3 });
});
-test("same_effect_stacking max consumes overlapping attack-duration extra skill effects as one group", () => {
+test("overlapping attack-duration extra skill effects are consumed independently", () => {
const result = runOnce(
{
maxRounds: 2,
attacker: {
@@ -1367,9 +1498,8 @@
effects: {
hitAgain: {
type: "extra_skill_attack",
value: 100,
- same_effect_stacking: "max",
units: { applies_to: ["infantry"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
duration: { attacks: { count: 2 } }
}
@@ -1380,17 +1510,17 @@
}),
{ mode: "trace" }
);
- const roundTwoNormalOutcome = result.attacks.find((attack) => attack.kind === "normal" && attack.jobId.startsWith("r2:attacker:infantry"));
- const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));
+ const roundTwoNormalOutcome = result.attacks.find((attack) => attack.kind === "normal" && attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");
+ const roundTwoSkillOutcomes = result.attacks.filter((attack) => attack.kind === "skill" && attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");
assert.notEqual(roundTwoNormalOutcome, undefined);
- const extraAttackEvents = roundTwoNormalOutcome!.appliedEffects.filter((event) => event.kind === "extra_attack");
- assert.equal(extraAttackEvents.length, 1);
- assert.equal(extraAttackEvents[0].kind === "extra_attack" && extraAttackEvents[0].spawnedJobIds.length, 1);
- assert.notEqual(roundTwoSkillOutcome, undefined);
- assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
+ const extraAttackEvents = (roundTwoNormalOutcome!.appliedEffects ?? []).filter((event) => hasEffectKind(event, "extra_attack"));
+ assert.equal(extraAttackEvents.length, 2);
+ assert.equal(extraAttackEvents.every((event) => "kind" in event && event.kind === "extra_attack" && event.spawnedJobCount === 1), true);
+ assert.equal(roundTwoSkillOutcomes.length, 2);
+ assert.equal(roundTwoSkillOutcomes.every((outcome) => !(outcome.appliedEffects ?? []).some((event) => hasEffectKind(event, "extra_attack"))), true);
});
test("requires_effect is ignored by native simulator effect activation", () => {
const result = runOnce(
@@ -1409,9 +1539,9 @@
LegacyDependency: {
name: "LegacyDependency",
skills: {
BothEffectsActivate: {
- trigger: { type: "battle_start" },
+ trigger: { type: "pre_battle" },
effects: {
base: {
type: "passive.attack.up",
value: 10
@@ -1431,9 +1561,9 @@
const report = result.skillReport.attacker.find((entry) => entry.skillId === "BothEffectsActivate");
assert.equal(report?.effectActivations, 2);
});
-test("fighter passive effects are added to the static profile after battle_start effects", () => {
+test("fighter passive effects are added to the static profile after pre_battle skill effects", () => {
const result = runOnce(
{
maxRounds: 1,
attacker: {
@@ -1454,9 +1584,9 @@
BattleStart: {
name: "BattleStart",
skills: {
StartsFirst: {
- trigger: { type: "battle_start" },
+ trigger: { type: "pre_battle" },
effects: {
skillBuff: {
type: "passive.attack.up",
value: 10
@@ -1624,15 +1754,10 @@
);
const powerShot = result.skillReport.attacker.find((entry) => entry.skillId === "PowerShot");
const crystalShield = result.skillReport.defender.find((entry) => entry.skillId === "CrystalShield");
- const skillOutcome = result.attacks.find((entry) => entry.kind === "skill");
- const skillJob = result.trace?.rounds.flatMap((round) => round.jobs).find((entry) => entry.kind === "skill");
-
assert.ok((powerShot?.skillKills ?? 0) > 0);
assert.equal(crystalShield?.skillKills, 0);
- assert.match(skillOutcome?.sourceSkillReportKey ?? "", /PowerShot/);
- assert.equal(skillJob?.sourceSkillReportKey, skillOutcome?.sourceSkillReportKey);
});
test("same-round outcomes are capped to available target troops before tracing skill kills", () => {
const result = runOnce(
@@ -1721,9 +1846,9 @@
minimalConfig(),
{ mode: "trace" }
);
const roundOneInfantryLosses = result.attacks
- .filter((attack) => attack.jobId.startsWith("r1:") && attack.takerSide === "attacker" && attack.takerUnit === "infantry")
+ .filter((attack) => attack.round === 1 && attack.takerSide === "attacker" && attack.takerUnit === "infantry")
.map((attack) => attack.kills);
const roundTwo = result.trace?.rounds.find((round) => round.round === 2);
const defenderTargets = roundTwo?.intents
.filter((intent) => intent.dealerSide === "defender")
@@ -1859,8 +1984,96 @@
assert.equal(skillJobs[0]?.takerSide, normalJobs[0]?.takerSide);
assert.equal(skillJobs[0]?.takerUnit, normalJobs[0]?.takerUnit);
});
+test("deferred shields use finalized normal plus linked skill kills and reapply to every hit next turn", () => {
+ const result = runOnce(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 100000 },
+ heroes: { ShieldCollector: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100000 },
+ heroes: { MultiHit: { skill_1: 1 } }
+ }
+ },
+ minimalConfig({
+ ShieldCollector: {
+ name: "ShieldCollector",
+ troop_type: "infantry",
+ skills: {
+ CollectDamage: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ linkedHit: {
+ type: "extra_skill_attack",
+ value: 50,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { attacks: { count: 1 } }
+ },
+ shield: {
+ type: "active.hero.shield",
+ value: 100,
+ value_formula: { type: "percent_of", source: "trigger.total_kills" },
+ units: { applies_to: "trigger.source", applies_vs: "enemy.any" },
+ duration: { turns: { delay: 1, count: 1 } },
+ same_effect_stacking: "max"
+ }
+ }
+ }
+ }
+ },
+ MultiHit: {
+ name: "MultiHit",
+ troop_type: "infantry",
+ skills: {
+ PersistentExtraHit: {
+ trigger: { type: "battle_start" },
+ effects: {
+ defenderHit: {
+ type: "extra_skill_attack",
+ value: 50,
+ units: { applies_to: "self.infantry", applies_vs: "enemy.any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { turns: { count: 3 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const roundOneCluster = result.attacks.filter(
+ (attack) => attack.round === 1 && attack.dealerSide === "attacker" && attack.dealerUnit === "infantry"
+ );
+ assert.equal(roundOneCluster.some((attack) => attack.kind === "normal"), true);
+ assert.equal(roundOneCluster.some((attack) => attack.kind === "skill" && attack.sourceEffectId === "linkedHit"), true);
+ const expectedShield = roundOneCluster.reduce((sum, attack) => sum + attack.kills, 0);
+
+ const roundOneIncoming = result.attacks.filter(
+ (attack) => attack.round === 1 && attack.dealerSide === "defender" && attack.takerSide === "attacker"
+ );
+ assert.equal(roundOneIncoming.some((attack) => (attack.appliedEffects ?? []).some((effect) => hasEffectKind(effect, "shield"))), false);
+
+ const roundTwoIncoming = result.attacks.filter(
+ (attack) => attack.round === 2 && attack.dealerSide === "defender" && attack.takerSide === "attacker"
+ );
+ assert.deepEqual(roundTwoIncoming.map((attack) => attack.kind).sort(), ["normal", "skill"]);
+ for (const attack of roundTwoIncoming) {
+ const shield = (attack.appliedEffects ?? []).find((effect) => hasEffectKind(effect, "shield"));
+ assert.notEqual(shield, undefined);
+ const shieldValue = shield && "value" in shield ? shield.value : NaN;
+ assert.ok(Math.abs(shieldValue - expectedShield) < 1e-9);
+ assert.ok(Math.abs((attack.trace?.offsetDamage ?? NaN) - expectedShield) < 1e-9);
+ assert.ok(Math.abs((attack.trace?.rawDamage ?? NaN) - Math.max(0, (attack.trace?.damageBeforeOffsets ?? 0) - expectedShield)) < 1e-9);
+ }
+});
+
test("cancelled normal attacks do not consume extra skill attack uses", () => {
const result = runOnce(
{
maxRounds: 2,
@@ -1912,9 +2125,9 @@
);
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
- assert.equal(cancelled!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
+ assert.equal((cancelled!.appliedEffects ?? []).some((event) => hasEffectKind(event, "extra_attack")), false);
const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
assert.deepEqual(skillJobsByRound, [0, 1]);
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });
@@ -2327,14 +2540,14 @@
name: "Gated",
skills: {
RallyOnly: {
requirements: [{ level: 1, type: "engagement_type", value: "rally" }],
- trigger: { type: "battle_start" },
+ trigger: { type: "pre_battle" },
effects: { rally: { type: "passive.attack.up", value: 10 } }
},
GarrisonOnly: {
requirements: [{ level: 1, type: "engagement_type", value: "garrison" }],
- trigger: { type: "battle_start" },
+ trigger: { type: "pre_battle" },
effects: { garrison: { type: "passive.defense.up", value: 10 } }
}
}
}
@@ -2373,14 +2586,14 @@
name: "Gated",
skills: {
RallyOnly: {
requirements: [{ level: 1, type: "engagement_type", value: "rally" }],
- trigger: { type: "battle_start" },
+ trigger: { type: "pre_battle" },
effects: { rally: { type: "passive.attack.up", value: 10 } }
},
GarrisonOnly: {
requirements: [{ level: 1, type: "engagement_type", value: "garrison" }],
- trigger: { type: "battle_start" },
+ trigger: { type: "pre_battle" },
effects: { garrison: { type: "passive.defense.up", value: 10 } }
}
}
}
@@ -2417,14 +2630,14 @@
name: "Chance",
skills: {
CoinFlip: {
trigger: { type: "battle_start", probability: [50] },
- effects: { buff: { type: "passive.attack.up", value: 10 } }
+ effects: { buff: { type: "active.hero.attack.up", value: 10 } }
},
RallyCoinFlip: {
requirements: [{ level: 1, type: "engagement_type", value: "rally" }],
trigger: { type: "battle_start", probability: [50] },
- effects: { rally: { type: "passive.attack.up", value: 10 } }
+ effects: { rally: { type: "active.hero.attack.up", value: 10 } }
}
}
}
});
@@ -2490,9 +2703,9 @@
troops: { infantry_t1: 10000 },
heroes: {}
}
},
- config: sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up")
+ config: sameEffectStackingConfig("Repeat", "max")
},
{
name: "controls and extra skill attacks",
input: {
@@ -2668,26 +2881,16 @@
}
};
}
-function sameEffectStackingConfig(heroName: string, same_effect_stacking: "add" | "max", type: "active.hero.lethality.up" | "extra_skill_attack"): SimulatorConfig {
- const effect: Omit<EffectIntentDefinition, "id"> =
- type === "extra_skill_attack"
- ? {
- type,
- value: 100,
- same_effect_stacking,
- units: { applies_to: ["infantry"], applies_vs: "any" },
- trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { turns: { count: 2 } }
- }
- : {
- type,
- value: 100,
- same_effect_stacking,
- units: { applies_to: ["infantry"], applies_vs: "any" },
- duration: { turns: { count: 2 } }
- };
+function sameEffectStackingConfig(heroName: string, same_effect_stacking: "add" | "max"): SimulatorConfig {
+ const effect: Omit<EffectIntentDefinition, "id"> = {
+ type: "active.hero.lethality.up",
+ value: 100,
+ same_effect_stacking,
+ units: { applies_to: ["infantry"], applies_vs: "any" },
+ duration: { turns: { count: 2 } }
+ };
return minimalConfig({
[heroName]: {
name: heroName,
skills: {
@@ -2699,8 +2902,30 @@
}
});
}
+function overlappingExtraAttackConfig(heroName: string): SimulatorConfig {
+ return minimalConfig({
+ [heroName]: {
+ name: heroName,
+ skills: {
+ Overlap: {
+ trigger: { type: "turn" },
+ effects: {
+ stacking: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: ["infantry"], applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { turns: { count: 2 } }
+ }
+ }
+ }
+ }
+ }
+ });
+}
+
function minimalConfig(heroDefinitions: Record<string, SkillFile> = {}): SimulatorConfig {
return {
troopStats: {
infantry_t1: { id: "infantry_t1", type: "infantry", tier: 1, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } },
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -3,129 +3,94 @@
AttackIntent,
AttackOutcome,
BearBattleResult,
BattleInput,
- BattleRandomness,
BattleResult,
BattleTrace,
DamageJob,
FighterInput,
- ResolvedEffectIntentDefinition,
ResolvedFighter,
ResolvedSkill,
SideId,
SimulationOptions,
SimulatorConfig,
- TriggerDamageJobSelector,
UnitType
} from "./types";
-import { ALL_UNIT_MASK, UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";
-import { calculateDamageJob, createDamageScratch, type DamageResult, type DamageScratch } from "./damage";
-import { createRecorder, NULL_RECORDER, type BattleRecorder } from "./recorder";
+import { UNIT_TYPES, unitMaskHas } from "./types";
+import { calculateDamageJob, sqrtMinInitialArmy, type DamageResult } from "./damage";
+import { createRecorder, type BattleRecorder } from "./recorder";
import {
activateEffect,
advanceEffectAttackDelay,
chancePasses,
compiledTriggerForSkill,
- constantActiveEffectValuePct,
createSeededRng,
- effectAttackUseLimit,
- effectRoundWindow,
hasAttackDurationConstraint,
isEffectAttackReady,
oppositeSide,
- resolvedEffectScopeKey,
skillMatchesTrigger,
type Rng
} from "./effects";
-import { classifyEffectForJob } from "./classifier";
-import { cloneEffectIndex, createEffectIndex, damageBucketIndex, damageJobShapeSlot, damageJobSlot, damageShapeSlotsForEffect, DAMAGE_JOB_SHAPE_SLOTS, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";
-import { normalizeUnitType } from "./normalize";
-import { emptyTroops, resolveFighter } from "./resolve";
+import { damageJobShapeSlot, damageJobSlot, type EffectIndex } from "./effectIndex";
+import { buildRuntimeSkills, type DeferredEffectPlan, type RuntimeSkills } from "./runtimeSkills";
+import { activatePreBattleEffects, buildResolved, prepareBattle, type CompiledBattle } from "./prepare";
+import {
+ addActiveEffect,
+ capJobToRemainingTarget,
+ chargeEffectUse,
+ chargeUsedEffects,
+ createRuntime,
+ emptyRoundTargetDamage,
+ materializeDeferredEffects,
+ processEffectSchedule,
+ targetExhausted,
+ triggerAttackSkills,
+ triggerSkills,
+ type DamageJobResult,
+ type RunLoopOptions,
+ type Runtime
+} from "./runtime";
+import { processExtraSkillAttacks } from "./extraAttacks";
+
+// Re-exported so the public battle API stays importable from one module.
+export { prepareBattle, type CompiledBattle } from "./prepare";
+import { emptyTroops, resolveFighter } from "./fighterResolution";
import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";
-import { bucketDefinition } from "./damageBuckets";
const DEFAULT_MAX_ROUNDS = 1500;
const BEAR_ROUNDS = 10;
-const BEAR_DEFENSE = 36;
+const BEAR_DEFENSE = 250/3;
const BEAR_TROOP_ID = "bear_infantry";
-interface Runtime {
- effectIndex: EffectIndex;
- preparedEffects: ActiveEffect[];
- activateEffectsByRound: Array<ActiveEffect[] | undefined>;
- expireEffectsByRound: Array<ActiveEffect[] | undefined>;
- // Per-job scratch: effects that affected the job being calculated; drained by
- // chargeUsedEffects (uses += 1 each) after every job in every mode.
- usedEffects: ActiveEffect[];
- staticDamageProfile?: StaticDamageProfile;
- // Materialized battle_start + input passive effects; recorders derive their own static profile
- // description from these, so the runtime carries no trace-shaped data.
- setupEffects: ActiveEffect[];
- damageScratch: DamageScratch;
- rng: Rng;
- skills: RuntimeSkills;
- effectActivationCounts: Record<SideId, number>;
- extraSkillAttackJobsByEffect: Record<string, number>;
- attackControlCounts: { dodge: number; no_attack: number };
- counters: {
- attacks: Record<SideId, Record<UnitType, number>>;
- received: Record<SideId, Record<UnitType, number>>;
- };
-}
-
-interface RuntimeSkills {
- all: ResolvedSkill[];
- battleStart: ResolvedSkill[];
- roundStart: ResolvedSkill[];
- roundStartGlobal: ResolvedSkill[];
- roundStartPerUnit: ResolvedSkill[];
- attackDeclared: ResolvedSkill[];
- attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined>;
- damageGroups: NonNullable<ActiveEffect["damageIndexGroup"]>[];
- damageGroupsByJobShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][];
-}
-
-interface ExtraAttackEffectGroup {
- selected: ActiveEffect;
- effects: ActiveEffect[];
-}
-
-interface ExtraSkillJobsResult {
- jobs: DamageJob[];
- usedEffectGroups: ExtraSkillUsedEffectGroup[];
-}
-
-interface ExtraSkillUsedEffectGroup {
- sourceEffectId: string;
- selected: ActiveEffect;
- effects: ActiveEffect[];
- firstJobIndex: number;
- jobCount: number;
-}
-
interface BattleRun {
fighters: Record<SideId, ResolvedFighter>;
runtime: Runtime;
winner: SideId | "draw";
rounds: number;
attacks: AttackOutcome[];
trace?: BattleTrace;
skillReport: BattleResult["skillReport"];
- score?: number;
+ score: number;
}
-interface RunLoopOptions {
- capRoundKills: boolean;
- capJobKills: boolean;
- commitLosses: boolean;
- scoreSide?: {
- dealerSide: SideId;
- takerSide: SideId;
- };
+interface CancelledAttack {
+ intent: AttackIntent;
+ control: Control;
}
-export function bearFighterInput(): FighterInput {
+interface ApplicableControls {
+ dodge?: Control;
+ no_attack?: Control;
+ attackDurationEffects: ActiveEffect[];
+}
+
+interface Control {
+ effect: ActiveEffect;
+ reason: "dodge" | "no_attack";
+ attackDurationEffects: ActiveEffect[];
+}
+
+function bearFighterInput(): FighterInput {
return {
name: "Bear",
troops: { [BEAR_TROOP_ID]: 5000 },
stats: {
@@ -156,52 +121,17 @@
scoreSide: { dealerSide: "attacker", takerSide: "defender" }
});
return {
...buildBattleResult(run),
- score: run.score ?? bearScore(run.attacks)
+ score: run.score
};
}
-/**
- * A resolved battle ready to run many times with different seeds. When battle-start is deterministic,
- * the entire pre-loop runtime (`template`) is seed-independent and built once; each run clones its
- * mutable state and reuses the skills + static profile by reference. For stochastic battle-start,
- * the template is absent; resolved fighters, skills, and the static profile are still reused while
- * chance-bearing setup state is rebuilt per run.
- */
-export interface CompiledBattle {
- input: BattleInput;
- config: SimulatorConfig;
- fighters: Record<SideId, ResolvedFighter>;
- template?: Runtime;
- staticProfile: StaticDamageProfile;
- runtimeSkills: RuntimeSkills;
- deterministicBattleStart: boolean;
-}
-
-export function prepareBattle(input: BattleInput, config: SimulatorConfig): CompiledBattle {
- const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);
- const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);
- const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
- const runtimeSkills = buildRuntimeSkills([attacker, defender]);
- const deterministicBattleStart = !runtimeSkills.battleStart.some(hasChanceTrigger);
- if (deterministicBattleStart) {
- // The entire pre-loop runtime is seed-independent, so build it once and clone it per run.
- const template = setupRuntime(fighters, input, "simulator-prepare", NULL_RECORDER, runtimeSkills);
- return { input, config, fighters, template, staticProfile: template.staticDamageProfile!, runtimeSkills, deterministicBattleStart };
- }
- // Chance-bearing battle-start effects must be rolled per run. Build only the deterministic
- // setup effects that feed static buckets here; do not execute and discard the stochastic setup.
- const staticEffects = materializeStaticSetupEffects(fighters, input, runtimeSkills);
- const staticProfile = buildStaticDamageProfile(fighters, staticEffects);
- return { input, config, fighters, staticProfile, runtimeSkills, deterministicBattleStart };
-}
-
export function runPrepared(compiled: CompiledBattle, seed?: string | number, options: SimulationOptions = {}): BattleResult {
// Only override the compiled input's seed when a seed is explicitly supplied; spreading an
// undefined seed would otherwise clobber compiled.input.seed and silently lose reproducibility.
const runInput = seed === undefined ? compiled.input : { ...compiled.input, seed };
- return buildBattleResult(runBattle(runInput, compiled.config, options, compiled));
+ return buildBattleResult(runBattle(runInput, compiled.config, options, compiled), compiled.resolved);
}
/**
* Prepare the battle once and run `count` replicates, reusing the compiled fighters, skills, and
@@ -223,121 +153,55 @@
runPrepared(compiled, index === 0 ? baseSeed : `${baseSeed}#${index}`, runOptions)
);
}
-function buildBattleResult(run: BattleRun): BattleResult {
+function buildBattleResult(run: BattleRun, resolved?: BattleResult["resolved"]): BattleResult {
const { fighters, runtime } = run;
return {
winner: run.winner,
rounds: run.rounds,
- remaining: { attacker: ceilTroops(fighters.attacker.troops), defender: ceilTroops(fighters.defender.troops) },
+ remaining: { attacker: ceilTroops(runtime.troops.attacker), defender: ceilTroops(runtime.troops.defender) },
attacks: run.attacks,
skillReport: run.skillReport,
- resolved: buildResolved(fighters.attacker, fighters.defender),
+ resolved: resolved ?? buildResolved(fighters.attacker, fighters.defender),
effectActivationCounts: runtime.effectActivationCounts,
extraSkillAttackJobsByEffect: runtime.extraSkillAttackJobsByEffect,
attackControlCounts: runtime.attackControlCounts,
- randomness: classifyRandomness(runtime.skills.all),
+ randomness: runtime.skills.randomness,
trace: run.trace
};
}
-// Reuse a prepared fighter's immutable resolution; only the troop counts mutate during a run.
-function cloneFighterForRun(fighter: ResolvedFighter): ResolvedFighter {
- return { ...fighter, troops: { ...fighter.initialTroops } };
-}
-
-// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage
-// profile. Static-phase effects never enter the per-job index.
+// Build the pre-loop runtime: record the prepared pre_battle phase, then fire battle_start
+// with this run's seed. Pre-battle effects never enter the per-job index.
function setupRuntime(
fighters: Record<SideId, ResolvedFighter>,
- input: BattleInput,
seed: string | number,
recorder: BattleRecorder,
- runtimeSkills?: RuntimeSkills,
- prebuiltProfile?: StaticDamageProfile
+ runtimeSkills: RuntimeSkills,
+ staticProfile: StaticDamageProfile,
+ preBattleEffects: ActiveEffect[]
): Runtime {
- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), runtimeSkills);
- const setupEffects = [
- ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime, recorder),
- ...createInputPassiveEffects(input.attacker.passive, "attacker"),
- ...createInputPassiveEffects(input.defender.passive, "defender")
- ];
- // Effects feeding static buckets are validated deterministic, so the profile is seed-independent
- // and a prepared build can be reused even when other battle_start skills are stochastic.
- runtime.staticDamageProfile = prebuiltProfile ?? buildStaticDamageProfile(fighters, setupEffects);
- runtime.setupEffects = setupEffects;
- recorder.recordStaticProfile(fighters, setupEffects);
- runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);
+ const runtime = createRuntime(fighters, createSeededRng(seed), runtimeSkills, staticProfile);
+ recorder.recordStaticProfile(fighters, preBattleEffects);
+ recordPreBattleSkills(runtime, recorder);
+ triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime, recorder);
return runtime;
}
-function materializeStaticSetupEffects(
- fighters: Record<SideId, ResolvedFighter>,
- input: BattleInput,
- runtimeSkills: RuntimeSkills
-): ActiveEffect[] {
- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng("simulator-static-profile"), runtimeSkills);
- const staticSkills = runtimeSkills.battleStart.filter((skill) =>
- skill.effects.some((effect) => bucketDefinition(effect.type)?.phase === "static")
- );
- return [
- ...triggerSkills("battle_start", 0, staticSkills, runtime, NULL_RECORDER),
- ...createInputPassiveEffects(input.attacker.passive, "attacker"),
- ...createInputPassiveEffects(input.defender.passive, "defender")
- ];
-}
-
-// Deterministic battle-start effects are materialized once in a prepared runtime. Replay only
-// their observation events for each run; combat state continues to come from the cloned template.
-function recordPreparedBattleStartSkills(runtime: Runtime, recorder: BattleRecorder): void {
- for (const skill of runtime.skills.battleStart) {
- if (!skillMatchesTrigger(skill, "battle_start", 0)) continue;
+// Pre-battle effects are activated once at prepare time; each run replays their skill
+// observation events so skill reports and activation counts describe them per battle.
+function recordPreBattleSkills(runtime: Runtime, recorder: BattleRecorder): void {
+ for (const skill of runtime.skills.preBattle) {
recorder.recordSkillTriggerAttempt(skill);
- if (!chancePasses(skill, runtime.rng)) continue;
recorder.recordSkillTriggered(skill);
- for (const _effect of skill.effects) recorder.recordSkillEffectActivated(skill);
+ for (const _effect of skill.effects) {
+ runtime.effectActivationCounts[skill.side] += 1;
+ recorder.recordSkillEffectActivated(skill);
+ }
}
}
-function cloneRuntime(template: Runtime, rng: Rng): Runtime {
- const effectClones = new Map<ActiveEffect, ActiveEffect>();
- const preparedEffects = template.preparedEffects.map((effect) => {
- const clone = { ...effect };
- effectClones.set(effect, clone);
- return clone;
- });
- const cloneEffect = (effect: ActiveEffect): ActiveEffect => {
- const clone = effectClones.get(effect);
- if (!clone) throw new Error(`prepared effect ${effect.id} is missing from the runtime template`);
- return clone;
- };
- const runtime: Runtime = {
- effectIndex: cloneEffectIndex(template.effectIndex, preparedEffects, cloneEffect),
- preparedEffects,
- activateEffectsByRound: cloneEffectSchedule(template.activateEffectsByRound, cloneEffect),
- expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),
- usedEffects: [],
- staticDamageProfile: template.staticDamageProfile,
- setupEffects: template.setupEffects,
- damageScratch: createDamageScratch(),
- rng,
- skills: template.skills,
- effectActivationCounts: { ...template.effectActivationCounts },
- extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },
- attackControlCounts: { ...template.attackControlCounts },
- counters: {
- attacks: { attacker: { ...template.counters.attacks.attacker }, defender: { ...template.counters.attacks.defender } },
- received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }
- }
- };
- return runtime;
-}
-
-function cloneEffectSchedule(schedule: Array<ActiveEffect[] | undefined>, cloneEffect: (effect: ActiveEffect) => ActiveEffect): Array<ActiveEffect[] | undefined> {
- return schedule.map((effects) => effects?.map(cloneEffect));
-}
-
function configWithBearTroop(config: SimulatorConfig): SimulatorConfig {
return {
...config,
troopStats: {
@@ -363,24 +227,16 @@
options: SimulationOptions,
prepared?: CompiledBattle,
loopOptions: RunLoopOptions = { capRoundKills: true, capJobKills: true, commitLosses: true }
): BattleRun {
- if (prepared?.template) {
- const fighters: Record<SideId, ResolvedFighter> = {
- attacker: cloneFighterForRun(prepared.fighters.attacker),
- defender: cloneFighterForRun(prepared.fighters.defender)
- };
- const recorder = recorderFor(options, fighters);
- const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));
- recorder.recordStaticProfile(fighters, runtime.setupEffects);
- recordPreparedBattleStartSkills(runtime, recorder);
- return runLoop(input, fighters, runtime, recorder, options, loopOptions);
- }
- const attacker = prepared ? cloneFighterForRun(prepared.fighters.attacker) : resolveFighter(input.attacker, "attacker", config, input.engagement_type);
- const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);
+ const attacker = prepared ? prepared.fighters.attacker : resolveFighter(input.attacker, "attacker", config, input.engagement_type);
+ const defender = prepared ? prepared.fighters.defender : resolveFighter(input.defender, "defender", config, input.engagement_type);
const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
+ const runtimeSkills = prepared?.runtimeSkills ?? buildRuntimeSkills([attacker, defender]);
+ const preBattleEffects = prepared?.preBattleEffects ?? activatePreBattleEffects(runtimeSkills, input);
+ const staticProfile = prepared?.staticProfile ?? buildStaticDamageProfile(fighters, preBattleEffects);
const recorder = recorderFor(options, fighters);
- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", recorder, prepared?.runtimeSkills, prepared?.staticProfile);
+ const runtime = setupRuntime(fighters, input.seed ?? "simulator-default", recorder, runtimeSkills, staticProfile, preBattleEffects);
return runLoop(input, fighters, runtime, recorder, options, loopOptions);
}
function recorderFor(options: SimulationOptions, fighters: Record<SideId, ResolvedFighter>): BattleRecorder {
@@ -403,24 +259,24 @@
const useEffectsOnCancel = {
dodge: options.useEffectsOnDodge ?? true,
no_attack: options.useEffectsOnNoAttack ?? true
};
- if (!runtime.staticDamageProfile) throw new Error("runLoop requires a runtime with a static damage profile");
const damageJobOptions = {
recorder,
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
scratch: runtime.damageScratch,
capToTakerTroops: loopOptions.capJobKills,
- usedEffects: runtime.usedEffects
+ usedEffects: runtime.usedEffects,
+ sqrtMinInitialArmy: sqrtMinInitialArmy(fighters)
};
let rounds = 0;
let score = 0;
for (let round = 1; round <= maxRounds; round += 1) {
- if (winnerFor(fighters)) break;
+ if (winnerFor(runtime.troops)) break;
rounds = round;
- const roundStartTroops = snapshotTroops(fighters);
+ const roundStartTroops = snapshotTroops(runtime.troops);
processEffectSchedule(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops, recorder);
const intents = resolveAttackIntents(round, runtime, roundStartTroops, recorder);
@@ -428,35 +284,37 @@
const cancelled: CancelledAttack[] = [];
// Phase 1: fire all attack_declared triggers for every intended attack before
// evaluating any controls or damage, per the battle-core spec.
- const pendingNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; control?: Control }> = [];
- const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];
+ const pendingNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; deferredEffects?: DeferredEffectPlan[]; control?: Control }> = [];
+ const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; deferredEffects?: DeferredEffectPlan[] }> = [];
const roundTargetDamage = emptyRoundTargetDamage();
for (const intent of intents) {
const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[
damageJobShapeSlot("normal", intent.dealerSide, intent.dealerUnit, intent.takerSide, intent.takerUnit)
- ] ?? [];
- triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, recorder, intent);
+ ];
+ const deferredEffects = matchingTriggerSkills
+ ? triggerAttackSkills(round, matchingTriggerSkills, runtime, recorder, intent)
+ : undefined;
const job = normalJob(intent, roundStartTroops);
- declaredNormalJobs.push({ intent, job });
+ declaredNormalJobs.push({ intent, job, deferredEffects });
}
- for (const { intent, job } of declaredNormalJobs) {
+ for (const { intent, job, deferredEffects } of declaredNormalJobs) {
const controls = applicableControls(job, runtime);
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
- pendingNormalJobs.push({ intent, job, control });
+ pendingNormalJobs.push({ intent, job, deferredEffects, control });
} else {
- pendingNormalJobs.push({ intent, job });
+ pendingNormalJobs.push({ intent, job, deferredEffects });
}
}
// Phase 2: calculate each normal job; any extra_skill_attack effect it
// uses spawns extra DamageJobs that are calculated immediately after.
// chargeUsedEffects runs after each job so subsequent normal jobs see the
// correct uses count on any shared extra_skill_attack effects.
- for (const { intent, job, control } of pendingNormalJobs) {
+ for (const { intent, job, deferredEffects, control } of pendingNormalJobs) {
if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;
if (control) {
runtime.attackControlCounts[control.reason] += 1;
@@ -477,56 +335,25 @@
score += normalResult.kills;
}
chargeUsedEffects(runtime);
- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder);
- const processedExtraEffectIds = new Set<string>();
- const processedExtraJobIds = new Set<string>();
- const normalEntry: DamageJobResult = {
- job,
- result: normalResult
- };
- results.push(normalEntry);
-
- for (const extraJob of extraSkill.jobs) {
- if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
- recorder.recordScheduledDamageJob(extraJob);
- const extraResult = calculateDamageJob(extraJob, fighters, damageJobOptions);
- if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage, recorder);
- processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");
- processedExtraJobIds.add(extraJob.id);
- if (extraJob.sourceEffectId) {
- runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] ?? 0) + 1;
- }
- results.push({ job: extraJob, result: extraResult });
- if (loopOptions.scoreSide && extraJob.dealerSide === loopOptions.scoreSide.dealerSide && extraJob.takerSide === loopOptions.scoreSide.takerSide) {
- score += extraResult.kills;
- }
- chargeUsedEffects(runtime);
+ results.push({ job, result: normalResult, intent });
+ const triggeredKills = processExtraSkillAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);
+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {
+ score += triggeredKills;
}
- for (const usedEffectGroup of extraSkill.usedEffectGroups) {
- if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;
- for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);
- recorder.recordExtraAttack(
- job,
- usedEffectGroup.selected,
- extraSkill.jobs,
- usedEffectGroup.firstJobIndex,
- usedEffectGroup.jobCount,
- processedExtraJobIds
- );
- }
+ materializeDeferredEffects(deferredEffects, round, intent, normalResult.kills, triggeredKills, runtime, recorder);
}
- if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);
+ if (loopOptions.commitLosses) commitRound(cancelled, results, runtime);
else commitRoundCounters(cancelled, results, runtime);
for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.control.effect, entry.control.reason);
- for (const entry of results) recorder.recordDamageJob(entry.job, entry.result);
+ for (const entry of results) recorder.recordDamageJob(entry.job, entry.result, entry.intent);
recorder.recordRound(round, roundStartTroops, intents);
}
- const winner = winnerFor(fighters) ?? "draw";
+ const winner = winnerFor(runtime.troops) ?? "draw";
return {
fighters,
runtime,
winner,
@@ -537,18 +364,8 @@
score
};
}
-interface CancelledAttack {
- intent: AttackIntent;
- control: Control;
-}
-
-interface DamageJobResult {
- job: DamageJob;
- result: DamageResult;
-}
-
function triggerRoundStartSkills(
round: number,
runtime: Runtime,
roundStartTroops: DamageJob["roundStartTroops"],
@@ -581,12 +398,8 @@
}
return activated;
}
-function hasPerUnitRoundTrigger(skill: ResolvedSkill): boolean {
- return skill.trigger.type === "turn" && skill.trigger.source !== undefined;
-}
-
function roundTriggerUnits(skill: ResolvedSkill, roundStartTroops: DamageJob["roundStartTroops"]): UnitType[] {
const source = compiledTriggerForSkill(skill).source;
return UNIT_TYPES.filter((unit) => (roundStartTroops[source.side][unit] ?? 0) > 0 && unitMaskHas(source.units, unit));
}
@@ -609,9 +422,8 @@
orderIndex: number,
runtime: Runtime
): AttackIntent {
return {
- id: `r${round}:${dealerSide}:${dealerUnit}:turn:${orderIndex}`,
round,
source: "normal",
dealerSide,
dealerUnit,
@@ -624,280 +436,8 @@
projectedReceivedAttackCount: runtime.counters.received[takerSide][takerUnit]
};
}
-function classifyRandomness(skills: ResolvedSkill[]): BattleRandomness {
- const chanceSkillIds: Record<SideId, string[]> = { attacker: [], defender: [] };
- for (const skill of skills) {
- if (hasChanceTrigger(skill)) chanceSkillIds[skill.side].push(skill.id);
- }
- return {
- deterministic: chanceSkillIds.attacker.length === 0 && chanceSkillIds.defender.length === 0,
- chanceSkillIds
- };
-}
-
-function hasChanceTrigger(skill: ResolvedSkill): boolean {
- const probability = skill.trigger.probability;
- if (probability === undefined) return false;
- const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);
- return Number.isFinite(value) && value > 0 && value < 100;
-}
-
-function createRuntime(fighters: ResolvedFighter[], rng: Rng, preparedSkills?: RuntimeSkills): Runtime {
- const skills = preparedSkills ?? buildRuntimeSkills(fighters);
- return {
- effectIndex: createEffectIndex(
- skills.damageGroups,
- skills.damageGroupsByJobShape
- ),
- preparedEffects: [],
- activateEffectsByRound: [],
- expireEffectsByRound: [],
- usedEffects: [],
- staticDamageProfile: undefined,
- setupEffects: [],
- damageScratch: createDamageScratch(),
- rng,
- skills,
- effectActivationCounts: { attacker: 0, defender: 0 },
- extraSkillAttackJobsByEffect: {},
- attackControlCounts: { dodge: 0, no_attack: 0 },
- counters: {
- attacks: { attacker: emptyTroops(), defender: emptyTroops() },
- received: { attacker: emptyTroops(), defender: emptyTroops() }
- }
- };
-}
-
-function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {
- const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);
- for (const skill of all) compiledTriggerForSkill(skill);
- const damageGroups: NonNullable<ActiveEffect["damageIndexGroup"]>[] = [];
- const damageGroupsByJobShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][] = Array.from({ length: 72 }, () => []);
- const plansByDefinition: Record<SideId, Map<object, DamageGroupPlan>> = {
- attacker: new Map(),
- defender: new Map()
- };
- for (const skill of all) {
- for (const intent of skill.effects) {
- const definition = bucketDefinition(intent.type);
- if (!definition || definition.valueType !== "pct" || definition.phase === "static") continue;
- const existingPlan = plansByDefinition[skill.side].get(intent.sourceDefinition);
- if (existingPlan) {
- assignDamageGroupPlan(intent, existingPlan);
- continue;
- }
- const groupsByScopeKey: Array<NonNullable<ActiveEffect["damageIndexGroup"]> | undefined> = [];
- const groupsForDefinition: NonNullable<ActiveEffect["damageIndexGroup"]>[] = [];
- const slotsForDefinition: Uint8Array[] = [];
- for (const attackIntent of potentialActivationIntents(skill)) {
- const candidate = activateEffect(skill, intent, 1, attackIntent);
- const scopeKey = resolvedEffectScopeKey(candidate.appliesTo, candidate.appliesVs);
- if (groupsByScopeKey[scopeKey]) continue;
- const slots = damageShapeSlotsForEffect(candidate, definition.path);
- if (slots.length === 0) continue;
- if (candidate.sameEffectStacking === "max") assertDisjointResolvedGroupSlots(intent.id, slots, slotsForDefinition);
- const group: NonNullable<ActiveEffect["damageIndexGroup"]> = {
- effects: [],
- bucketIndex: damageBucketIndex(definition.path),
- sameEffectStacking: candidate.sameEffectStacking
- };
- groupsByScopeKey[scopeKey] = group;
- groupsForDefinition.push(group);
- slotsForDefinition.push(slots);
- damageGroups.push(group);
- for (const slot of slots) damageGroupsByJobShape[slot].push(group);
- }
- if (groupsForDefinition.length === 0) throw new Error(`Runtime modifier ${intent.id} has no resolvable damage group`);
- const plan: DamageGroupPlan = groupsForDefinition.length === 1 ? { fixed: groupsForDefinition[0] } : { byScopeKey: groupsByScopeKey };
- plansByDefinition[skill.side].set(intent.sourceDefinition, plan);
- assignDamageGroupPlan(intent, plan);
- }
- }
- const battleStart = all.filter((skill) => skill.trigger.type === "battle_start");
- const roundStart = all.filter((skill) => skill.trigger.type === "turn");
- const attackDeclared = all.filter((skill) => skill.trigger.type === "attack");
- const attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });
- for (const skill of attackDeclared) {
- const trigger = compiledTriggerForSkill(skill);
- for (const dealerUnit of unitsFromMask(trigger.source.units)) {
- for (const takerUnit of unitsFromMask(trigger.target.units)) {
- const slot = damageJobShapeSlot("normal", trigger.source.side, dealerUnit, trigger.target.side, takerUnit);
- const matching = attackDeclaredByJobShape[slot];
- if (matching) matching.push(skill);
- else attackDeclaredByJobShape[slot] = [skill];
- }
- }
- }
- return {
- all,
- battleStart,
- roundStart,
- roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),
- roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),
- attackDeclared,
- attackDeclaredByJobShape,
- damageGroups,
- damageGroupsByJobShape
- };
-}
-
-interface DamageGroupPlan {
- fixed?: NonNullable<ActiveEffect["damageIndexGroup"]>;
- byScopeKey?: Array<NonNullable<ActiveEffect["damageIndexGroup"]> | undefined>;
-}
-
-function assignDamageGroupPlan(intent: ResolvedEffectIntentDefinition, plan: DamageGroupPlan): void {
- intent.damageGroup = plan.fixed;
- intent.damageGroupsByScopeKey = plan.byScopeKey;
-}
-
-function assertDisjointResolvedGroupSlots(effectId: string, slots: Uint8Array, existingGroups: Iterable<Uint8Array>): void {
- for (const existing of existingGroups) {
- for (const slot of slots) {
- if (existing.includes(slot)) {
- throw new Error(`Resolved scopes for max-stacking effect ${effectId} overlap at damage job slot ${slot}`);
- }
- }
- }
-}
-
-function potentialActivationIntents(skill: ResolvedSkill): Array<AttackIntent | undefined> {
- if (skill.trigger.type === "battle_start" || (skill.trigger.type === "turn" && skill.trigger.source === undefined)) return [undefined];
- const trigger = compiledTriggerForSkill(skill);
- const dealerSide = trigger.source.side;
- let takerSide = trigger.target.side;
- if (skill.trigger.type === "turn" && takerSide === dealerSide) takerSide = oppositeSide(dealerSide);
- if (takerSide === dealerSide) return [];
- const dealerUnits = unitsFromMask(trigger.source.units);
- const takerUnits = unitsFromMask(trigger.target.units);
- const intents: AttackIntent[] = [];
- for (const dealerUnit of dealerUnits) {
- for (const takerUnit of takerUnits) {
- intents.push({
- id: "group-plan",
- round: 1,
- source: "normal",
- dealerSide,
- dealerUnit,
- takerSide,
- takerUnit,
- orderIndex: 0,
- previousAttackCount: 0,
- projectedAttackCount: 1,
- previousReceivedAttackCount: 0,
- projectedReceivedAttackCount: 1
- });
- }
- }
- return intents;
-}
-
-function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
- if (!isRuntimeIndexableEffect(effect)) return;
- const window = effectRoundWindow(effect);
- if (!window) {
- indexEffect(runtime.effectIndex, effect);
- return;
- }
- if (window.expirationRound !== undefined) {
- if (window.expirationRound <= window.activationRound) {
- effect.expired = true;
- return;
- }
- scheduleEffect(runtime.expireEffectsByRound, window.expirationRound, effect);
- }
- if (window.activationRound <= effect.createdRound) indexEffect(runtime.effectIndex, effect);
- else scheduleEffect(runtime.activateEffectsByRound, window.activationRound, effect);
-}
-
-function scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {
- const effects = schedule[round];
- if (effects) effects.push(effect);
- else schedule[round] = [effect];
-}
-
-function processEffectSchedule(runtime: Runtime, round: number): void {
- const expiring = runtime.expireEffectsByRound[round];
- if (expiring) {
- for (const effect of expiring) expireActiveEffect(runtime, effect);
- runtime.expireEffectsByRound[round] = undefined;
- }
- const activating = runtime.activateEffectsByRound[round];
- if (activating) {
- for (const effect of activating) {
- if (!effect.expired) indexEffect(runtime.effectIndex, effect);
- }
- runtime.activateEffectsByRound[round] = undefined;
- }
-}
-
-function createInputPassiveEffects(passive: FighterInput["passive"], side: SideId): ActiveEffect[] {
- const effects: ActiveEffect[] = [];
- if (!passive) return effects;
- for (const stat of ["attack", "defense", "lethality", "health"] as const) {
- for (const direction of ["up", "down"] as const) {
- const valuePct = Number(passive[stat]?.[direction] ?? 0);
- if (!Number.isFinite(valuePct) || valuePct <= 0) continue;
- const bucket = `passive.${stat}.${direction}`;
- const effect: ActiveEffect = {
- id: `${side}:input_stat:${bucket}`,
- source: {
- kind: "input_stat",
- side,
- effectId: `input:${bucket}`
- },
- intent: {
- id: `input:${bucket}`,
- type: bucket,
- value: valuePct
- },
- ownerSide: side,
- kind: "modifier",
- bucketIndex: -1,
- initialValuePct: valuePct,
- getCurrentValuePct: constantActiveEffectValuePct,
- appliesTo: { side, units: ALL_UNIT_MASK },
- appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },
- createdRound: 0,
- startRound: 0,
- duration: {},
- remainingAttackDelay: 0,
- uses: 0,
- sameEffectStacking: "add"
- };
- effects.push(effect);
- }
- }
- return effects;
-}
-
-function triggerSkills(
- triggerType: "battle_start" | "round_start" | "attack_declared",
- round: number,
- skills: ResolvedSkill[],
- runtime: Runtime,
- recorder: BattleRecorder,
- intent?: AttackIntent
-): ActiveEffect[] {
- const activated: ActiveEffect[] = [];
- for (const skill of skills) {
- if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;
- recorder.recordSkillTriggerAttempt(skill);
- if (!chancePasses(skill, runtime.rng)) continue;
- recorder.recordSkillTriggered(skill);
- for (const effectIntent of skill.effects) {
- const effect = activateEffect(skill, effectIntent, round, intent);
- addActiveEffect(runtime, effect);
- activated.push(effect);
- runtime.effectActivationCounts[skill.side] += 1;
- recorder.recordSkillEffectActivated(skill);
- }
- }
- return activated;
-}
-
function resolveAttackIntents(
round: number,
runtime: Runtime,
roundStartTroops: DamageJob["roundStartTroops"],
@@ -911,17 +451,11 @@
if ((roundStartTroops[side][dealerUnit] ?? 0) <= 0) continue;
const ordered = orderFromEffects(dealerUnit, side, runtime.effectIndex, true);
const takerUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, takerSide, roundStartTroops);
if (!takerUnit) continue;
- const intentId = `r${round}:${side}:${dealerUnit}:${orderIndex}`;
- if (ordered) {
- chargeEffectUse(runtime, ordered.effect);
- recorder.recordBattleOrder(intentId, ordered.effect, takerUnit);
- }
const previousAttackCount = runtime.counters.attacks[side][dealerUnit];
const previousReceivedAttackCount = runtime.counters.received[takerSide][takerUnit];
- intents.push({
- id: intentId,
+ const intent: AttackIntent = {
round,
source: "normal",
dealerSide: side,
dealerUnit,
@@ -931,9 +465,14 @@
previousAttackCount,
projectedAttackCount: previousAttackCount + 1,
previousReceivedAttackCount,
projectedReceivedAttackCount: previousReceivedAttackCount + 1
- });
+ };
+ if (ordered) {
+ chargeEffectUse(runtime, ordered.effect);
+ recorder.recordBattleOrder(intent, ordered.effect, takerUnit);
+ }
+ intents.push(intent);
orderIndex += 1;
}
}
return intents;
@@ -960,62 +499,62 @@
dealerUnit: UnitType,
dealerSide: SideId,
index: EffectIndex,
advanceAttackDelay: boolean
-): { order: UnitType[]; effect: ActiveEffect } | undefined {
+): { order: readonly UnitType[]; effect: ActiveEffect } | undefined {
for (const effect of index.battleOrder) {
- if (effect.intent.type !== "attack_order") continue;
if (effect.appliesTo.side !== dealerSide || !unitMaskHas(effect.appliesTo.units, dealerUnit)) continue;
- if (Array.isArray(effect.intent.value)) {
- try {
- if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;
- return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };
- } catch {
- return undefined;
- }
- }
+ const order = effect.attackOrder;
+ if (!order) continue;
+ if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;
+ return { order, effect };
}
return undefined;
}
+// Shared empty result for the common no-control-effects case; callers only read it.
+const NO_APPLICABLE_CONTROLS: ApplicableControls = { attackDurationEffects: [] };
+
function applicableControls(
job: DamageJob,
runtime: Runtime
): ApplicableControls {
+ if (runtime.effectIndex.controls.length === 0) return NO_APPLICABLE_CONTROLS;
const controls: ApplicableControls = { attackDurationEffects: [] };
for (const effect of runtime.effectIndex.controls) {
- const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "control" && classification.control) {
- if (!advanceEffectAttackDelay(effect)) continue;
- if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);
- controls[classification.control] = {
- effect,
- reason: classification.control,
- attackDurationEffects: controls.attackDurationEffects
- };
- }
+ const reason = controlType(effect);
+ if (!controlEffectApplies(effect, job, reason)) continue;
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);
+ controls[reason] = {
+ effect,
+ reason,
+ attackDurationEffects: controls.attackDurationEffects
+ };
}
return controls;
}
-interface ApplicableControls {
- dodge?: Control;
- no_attack?: Control;
- attackDurationEffects: ActiveEffect[];
+function controlType(effect: ActiveEffect): "dodge" | "no_attack" {
+ const type = effect.intent.type;
+ if (type === "dodge" || type === "no_attack") return type;
+ throw new Error(`control effect ${effect.intent.id} has unsupported type ${JSON.stringify(type)}`);
}
-interface Control {
- effect: ActiveEffect;
- reason: "dodge" | "no_attack";
- attackDurationEffects: ActiveEffect[];
+function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {
+ const appliesToSide = control === "no_attack" ? job.dealerSide : job.takerSide;
+ const appliesToUnit = control === "no_attack" ? job.dealerUnit : job.takerUnit;
+ if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;
+
+ const appliesVsSide = control === "no_attack" ? job.takerSide : job.dealerSide;
+ const appliesVsUnit = control === "no_attack" ? job.takerUnit : job.dealerUnit;
+ return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);
}
function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {
return {
- id: `${intent.id}:normal`,
round: intent.round,
kind: "normal",
- sourceIntentId: intent.id,
roundStartTroops,
dealerSide: intent.dealerSide,
dealerUnit: intent.dealerUnit,
takerSide: intent.takerSide,
@@ -1023,196 +562,17 @@
sourceMultiplier: 1
};
}
-function extraSkillJobs(
- normalAttack: DamageJob,
- round: number,
- runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"],
- recorder: BattleRecorder
-): ExtraSkillJobsResult {
- const jobs: DamageJob[] = [];
- const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];
- const effectGroups = selectStackedExtraAttackEffectGroups(
- runtime.effectIndex.extraAttacks.filter(
- (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)
- ),
- round
- );
- for (const effectGroup of effectGroups) {
- const effect = effectGroup.selected;
- const sourceEffectId = effect.source.effectId ?? effect.intent.id;
- const definitions = effect.triggerDamageJobs ?? [];
- const firstJobIndex = jobs.length;
- let definitionIndex = 0;
- for (const definition of definitions) {
- const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);
- const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);
- const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);
- if (multiplier <= 0) continue;
- for (const source of sources) {
- if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;
- for (const target of targets) {
- if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;
- const job: DamageJob = {
- id: `${normalAttack.sourceIntentId}:skill:${sourceEffectId}:${definitionIndex}:${jobs.length}`,
- round,
- kind: "skill",
- sourceIntentId: normalAttack.sourceIntentId,
- roundStartTroops,
- dealerSide: source.side,
- dealerUnit: source.unit,
- takerSide: target.side,
- takerUnit: target.unit,
- sourceEffectId,
- sourceMultiplier: multiplier
- };
- jobs.push(job);
- recorder.recordSkillDamageJob(job, effect);
- }
- }
- definitionIndex += 1;
- }
- if (jobs.length > firstJobIndex) {
- // The whole stacking group is charged whenever the selected effect spawned jobs,
- // regardless of duration constraints (extra attacks always deplete per firing).
- usedEffectGroups.push({
- sourceEffectId,
- selected: effect,
- effects: effectGroup.effects,
- firstJobIndex,
- jobCount: jobs.length - firstJobIndex
- });
- }
- }
- return { jobs, usedEffectGroups };
-}
-
-function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {
- const selected: ExtraAttackEffectGroup[] = [];
- const maxByKey = new Map<string, number>();
- for (const effect of effects) {
- if (effect.sameEffectStacking !== "max" || !effect.stackingKey) {
- selected.push({ selected: effect, effects: [effect] });
- continue;
- }
- const existingIndex = maxByKey.get(effect.stackingKey);
- if (existingIndex === undefined) {
- maxByKey.set(effect.stackingKey, selected.length);
- selected.push({ selected: effect, effects: [effect] });
- continue;
- }
- const existing = selected[existingIndex];
- existing.effects.push(effect);
- if (effect.getCurrentValuePct(round) > existing.selected.getCurrentValuePct(round)) existing.selected = effect;
- }
- return selected;
-}
-
-function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {
- return (
- effect.appliesTo.side === normalAttack.dealerSide &&
- unitMaskHas(effect.appliesTo.units, normalAttack.dealerUnit) &&
- effect.appliesVs.side === normalAttack.takerSide &&
- unitMaskHas(effect.appliesVs.units, normalAttack.takerUnit)
- );
-}
-
-interface TriggerJobUnit {
- side: SideId;
- unit: UnitType;
-}
-
-function resolveTriggerJobSelector(
- selector: TriggerDamageJobSelector,
- role: "source" | "target",
- effect: ActiveEffect,
- normalAttack: DamageJob,
- roundStartTroops: DamageJob["roundStartTroops"]
-): TriggerJobUnit[] {
- if (selector === "use.source") return [{ side: normalAttack.dealerSide, unit: normalAttack.dealerUnit }];
- if (selector === "use.target") return [{ side: normalAttack.takerSide, unit: normalAttack.takerUnit }];
- if (selector === "effect.applies_to") return unitsFromMask(effect.appliesTo.units).map((unit) => ({ side: effect.appliesTo.side, unit }));
- if (selector === "effect.applies_vs") return unitsFromMask(effect.appliesVs.units).map((unit) => ({ side: effect.appliesVs.side, unit }));
- if (selector === "enemy.living") return livingUnits(normalAttack.takerSide, roundStartTroops);
- if (selector === "self.living") return livingUnits(normalAttack.dealerSide, roundStartTroops);
- const units = unitListFromSelector(selector);
- if (!units) {
- throw new Error(`trigger_damage_jobs ${role} selector is required and must be a supported selector, got ${JSON.stringify(selector)}`);
- }
- const fallbackSide = role === "source" ? normalAttack.dealerSide : normalAttack.takerSide;
- return units.map((unit) => ({ side: fallbackSide, unit }));
-}
-
-function livingUnits(side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): TriggerJobUnit[] {
- return UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0).map((unit) => ({ side, unit }));
-}
-
-function unitListFromSelector(selector: TriggerDamageJobSelector): UnitType[] | undefined {
- if (Array.isArray(selector)) {
- try {
- return selector.map((entry) => normalizeUnitType(String(entry)));
- } catch {
- return undefined;
- }
- }
- if (typeof selector === "string") {
- try {
- return [normalizeUnitType(selector)];
- } catch {
- return undefined;
- }
- }
- return undefined;
-}
-
-function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {
- const raw = multiplier === undefined ? effect.getCurrentValuePct(round) : multiplier;
- const pct = Number(raw ?? 0);
- return Number.isFinite(pct) ? pct / 100 : 0;
-}
-
-function emptyRoundTargetDamage(): Record<SideId, Record<UnitType, number>> {
- return { attacker: emptyTroops(), defender: emptyTroops() };
-}
-
-function targetExhausted(
- job: DamageJob,
- roundStartTroops: DamageJob["roundStartTroops"],
- roundTargetDamage: Record<SideId, Record<UnitType, number>>
-): boolean {
- const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);
- return available <= 0 || roundTargetDamage[job.takerSide][job.takerUnit] >= available;
-}
-
-function capJobToRemainingTarget(
- result: DamageResult,
- job: DamageJob,
- roundStartTroops: DamageJob["roundStartTroops"],
- roundTargetDamage: Record<SideId, Record<UnitType, number>>,
- recorder: BattleRecorder
-): void {
- const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);
- const alreadyDamaged = roundTargetDamage[job.takerSide][job.takerUnit];
- const remaining = Math.max(0, available - alreadyDamaged);
- result.kills = Math.min(result.kills, remaining);
- roundTargetDamage[job.takerSide][job.takerUnit] += result.kills;
- recorder.recordFinalKills(result);
-}
-
-// 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 {
+function commitRound(cancelled: CancelledAttack[], results: DamageJobResult[], runtime: Runtime): void {
commitRoundCounters(cancelled, results, runtime);
const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };
for (const { job, result } of results) {
losses[job.takerSide][job.takerUnit] += result.kills;
}
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]);
+ runtime.troops[side][unit] = Math.max(0, runtime.troops[side][unit] - losses[side][unit]);
}
}
}
@@ -1226,38 +586,17 @@
runtime.counters.received[job.takerSide][job.takerUnit] += 1;
}
}
-// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in
-// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.
-function chargeUsedEffects(runtime: Runtime): void {
- if (runtime.usedEffects.length === 0) return;
- for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);
- runtime.usedEffects.length = 0;
-}
-
-function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {
- effect.uses += 1;
- const limit = effectAttackUseLimit(effect);
- if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);
-}
-
-function expireActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
- if (effect.expired) return;
- expireEffectIndex(runtime.effectIndex, effect);
-}
-
-// A cancelled attack still charges the attacker's attack-constrained effects (the attack
-// happened, it just didn't land) plus the control that cancelled it, whatever its duration.
function chargeCancelledAttack(
job: DamageJob,
winningControl: ActiveEffect,
controlEffects: ActiveEffect[],
runtime: Runtime
): void {
const used = runtime.usedEffects;
for (const group of runtime.effectIndex.damageGroupsByJobShape[damageJobSlot(job)]) {
- for (const effect of group.effects) {
+ for (const effect of runtime.effectIndex.liveEffectsByGroup[group.ordinal]) {
if (!advanceEffectAttackDelay(effect)) continue;
if (hasAttackDurationConstraint(effect)) used.push(effect);
}
}
@@ -1265,11 +604,11 @@
if (!controlEffects.includes(winningControl)) used.push(winningControl);
chargeUsedEffects(runtime);
}
-function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {
- const attackerAlive = total(fighters.attacker.troops) > 0;
- const defenderAlive = total(fighters.defender.troops) > 0;
+function winnerFor(troops: Record<SideId, Record<UnitType, number>>): SideId | undefined {
+ const attackerAlive = total(troops.attacker) > 0;
+ const defenderAlive = total(troops.defender) > 0;
if (attackerAlive && !defenderAlive) return "attacker";
if (defenderAlive && !attackerAlive) return "defender";
return undefined;
}
@@ -1281,41 +620,18 @@
if (result.winner === "defender") return -total(result.remaining.defender);
return 0;
}
-function bearScore(attacks: AttackOutcome[]): number {
- return attacks
- .filter((attack) => attack.dealerSide === "attacker" && attack.takerSide === "defender")
- .reduce((sum, attack) => sum + attack.kills, 0);
-}
-
function total(troops: Record<UnitType, number>): number {
return UNIT_TYPES.reduce((sum, unit) => sum + troops[unit], 0);
}
-function snapshotTroops(fighters: Record<SideId, ResolvedFighter>): DamageJob["roundStartTroops"] {
+function snapshotTroops(troops: Record<SideId, Record<UnitType, number>>): DamageJob["roundStartTroops"] {
return {
- attacker: { ...fighters.attacker.troops },
- defender: { ...fighters.defender.troops }
+ attacker: { ...troops.attacker },
+ defender: { ...troops.defender }
};
}
function ceilTroops(troops: Record<UnitType, number>): Record<UnitType, number> {
return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, Math.ceil(troops[unit] ?? 0)])) as Record<UnitType, number>;
}
-
-function buildResolved(attacker: ResolvedFighter, defender: ResolvedFighter): BattleResult["resolved"] {
- return {
- attacker: {
- troops: { ...attacker.initialTroops },
- heroes: attacker.heroes,
- troopSkillIds: attacker.troopSkills.map((skill) => skill.id),
- diagnostics: attacker.diagnostics
- },
- defender: {
- troops: { ...defender.initialTroops },
- heroes: defender.heroes,
- troopSkillIds: defender.troopSkills.map((skill) => skill.id),
- diagnostics: defender.diagnostics
- }
- };
-}
Index: simulator/src/staticDamageProfile.ts
===================================================================
--- simulator/src/staticDamageProfile.ts prev run
+++ simulator/src/staticDamageProfile.ts this run
@@ -1,11 +1,10 @@
import type { ActiveEffect, DamageBucketTrace, ResolvedFighter, SideId, StatBlock, UnitType } from "./types";
import { UNIT_TYPES, unitMaskHas } from "./types";
import {
- BUCKET_DEFINITIONS,
isPassiveBucket,
- passiveBucketRole,
- type BucketRole,
+ staticBucketDefinition,
+ type BucketJobSide,
type PassiveBucket,
type StaticPlayerBucket
} from "./damageBuckets";
import {
@@ -43,9 +42,9 @@
* Selection (including same-effect max stacking) lives here so the numeric profile and any
* recorder-side description of it can never disagree.
*/
export interface PassiveContribution {
- role: BucketRole;
+ jobSide: BucketJobSide;
side: SideId;
unit: UnitType;
bucket: PassiveBucket;
effect: ActiveEffect;
@@ -91,9 +90,9 @@
const factor = applyStaticDamageBucketValue(factors, bucket, totalPct);
if (factor <= 0) {
const stat = bucket.slice("player.".length);
throw new DamageAggregationError({
- groupId: `player.${BUCKET_DEFINITIONS[bucket].role}.${stat}`,
+ groupId: `player.${staticBucketDefinition(bucket)!.jobSide}.${stat}`,
netPct: totalPct,
factor,
contributors: [{ effectId: `input:${stat}`, source: "input_stats", valuePct: totalPct, bucket }]
});
@@ -101,34 +100,27 @@
}
export function selectPassiveContributions(activeEffects: ActiveEffect[]): PassiveContribution[] {
const contributions: PassiveContribution[] = [];
- const maxGroups = new Map<string, PassiveContribution>();
for (const effect of activeEffects) {
const bucket = effect.intent.type;
if (!isPassiveBucket(bucket)) continue;
- const role = passiveBucketRole(bucket)!;
+ const jobSide = staticBucketDefinition(bucket)!.jobSide;
const side = effect.appliesTo.side;
for (const unit of UNIT_TYPES) {
if (!unitMaskHas(effect.appliesTo.units, unit)) continue;
- const contribution: PassiveContribution = { role, side, unit, bucket, effect, valuePct: effect.getCurrentValuePct(1) };
- if (effect.sameEffectStacking === "max" && effect.stackingKey) {
- const key = `${role}:${side}:${unit}:${bucket}:${effect.stackingKey}`;
- const selected = maxGroups.get(key);
- if (!selected || contribution.valuePct > selected.valuePct) maxGroups.set(key, contribution);
- } else {
- contributions.push(contribution);
- }
+ const contribution: PassiveContribution = { jobSide, side, unit, bucket, effect, valuePct: effect.getCurrentValue(1) };
+ contributions.push(contribution);
}
}
- contributions.push(...maxGroups.values());
return contributions;
}
function buildSideFactors(fighter: ResolvedFighter): Record<UnitType, StaticDamageBucketFactors> {
return Object.fromEntries(
UNIT_TYPES.map((unit) => {
const factors = createStaticDamageBucketFactors();
+ if (fighter.initialTroops[unit] <= 0) return [unit, factors];
const stats = unitBaseStats(fighter, unit);
const bonuses = unitPlayerBonuses(fighter, unit);
applyStaticDamageBucketValue(factors, "troops.baseAttack", stats.attack);
applyStaticDamageBucketValue(factors, "troops.baseLethality", stats.lethality);
@@ -142,16 +134,21 @@
})
) as Record<UnitType, StaticDamageBucketFactors>;
}
-/** Base stats a static entry reads for a unit; units with no troops resolve to neutral 1s. */
+/** Base stats for a live unit. Missing details indicate an invalid resolved fighter. */
export function unitBaseStats(fighter: ResolvedFighter, unit: UnitType): StatBlock {
- return fighter.troopDetails[unit]?.stats ?? FALLBACK_STATS;
+ const troop = fighter.troopDetails[unit];
+ if (!troop) {
+ throw new Error(`Resolved fighter ${fighter.side} has ${fighter.initialTroops[unit]} ${unit} troops but no troop details`);
+ }
+ return troop.stats;
}
-/** Player stat bonuses a static entry reads for a unit; missing units contribute nothing. */
+/** Player stat bonuses for a live unit. Resolved fighters must contain every unit key. */
export function unitPlayerBonuses(fighter: ResolvedFighter, unit: UnitType): StatBlock {
- return fighter.statBonuses[unit] ?? EMPTY_STATS;
+ const bonuses = fighter.statBonuses[unit];
+ if (!bonuses) {
+ throw new Error(`Resolved fighter ${fighter.side} has ${fighter.initialTroops[unit]} ${unit} troops but no stat bonuses`);
+ }
+ return bonuses;
}
-
-const FALLBACK_STATS: StatBlock = { attack: 1, defense: 1, lethality: 1, health: 1 };
-const EMPTY_STATS: StatBlock = { attack: 0, defense: 0, lethality: 0, health: 0 };
Index: simulator/src/testcases.test.ts
===================================================================
--- simulator/src/testcases.test.ts prev run
+++ simulator/src/testcases.test.ts this run
@@ -1,420 +0,0 @@
-import assert from "node:assert/strict";
-import { mkdirSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { resolve } from "node:path";
-import { test } from "node:test";
-
-import { loadSimulatorConfig } from "./config";
-import { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";
-import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
-import { adaptTestcaseEntry, applyComparisonQValues, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, discoverTestcaseFiles, runTestcases, type TestcaseSummaryEntry } from "./testcases";
-
-test("discoverTestcaseFiles follows simulator/testcases symlink and skips disabled or stale files by default", () => {
- const files = discoverTestcaseFiles();
-
- assert.ok(files.some((file) => file.endsWith("emulator_verified/simple_001_nc.json")));
- assert.ok(!files.some((file) => file.endsWith(".disabled")));
- assert.ok(!files.some((file) => file.endsWith(".stale_troops")));
-});
-
-test("discoverTestcaseFiles includes disabled and stale testcase files when requested", () => {
- const files = discoverTestcaseFiles({ includeDisabled: true });
-
- assert.ok(files.some((file) => file.endsWith("emulator_verified/jasser_solo.json.disabled")));
- assert.ok(files.some((file) => file.endsWith("emulator_verified/reina_logan_combo_v2.json.stale_troops")));
-});
-
-test("runTestcases returns compact summary entries and full detail entries separately", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "simple_001", repeat: 5 }, config);
- const key = Object.keys(report.testcases)[0];
- const summary = report.testcases[key];
- const detail = report.details[0];
-
- assert.equal(report.reportKind, "simulator-parity-summary");
- assert.equal(report.counts.filesFound, 1);
- assert.equal(report.counts.testcasesFound, 1);
- assert.equal(report.counts.executed, 1);
- assert.equal(report.counts.errors, 0);
- assert.equal(summary?.testcase_id, "simple_001");
- assert.equal(summary?.deterministic, false);
- assert.equal(summary?.sampleCount, 5);
- assert.equal(typeof summary?.game?.mu_candidate, "number");
- assert.equal(summary?.baseline, null);
- assert.equal(report.counts.comparedToBaseline, 0);
- assert.equal("result" in (summary as object), false);
- assert.ok(detail?.result);
- assert.equal(detail?.simulatorStats?.n, 5);
- assert.ok(detail?.visibility.attacker.troops.lancer);
-});
-
-test("runTestcases defaults stochastic cases to 100 samples", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "simple_001" }, config);
- const summary = Object.values(report.testcases)[0];
- const detail = report.details[0];
-
- assert.equal(report.options.repeat, 100);
- assert.equal(summary?.deterministic, false);
- assert.equal(summary?.sampleCount, 100);
- assert.equal(detail?.simulatorStats?.n, 100);
-});
-
-test("assignDetailArtifactPaths assigns deterministic compact detail paths", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
-
- assignDetailArtifactPaths(report, "simulator_parity_test");
-
- assert.equal(report.artifactRoot, "simulator_parity_test");
- const keys = Object.keys(report.testcases);
- assert.deepEqual(keys, [...keys].sort());
- assert.equal(report.testcases[keys[0]!]?.detailArtifact, "simulator_parity_test/cases/000001.json");
-});
-
-test("buildSummaryForOutput excludes full detail artifacts from compact output", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
- assignDetailArtifactPaths(report, "simulator_parity_test");
-
- const summary = buildSummaryForOutput(report);
- const json = JSON.stringify(summary);
-
- assert.equal("details" in summary, false);
- assert.equal(json.includes("\"result\""), false);
- assert.equal(json.includes("\"attacks\""), false);
- assert.equal(Object.values(summary.testcases)[0]?.detailArtifact, "simulator_parity_test/cases/000001.json");
-});
-
-test("assignDetailArtifactPaths exposes failed testcase diagnostics through errors", () => {
- const testcaseRoot = tempDir("simulator-invalid-testcases");
- writeFileSync(
- resolve(testcaseRoot, "invalid.json"),
- JSON.stringify([{ test_id: "bad_case", attacker: { troops: { infantry_t1: 1 } } }]),
- );
- const config = loadSimulatorConfig();
- const report = runTestcases({ testcaseRoot, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
-
- assignDetailArtifactPaths(report, "simulator_parity_failed");
-
- assert.equal(report.counts.errors, 1);
- assert.equal(report.details[0]?.detailArtifact, "simulator_parity_failed/cases/000001.json");
- assert.equal(report.errors[0]?.detailArtifact, "simulator_parity_failed/cases/000001.json");
- assert.equal(report.errors[0]?.stage, "adapt");
- assert.equal(Object.keys(report.testcases).length, 0);
-});
-
-test("runTestcases logs structured damage aggregation errors and continues", () => {
- const testcaseRoot = tempDir("simulator-aggregation-error-testcases");
- writeFileSync(
- resolve(testcaseRoot, "bad-aggregation.json"),
- JSON.stringify([
- {
- test_id: "bad_aggregation",
- attacker: {
- troops: { infantry_t1: 1000 },
- stats: { infantry: { attack: 0, defense: 0, lethality: 0, health: 0 } }
- },
- defender: {
- troops: { lancer_t1: 1000 },
- stats: { lancer: { attack: 0, defense: 0, lethality: 0, health: -105 } }
- }
- },
- {
- test_id: "next_case_runs",
- attacker: {
- troops: { infantry_t1: 1000 },
- stats: { infantry: { attack: 0, defense: 0, lethality: 0, health: 0 } }
- },
- defender: {
- troops: { lancer_t1: 1000 },
- stats: { lancer: { attack: 0, defense: 0, lethality: 0, health: 0 } }
- }
- }
- ])
- );
- const config = loadSimulatorConfig();
- const report = runTestcases({ testcaseRoot, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
-
- assert.equal(report.counts.errors, 1);
- assert.equal(report.counts.executed, 1);
- assert.equal(report.errors[0]?.testcase_id, "bad_aggregation");
- assert.equal(report.details[0]?.errorDetails?.type, "DamageAggregationError");
- assert.equal(report.details[0]?.errorDetails?.groupId, "player.taker.health");
- assert.equal(report.details[0]?.errorDetails?.factor, -0.050000000000000044);
- assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");
-});
-
-test("runTestcases keeps executed testcase without warning when legacy baseline is unavailable", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "simple_001", repeat: 1, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
- const summary = Object.values(report.testcases)[0];
-
- assert.equal(report.counts.executed, 1);
- assert.equal(summary?.game?.n_candidate, 1);
- assert.equal(summary?.baseline, null);
- assert.equal(report.counts.comparedToBaseline, 0);
- assert.deepEqual(report.warnings, []);
-});
-
-test("applyComparisonQValues corrects game comparisons only", () => {
- const firstGame = comparisonMetric(0.01);
- const secondGame = comparisonMetric(0.02);
- const ignoredBaseline = comparisonMetric(0.04);
- const testcases: Record<string, TestcaseSummaryEntry> = {
- "testcases/a.json#0": summaryEntry("a", 0, firstGame, ignoredBaseline),
- "testcases/b.json#0": summaryEntry("b", 0, secondGame, null)
- };
-
- applyComparisonQValues({ testcases });
-
- assert.equal(firstGame.q, 0.02);
- assert.equal(secondGame.q, 0.02);
- assert.equal(ignoredBaseline.q, null);
-});
-
-test("calibration lookup supports simulator symlink and source testcase path variants", () => {
- assert.deepEqual(testcaseFileLookupVariants("simulator/testcases/emulator_verified/simple_001_nc.json"), [
- "simulator/testcases/emulator_verified/simple_001_nc.json",
- "testcases/emulator_verified/simple_001_nc.json"
- ]);
-
- const comparison = loadCalibrationComparison();
- const sourceRow = readCalibrationCase(comparison, "testcases/emulator_verified/simple_001_nc.json", "simple_001");
- if (sourceRow) {
- assert.deepEqual(readCalibrationCase(comparison, "simulator/testcases/emulator_verified/simple_001_nc.json", "simple_001"), sourceRow);
- }
-});
-
-test("duplicate no-hero testcase ids keep game rows aligned by case index", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "greg_mia_nohero_control_current", repeat: 1 }, config);
-
- assert.equal(report.counts.testcasesFound, 2);
- assert.equal(report.counts.executed, 2);
-
- const first = report.details.find((entry) => entry.index === 0);
- const second = report.details.find((entry) => entry.index === 1);
- assert.equal(first?.testcaseId, "greg_mia_nohero_control_current");
- assert.equal(second?.testcaseId, "greg_mia_nohero_control_current");
- assert.equal(first?.visibility.attacker.heroes.length, 0);
- assert.equal(second?.visibility.attacker.heroes.length, 0);
- assert.equal(first?.calibration, undefined);
- assert.equal(second?.calibration, undefined);
- assert.equal(battleScoreDelta(first?.gameResult), 3752);
- assert.equal(battleScoreDelta(second?.gameResult), 3652);
-});
-
-test("no-hero simple testcase loads, runs, compares to game result, and exposes aligned core fields", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
- const entry = report.details[0];
- const summary = Object.values(report.testcases)[0];
-
- assert.equal(report.counts.testcasesFound, 1);
- assert.equal(summary?.testcase_id, "simple_001");
- assert.equal(summary?.game?.n_reference, 1);
- assert.equal(summary?.baseline, null);
- assert.equal(entry?.visibility.attacker.heroes.length, 0);
- assert.equal(entry?.visibility.defender.heroes.length, 0);
- assert.equal(entry?.calibration, undefined);
- assert.equal(battleScoreDelta(entry?.gameResult), -186);
- assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);
-});
-
-test("runTestcases applies possible stat rounding correction to exact deterministic misses", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "norah_s2_inf_only_B" }, config);
- const summary = Object.values(report.testcases)[0];
-
- assert.equal(summary?.testcase_id, "norah_s2_inf_only_B");
- assert.equal(summary?.deterministic, true);
- assert.equal(summary?.game?.passes, true);
- assert.equal(summary?.game?.bias_raw, 0);
- assert.equal(summary?.gameStatAdjustment?.mode, "deterministic_exact");
- assert.equal(summary?.gameStatAdjustment?.value, 0.05);
- assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);
-});
-
-test("runTestcases applies stat rounding correction for stochastic misses", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "greg_mia_combo", repeat: 5, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
- const summaries = Object.values(report.testcases);
-
- assert.ok(summaries.some((summary) => summary.deterministic === false && summary.game?.passes === false));
- assert.ok(summaries.some((summary) => summary.deterministic === false && summary.gameStatAdjustment));
-});
-
-test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "1-testcases_no-heroes_t6_single-type_nc.json", repeat: 1 }, config);
- const entry = report.details.find((item) => item.testcaseId === "daut_viper_9");
-
- assert.equal(entry?.result?.winner, "attacker");
- assert.ok((entry?.result?.rounds ?? 0) > 100);
-});
-
-test("runTestcases reports a game-focused parity summary without legacy baseline comparison", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
- const row = Object.values(report.testcases)[0];
- const detail = report.details[0];
-
- assert.ok(report.calibrationReportPath?.endsWith("baseline_result_2026-05-21T04-46-47Z.json"));
- assert.equal(row?.testcase_id, "simple_001");
- assert.equal(row?.idx, 0);
- assert.equal(row?.game?.mu_reference, -186);
- assert.equal(row?.baseline, null);
- assert.equal(typeof detail?.simulatorScoreDelta, "number");
- assert.equal(typeof row?.game?.bias_raw, "number");
- assert.equal(row?.game?.n_candidate, 1);
- assert.equal(typeof row?.game?.mu_candidate, "number");
- assert.equal(typeof row?.game?.bias_raw, "number");
- assert.equal(typeof row?.game?.passes, "boolean");
- assert.equal(report.counts.comparedToGame, 1);
- assert.equal(report.counts.comparedToBaseline, 0);
-});
-
-test("adaptTestcaseEntry promotes engagement_type to a top-level BattleInput key", () => {
- const input = adaptTestcaseEntry({
- test_id: "engagement_case",
- engagement_type: "rally",
- attacker: { troops: { infantry_t1: 1 } },
- defender: { troops: { infantry_t1: 1 } }
- });
-
- assert.equal(input.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({
- candidate: { n: 1, mu: -186, sigma: 0 },
- reference: { n: 1, mu: -186, sigma: 0 },
- initialTroops: 1200,
- deterministic: true,
- thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
- });
-
- assert.deepEqual(metrics, {
- n_candidate: 1,
- mu_candidate: -186,
- sigma_candidate: 0,
- n_reference: 1,
- mu_reference: -186,
- sigma_reference: 0,
- bias_raw: 0,
- bias_pct: 0,
- sem: 0,
- stat_type: "deterministic",
- stat: null,
- p: null,
- q: null,
- passes: true
- } satisfies ParityComparisonMetrics);
-});
-
-test("compareOutcomeDistribution uses candidate spread for single-observation stochastic references", () => {
- const metrics = compareOutcomeDistribution({
- candidate: { n: 5, mu: 10, sigma: 2 },
- reference: { n: 1, mu: 8, sigma: 0 },
- initialTroops: 100,
- deterministic: false,
- thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
- });
-
- assert.equal(metrics.stat_type, "single_obs");
- assert.equal(metrics.passes, true);
- assert.equal(metrics.bias_raw, 2);
- assert.equal(metrics.bias_pct, 2);
- assert.equal(metrics.sem, 2);
- assert.equal(metrics.stat, 1);
-});
-
-test("compareOutcomeDistribution fails single-observation references outside candidate spread", () => {
- const metrics = compareOutcomeDistribution({
- candidate: { n: 100, mu: 862.15, sigma: 3.79 },
- reference: { n: 1, mu: 879, sigma: 0 },
- initialTroops: 1440,
- deterministic: false,
- thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
- });
-
- assert.equal(metrics.stat_type, "single_obs");
- assert.equal(metrics.passes, false);
- assert.equal(metrics.bias_raw, -16.85);
- assert.equal(metrics.bias_pct, -1.17);
- assert.equal(metrics.sem, 3.79);
- assert.equal(metrics.stat, -4.4459);
-});
-
-test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {
- const rows = [
- { p: 0.01, q: null },
- { p: 0.03, q: null },
- { p: null, q: null }
- ];
-
- applyBenjaminiHochberg(rows);
-
- assert.equal(rows[0].q, 0.02);
- assert.equal(rows[1].q, 0.03);
- assert.equal(rows[2].q, null);
-});
-
-test("calibration lookup exposes full baseline snapshot metrics", () => {
- const comparison = loadCalibrationComparison();
- const row = readCalibrationCase(comparison, "testcases/emulator_verified/simple_001_nc.json", "simple_001");
-
- assert.equal(row?.biasRaw, 0);
- assert.equal(row?.sem, 0);
- assert.equal(row?.p, null);
- assert.equal(row?.q, null);
-});
-
-function tempDir(prefix: string): string {
- const dir = resolve(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
- mkdirSync(dir, { recursive: true });
- return dir;
-}
-
-function comparisonMetric(p: number): ParityComparisonMetrics {
- return {
- n_candidate: 2,
- mu_candidate: 10,
- sigma_candidate: 1,
- n_reference: 2,
- mu_reference: 8,
- sigma_reference: 1,
- bias_raw: 2,
- bias_pct: 1,
- sem: 1,
- stat_type: "t",
- stat: 2,
- p,
- q: null,
- passes: true
- };
-}
-
-function summaryEntry(testcaseId: string, idx: number, game: ParityComparisonMetrics | null, baseline: ParityComparisonMetrics | null): TestcaseSummaryEntry {
- return {
- file: `testcases/${testcaseId}.json`,
- testcase_id: testcaseId,
- idx,
- deterministic: false,
- sampleCount: 2,
- game,
- baseline
- };
-}
Index: simulator/src/testcases.ts
===================================================================
--- simulator/src/testcases.ts prev run
+++ simulator/src/testcases.ts this run
@@ -1,831 +0,0 @@
-import { readdirSync, readFileSync, statSync } from "node:fs";
-import { dirname, relative, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-
-import {
- loadCalibrationComparison,
- type CalibrationCaseComparison,
- type SampleStats,
- sampleStats
-} from "./calibration";
-import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
-import { prepareBattle, runPrepared } from "./simulator";
-import { DamageAggregationError } from "./staticDamageProfile";
-import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "./types";
-
-const DEFAULT_STOCHASTIC_REPEAT = 100;
-const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;
-const STAT_ROUNDING_SCAN_STEPS = 50;
-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;
- workers?: number;
-}
-
-export interface TestcaseCaseReport {
- file: string;
- testcaseId: string;
- index: number;
- detailArtifact?: string;
- diagnostics: string[];
- gameResult?: unknown;
- calibration?: CalibrationCaseComparison;
- result?: BattleResult;
- simulatorScoreDelta?: number;
- simulatorStats?: SampleStats;
- simulatorSampleOutcomes?: TestcaseSampleOutcome[];
- simulatorSampleDeltas?: number[];
- gameStatAdjustment?: TestcaseStatAdjustment;
- deterministic?: boolean;
- sampleCount?: number;
- visibility: {
- attacker: CaseVisibility;
- defender: CaseVisibility;
- };
- error?: string;
- errorDetails?: TestcaseErrorDetails;
-}
-
-export interface TestcaseRunWarning {
- file: string;
- testcase_id: string;
- idx: number;
- stage: "parse" | "adapt" | "execute" | "game_comparison" | "baseline_comparison" | "artifact";
- reason: string;
- detailArtifact?: string;
-}
-
-export interface TestcaseSummaryEntry {
- file: string;
- testcase_id: string;
- idx: number;
- detailArtifact?: string;
- deterministic: boolean;
- sampleCount: number;
- game: ParityComparisonMetrics | null;
- baseline: ParityComparisonMetrics | null;
- gameStatAdjustment?: TestcaseStatAdjustment;
-}
-
-export interface TestcaseRunReport {
- reportKind: "simulator-parity-summary";
- schemaVersion: 1;
- createdAt: string;
- options: TestcaseRunOptions;
- calibrationReportPath?: string;
- artifactRoot?: string;
- counts: {
- filesFound: number;
- testcasesFound: number;
- executed: number;
- warnings: number;
- errors: number;
- comparedToGame: number;
- comparedToBaseline: number;
- };
- warnings: TestcaseRunWarning[];
- errors: TestcaseRunWarning[];
- testcases: Record<string, TestcaseSummaryEntry>;
- details: TestcaseCaseReport[];
-}
-
-export type TestcaseSummaryOutput = Omit<TestcaseRunReport, "details">;
-
-interface CaseVisibility {
- heroes: string[];
- troopSkillIds: string[];
- troops: Partial<Record<UnitType, number>>;
- skillEffectActivations: number;
-}
-
-interface TestcaseErrorDetails {
- type: string;
- [key: string]: unknown;
-}
-
-export interface PreparedTestcaseCase {
- file: string;
- reportFile: string;
- entry: unknown;
- testcaseId: string;
- index: number;
- detail: TestcaseCaseReport;
- input?: BattleInput;
- key?: string;
- adaptError?: TestcaseRunWarning;
-}
-
-export interface TestcaseExecutionJob {
- file: string;
- reportFile: string;
- testcaseId: string;
- index: number;
- input: BattleInput;
- repeat: number;
- seed?: string | number;
-}
-
-export interface TestcaseExecutionResult {
- testcaseId: string;
- index: number;
- result?: BattleResult;
- deterministic?: boolean;
- sampleCount?: number;
- simulatorStats?: SampleStats;
- simulatorScoreDelta?: number;
- simulatorSampleOutcomes?: TestcaseSampleOutcome[];
- simulatorSampleDeltas?: number[];
- diagnostics: string[];
- error?: string;
- errorDetails?: TestcaseErrorDetails;
-}
-
-export interface TestcaseSampleOutcome {
- run: number;
- attackerHeroes: string[];
- defenderHeroes: string[];
- attackerTroops: Partial<Record<UnitType, number>>;
- defenderTroops: Partial<Record<UnitType, number>>;
- attackerRemainingByType: Partial<Record<UnitType, number>>;
- defenderRemainingByType: Partial<Record<UnitType, number>>;
- attackerRemaining: number;
- defenderRemaining: number;
- scoreDelta: number;
-}
-
-export interface TestcaseStatAdjustment {
- value: number;
- mode: "deterministic_exact" | "deterministic_within_one" | "stochastic_tolerance" | "best_effort";
- unadjusted: ParityComparisonMetrics;
-}
-
-export function defaultTestcaseRoot(): string {
- return resolve(dirname(fileURLToPath(import.meta.url)), "..", "testcases");
-}
-
-export function discoverTestcaseFiles(options: Pick<TestcaseRunOptions, "testcaseRoot" | "matching" | "includeDisabled"> = {}): string[] {
- const root = resolve(options.testcaseRoot ?? defaultTestcaseRoot());
- const files: string[] = [];
- walk(root, files);
- return files
- .filter((file) => isDiscoverableTestcaseFile(file, options.includeDisabled))
- .filter((file) => options.includeDisabled || (!file.endsWith(".disabled") && !file.endsWith(".stale_troops")))
- .filter((file) => !options.matching || file.includes(options.matching))
- .sort();
-}
-
-export function runTestcases(options: TestcaseRunOptions, config: SimulatorConfig): TestcaseRunReport {
- const prepared = prepareTestcaseCases(options);
- return runPreparedTestcases(options, config, prepared, (job) => executeTestcaseCase(job, config));
-}
-
-export function prepareTestcaseCases(options: TestcaseRunOptions): { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] } {
- const files = discoverTestcaseFiles(options);
- const cases: PreparedTestcaseCase[] = [];
- const parseErrors: TestcaseRunWarning[] = [];
- for (const file of files) {
- const reportFile = normalizeReportPath(relative(process.cwd(), file));
- let entries: unknown[];
- try {
- const parsed = JSON.parse(readFileSync(file, "utf8"));
- entries = Array.isArray(parsed) ? parsed : [parsed];
- } catch (error) {
- parseErrors.push({ file: reportFile, testcase_id: "(parse_error)", idx: 0, stage: "parse", reason: `Failed to parse JSON: ${errorMessage(error)}` });
- continue;
- }
-
- entries.forEach((entry, index) => {
- const testcaseId = testcaseIdFor(entry, index);
- const diagnostics: string[] = [];
- const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);
- const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };
- try {
- preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed }, diagnostics);
- preparedCase.key = snapshotKey(reportFile, index);
- } catch (error) {
- detail.error = errorMessage(error);
- diagnostics.push(detail.error);
- preparedCase.adaptError = { file: reportFile, testcase_id: testcaseId, idx: index, stage: "adapt", reason: detail.error };
- }
- cases.push(preparedCase);
- });
- }
- return { filesFound: files.length, cases, parseErrors };
-}
-
-export function runPreparedTestcases(
- options: TestcaseRunOptions,
- config: SimulatorConfig,
- prepared: { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] },
- execute: (job: TestcaseExecutionJob, config: SimulatorConfig) => TestcaseExecutionResult
-): TestcaseRunReport {
- const comparison = loadCalibrationComparison(options.calibrationReportPath);
- const repeat = normalizeRepeat(options.repeat);
- const report: TestcaseRunReport = {
- reportKind: "simulator-parity-summary",
- schemaVersion: 1,
- createdAt: new Date().toISOString(),
- options: { ...options, repeat },
- calibrationReportPath: comparison.reportPath,
- counts: { filesFound: prepared.filesFound, testcasesFound: prepared.cases.length, executed: 0, warnings: 0, errors: 0, comparedToGame: 0, comparedToBaseline: 0 },
- warnings: [],
- errors: [...prepared.parseErrors],
- testcases: {},
- details: []
- };
-
- for (const preparedCase of prepared.cases) {
- const { file, reportFile, testcaseId, index, detail } = preparedCase;
- if (!preparedCase.input || !preparedCase.key) {
- if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);
- report.details.push(detail);
- continue;
- }
- try {
- const execution = execute({ file, reportFile, testcaseId, index, input: preparedCase.input, repeat, seed: options.seed }, config);
- applyExecutionResult(report, comparison, preparedCase, execution, config);
- } catch (error) {
- detail.error = errorMessage(error);
- detail.errorDetails = errorDetails(error);
- detail.diagnostics.push(detail.error);
- report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: detail.error });
- }
- report.details.push(detail);
- }
-
- finalizeReport(report);
- return report;
-}
-
-export async function runPreparedTestcasesAsync(
- options: TestcaseRunOptions,
- config: SimulatorConfig,
- prepared: { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] },
- execute: (job: TestcaseExecutionJob) => Promise<TestcaseExecutionResult>
-): Promise<TestcaseRunReport> {
- const comparison = loadCalibrationComparison(options.calibrationReportPath);
- const repeat = normalizeRepeat(options.repeat);
- const report: TestcaseRunReport = {
- reportKind: "simulator-parity-summary",
- schemaVersion: 1,
- createdAt: new Date().toISOString(),
- options: { ...options, repeat },
- calibrationReportPath: comparison.reportPath,
- counts: { filesFound: prepared.filesFound, testcasesFound: prepared.cases.length, executed: 0, warnings: 0, errors: 0, comparedToGame: 0, comparedToBaseline: 0 },
- warnings: [],
- errors: [...prepared.parseErrors],
- testcases: {},
- details: []
- };
-
- const jobs = prepared.cases.map(async (preparedCase) => {
- if (!preparedCase.input || !preparedCase.key) return { preparedCase };
- try {
- const execution = await execute({
- file: preparedCase.file,
- reportFile: preparedCase.reportFile,
- testcaseId: preparedCase.testcaseId,
- index: preparedCase.index,
- input: preparedCase.input,
- repeat,
- seed: options.seed
- });
- return { preparedCase, execution };
- } catch (error) {
- return { preparedCase, error };
- }
- });
-
- for (const { preparedCase, execution, error } of await Promise.all(jobs)) {
- const { reportFile, testcaseId, index, detail } = preparedCase;
- if (!preparedCase.input || !preparedCase.key) {
- if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);
- report.details.push(detail);
- continue;
- }
- if (error !== undefined) {
- detail.error = errorMessage(error);
- detail.errorDetails = errorDetails(error);
- detail.diagnostics.push(detail.error);
- report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: detail.error });
- } else {
- applyExecutionResult(report, comparison, preparedCase, execution!, config);
- }
- report.details.push(detail);
- }
-
- finalizeReport(report);
- return report;
-}
-
-function applyExecutionResult(
- report: TestcaseRunReport,
- comparison: ReturnType<typeof loadCalibrationComparison>,
- preparedCase: PreparedTestcaseCase,
- execution: TestcaseExecutionResult,
- config: SimulatorConfig
-): void {
- const { reportFile, entry, testcaseId, index, detail } = preparedCase;
- if (execution.error) {
- detail.error = execution.error;
- detail.errorDetails = execution.errorDetails;
- detail.diagnostics.push(execution.error);
- report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: execution.error });
- return;
- }
- const result = execution.result;
- if (!result || !execution.simulatorStats || execution.deterministic === undefined || execution.sampleCount === undefined) {
- const reason = "Worker returned incomplete testcase execution result";
- detail.error = reason;
- detail.errorDetails = { type: "IncompleteExecutionResult" };
- detail.diagnostics.push(reason);
- report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason });
- return;
- }
- const stats = execution.simulatorStats;
- const gameResult = (entry as { game_report_result?: unknown }).game_report_result;
- const initialTroops = totalInputTroops(preparedCase.input!.attacker) + totalInputTroops(preparedCase.input!.defender);
- const simulatorDistribution = { n: stats.n, mu: stats.mu, sigma: stats.sigma };
- const gameDistribution = distributionFromGameResult(gameResult);
- let game = gameDistribution
- ? compareOutcomeDistribution({
- candidate: simulatorDistribution,
- reference: gameDistribution,
- initialTroops,
- deterministic: result.randomness.deterministic,
- thresholds: comparison.thresholds
- })
- : null;
- const gameStatAdjustment = game && preparedCase.input
- ? findGameStatAdjustment({
- game,
- input: preparedCase.input,
- config,
- job: { file: preparedCase.file, reportFile, testcaseId, index, input: preparedCase.input, repeat: execution.sampleCount, seed: undefined },
- reference: gameDistribution!,
- initialTroops,
- deterministic: result.randomness.deterministic,
- thresholds: comparison.thresholds
- })
- : undefined;
- if (gameStatAdjustment) game = gameStatAdjustment.adjusted;
-
- detail.result = result;
- detail.deterministic = execution.deterministic;
- detail.sampleCount = execution.sampleCount;
- detail.simulatorStats = stats;
- detail.simulatorScoreDelta = execution.simulatorScoreDelta;
- detail.simulatorSampleOutcomes = execution.simulatorSampleOutcomes;
- detail.simulatorSampleDeltas = execution.simulatorSampleDeltas;
- detail.gameStatAdjustment = gameStatAdjustment ? statAdjustmentForReport(gameStatAdjustment) : undefined;
- detail.gameResult = gameResult;
- detail.visibility = visibilityFromResult(result);
- detail.diagnostics.push(...execution.diagnostics);
- if (!game) {
- report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "game_comparison", reason: "Missing game_report_result" });
- }
- report.counts.executed += 1;
- report.testcases[preparedCase.key!] = {
- file: reportFile,
- testcase_id: testcaseId,
- idx: index,
- deterministic: execution.deterministic,
- sampleCount: execution.sampleCount,
- game,
- baseline: null,
- ...(gameStatAdjustment ? { gameStatAdjustment: statAdjustmentForReport(gameStatAdjustment) } : {})
- };
-}
-
-interface InternalStatAdjustment {
- value: number;
- mode: TestcaseStatAdjustment["mode"];
- unadjusted: ParityComparisonMetrics;
- adjusted: ParityComparisonMetrics;
-}
-
-function findGameStatAdjustment(options: {
- game: ParityComparisonMetrics;
- input: BattleInput;
- config: SimulatorConfig;
- job: TestcaseExecutionJob;
- reference: { n: number; mu: number; sigma: number };
- initialTroops: number;
- deterministic: boolean;
- thresholds?: Record<string, number>;
-}): InternalStatAdjustment | undefined {
- // Deterministic cases correct any nonzero bias; stochastic cases correct only outright misses.
- // Either way a nonzero bias is needed to pick a search direction.
- const shouldCorrect = options.deterministic ? options.game.bias_raw !== 0 : !options.game.passes;
- if (!shouldCorrect || options.game.bias_raw === 0) return undefined;
- const direction = -Math.sign(options.game.bias_raw);
- const 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 };
- }
- }
-
- return best;
-}
-
-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[] = [];
- const compiled = prepareBattle(input, config);
- for (let iteration = 0; iteration < job.repeat; iteration += 1) {
- const seed = sampleSeed(job.seed ?? input.seed, job.file, job.testcaseId, job.index, iteration);
- const result = runPrepared(compiled, seed, { mode: "fast" });
- const score = battleScoreDelta(result);
- if (score !== undefined) samples.push(score);
- }
- return sampleStats(samples);
-}
-
-function inputWithStatAdjustment(input: BattleInput, value: number): BattleInput {
- const adjusted = structuredClone(input);
- adjustFighterStats(adjusted.attacker, value);
- adjustFighterStats(adjusted.defender, -value);
- return adjusted;
-}
-
-function adjustFighterStats(fighter: FighterInput, value: number): void {
- for (const stats of Object.values(fighter.stats ?? {}) as Array<Partial<StatBlock>>) {
- for (const key of ["attack", "defense", "lethality", "health"] as Array<keyof StatBlock>) {
- if (stats[key] === undefined) continue;
- stats[key] = roundStatAdjustment(Number(stats[key]) + value);
- }
- }
-}
-
-function adjustmentMode(adjusted: ParityComparisonMetrics, deterministic: boolean): TestcaseStatAdjustment["mode"] {
- if (deterministic) {
- if (adjusted.bias_raw === 0) return "deterministic_exact";
- if (Math.abs(adjusted.bias_raw) <= 1) return "deterministic_within_one";
- return "best_effort";
- }
- return adjusted.passes ? "stochastic_tolerance" : "best_effort";
-}
-
-function adjustedForRoundingRules(metric: ParityComparisonMetrics, deterministic: boolean): ParityComparisonMetrics {
- if (!deterministic) return metric;
- return { ...metric, passes: Math.abs(metric.bias_raw) <= 1 };
-}
-
-function correctionScore(metric: ParityComparisonMetrics, deterministic: boolean): number {
- if (deterministic) return Math.abs(metric.bias_raw);
- if (metric.stat !== null && Number.isFinite(metric.stat)) return Math.abs(metric.stat);
- return Math.abs(metric.bias_raw);
-}
-
-function statAdjustmentForReport(adjustment: InternalStatAdjustment): TestcaseStatAdjustment {
- return {
- value: adjustment.value,
- mode: adjustment.mode,
- unadjusted: adjustment.unadjusted
- };
-}
-
-function roundStatAdjustment(value: number): number {
- return Number(value.toFixed(3));
-}
-
-function finalizeReport(report: TestcaseRunReport): void {
- applyComparisonQValues(report);
- report.counts.warnings = report.warnings.length;
- report.counts.errors = report.errors.length;
- report.counts.comparedToGame = Object.values(report.testcases).filter((entry) => entry.game).length;
- report.counts.comparedToBaseline = Object.values(report.testcases).filter((entry) => entry.baseline).length;
-}
-
-function normalizeRepeat(repeat: number | undefined): number {
- return Math.max(1, repeat ?? DEFAULT_STOCHASTIC_REPEAT);
-}
-
-export function executeTestcaseCase(job: TestcaseExecutionJob, config: SimulatorConfig): TestcaseExecutionResult {
- try {
- const samples: number[] = [];
- const sampleOutcomes: TestcaseSampleOutcome[] = [];
- const sampleDeltas: number[] = [];
- // Resolve the battle once and reuse it across every seeded sample of this case.
- const compiled = prepareBattle(job.input, config);
- const baseSeed = job.seed ?? job.input.seed;
- const sample = (iteration: number) => runPrepared(compiled, sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration));
- let result = sample(0);
- const firstScore = battleScoreDelta(result);
- if (firstScore !== undefined) {
- samples.push(firstScore);
- sampleOutcomes.push(sampleOutcome(1, result, firstScore));
- sampleDeltas.push(firstScore);
- }
- const sampleCount = result.randomness.deterministic ? 1 : job.repeat;
- for (let iteration = 1; iteration < sampleCount; iteration += 1) {
- result = sample(iteration);
- const score = battleScoreDelta(result);
- if (score !== undefined) {
- samples.push(score);
- if (sampleDeltas.length < 10) {
- sampleOutcomes.push(sampleOutcome(iteration + 1, result, score));
- sampleDeltas.push(score);
- }
- }
- }
- return {
- testcaseId: job.testcaseId,
- index: job.index,
- result,
- deterministic: result.randomness.deterministic,
- sampleCount,
- simulatorStats: sampleStats(samples),
- simulatorScoreDelta: battleScoreDelta(result),
- simulatorSampleOutcomes: sampleOutcomes,
- simulatorSampleDeltas: sampleDeltas,
- diagnostics: [...result.resolved.attacker.diagnostics, ...result.resolved.defender.diagnostics]
- };
- } catch (error) {
- return {
- testcaseId: job.testcaseId,
- index: job.index,
- diagnostics: [errorMessage(error)],
- error: errorMessage(error),
- errorDetails: errorDetails(error)
- };
- }
-}
-
-export function applyComparisonQValues(report: Pick<TestcaseRunReport, "testcases">): void {
- applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.game).filter((value): value is ParityComparisonMetrics => !!value));
-}
-
-export function assignDetailArtifactPaths(report: TestcaseRunReport, artifactRoot: string): void {
- report.artifactRoot = artifactRoot;
- report.details.forEach((detail, index) => {
- const detailArtifact = `${artifactRoot}/cases/${String(index + 1).padStart(6, "0")}.json`;
- detail.detailArtifact = detailArtifact;
- const testcase = report.testcases[snapshotKey(detail.file, detail.index)];
- if (testcase) testcase.detailArtifact = detailArtifact;
- for (const issue of [...report.warnings, ...report.errors]) {
- if (issue.file === detail.file && issue.idx === detail.index && issue.testcase_id === detail.testcaseId) {
- issue.detailArtifact = detailArtifact;
- }
- }
- });
-}
-
-export function buildSummaryForOutput(report: TestcaseRunReport): TestcaseSummaryOutput {
- const { details: _details, ...summary } = report;
- return summary;
-}
-
-export function adaptTestcaseEntry(
- entry: unknown,
- options: { seed?: string | number } = {},
- diagnostics: string[] = []
-): BattleInput {
- const object = entry as {
- attacker?: FighterInput;
- defender?: FighterInput;
- test_id?: string;
- 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 engagementType = engagementTypeFromEntry(object);
- const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);
- return {
- attacker: object.attacker,
- defender: object.defender,
- seed: options.seed,
- ...(maxRounds !== undefined ? { maxRounds } : {}),
- ...(engagementType !== undefined ? { engagement_type: engagementType } : {})
- };
-}
-
-export function battleScoreDelta(value: unknown): number | undefined {
- if (isBattleResult(value)) return totalSide(value.remaining.attacker) - totalSide(value.remaining.defender);
- const gameResult = Array.isArray(value) ? value[0] : value;
- if (!gameResult || typeof gameResult !== "object") return undefined;
- const attacker = Number((gameResult as { attacker?: unknown }).attacker);
- const defender = Number((gameResult as { defender?: unknown }).defender);
- if (!Number.isFinite(attacker) || !Number.isFinite(defender)) return undefined;
- return attacker - defender;
-}
-
-function sampleOutcome(run: number, result: BattleResult, scoreDelta: number): TestcaseSampleOutcome {
- return {
- run,
- attackerHeroes: result.resolved.attacker.heroes.map((hero) => hero.name),
- defenderHeroes: result.resolved.defender.heroes.map((hero) => hero.name),
- attackerTroops: result.resolved.attacker.troops,
- defenderTroops: result.resolved.defender.troops,
- attackerRemainingByType: result.remaining.attacker,
- defenderRemainingByType: result.remaining.defender,
- attackerRemaining: totalSide(result.remaining.attacker),
- defenderRemaining: totalSide(result.remaining.defender),
- scoreDelta
- };
-}
-
-function isDiscoverableTestcaseFile(file: string, includeDisabled?: boolean): boolean {
- if (file.endsWith(".json")) return true;
- return !!includeDisabled && (file.endsWith(".json.disabled") || file.endsWith(".json.stale_troops"));
-}
-
-function walk(path: string, files: string[]): void {
- const stat = statSync(path);
- if (stat.isDirectory()) {
- for (const child of readdirSync(path)) walk(resolve(path, child), files);
- } else if (stat.isFile()) {
- files.push(path);
- }
-}
-
-function emptyCaseReport(file: string, testcaseId: string, index: number, diagnostics: string[]): TestcaseCaseReport {
- return {
- file,
- testcaseId,
- index,
- diagnostics,
- visibility: {
- attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },
- defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }
- }
- };
-}
-
-function testcaseIdFor(entry: unknown, index: number): string {
- const id = (entry as { test_id?: unknown; id?: unknown }).test_id ?? (entry as { id?: unknown }).id;
- return id === undefined ? `case_${index}` : String(id);
-}
-
-function diagnoseFighterShape(side: string, fighter: FighterInput): string[] {
- const diagnostics: string[] = [];
- if (!fighter.troops || Object.keys(fighter.troops).length === 0) diagnostics.push(`${side} has no troops`);
- if (!fighter.stats) diagnostics.push(`${side} has no stats block`);
- return diagnostics;
-}
-
-function 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) {
- return {
- attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },
- defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }
- };
- }
- return {
- attacker: {
- heroes: result.resolved.attacker.heroes.map((hero) => hero.name),
- troopSkillIds: result.resolved.attacker.troopSkillIds,
- troops: result.resolved.attacker.troops,
- skillEffectActivations: result.effectActivationCounts.attacker
- },
- defender: {
- heroes: result.resolved.defender.heroes.map((hero) => hero.name),
- troopSkillIds: result.resolved.defender.troopSkillIds,
- troops: result.resolved.defender.troops,
- skillEffectActivations: result.effectActivationCounts.defender
- }
- };
-}
-
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error);
-}
-
-function errorDetails(error: unknown): TestcaseErrorDetails | undefined {
- if (error instanceof DamageAggregationError) {
- return {
- type: error.name,
- groupId: error.groupId,
- netPct: error.netPct,
- factor: error.factor,
- contributors: error.contributors
- };
- }
- if (error instanceof Error) return { type: error.name };
- return undefined;
-}
-
-function optionalNumber(value: unknown): number | undefined {
- if (value === undefined) return undefined;
- const number = Number(value);
- return Number.isFinite(number) ? number : undefined;
-}
-
-function isBattleResult(value: unknown): value is BattleResult {
- return !!value && typeof value === "object" && "remaining" in value;
-}
-
-function totalSide(troops: Record<UnitType, number>): number {
- return (troops.infantry ?? 0) + (troops.lancer ?? 0) + (troops.marksman ?? 0);
-}
-
-function sampleSeed(baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): string {
- return `${baseSeed ?? "simulator-default"}:${relative(process.cwd(), file)}:${testcaseId}:${index}:${iteration}`;
-}
-
-function snapshotKey(filePath: string, index: number): string {
- return `${normalizeReportPath(filePath)}#${index}`;
-}
-
-function normalizeReportPath(filePath: string): string {
- const normalized = filePath.replaceAll("\\", "/");
- const testcaseIndex = normalized.indexOf("testcases/");
- return testcaseIndex >= 0 ? normalized.slice(testcaseIndex) : normalized;
-}
-
-function distributionFromGameResult(value: unknown): { n: number; mu: number; sigma: number } | undefined {
- const outcomes = extractOutcomeScores(value);
- if (outcomes.length === 0) return undefined;
- const stats = sampleStats(outcomes);
- return { n: stats.n, mu: stats.mu, sigma: stats.sigma };
-}
-
-function extractOutcomeScores(value: unknown): number[] {
- const rows = Array.isArray(value) ? value : value ? [value] : [];
- return rows
- .map((row) => battleScoreDelta(row))
- .filter((score): score is number => score !== undefined);
-}
-
-function totalInputTroops(fighter: FighterInput): number {
- return Object.values(fighter.troops ?? {}).reduce((sum, count) => sum + Number(count || 0), 0);
-}
Index: simulator/src/tooling/calibration.ts
===================================================================
--- simulator/src/tooling/calibration.ts prev run
+++ simulator/src/tooling/calibration.ts this run
@@ -0,0 +1,231 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export interface CalibrationCaseComparison {
+ file: string;
+ testcaseId: string;
+ idx: number;
+ nSim?: number;
+ muSim?: number;
+ sigmaSim?: number;
+ nGame?: number;
+ muGame?: number;
+ sigmaGame?: number;
+ biasRaw?: number;
+ biasPct?: number;
+ sem?: number;
+ p?: number | null;
+ q?: number | null;
+ statType?: string;
+ passes?: boolean;
+}
+
+export interface SampleStats {
+ n: number;
+ mu: number;
+ sigma: number;
+ sem: number;
+ samples?: number[];
+}
+
+export interface CalibrationComparison {
+ calibrationAvailable: boolean;
+ reportPath?: string;
+ startedAt?: string;
+ finishedAt?: string;
+ gitSha?: string;
+ totalReferenceCases: number;
+ referencePassedCases: number;
+ referencePassRate: number;
+ thresholds?: Record<string, number>;
+ cases: CalibrationCaseComparison[];
+}
+
+function defaultCalibrationDir(): string {
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "testcase_results");
+}
+
+export function loadCalibrationComparison(path?: string): CalibrationComparison {
+ const reportPath = path ? resolve(path) : latestCalibrationReportPath(defaultCalibrationDir());
+ if (!reportPath || !fileExists(reportPath)) return emptyCalibrationComparison();
+
+ const parsed = JSON.parse(readFileSync(reportPath, "utf8")) as CalibrationReportJson;
+ const cases = Object.values(parsed.testcases ?? {}).map(normalizeCalibrationCase).filter((entry): entry is CalibrationCaseComparison => !!entry);
+ const referencePassedCases = cases.filter((entry) => entry.passes).length;
+ return {
+ calibrationAvailable: true,
+ reportPath,
+ startedAt: stringOrUndefined(parsed.started_at),
+ finishedAt: stringOrUndefined(parsed.finished_at),
+ gitSha: stringOrUndefined(parsed.git_sha),
+ totalReferenceCases: cases.length,
+ referencePassedCases,
+ referencePassRate: cases.length > 0 ? referencePassedCases / cases.length : 0,
+ thresholds: normalizeThresholds(parsed.thresholds),
+ cases
+ };
+}
+
+export function readCalibrationCase(
+ comparison: CalibrationComparison,
+ testcaseFile: string,
+ testcaseId: string,
+ options: { index?: number } = {}
+): CalibrationCaseComparison | undefined {
+ if (!comparison.calibrationAvailable) return undefined;
+ const files = new Set(testcaseFileLookupVariants(testcaseFile));
+ return comparison.cases.find((entry) => {
+ if (entry.testcaseId !== testcaseId) return false;
+ if (options.index !== undefined && entry.idx !== options.index) return false;
+ return testcaseFileLookupVariants(entry.file).some((variant) => files.has(variant));
+ });
+}
+
+export function testcaseFileLookupVariants(path: string): string[] {
+ const normalized = normalizePath(path);
+ const variants = new Set<string>([normalized]);
+ // Current parity reports may embed "simulator/testcases/"; both that form and
+ // the root "testcases/" form normalize to the canonical testcase id.
+ for (const prefix of ["simulator/testcases/"]) {
+ const idx = normalized.indexOf(prefix);
+ if (idx >= 0) variants.add(`testcases/${normalized.slice(idx + prefix.length)}`);
+ }
+ const testcaseIndex = normalized.indexOf("testcases/");
+ if (testcaseIndex >= 0) {
+ const testcasePath = normalized.slice(testcaseIndex);
+ variants.add(testcasePath);
+ variants.add(`simulator/${testcasePath}`);
+ }
+ return [...variants];
+}
+
+export function sampleStats(samples: number[], options: { includeSamples?: boolean } = {}): SampleStats {
+ const n = samples.length;
+ const mu = n > 0 ? samples.reduce((sum, value) => sum + value, 0) / n : 0;
+ const variance = n > 1 ? samples.reduce((sum, value) => sum + (value - mu) ** 2, 0) / (n - 1) : 0;
+ const sigma = Math.sqrt(variance);
+ const sem = n > 0 ? sigma / Math.sqrt(n) : 0;
+ return {
+ n,
+ mu,
+ sigma,
+ sem,
+ ...(options.includeSamples ? { samples: [...samples] } : {})
+ };
+}
+
+function latestCalibrationReportPath(dir: string): string | undefined {
+ if (!directoryExists(dir)) return undefined;
+ const candidates = readdirSync(dir)
+ .filter((name) => name.endsWith(".json"))
+ .map((name) => resolve(dir, name))
+ .filter((path) => statSync(path).isFile())
+ .map((path) => ({ path, finishedAt: calibrationFinishedAt(path) }))
+ .sort((a, b) => b.finishedAt - a.finishedAt || a.path.localeCompare(b.path));
+ return candidates[0]?.path;
+}
+
+function fileExists(path: string): boolean {
+ try {
+ return statSync(path).isFile();
+ } catch {
+ return false;
+ }
+}
+
+function directoryExists(path: string): boolean {
+ try {
+ return statSync(path).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+function calibrationFinishedAt(path: string): number {
+ try {
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { finished_at?: unknown };
+ const time = Date.parse(String(parsed.finished_at ?? ""));
+ return Number.isFinite(time) ? time : 0;
+ } catch {
+ return 0;
+ }
+}
+
+function normalizeCalibrationCase(value: unknown): CalibrationCaseComparison | undefined {
+ if (!value || typeof value !== "object") return undefined;
+ const object = value as Record<string, unknown>;
+ const file = stringOrUndefined(object.file);
+ const testcaseId = stringOrUndefined(object.testcase_id ?? object.testcaseId);
+ if (!file || !testcaseId) return undefined;
+ return {
+ file: normalizePath(file),
+ testcaseId,
+ idx: numberOrDefault(object.idx, 0),
+ nSim: numberOrUndefined(object.n_sim),
+ muSim: numberOrUndefined(object.mu_sim),
+ sigmaSim: numberOrUndefined(object.sigma_sim),
+ nGame: numberOrUndefined(object.n_game),
+ muGame: numberOrUndefined(object.mu_game),
+ sigmaGame: numberOrUndefined(object.sigma_game),
+ biasRaw: numberOrUndefined(object.bias_raw),
+ biasPct: numberOrUndefined(object.bias_pct),
+ sem: numberOrUndefined(object.sem),
+ p: nullableNumber(object.p),
+ q: nullableNumber(object.q),
+ statType: stringOrUndefined(object.stat_type),
+ passes: booleanOrUndefined(object.passes)
+ };
+}
+
+function emptyCalibrationComparison(): CalibrationComparison {
+ return {
+ calibrationAvailable: false,
+ totalReferenceCases: 0,
+ referencePassedCases: 0,
+ referencePassRate: 0,
+ cases: [],
+ };
+}
+
+function normalizeThresholds(value: unknown): Record<string, number> | undefined {
+ if (!value || typeof value !== "object") return undefined;
+ const entries = Object.entries(value as Record<string, unknown>)
+ .map(([key, raw]) => [key, numberOrUndefined(raw)] as const)
+ .filter((entry): entry is readonly [string, number] => entry[1] !== undefined);
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
+}
+
+function normalizePath(path: string): string {
+ return path.replaceAll("\\", "/");
+}
+
+function stringOrUndefined(value: unknown): string | undefined {
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+function numberOrUndefined(value: unknown): number | undefined {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : undefined;
+}
+
+function nullableNumber(value: unknown): number | null | undefined {
+ if (value === null) return null;
+ return numberOrUndefined(value);
+}
+
+function numberOrDefault(value: unknown, fallback: number): number {
+ return numberOrUndefined(value) ?? fallback;
+}
+
+function booleanOrUndefined(value: unknown): boolean | undefined {
+ return typeof value === "boolean" ? value : undefined;
+}
+
+interface CalibrationReportJson {
+ started_at?: unknown;
+ finished_at?: unknown;
+ git_sha?: unknown;
+ thresholds?: unknown;
+ testcases?: Record<string, unknown>;
+}
Index: simulator/src/tooling/index.ts
===================================================================
--- simulator/src/tooling/index.ts prev run
+++ simulator/src/tooling/index.ts this run
@@ -0,0 +1,5 @@
+// Node-only parity, calibration, and testcase APIs. Keep these exports separate from
+// the browser-safe simulator package entry in ../index.ts.
+export * from "./calibration";
+export * from "./parityMetrics";
+export * from "./testcases";
Index: simulator/src/tooling/parityMetrics.ts
===================================================================
--- simulator/src/tooling/parityMetrics.ts prev run
+++ simulator/src/tooling/parityMetrics.ts this run
@@ -0,0 +1,117 @@
+export interface OutcomeDistribution {
+ n: number;
+ mu: number;
+ sigma: number;
+}
+
+export interface ParityThresholds {
+ z_threshold?: number;
+ min_bias_pct?: number;
+ max_diff_ratio?: number;
+ max_diff_ratio_deterministic?: number;
+}
+
+export interface ParityComparisonMetrics {
+ n_candidate: number;
+ mu_candidate: number;
+ sigma_candidate: number;
+ n_reference: number;
+ mu_reference: number;
+ sigma_reference: number;
+ bias_raw: number;
+ bias_pct: number;
+ sem: number;
+ stat_type: "deterministic" | "zero_var" | "single_obs" | "t";
+ stat: number | null;
+ p: number | null;
+ q: number | null;
+ passes: boolean;
+}
+
+export function compareOutcomeDistribution(options: {
+ candidate: OutcomeDistribution;
+ reference: OutcomeDistribution;
+ initialTroops: number;
+ deterministic: boolean;
+ thresholds?: ParityThresholds;
+}): ParityComparisonMetrics {
+ const thresholds = options.thresholds ?? {};
+ const initialTroops = options.initialTroops || 1;
+ const biasRaw = round(options.candidate.mu - options.reference.mu, 2);
+ const biasPct = round((biasRaw / initialTroops) * 100, 2);
+ const zThreshold = thresholds.z_threshold ?? 2;
+ const minBiasPct = thresholds.min_bias_pct ?? 0.5;
+ const deterministicLimit = (thresholds.max_diff_ratio_deterministic ?? thresholds.max_diff_ratio ?? 0.01) * 100;
+
+ let sem = 0;
+ let stat: number | null = null;
+ let p: number | null = null;
+ let statType: ParityComparisonMetrics["stat_type"];
+ let passes: boolean;
+
+ if (options.deterministic) {
+ statType = "deterministic";
+ passes = Math.abs(biasPct) <= deterministicLimit;
+ } else if (options.candidate.sigma === 0) {
+ statType = "zero_var";
+ passes = Math.abs(biasPct) <= deterministicLimit;
+ } else if (options.reference.n <= 1) {
+ statType = "single_obs";
+ sem = options.candidate.sigma;
+ stat = sem === 0 ? null : round(biasRaw / sem, 4);
+ p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
+ passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
+ } else {
+ statType = "t";
+ sem = options.candidate.sigma * Math.sqrt(1 / Math.max(options.candidate.n, 1) + 1 / options.reference.n);
+ stat = sem === 0 ? null : round(biasRaw / sem, 4);
+ p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
+ passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
+ }
+
+ return {
+ n_candidate: options.candidate.n,
+ mu_candidate: round(options.candidate.mu, 2),
+ sigma_candidate: round(options.candidate.sigma, 2),
+ n_reference: options.reference.n,
+ mu_reference: round(options.reference.mu, 2),
+ sigma_reference: round(options.reference.sigma, 2),
+ bias_raw: biasRaw,
+ bias_pct: biasPct,
+ sem: round(sem, 2),
+ stat_type: statType,
+ stat,
+ p,
+ q: null,
+ passes
+ };
+}
+
+export function applyBenjaminiHochberg(rows: Array<{ p: number | null; q: number | null }>): void {
+ const ranked = rows
+ .filter((row): row is { p: number; q: number | null } => row.p !== null)
+ .sort((a, b) => a.p - b.p);
+ const m = ranked.length;
+ let runningMin = 1;
+ for (let index = m - 1; index >= 0; index -= 1) {
+ const rawQ = (ranked[index].p * m) / (index + 1);
+ runningMin = Math.min(runningMin, rawQ);
+ ranked[index].q = round(Math.min(1, runningMin), 6);
+ }
+}
+
+function normalCdf(x: number): number {
+ return 0.5 * (1 + erf(x / Math.SQRT2));
+}
+
+function erf(x: number): number {
+ const sign = x < 0 ? -1 : 1;
+ const ax = Math.abs(x);
+ const t = 1 / (1 + 0.3275911 * ax);
+ const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
+ return sign * y;
+}
+
+function round(value: number, digits: number): number {
+ return Number(value.toFixed(digits));
+}
Index: simulator/src/tooling/testcases.test.ts
===================================================================
--- simulator/src/tooling/testcases.test.ts prev run
+++ simulator/src/tooling/testcases.test.ts this run
@@ -0,0 +1,420 @@
+import assert from "node:assert/strict";
+import { mkdirSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import { test } from "node:test";
+
+import { loadSimulatorConfig } from "../config";
+import { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";
+import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
+import { adaptTestcaseEntry, applyComparisonQValues, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, discoverTestcaseFiles, runTestcases, type TestcaseSummaryEntry } from "./testcases";
+
+test("discoverTestcaseFiles follows simulator/testcases symlink and skips disabled or stale files by default", () => {
+ const files = discoverTestcaseFiles();
+
+ assert.ok(files.some((file) => file.endsWith("emulator_verified/simple_001_nc.json")));
+ assert.ok(!files.some((file) => file.endsWith(".disabled")));
+ assert.ok(!files.some((file) => file.endsWith(".stale_troops")));
+});
+
+test("discoverTestcaseFiles includes disabled and stale testcase files when requested", () => {
+ const files = discoverTestcaseFiles({ includeDisabled: true });
+
+ assert.ok(files.some((file) => file.endsWith("emulator_verified/jasser_solo.json.disabled")));
+ assert.ok(files.some((file) => file.endsWith("emulator_verified/reina_logan_combo_v2.json.stale_troops")));
+});
+
+test("runTestcases returns compact summary entries and full detail entries separately", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 5 }, config);
+ const key = Object.keys(report.testcases)[0];
+ const summary = report.testcases[key];
+ const detail = report.details[0];
+
+ assert.equal(report.reportKind, "simulator-parity-summary");
+ assert.equal(report.counts.filesFound, 1);
+ assert.equal(report.counts.testcasesFound, 1);
+ assert.equal(report.counts.executed, 1);
+ assert.equal(report.counts.errors, 0);
+ assert.equal(summary?.testcase_id, "simple_001");
+ assert.equal(summary?.deterministic, false);
+ assert.equal(summary?.sampleCount, 5);
+ assert.equal(typeof summary?.game?.mu_candidate, "number");
+ assert.equal(summary?.baseline, null);
+ assert.equal(report.counts.comparedToBaseline, 0);
+ assert.equal("result" in (summary as object), false);
+ assert.ok(detail?.result);
+ assert.equal(detail?.simulatorStats?.n, 5);
+ assert.ok(detail?.visibility.attacker.troops.lancer);
+});
+
+test("runTestcases defaults stochastic cases to 100 samples", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001" }, config);
+ const summary = Object.values(report.testcases)[0];
+ const detail = report.details[0];
+
+ assert.equal(report.options.repeat, 100);
+ assert.equal(summary?.deterministic, false);
+ assert.equal(summary?.sampleCount, 100);
+ assert.equal(detail?.simulatorStats?.n, 100);
+});
+
+test("assignDetailArtifactPaths assigns deterministic compact detail paths", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
+
+ assignDetailArtifactPaths(report, "simulator_parity_test");
+
+ assert.equal(report.artifactRoot, "simulator_parity_test");
+ const keys = Object.keys(report.testcases);
+ assert.deepEqual(keys, [...keys].sort());
+ assert.equal(report.testcases[keys[0]!]?.detailArtifact, "simulator_parity_test/cases/000001.json");
+});
+
+test("buildSummaryForOutput excludes full detail artifacts from compact output", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
+ assignDetailArtifactPaths(report, "simulator_parity_test");
+
+ const summary = buildSummaryForOutput(report);
+ const json = JSON.stringify(summary);
+
+ assert.equal("details" in summary, false);
+ assert.equal(json.includes("\"result\""), false);
+ assert.equal(json.includes("\"attacks\""), false);
+ assert.equal(Object.values(summary.testcases)[0]?.detailArtifact, "simulator_parity_test/cases/000001.json");
+});
+
+test("assignDetailArtifactPaths exposes failed testcase diagnostics through errors", () => {
+ const testcaseRoot = tempDir("simulator-invalid-testcases");
+ writeFileSync(
+ resolve(testcaseRoot, "invalid.json"),
+ JSON.stringify([{ test_id: "bad_case", attacker: { troops: { infantry_t1: 1 } } }]),
+ );
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ testcaseRoot, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
+
+ assignDetailArtifactPaths(report, "simulator_parity_failed");
+
+ assert.equal(report.counts.errors, 1);
+ assert.equal(report.details[0]?.detailArtifact, "simulator_parity_failed/cases/000001.json");
+ assert.equal(report.errors[0]?.detailArtifact, "simulator_parity_failed/cases/000001.json");
+ assert.equal(report.errors[0]?.stage, "adapt");
+ assert.equal(Object.keys(report.testcases).length, 0);
+});
+
+test("runTestcases logs structured damage aggregation errors and continues", () => {
+ const testcaseRoot = tempDir("simulator-aggregation-error-testcases");
+ writeFileSync(
+ resolve(testcaseRoot, "bad-aggregation.json"),
+ JSON.stringify([
+ {
+ test_id: "bad_aggregation",
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ stats: { infantry: { attack: 0, defense: 0, lethality: 0, health: 0 } }
+ },
+ defender: {
+ troops: { lancer_t1: 1000 },
+ stats: { lancer: { attack: 0, defense: 0, lethality: 0, health: -105 } }
+ }
+ },
+ {
+ test_id: "next_case_runs",
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ stats: { infantry: { attack: 0, defense: 0, lethality: 0, health: 0 } }
+ },
+ defender: {
+ troops: { lancer_t1: 1000 },
+ stats: { lancer: { attack: 0, defense: 0, lethality: 0, health: 0 } }
+ }
+ }
+ ])
+ );
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ testcaseRoot, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
+
+ assert.equal(report.counts.errors, 1);
+ assert.equal(report.counts.executed, 1);
+ assert.equal(report.errors[0]?.testcase_id, "bad_aggregation");
+ assert.equal(report.details[0]?.errorDetails?.type, "DamageAggregationError");
+ assert.equal(report.details[0]?.errorDetails?.groupId, "player.taker.health");
+ assert.equal(report.details[0]?.errorDetails?.factor, -0.050000000000000044);
+ assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");
+});
+
+test("runTestcases keeps executed testcase without warning when legacy baseline is unavailable", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
+ const summary = Object.values(report.testcases)[0];
+
+ assert.equal(report.counts.executed, 1);
+ assert.equal(summary?.game?.n_candidate, 1);
+ assert.equal(summary?.baseline, null);
+ assert.equal(report.counts.comparedToBaseline, 0);
+ assert.deepEqual(report.warnings, []);
+});
+
+test("applyComparisonQValues corrects game comparisons only", () => {
+ const firstGame = comparisonMetric(0.01);
+ const secondGame = comparisonMetric(0.02);
+ const ignoredBaseline = comparisonMetric(0.04);
+ const testcases: Record<string, TestcaseSummaryEntry> = {
+ "testcases/a.json#0": summaryEntry("a", 0, firstGame, ignoredBaseline),
+ "testcases/b.json#0": summaryEntry("b", 0, secondGame, null)
+ };
+
+ applyComparisonQValues({ testcases });
+
+ assert.equal(firstGame.q, 0.02);
+ assert.equal(secondGame.q, 0.02);
+ assert.equal(ignoredBaseline.q, null);
+});
+
+test("calibration lookup supports simulator symlink and source testcase path variants", () => {
+ assert.deepEqual(testcaseFileLookupVariants("simulator/testcases/emulator_verified/simple_001_nc.json"), [
+ "simulator/testcases/emulator_verified/simple_001_nc.json",
+ "testcases/emulator_verified/simple_001_nc.json"
+ ]);
+
+ const comparison = loadCalibrationComparison();
+ const sourceRow = readCalibrationCase(comparison, "testcases/emulator_verified/simple_001_nc.json", "simple_001");
+ if (sourceRow) {
+ assert.deepEqual(readCalibrationCase(comparison, "simulator/testcases/emulator_verified/simple_001_nc.json", "simple_001"), sourceRow);
+ }
+});
+
+test("duplicate no-hero testcase ids keep game rows aligned by case index", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "greg_mia_nohero_control_current", repeat: 1 }, config);
+
+ assert.equal(report.counts.testcasesFound, 2);
+ assert.equal(report.counts.executed, 2);
+
+ const first = report.details.find((entry) => entry.index === 0);
+ const second = report.details.find((entry) => entry.index === 1);
+ assert.equal(first?.testcaseId, "greg_mia_nohero_control_current");
+ assert.equal(second?.testcaseId, "greg_mia_nohero_control_current");
+ assert.equal(first?.visibility.attacker.heroes.length, 0);
+ assert.equal(second?.visibility.attacker.heroes.length, 0);
+ assert.equal(first?.calibration, undefined);
+ assert.equal(second?.calibration, undefined);
+ assert.equal(battleScoreDelta(first?.gameResult), 3752);
+ assert.equal(battleScoreDelta(second?.gameResult), 3652);
+});
+
+test("no-hero simple testcase loads, runs, compares to game result, and exposes aligned core fields", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
+ const entry = report.details[0];
+ const summary = Object.values(report.testcases)[0];
+
+ assert.equal(report.counts.testcasesFound, 1);
+ assert.equal(summary?.testcase_id, "simple_001");
+ assert.equal(summary?.game?.n_reference, 1);
+ assert.equal(summary?.baseline, null);
+ assert.equal(entry?.visibility.attacker.heroes.length, 0);
+ assert.equal(entry?.visibility.defender.heroes.length, 0);
+ assert.equal(entry?.calibration, undefined);
+ assert.equal(battleScoreDelta(entry?.gameResult), -186);
+ assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);
+});
+
+test("runTestcases applies possible stat rounding correction to exact deterministic misses", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "norah_s2_inf_only_B" }, config);
+ const summary = Object.values(report.testcases)[0];
+
+ assert.equal(summary?.testcase_id, "norah_s2_inf_only_B");
+ assert.equal(summary?.deterministic, true);
+ assert.equal(summary?.game?.passes, true);
+ assert.equal(summary?.game?.bias_raw, 0);
+ assert.equal(summary?.gameStatAdjustment?.mode, "deterministic_exact");
+ assert.equal(summary?.gameStatAdjustment?.value, 0.05);
+ assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);
+});
+
+test("runTestcases applies stat rounding correction for stochastic misses", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "greg_mia_combo", repeat: 5, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
+ const summaries = Object.values(report.testcases);
+
+ assert.ok(summaries.some((summary) => summary.deterministic === false && summary.game?.passes === false));
+ assert.ok(summaries.some((summary) => summary.deterministic === false && summary.gameStatAdjustment));
+});
+
+test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "1-testcases_no-heroes_t6_single-type_nc.json", repeat: 1 }, config);
+ const entry = report.details.find((item) => item.testcaseId === "daut_viper_9");
+
+ assert.equal(entry?.result?.winner, "attacker");
+ assert.ok((entry?.result?.rounds ?? 0) > 100);
+});
+
+test("runTestcases reports a game-focused parity summary without legacy baseline comparison", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
+ const row = Object.values(report.testcases)[0];
+ const detail = report.details[0];
+
+ assert.ok(report.calibrationReportPath?.endsWith("baseline_result_2026-05-21T04-46-47Z.json"));
+ assert.equal(row?.testcase_id, "simple_001");
+ assert.equal(row?.idx, 0);
+ assert.equal(row?.game?.mu_reference, -186);
+ assert.equal(row?.baseline, null);
+ assert.equal(typeof detail?.simulatorScoreDelta, "number");
+ assert.equal(typeof row?.game?.bias_raw, "number");
+ assert.equal(row?.game?.n_candidate, 1);
+ assert.equal(typeof row?.game?.mu_candidate, "number");
+ assert.equal(typeof row?.game?.bias_raw, "number");
+ assert.equal(typeof row?.game?.passes, "boolean");
+ assert.equal(report.counts.comparedToGame, 1);
+ assert.equal(report.counts.comparedToBaseline, 0);
+});
+
+test("adaptTestcaseEntry promotes engagement_type to a top-level BattleInput key", () => {
+ const input = adaptTestcaseEntry({
+ test_id: "engagement_case",
+ engagement_type: "rally",
+ attacker: { troops: { infantry_t1: 1 } },
+ defender: { troops: { infantry_t1: 1 } }
+ });
+
+ assert.equal(input.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({
+ candidate: { n: 1, mu: -186, sigma: 0 },
+ reference: { n: 1, mu: -186, sigma: 0 },
+ initialTroops: 1200,
+ deterministic: true,
+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ });
+
+ assert.deepEqual(metrics, {
+ n_candidate: 1,
+ mu_candidate: -186,
+ sigma_candidate: 0,
+ n_reference: 1,
+ mu_reference: -186,
+ sigma_reference: 0,
+ bias_raw: 0,
+ bias_pct: 0,
+ sem: 0,
+ stat_type: "deterministic",
+ stat: null,
+ p: null,
+ q: null,
+ passes: true
+ } satisfies ParityComparisonMetrics);
+});
+
+test("compareOutcomeDistribution uses candidate spread for single-observation stochastic references", () => {
+ const metrics = compareOutcomeDistribution({
+ candidate: { n: 5, mu: 10, sigma: 2 },
+ reference: { n: 1, mu: 8, sigma: 0 },
+ initialTroops: 100,
+ deterministic: false,
+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ });
+
+ assert.equal(metrics.stat_type, "single_obs");
+ assert.equal(metrics.passes, true);
+ assert.equal(metrics.bias_raw, 2);
+ assert.equal(metrics.bias_pct, 2);
+ assert.equal(metrics.sem, 2);
+ assert.equal(metrics.stat, 1);
+});
+
+test("compareOutcomeDistribution fails single-observation references outside candidate spread", () => {
+ const metrics = compareOutcomeDistribution({
+ candidate: { n: 100, mu: 862.15, sigma: 3.79 },
+ reference: { n: 1, mu: 879, sigma: 0 },
+ initialTroops: 1440,
+ deterministic: false,
+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ });
+
+ assert.equal(metrics.stat_type, "single_obs");
+ assert.equal(metrics.passes, false);
+ assert.equal(metrics.bias_raw, -16.85);
+ assert.equal(metrics.bias_pct, -1.17);
+ assert.equal(metrics.sem, 3.79);
+ assert.equal(metrics.stat, -4.4459);
+});
+
+test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {
+ const rows = [
+ { p: 0.01, q: null },
+ { p: 0.03, q: null },
+ { p: null, q: null }
+ ];
+
+ applyBenjaminiHochberg(rows);
+
+ assert.equal(rows[0].q, 0.02);
+ assert.equal(rows[1].q, 0.03);
+ assert.equal(rows[2].q, null);
+});
+
+test("calibration lookup exposes full baseline snapshot metrics", () => {
+ const comparison = loadCalibrationComparison();
+ const row = readCalibrationCase(comparison, "testcases/emulator_verified/simple_001_nc.json", "simple_001");
+
+ assert.equal(row?.biasRaw, 0);
+ assert.equal(row?.sem, 0);
+ assert.equal(row?.p, null);
+ assert.equal(row?.q, null);
+});
+
+function tempDir(prefix: string): string {
+ const dir = resolve(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
+ mkdirSync(dir, { recursive: true });
+ return dir;
+}
+
+function comparisonMetric(p: number): ParityComparisonMetrics {
+ return {
+ n_candidate: 2,
+ mu_candidate: 10,
+ sigma_candidate: 1,
+ n_reference: 2,
+ mu_reference: 8,
+ sigma_reference: 1,
+ bias_raw: 2,
+ bias_pct: 1,
+ sem: 1,
+ stat_type: "t",
+ stat: 2,
+ p,
+ q: null,
+ passes: true
+ };
+}
+
+function summaryEntry(testcaseId: string, idx: number, game: ParityComparisonMetrics | null, baseline: ParityComparisonMetrics | null): TestcaseSummaryEntry {
+ return {
+ file: `testcases/${testcaseId}.json`,
+ testcase_id: testcaseId,
+ idx,
+ deterministic: false,
+ sampleCount: 2,
+ game,
+ baseline
+ };
+}
Index: simulator/src/tooling/testcases.ts
===================================================================
--- simulator/src/tooling/testcases.ts prev run
+++ simulator/src/tooling/testcases.ts this run
@@ -0,0 +1,831 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ loadCalibrationComparison,
+ type CalibrationCaseComparison,
+ type SampleStats,
+ sampleStats
+} from "./calibration";
+import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
+import { prepareBattle, runPrepared } from "../simulator";
+import { DamageAggregationError } from "../staticDamageProfile";
+import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "../types";
+
+const DEFAULT_STOCHASTIC_REPEAT = 100;
+const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;
+const STAT_ROUNDING_SCAN_STEPS = 50;
+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;
+ workers?: number;
+}
+
+export interface TestcaseCaseReport {
+ file: string;
+ testcaseId: string;
+ index: number;
+ detailArtifact?: string;
+ diagnostics: string[];
+ gameResult?: unknown;
+ calibration?: CalibrationCaseComparison;
+ result?: BattleResult;
+ simulatorScoreDelta?: number;
+ simulatorStats?: SampleStats;
+ simulatorSampleOutcomes?: TestcaseSampleOutcome[];
+ simulatorSampleDeltas?: number[];
+ gameStatAdjustment?: TestcaseStatAdjustment;
+ deterministic?: boolean;
+ sampleCount?: number;
+ visibility: {
+ attacker: CaseVisibility;
+ defender: CaseVisibility;
+ };
+ error?: string;
+ errorDetails?: TestcaseErrorDetails;
+}
+
+export interface TestcaseRunWarning {
+ file: string;
+ testcase_id: string;
+ idx: number;
+ stage: "parse" | "adapt" | "execute" | "game_comparison" | "baseline_comparison" | "artifact";
+ reason: string;
+ detailArtifact?: string;
+}
+
+export interface TestcaseSummaryEntry {
+ file: string;
+ testcase_id: string;
+ idx: number;
+ detailArtifact?: string;
+ deterministic: boolean;
+ sampleCount: number;
+ game: ParityComparisonMetrics | null;
+ baseline: ParityComparisonMetrics | null;
+ gameStatAdjustment?: TestcaseStatAdjustment;
+}
+
+export interface TestcaseRunReport {
+ reportKind: "simulator-parity-summary";
+ schemaVersion: 1;
+ createdAt: string;
+ options: TestcaseRunOptions;
+ calibrationReportPath?: string;
+ artifactRoot?: string;
+ counts: {
+ filesFound: number;
+ testcasesFound: number;
+ executed: number;
+ warnings: number;
+ errors: number;
+ comparedToGame: number;
+ comparedToBaseline: number;
+ };
+ warnings: TestcaseRunWarning[];
+ errors: TestcaseRunWarning[];
+ testcases: Record<string, TestcaseSummaryEntry>;
+ details: TestcaseCaseReport[];
+}
+
+export type TestcaseSummaryOutput = Omit<TestcaseRunReport, "details">;
+
+interface CaseVisibility {
+ heroes: string[];
+ troopSkillIds: string[];
+ troops: Partial<Record<UnitType, number>>;
+ skillEffectActivations: number;
+}
+
+interface TestcaseErrorDetails {
+ type: string;
+ [key: string]: unknown;
+}
+
+export interface PreparedTestcaseCase {
+ file: string;
+ reportFile: string;
+ entry: unknown;
+ testcaseId: string;
+ index: number;
+ detail: TestcaseCaseReport;
+ input?: BattleInput;
+ key?: string;
+ adaptError?: TestcaseRunWarning;
+}
+
+export interface TestcaseExecutionJob {
+ file: string;
+ reportFile: string;
+ testcaseId: string;
+ index: number;
+ input: BattleInput;
+ repeat: number;
+ seed?: string | number;
+}
+
+export interface TestcaseExecutionResult {
+ testcaseId: string;
+ index: number;
+ result?: BattleResult;
+ deterministic?: boolean;
+ sampleCount?: number;
+ simulatorStats?: SampleStats;
+ simulatorScoreDelta?: number;
+ simulatorSampleOutcomes?: TestcaseSampleOutcome[];
+ simulatorSampleDeltas?: number[];
+ diagnostics: string[];
+ error?: string;
+ errorDetails?: TestcaseErrorDetails;
+}
+
+export interface TestcaseSampleOutcome {
+ run: number;
+ attackerHeroes: string[];
+ defenderHeroes: string[];
+ attackerTroops: Partial<Record<UnitType, number>>;
+ defenderTroops: Partial<Record<UnitType, number>>;
+ attackerRemainingByType: Partial<Record<UnitType, number>>;
+ defenderRemainingByType: Partial<Record<UnitType, number>>;
+ attackerRemaining: number;
+ defenderRemaining: number;
+ scoreDelta: number;
+}
+
+export interface TestcaseStatAdjustment {
+ value: number;
+ mode: "deterministic_exact" | "deterministic_within_one" | "stochastic_tolerance" | "best_effort";
+ unadjusted: ParityComparisonMetrics;
+}
+
+export function defaultTestcaseRoot(): string {
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "testcases");
+}
+
+export function discoverTestcaseFiles(options: Pick<TestcaseRunOptions, "testcaseRoot" | "matching" | "includeDisabled"> = {}): string[] {
+ const root = resolve(options.testcaseRoot ?? defaultTestcaseRoot());
+ const files: string[] = [];
+ walk(root, files);
+ return files
+ .filter((file) => isDiscoverableTestcaseFile(file, options.includeDisabled))
+ .filter((file) => options.includeDisabled || (!file.endsWith(".disabled") && !file.endsWith(".stale_troops")))
+ .filter((file) => !options.matching || file.includes(options.matching))
+ .sort();
+}
+
+export function runTestcases(options: TestcaseRunOptions, config: SimulatorConfig): TestcaseRunReport {
+ const prepared = prepareTestcaseCases(options);
+ return runPreparedTestcases(options, config, prepared, (job) => executeTestcaseCase(job, config));
+}
+
+export function prepareTestcaseCases(options: TestcaseRunOptions): { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] } {
+ const files = discoverTestcaseFiles(options);
+ const cases: PreparedTestcaseCase[] = [];
+ const parseErrors: TestcaseRunWarning[] = [];
+ for (const file of files) {
+ const reportFile = normalizeReportPath(relative(process.cwd(), file));
+ let entries: unknown[];
+ try {
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
+ entries = Array.isArray(parsed) ? parsed : [parsed];
+ } catch (error) {
+ parseErrors.push({ file: reportFile, testcase_id: "(parse_error)", idx: 0, stage: "parse", reason: `Failed to parse JSON: ${errorMessage(error)}` });
+ continue;
+ }
+
+ entries.forEach((entry, index) => {
+ const testcaseId = testcaseIdFor(entry, index);
+ const diagnostics: string[] = [];
+ const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);
+ const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };
+ try {
+ preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed }, diagnostics);
+ preparedCase.key = snapshotKey(reportFile, index);
+ } catch (error) {
+ detail.error = errorMessage(error);
+ diagnostics.push(detail.error);
+ preparedCase.adaptError = { file: reportFile, testcase_id: testcaseId, idx: index, stage: "adapt", reason: detail.error };
+ }
+ cases.push(preparedCase);
+ });
+ }
+ return { filesFound: files.length, cases, parseErrors };
+}
+
+export function runPreparedTestcases(
+ options: TestcaseRunOptions,
+ config: SimulatorConfig,
+ prepared: { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] },
+ execute: (job: TestcaseExecutionJob, config: SimulatorConfig) => TestcaseExecutionResult
+): TestcaseRunReport {
+ const comparison = loadCalibrationComparison(options.calibrationReportPath);
+ const repeat = normalizeRepeat(options.repeat);
+ const report: TestcaseRunReport = {
+ reportKind: "simulator-parity-summary",
+ schemaVersion: 1,
+ createdAt: new Date().toISOString(),
+ options: { ...options, repeat },
+ calibrationReportPath: comparison.reportPath,
+ counts: { filesFound: prepared.filesFound, testcasesFound: prepared.cases.length, executed: 0, warnings: 0, errors: 0, comparedToGame: 0, comparedToBaseline: 0 },
+ warnings: [],
+ errors: [...prepared.parseErrors],
+ testcases: {},
+ details: []
+ };
+
+ for (const preparedCase of prepared.cases) {
+ const { file, reportFile, testcaseId, index, detail } = preparedCase;
+ if (!preparedCase.input || !preparedCase.key) {
+ if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);
+ report.details.push(detail);
+ continue;
+ }
+ try {
+ const execution = execute({ file, reportFile, testcaseId, index, input: preparedCase.input, repeat, seed: options.seed }, config);
+ applyExecutionResult(report, comparison, preparedCase, execution, config);
+ } catch (error) {
+ detail.error = errorMessage(error);
+ detail.errorDetails = errorDetails(error);
+ detail.diagnostics.push(detail.error);
+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: detail.error });
+ }
+ report.details.push(detail);
+ }
+
+ finalizeReport(report);
+ return report;
+}
+
+export async function runPreparedTestcasesAsync(
+ options: TestcaseRunOptions,
+ config: SimulatorConfig,
+ prepared: { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] },
+ execute: (job: TestcaseExecutionJob) => Promise<TestcaseExecutionResult>
+): Promise<TestcaseRunReport> {
+ const comparison = loadCalibrationComparison(options.calibrationReportPath);
+ const repeat = normalizeRepeat(options.repeat);
+ const report: TestcaseRunReport = {
+ reportKind: "simulator-parity-summary",
+ schemaVersion: 1,
+ createdAt: new Date().toISOString(),
+ options: { ...options, repeat },
+ calibrationReportPath: comparison.reportPath,
+ counts: { filesFound: prepared.filesFound, testcasesFound: prepared.cases.length, executed: 0, warnings: 0, errors: 0, comparedToGame: 0, comparedToBaseline: 0 },
+ warnings: [],
+ errors: [...prepared.parseErrors],
+ testcases: {},
+ details: []
+ };
+
+ const jobs = prepared.cases.map(async (preparedCase) => {
+ if (!preparedCase.input || !preparedCase.key) return { preparedCase };
+ try {
+ const execution = await execute({
+ file: preparedCase.file,
+ reportFile: preparedCase.reportFile,
+ testcaseId: preparedCase.testcaseId,
+ index: preparedCase.index,
+ input: preparedCase.input,
+ repeat,
+ seed: options.seed
+ });
+ return { preparedCase, execution };
+ } catch (error) {
+ return { preparedCase, error };
+ }
+ });
+
+ for (const { preparedCase, execution, error } of await Promise.all(jobs)) {
+ const { reportFile, testcaseId, index, detail } = preparedCase;
+ if (!preparedCase.input || !preparedCase.key) {
+ if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);
+ report.details.push(detail);
+ continue;
+ }
+ if (error !== undefined) {
+ detail.error = errorMessage(error);
+ detail.errorDetails = errorDetails(error);
+ detail.diagnostics.push(detail.error);
+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: detail.error });
+ } else {
+ applyExecutionResult(report, comparison, preparedCase, execution!, config);
+ }
+ report.details.push(detail);
+ }
+
+ finalizeReport(report);
+ return report;
+}
+
+function applyExecutionResult(
+ report: TestcaseRunReport,
+ comparison: ReturnType<typeof loadCalibrationComparison>,
+ preparedCase: PreparedTestcaseCase,
+ execution: TestcaseExecutionResult,
+ config: SimulatorConfig
+): void {
+ const { reportFile, entry, testcaseId, index, detail } = preparedCase;
+ if (execution.error) {
+ detail.error = execution.error;
+ detail.errorDetails = execution.errorDetails;
+ detail.diagnostics.push(execution.error);
+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: execution.error });
+ return;
+ }
+ const result = execution.result;
+ if (!result || !execution.simulatorStats || execution.deterministic === undefined || execution.sampleCount === undefined) {
+ const reason = "Worker returned incomplete testcase execution result";
+ detail.error = reason;
+ detail.errorDetails = { type: "IncompleteExecutionResult" };
+ detail.diagnostics.push(reason);
+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason });
+ return;
+ }
+ const stats = execution.simulatorStats;
+ const gameResult = (entry as { game_report_result?: unknown }).game_report_result;
+ const initialTroops = totalInputTroops(preparedCase.input!.attacker) + totalInputTroops(preparedCase.input!.defender);
+ const simulatorDistribution = { n: stats.n, mu: stats.mu, sigma: stats.sigma };
+ const gameDistribution = distributionFromGameResult(gameResult);
+ let game = gameDistribution
+ ? compareOutcomeDistribution({
+ candidate: simulatorDistribution,
+ reference: gameDistribution,
+ initialTroops,
+ deterministic: result.randomness.deterministic,
+ thresholds: comparison.thresholds
+ })
+ : null;
+ const gameStatAdjustment = game && preparedCase.input
+ ? findGameStatAdjustment({
+ game,
+ input: preparedCase.input,
+ config,
+ job: { file: preparedCase.file, reportFile, testcaseId, index, input: preparedCase.input, repeat: execution.sampleCount, seed: undefined },
+ reference: gameDistribution!,
+ initialTroops,
+ deterministic: result.randomness.deterministic,
+ thresholds: comparison.thresholds
+ })
+ : undefined;
+ if (gameStatAdjustment) game = gameStatAdjustment.adjusted;
+
+ detail.result = result;
+ detail.deterministic = execution.deterministic;
+ detail.sampleCount = execution.sampleCount;
+ detail.simulatorStats = stats;
+ detail.simulatorScoreDelta = execution.simulatorScoreDelta;
+ detail.simulatorSampleOutcomes = execution.simulatorSampleOutcomes;
+ detail.simulatorSampleDeltas = execution.simulatorSampleDeltas;
+ detail.gameStatAdjustment = gameStatAdjustment ? statAdjustmentForReport(gameStatAdjustment) : undefined;
+ detail.gameResult = gameResult;
+ detail.visibility = visibilityFromResult(result);
+ detail.diagnostics.push(...execution.diagnostics);
+ if (!game) {
+ report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "game_comparison", reason: "Missing game_report_result" });
+ }
+ report.counts.executed += 1;
+ report.testcases[preparedCase.key!] = {
+ file: reportFile,
+ testcase_id: testcaseId,
+ idx: index,
+ deterministic: execution.deterministic,
+ sampleCount: execution.sampleCount,
+ game,
+ baseline: null,
+ ...(gameStatAdjustment ? { gameStatAdjustment: statAdjustmentForReport(gameStatAdjustment) } : {})
+ };
+}
+
+interface InternalStatAdjustment {
+ value: number;
+ mode: TestcaseStatAdjustment["mode"];
+ unadjusted: ParityComparisonMetrics;
+ adjusted: ParityComparisonMetrics;
+}
+
+function findGameStatAdjustment(options: {
+ game: ParityComparisonMetrics;
+ input: BattleInput;
+ config: SimulatorConfig;
+ job: TestcaseExecutionJob;
+ reference: { n: number; mu: number; sigma: number };
+ initialTroops: number;
+ deterministic: boolean;
+ thresholds?: Record<string, number>;
+}): InternalStatAdjustment | undefined {
+ // Deterministic cases correct any nonzero bias; stochastic cases correct only outright misses.
+ // Either way a nonzero bias is needed to pick a search direction.
+ const shouldCorrect = options.deterministic ? options.game.bias_raw !== 0 : !options.game.passes;
+ if (!shouldCorrect || options.game.bias_raw === 0) return undefined;
+ const direction = -Math.sign(options.game.bias_raw);
+ const 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 };
+ }
+ }
+
+ return best;
+}
+
+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[] = [];
+ const compiled = prepareBattle(input, config);
+ for (let iteration = 0; iteration < job.repeat; iteration += 1) {
+ const seed = sampleSeed(job.seed ?? input.seed, job.file, job.testcaseId, job.index, iteration);
+ const result = runPrepared(compiled, seed, { mode: "fast" });
+ const score = battleScoreDelta(result);
+ if (score !== undefined) samples.push(score);
+ }
+ return sampleStats(samples);
+}
+
+function inputWithStatAdjustment(input: BattleInput, value: number): BattleInput {
+ const adjusted = structuredClone(input);
+ adjustFighterStats(adjusted.attacker, value);
+ adjustFighterStats(adjusted.defender, -value);
+ return adjusted;
+}
+
+function adjustFighterStats(fighter: FighterInput, value: number): void {
+ for (const stats of Object.values(fighter.stats ?? {}) as Array<Partial<StatBlock>>) {
+ for (const key of ["attack", "defense", "lethality", "health"] as Array<keyof StatBlock>) {
+ if (stats[key] === undefined) continue;
+ stats[key] = roundStatAdjustment(Number(stats[key]) + value);
+ }
+ }
+}
+
+function adjustmentMode(adjusted: ParityComparisonMetrics, deterministic: boolean): TestcaseStatAdjustment["mode"] {
+ if (deterministic) {
+ if (adjusted.bias_raw === 0) return "deterministic_exact";
+ if (Math.abs(adjusted.bias_raw) <= 1) return "deterministic_within_one";
+ return "best_effort";
+ }
+ return adjusted.passes ? "stochastic_tolerance" : "best_effort";
+}
+
+function adjustedForRoundingRules(metric: ParityComparisonMetrics, deterministic: boolean): ParityComparisonMetrics {
+ if (!deterministic) return metric;
+ return { ...metric, passes: Math.abs(metric.bias_raw) <= 1 };
+}
+
+function correctionScore(metric: ParityComparisonMetrics, deterministic: boolean): number {
+ if (deterministic) return Math.abs(metric.bias_raw);
+ if (metric.stat !== null && Number.isFinite(metric.stat)) return Math.abs(metric.stat);
+ return Math.abs(metric.bias_raw);
+}
+
+function statAdjustmentForReport(adjustment: InternalStatAdjustment): TestcaseStatAdjustment {
+ return {
+ value: adjustment.value,
+ mode: adjustment.mode,
+ unadjusted: adjustment.unadjusted
+ };
+}
+
+function roundStatAdjustment(value: number): number {
+ return Number(value.toFixed(3));
+}
+
+function finalizeReport(report: TestcaseRunReport): void {
+ applyComparisonQValues(report);
+ report.counts.warnings = report.warnings.length;
+ report.counts.errors = report.errors.length;
+ report.counts.comparedToGame = Object.values(report.testcases).filter((entry) => entry.game).length;
+ report.counts.comparedToBaseline = Object.values(report.testcases).filter((entry) => entry.baseline).length;
+}
+
+function normalizeRepeat(repeat: number | undefined): number {
+ return Math.max(1, repeat ?? DEFAULT_STOCHASTIC_REPEAT);
+}
+
+export function executeTestcaseCase(job: TestcaseExecutionJob, config: SimulatorConfig): TestcaseExecutionResult {
+ try {
+ const samples: number[] = [];
+ const sampleOutcomes: TestcaseSampleOutcome[] = [];
+ const sampleDeltas: number[] = [];
+ // Resolve the battle once and reuse it across every seeded sample of this case.
+ const compiled = prepareBattle(job.input, config);
+ const baseSeed = job.seed ?? job.input.seed;
+ const sample = (iteration: number) => runPrepared(compiled, sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration));
+ let result = sample(0);
+ const firstScore = battleScoreDelta(result);
+ if (firstScore !== undefined) {
+ samples.push(firstScore);
+ sampleOutcomes.push(sampleOutcome(1, result, firstScore));
+ sampleDeltas.push(firstScore);
+ }
+ const sampleCount = result.randomness.deterministic ? 1 : job.repeat;
+ for (let iteration = 1; iteration < sampleCount; iteration += 1) {
+ result = sample(iteration);
+ const score = battleScoreDelta(result);
+ if (score !== undefined) {
+ samples.push(score);
+ if (sampleDeltas.length < 10) {
+ sampleOutcomes.push(sampleOutcome(iteration + 1, result, score));
+ sampleDeltas.push(score);
+ }
+ }
+ }
+ return {
+ testcaseId: job.testcaseId,
+ index: job.index,
+ result,
+ deterministic: result.randomness.deterministic,
+ sampleCount,
+ simulatorStats: sampleStats(samples),
+ simulatorScoreDelta: battleScoreDelta(result),
+ simulatorSampleOutcomes: sampleOutcomes,
+ simulatorSampleDeltas: sampleDeltas,
+ diagnostics: [...result.resolved.attacker.diagnostics, ...result.resolved.defender.diagnostics]
+ };
+ } catch (error) {
+ return {
+ testcaseId: job.testcaseId,
+ index: job.index,
+ diagnostics: [errorMessage(error)],
+ error: errorMessage(error),
+ errorDetails: errorDetails(error)
+ };
+ }
+}
+
+export function applyComparisonQValues(report: Pick<TestcaseRunReport, "testcases">): void {
+ applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.game).filter((value): value is ParityComparisonMetrics => !!value));
+}
+
+export function assignDetailArtifactPaths(report: TestcaseRunReport, artifactRoot: string): void {
+ report.artifactRoot = artifactRoot;
+ report.details.forEach((detail, index) => {
+ const detailArtifact = `${artifactRoot}/cases/${String(index + 1).padStart(6, "0")}.json`;
+ detail.detailArtifact = detailArtifact;
+ const testcase = report.testcases[snapshotKey(detail.file, detail.index)];
+ if (testcase) testcase.detailArtifact = detailArtifact;
+ for (const issue of [...report.warnings, ...report.errors]) {
+ if (issue.file === detail.file && issue.idx === detail.index && issue.testcase_id === detail.testcaseId) {
+ issue.detailArtifact = detailArtifact;
+ }
+ }
+ });
+}
+
+export function buildSummaryForOutput(report: TestcaseRunReport): TestcaseSummaryOutput {
+ const { details: _details, ...summary } = report;
+ return summary;
+}
+
+export function adaptTestcaseEntry(
+ entry: unknown,
+ options: { seed?: string | number } = {},
+ diagnostics: string[] = []
+): BattleInput {
+ const object = entry as {
+ attacker?: FighterInput;
+ defender?: FighterInput;
+ test_id?: string;
+ 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 engagementType = engagementTypeFromEntry(object);
+ const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);
+ return {
+ attacker: object.attacker,
+ defender: object.defender,
+ seed: options.seed,
+ ...(maxRounds !== undefined ? { maxRounds } : {}),
+ ...(engagementType !== undefined ? { engagement_type: engagementType } : {})
+ };
+}
+
+export function battleScoreDelta(value: unknown): number | undefined {
+ if (isBattleResult(value)) return totalSide(value.remaining.attacker) - totalSide(value.remaining.defender);
+ const gameResult = Array.isArray(value) ? value[0] : value;
+ if (!gameResult || typeof gameResult !== "object") return undefined;
+ const attacker = Number((gameResult as { attacker?: unknown }).attacker);
+ const defender = Number((gameResult as { defender?: unknown }).defender);
+ if (!Number.isFinite(attacker) || !Number.isFinite(defender)) return undefined;
+ return attacker - defender;
+}
+
+function sampleOutcome(run: number, result: BattleResult, scoreDelta: number): TestcaseSampleOutcome {
+ return {
+ run,
+ attackerHeroes: result.resolved.attacker.heroes.map((hero) => hero.name),
+ defenderHeroes: result.resolved.defender.heroes.map((hero) => hero.name),
+ attackerTroops: result.resolved.attacker.troops,
+ defenderTroops: result.resolved.defender.troops,
+ attackerRemainingByType: result.remaining.attacker,
+ defenderRemainingByType: result.remaining.defender,
+ attackerRemaining: totalSide(result.remaining.attacker),
+ defenderRemaining: totalSide(result.remaining.defender),
+ scoreDelta
+ };
+}
+
+function isDiscoverableTestcaseFile(file: string, includeDisabled?: boolean): boolean {
+ if (file.endsWith(".json")) return true;
+ return !!includeDisabled && (file.endsWith(".json.disabled") || file.endsWith(".json.stale_troops"));
+}
+
+function walk(path: string, files: string[]): void {
+ const stat = statSync(path);
+ if (stat.isDirectory()) {
+ for (const child of readdirSync(path)) walk(resolve(path, child), files);
+ } else if (stat.isFile()) {
+ files.push(path);
+ }
+}
+
+function emptyCaseReport(file: string, testcaseId: string, index: number, diagnostics: string[]): TestcaseCaseReport {
+ return {
+ file,
+ testcaseId,
+ index,
+ diagnostics,
+ visibility: {
+ attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },
+ defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }
+ }
+ };
+}
+
+function testcaseIdFor(entry: unknown, index: number): string {
+ const id = (entry as { test_id?: unknown; id?: unknown }).test_id ?? (entry as { id?: unknown }).id;
+ return id === undefined ? `case_${index}` : String(id);
+}
+
+function diagnoseFighterShape(side: string, fighter: FighterInput): string[] {
+ const diagnostics: string[] = [];
+ if (!fighter.troops || Object.keys(fighter.troops).length === 0) diagnostics.push(`${side} has no troops`);
+ if (!fighter.stats) diagnostics.push(`${side} has no stats block`);
+ return diagnostics;
+}
+
+function 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) {
+ return {
+ attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },
+ defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }
+ };
+ }
+ return {
+ attacker: {
+ heroes: result.resolved.attacker.heroes.map((hero) => hero.name),
+ troopSkillIds: result.resolved.attacker.troopSkillIds,
+ troops: result.resolved.attacker.troops,
+ skillEffectActivations: result.effectActivationCounts.attacker
+ },
+ defender: {
+ heroes: result.resolved.defender.heroes.map((hero) => hero.name),
+ troopSkillIds: result.resolved.defender.troopSkillIds,
+ troops: result.resolved.defender.troops,
+ skillEffectActivations: result.effectActivationCounts.defender
+ }
+ };
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function errorDetails(error: unknown): TestcaseErrorDetails | undefined {
+ if (error instanceof DamageAggregationError) {
+ return {
+ type: error.name,
+ groupId: error.groupId,
+ netPct: error.netPct,
+ factor: error.factor,
+ contributors: error.contributors
+ };
+ }
+ if (error instanceof Error) return { type: error.name };
+ return undefined;
+}
+
+function optionalNumber(value: unknown): number | undefined {
+ if (value === undefined) return undefined;
+ const number = Number(value);
+ return Number.isFinite(number) ? number : undefined;
+}
+
+function isBattleResult(value: unknown): value is BattleResult {
+ return !!value && typeof value === "object" && "remaining" in value;
+}
+
+function totalSide(troops: Record<UnitType, number>): number {
+ return (troops.infantry ?? 0) + (troops.lancer ?? 0) + (troops.marksman ?? 0);
+}
+
+function sampleSeed(baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): string {
+ return `${baseSeed ?? "simulator-default"}:${relative(process.cwd(), file)}:${testcaseId}:${index}:${iteration}`;
+}
+
+function snapshotKey(filePath: string, index: number): string {
+ return `${normalizeReportPath(filePath)}#${index}`;
+}
+
+function normalizeReportPath(filePath: string): string {
+ const normalized = filePath.replaceAll("\\", "/");
+ const testcaseIndex = normalized.indexOf("testcases/");
+ return testcaseIndex >= 0 ? normalized.slice(testcaseIndex) : normalized;
+}
+
+function distributionFromGameResult(value: unknown): { n: number; mu: number; sigma: number } | undefined {
+ const outcomes = extractOutcomeScores(value);
+ if (outcomes.length === 0) return undefined;
+ const stats = sampleStats(outcomes);
+ return { n: stats.n, mu: stats.mu, sigma: stats.sigma };
+}
+
+function extractOutcomeScores(value: unknown): number[] {
+ const rows = Array.isArray(value) ? value : value ? [value] : [];
+ return rows
+ .map((row) => battleScoreDelta(row))
+ .filter((score): score is number => score !== undefined);
+}
+
+function totalInputTroops(fighter: FighterInput): number {
+ return Object.values(fighter.troops ?? {}).reduce((sum, count) => sum + Number(count || 0), 0);
+}
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -1,9 +1,9 @@
export type SideId = "attacker" | "defender";
export type UnitType = "infantry" | "lancer" | "marksman";
export type DamageKind = "normal" | "skill";
export type UnitMask = number;
-export type ActiveEffectKind = "modifier" | "extra_attack" | "control" | "battle_order";
+export type ActiveEffectKind = "modifier" | "shield" | "extra_attack" | "control" | "battle_order";
export type SameEffectStacking = "add" | "max";
export const UNIT_TYPES: UnitType[] = ["infantry", "lancer", "marksman"];
export const UNIT_BITS: Record<UnitType, UnitMask> = {
@@ -25,14 +25,12 @@
| "effect.applies_vs"
| "enemy.living"
| "self.living";
export type TriggerDamageJobSelector = SupportedTriggerDamageJobSelector | UnitType | UnitType[];
-export type TriggerDamageJobMultiplier = number;
export interface TriggerDamageJobDefinition {
source: TriggerDamageJobSelector;
target: TriggerDamageJobSelector;
- multiplier?: TriggerDamageJobMultiplier;
}
export function unitMask(units: UnitType | UnitType[]): UnitMask {
const list = Array.isArray(units) ? units : [units];
@@ -87,24 +85,31 @@
export interface EffectIntentDefinition {
id: string;
type: string;
value?: unknown;
+ value_formula?: PercentOfValueFormula;
value_evolution?: { type?: string; step?: string; value?: number };
units?: Record<string, unknown>;
trigger_damage_jobs?: TriggerDamageJobDefinition[];
duration?: EffectDuration;
same_effect_stacking?: SameEffectStacking;
reason?: string;
}
+export type TriggerValueSource = "trigger.normal_kills" | "trigger.skill_kills" | "trigger.total_kills";
+
+export interface PercentOfValueFormula {
+ type: "percent_of";
+ source: TriggerValueSource;
+}
+
export interface ResolvedEffectIntentDefinition extends EffectIntentDefinition {
// Canonical config object retained across per-instance/level resolution. Duplicate
// main/joiner copies use this identity to share prepared activation groups.
sourceDefinition: Omit<EffectIntentDefinition, "id">;
- // Fixed-scope effects take the direct group fast path. Dynamically resolved scopes
- // use the compact scope-key table populated during battle preparation.
- damageGroup?: ActiveEffectGroup;
- damageGroupsByScopeKey?: Array<ActiveEffectGroup | undefined>;
+ // Runtime damage modifiers only: prepared groups for every resolved scope this definition can
+ // activate with, indexed by resolvedEffectScopeKey. Populated during battle preparation.
+ effectGroupsByScopeKey?: Array<ActiveEffectGroup | undefined>;
}
export interface TriggerDefinition {
type: string;
@@ -132,8 +137,9 @@
}
export interface SkillFile {
name: string;
+ aliases?: string[];
hero_generation?: string;
troop_type?: UnitType | string;
skills: Record<string, Omit<SkillDefinition, "id" | "name">>;
}
@@ -232,12 +238,12 @@
role?: "main" | "joiner";
missing?: boolean;
}
+// Immutable once resolved; live troop counts during a run belong to the runtime, not the fighter.
export interface ResolvedFighter {
side: SideId;
name: string;
- troops: Record<UnitType, number>;
initialTroops: Record<UnitType, number>;
troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>;
statBonuses: Record<UnitType, StatBlock>;
heroes: ResolvedHero[];
@@ -256,34 +262,39 @@
skillName?: string;
effectId?: string;
}
-// Runtime damage modifiers with the same definition and resolved unit scopes share one
-// stable group in the job-shape index. Each activation records its dense position so
-// expiry can swap-remove it once, irrespective of how many job shapes reference the group.
+// Runtime modifiers with the same definition and resolved unit scopes share one stable,
+// immutable group descriptor. Live activations for a run are held per runtime in
+// EffectIndex.liveEffectsByGroup[ordinal]; job-producing and control effects are indexed
+// independently and never enter these groups.
export interface ActiveEffectGroup {
- effects: ActiveEffect[];
+ // Index into RuntimeSkills.effectGroups and every runtime's liveEffectsByGroup.
+ ordinal: number;
bucketIndex: number;
sameEffectStacking: SameEffectStacking;
}
export interface ActiveEffect {
- id: string;
expired?: boolean;
source: EffectSource;
+ /** Direct runtime association used for skill reporting; never emitted. */
+ sourceSkill?: ResolvedSkill;
intent: EffectIntentDefinition;
ownerSide: SideId;
kind: ActiveEffectKind;
- // Numeric slot in the runtime damage scratch. Dynamic modifiers receive it
+ // Numeric slot in the runtime damage scratch. Dynamic damage effects receive it
// when indexed; non-damage effects keep -1.
bucketIndex: number;
- initialValuePct: number;
- getCurrentValuePct(round: number): number;
+ initialValue: number;
+ getCurrentValue(round: number): number;
// Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",
// trigger-relative selectors, or concrete unit selectors; it does not accept "all".
appliesTo: ResolvedUnitScope;
appliesVs: ResolvedUnitScope;
triggerDamageJobs?: TriggerDamageJobDefinition[];
+ // battle_order effects only: the target preference resolved once from intent.value.
+ attackOrder?: readonly UnitType[];
createdRound: number;
startRound: number;
duration: EffectDuration;
// Eligible attacks remaining before an attacks.delay effect can apply. This is
@@ -293,12 +304,11 @@
// attack ordered, extra attack spawned). Attack duration constraints and step:"attack"
// value evolution read it; cancelled attacks still charge attack-constrained effects
// unless useEffectsOnDodge/useEffectsOnNoAttack disable that.
uses: number;
- stackingKey?: string;
sameEffectStacking: SameEffectStacking;
- damageIndexGroup?: ActiveEffectGroup;
- damageIndexPosition?: number;
+ effectGroup?: ActiveEffectGroup;
+ effectGroupPosition?: number;
}
export interface EvolvingActiveEffect extends ActiveEffect {
valueEvolution: {
@@ -308,9 +318,8 @@
};
}
export interface AttackIntent {
- id: string;
round: number;
source: "normal";
dealerSide: SideId;
dealerUnit: UnitType;
@@ -323,19 +332,16 @@
projectedReceivedAttackCount: number;
}
export interface DamageJob {
- id: string;
round: number;
kind: DamageKind;
- sourceIntentId: string;
roundStartTroops: Record<SideId, Record<UnitType, number>>;
dealerSide: SideId;
dealerUnit: UnitType;
takerSide: SideId;
takerUnit: UnitType;
sourceEffectId?: string;
- sourceSkillReportKey?: string;
sourceMultiplier?: number;
}
export interface CounterDelta {
@@ -349,16 +355,25 @@
export interface DamageBucketTrace {
totalPct?: number;
factor: number;
raw?: number;
- contributors: Array<{ effectId: string; source: string; sourceSide?: SideId; valuePct: number; bucket: string; stackingKey?: string; sameEffectStacking?: SameEffectStacking }>;
+ contributors: Array<{
+ effectId: string;
+ source: string;
+ sourceSide?: SideId;
+ valuePct?: number;
+ value?: number;
+ bucket: string;
+ sameEffectStacking?: SameEffectStacking;
+ }>;
}
export interface DamageAggregationGroupTrace {
id: string;
mode: string;
- placement: "numerator" | "denominator";
+ placement: "numerator" | "denominator" | "post_subtract";
inputBuckets: string[];
+ raw?: number;
totalPct?: number;
factor: number;
contributors: DamageBucketTrace["contributors"];
}
@@ -367,65 +382,76 @@
roundStartTroops: Record<SideId, Record<UnitType, number>>;
armyTerm: number;
atomicBuckets: Record<string, DamageBucketTrace>;
aggregationGroups: Record<string, DamageAggregationGroupTrace>;
- appliedEffects: AppliedModifierEffect[];
+ appliedEffects: Array<AppliedModifierEffect | AppliedShieldEffect>;
rejectedEffects: Array<{ effectId: string; reason: string }>;
+ damageBeforeOffsets: number;
+ offsetDamage: number;
rawDamage: number;
finalKills: number;
}
-// One "this effect affected battle mechanics" event, discriminated on ActiveEffect.kind.
-// Events are built by the standard and full-trace recorders; the uses counter is charged in every mode.
-interface AppliedEffectBase {
- // ActiveEffect.id — the runtime instance. Static-profile/input-stat contributors have no
- // ActiveEffect and use the config-level effectId here too.
- activeEffectId: string;
- // Config-level id (source.effectId ?? ActiveEffect.id).
+interface AppliedEffectIdentity {
effectId: string;
+ sourceSide: SideId;
+ bucket: string;
+}
+
+export interface AppliedPercentageEffectSummary extends AppliedEffectIdentity {
+ valuePct: number;
+}
+
+export interface AppliedShieldEffectSummary extends AppliedEffectIdentity {
+ value: number;
+}
+
+interface DetailedPercentageEffectBase extends AppliedPercentageEffectSummary {
source: string;
- sourceSide?: SideId;
}
-export interface AppliedModifierEffect extends AppliedEffectBase {
+export interface AppliedModifierEffect extends DetailedPercentageEffectBase {
kind: "modifier";
- bucket: string;
- valuePct: number;
- stackingKey?: string;
- sameEffectStacking?: SameEffectStacking;
+ sameEffectStacking: SameEffectStacking;
}
-export interface AppliedControlEffect extends AppliedEffectBase {
+export interface AppliedShieldEffect extends AppliedShieldEffectSummary {
+ kind: "shield";
+ source: string;
+ sameEffectStacking: SameEffectStacking;
+}
+
+export interface AppliedControlEffect extends DetailedPercentageEffectBase {
kind: "control";
reason: "dodge" | "no_attack";
}
-export interface AppliedOrderEffect extends AppliedEffectBase {
+export interface AppliedOrderEffect extends DetailedPercentageEffectBase {
kind: "battle_order";
chosenTarget: UnitType;
}
-export interface AppliedExtraAttackEffect extends AppliedEffectBase {
+export interface AppliedExtraAttackEffect extends DetailedPercentageEffectBase {
kind: "extra_attack";
- spawnedJobIds: string[];
+ spawnedJobCount: number;
}
-export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;
+export type DetailedAppliedEffect = AppliedModifierEffect | AppliedShieldEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;
+export type AppliedEffect = AppliedPercentageEffectSummary | AppliedShieldEffectSummary | DetailedAppliedEffect;
export interface AttackOutcome {
- jobId: string;
round: number;
kind: DamageKind;
sourceEffectId?: string;
- sourceSkillReportKey?: string;
dealerSide: SideId;
dealerUnit: UnitType;
takerSide: SideId;
takerUnit: UnitType;
kills: number;
- counterDeltas: CounterDelta[];
- appliedEffects: AppliedEffect[];
- cancelledBy?: string;
+ /** Trace-only counter bookkeeping. */
+ counterDeltas?: CounterDelta[];
+ /** Standard emits four-field summaries; trace emits detailed causal events. */
+ appliedEffects?: AppliedEffect[];
cancelReason?: "dodge" | "no_attack";
trace?: DamageEquationTrace;
}
Index: simulator/src/workerPool.test.ts
===================================================================
--- simulator/src/workerPool.test.ts prev run
+++ simulator/src/workerPool.test.ts this run
@@ -46,8 +46,26 @@
await pool.close();
});
+test("BatchWorkerPool runs one task and preserves batch order", async () => {
+ const workers = [new DeferredWorker(), new DeferredWorker()];
+ const pool = new BatchWorkerPool<number, number>(2, (index) => workers[index]!);
+
+ const single = pool.runTask(1);
+ workers[0]!.resolveNext([10]);
+ assert.equal(await single, 10);
+
+ const batches = pool.runBatches([[2], [3], [4]]);
+ workers[0]!.resolveNext([20]);
+ workers[1]!.resolveNext([30]);
+ await Promise.resolve();
+ workers[0]!.resolveNext([40]);
+ assert.deepEqual(await batches, [20, 30, 40]);
+
+ await pool.close();
+});
+
test("batchTasksByWeight groups tasks without exceeding the target when possible", () => {
assert.deepEqual(
batchTasksByWeight([1, 1, 3, 1, 1], 2, (value) => value),
[[1, 1], [3], [1, 1]]
Index: simulator/src/workerPool.ts
===================================================================
--- simulator/src/workerPool.ts prev run
+++ simulator/src/workerPool.ts this run
@@ -2,8 +2,19 @@
runBatch(id: number, tasks: TTask[], onProgress?: (progress: TProgress) => void): Promise<TResult[]>;
close(): Promise<void> | void;
}
+export interface BatchWorkerRequest<TTask, TContext = undefined> {
+ id: number;
+ tasks: TTask[];
+ context?: TContext;
+}
+
+export type BatchWorkerResponse<TResult, TProgress = never> =
+ | { id: number; type: "progress"; progress: TProgress }
+ | { id: number; type: "result"; results: TResult[] }
+ | { id: number; type: "error"; error: string };
+
interface PendingBatch<TTask, TResult, TProgress> {
tasks: TTask[];
onProgress?: (progress: TProgress) => void;
resolve: (result: TResult[]) => void;
@@ -39,8 +50,28 @@
this.pump();
});
}
+ async runTask(task: TTask, onProgress?: (progress: TProgress) => void): Promise<TResult> {
+ const results = await this.runBatch([task], onProgress);
+ const result = results[0];
+ if (result === undefined) throw new Error("Worker returned no result for task");
+ return result;
+ }
+
+ async runBatches(
+ batches: readonly TTask[][],
+ onProgress?: (batchIndex: number, progress: TProgress) => void,
+ ): Promise<TResult[]> {
+ const results = await Promise.all(
+ batches.map((batch, batchIndex) => this.runBatch(
+ batch,
+ onProgress ? (progress) => onProgress(batchIndex, progress) : undefined,
+ )),
+ );
+ return results.flat();
+ }
+
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
while (this.queue.length > 0) this.queue.shift()!.reject(new Error("Worker pool closed before completing queued tasks"));
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/src/classifier.ts b/simulator/src/classifier.ts
--- a/simulator/src/classifier.ts
+++ b/simulator/src/classifier.ts
@@ -1,11 +1,10 @@
import type { ActiveEffect, DamageJob, SideId, UnitType } from "./types";
import { unitMaskHas } from "./types";
-import { bucketDefinition, type BucketName, type StaticPassiveBucket } from "./damageBuckets";
-import { staticPassiveBucketRole } from "./staticDamageProfile";
+import { bucketDefinition, passiveBucketRole, type BucketName, type PassiveBucket } from "./damageBuckets";
export interface Classification {
kind: "bucket" | "control" | "extra_skill_attack" | "battle_order" | "report_only";
- bucket?: BucketName | StaticPassiveBucket;
+ bucket?: BucketName | PassiveBucket;
control?: "dodge" | "no_attack";
reason?: string;
}
@@ -22,18 +21,18 @@
if (type === "attack_order") return { kind: "battle_order" };
const definition = bucketDefinition(type);
- const staticPassiveRole = staticPassiveBucketRole(type);
- if ((!definition || definition.valueType !== "pct") && !staticPassiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };
- if (staticPassiveRole) {
- if (staticPassiveRole === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };
- if (staticPassiveRole === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };
+ const passiveRole = passiveBucketRole(type);
+ if ((!definition || definition.valueType !== "pct") && !passiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };
+ if (passiveRole) {
+ if (passiveRole === "dealer" && effect.appliesTo.side === job.dealerSide) return { kind: "bucket", bucket: type as PassiveBucket };
+ if (passiveRole === "taker" && effect.appliesTo.side === job.takerSide) return { kind: "bucket", bucket: type as PassiveBucket };
return { kind: "report_only", reason: unsupportedReason(effect, job) };
}
if (!definition) return { kind: "report_only", reason: unsupportedReason(effect, job) };
if (definition.phase !== "dynamic") return { kind: "report_only", reason: unsupportedReason(effect, job) };
if (definition.appliesTo !== undefined && definition.appliesTo !== job.kind) return { kind: "report_only", reason: "not_applicable_to_job_kind" };
- if (definition.role === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: definition.path };
- if (definition.role === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: definition.path };
+ if (definition.role === "dealer" && effect.appliesTo.side === job.dealerSide) return { kind: "bucket", bucket: definition.path };
+ if (definition.role === "taker" && effect.appliesTo.side === job.takerSide) return { kind: "bucket", bucket: definition.path };
return { kind: "report_only", reason: unsupportedReason(effect, job) };
}
@@ -46,23 +45,23 @@
}
function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {
- const appliesToSide = control === "no_attack" ? job.attackerSide : job.defenderSide;
- const appliesToUnit = control === "no_attack" ? job.attackerUnit : job.defenderUnit;
+ const appliesToSide = control === "no_attack" ? job.dealerSide : job.takerSide;
+ const appliesToUnit = control === "no_attack" ? job.dealerUnit : job.takerUnit;
if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;
- const appliesVsSide = control === "no_attack" ? job.defenderSide : job.attackerSide;
- const appliesVsUnit = control === "no_attack" ? job.defenderUnit : job.attackerUnit;
+ const appliesVsSide = control === "no_attack" ? job.takerSide : job.dealerSide;
+ const appliesVsUnit = control === "no_attack" ? job.takerUnit : job.dealerUnit;
return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);
}
function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {
- if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";
- if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";
+ if (effect.appliesTo.side === job.dealerSide) return "unsupported_dealer_effect";
+ if (effect.appliesTo.side === job.takerSide) return "unsupported_taker_effect";
return "wrong_side";
}
function unitForSide(side: SideId, job: DamageJob): UnitType | undefined {
- if (side === job.attackerSide) return job.attackerUnit;
- if (side === job.defenderSide) return job.defenderUnit;
+ if (side === job.dealerSide) return job.dealerUnit;
+ if (side === job.takerSide) return job.takerUnit;
return undefined;
}
diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts
--- a/simulator/src/classifierDamage.test.ts
+++ b/simulator/src/classifierDamage.test.ts
@@ -2,11 +2,11 @@
import { test } from "node:test";
import { classifyEffectForJob } from "./classifier";
-import { calculateDamageJob } from "./damage";
-import { ATOMIC_BUCKETS, STATIC_PASSIVE_BUCKETS } from "./damageBuckets";
+import { calculateDamageJob, createDamageScratch, evaluateDamageExpression } from "./damage";
+import { ATOMIC_BUCKETS, DYNAMIC_BUCKETS, PASSIVE_BUCKETS, STATIC_BUCKETS, type BucketPlacement, type BucketRole } from "./damageBuckets";
import { createEffectIndex, damageShapeSlotsForEffect, indexEffect } from "./effectIndex";
import { activateEffect, evolvingActiveEffectValuePct, resolvedEffectScopeKey } from "./effects";
-import { buildStaticDamageProfile } from "./staticDamageProfile";
+import { buildStaticDamageBucketFactors, buildStaticDamageProfile } from "./staticDamageProfile";
import { createRecorder, type BattleRecorder } from "./recorder";
import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";
import { ALL_UNIT_MASK, unitMask } from "./types";
@@ -20,10 +20,10 @@
attacker: { infantry: 1000, lancer: 0, marksman: 0 },
defender: { infantry: 0, lancer: 1000, marksman: 0 }
},
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
sourceMultiplier: 1
};
@@ -52,7 +52,7 @@
classifyEffectForJob(active, {
...job,
id: "job-attacker-marksman",
- attackerUnit: "marksman"
+ dealerUnit: "marksman"
})?.reason,
"not_applicable_to_job"
);
@@ -60,7 +60,7 @@
classifyEffectForJob(active, {
...job,
id: "job-defender-marksman",
- defenderUnit: "marksman"
+ takerUnit: "marksman"
})?.reason,
"not_applicable_to_job"
);
@@ -100,7 +100,7 @@
damageJob: DamageJob,
fighters: Record<"attacker" | "defender", ResolvedFighter>,
effects: ActiveEffect[],
- options: Omit<Partial<Parameters<typeof calculateDamageJob>[3]>, "recorder"> & { recorder?: BattleRecorder; trace?: boolean } = {}
+ options: Omit<Partial<Parameters<typeof calculateDamageJob>[2]>, "recorder"> & { recorder?: BattleRecorder; trace?: boolean } = {}
) {
const effectIndex = options.effectIndex ?? preparedEffectIndex(effects);
if (!options.effectIndex) {
@@ -113,8 +113,9 @@
const recorder = options.recorder ?? createRecorder(options.trace === true ? "trace" : "standard", [], () => {
throw new Error("damage-only test recorder has no battle resolution");
});
+ recorder.recordStaticProfile(fighters, effects);
const { trace: _trace, ...damageOptions } = options;
- const result = calculateDamageJob(damageJob, fighters, effects, { ...damageOptions, recorder, effectIndex, staticDamageProfile, usedEffects });
+ const result = calculateDamageJob(damageJob, fighters, { ...damageOptions, recorder, effectIndex, staticDamageProfile, usedEffects });
return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };
}
@@ -222,7 +223,7 @@
const ownerSide = type.includes(".attack.") || type.includes(".lethality.") ? "attacker" : "defender";
const bucket = classifyEffectForJob(effect(type, ownerSide), job)?.bucket;
assert.equal(bucket, expectedBucket, type);
- assert.ok(bucket !== undefined && (STATIC_PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a static passive bucket`);
+ assert.ok(bucket !== undefined && (PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a passive bucket`);
}
assert.equal(classifyEffectForJob(effect("passive.attack.down", "attacker", 5), job)?.bucket, "passive.attack.down");
@@ -232,22 +233,22 @@
const reversedJob: DamageJob = {
...job,
id: "job-reversed",
- attackerSide: "defender",
- attackerUnit: "lancer",
- defenderSide: "attacker",
- defenderUnit: "infantry"
+ dealerSide: "defender",
+ dealerUnit: "lancer",
+ takerSide: "attacker",
+ takerUnit: "infantry"
};
const passiveAttack = effect("passive.attack.up", "attacker", 10);
const passiveHealth = effect("passive.health.up", "defender", 10);
assert.equal(classifyEffectForJob(passiveAttack, job)?.bucket, "passive.attack.up");
- assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_defender_effect");
+ assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_taker_effect");
assert.equal(classifyEffectForJob(passiveHealth, job)?.bucket, "passive.health.up");
- assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_attacker_effect");
+ assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_dealer_effect");
});
test("damage calculator requires indexed effect candidates", () => {
- assert.throws(() => calculateDamageJob(job, simpleFighters(), [], { trace: true } as never), /effectIndex/i);
+ assert.throws(() => calculateDamageJob(job, simpleFighters(), { trace: true } as never), /effectIndex/i);
});
test("damage calculator uses centralized bucket definitions for player stat routing", () => {
@@ -332,12 +333,12 @@
assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);
assert.equal(outcome.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
- assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.3);
- assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.down"].factor, 1.05);
+ assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.3);
+ assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.down"].factor, 1.05);
assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), Number((1.3 / 1.05).toFixed(6)));
});
-test("static damage profile produces the same static bucket damage as per-job passive processing", () => {
+test("static profile factoring is identical to evaluating its buckets unfactored", () => {
const fighters = simpleFighters();
fighters.attacker.statBonuses.infantry.attack = 50;
fighters.attacker.statBonuses.infantry.lethality = 25;
@@ -352,18 +353,89 @@
effect("passive.health.down", "defender", 30),
effect("passive.defense.up", "defender", 5)
];
- const baseline = calculateIndexedDamageJob(job, fighters, passives, { trace: true });
- const profile = buildStaticDamageProfile(fighters, passives);
- const profiled = calculateIndexedDamageJob(job, fighters, passives, { trace: true, staticDamageProfile: profile });
+ const runtimeEffects = [
+ effect("active.hero.attack.up", "attacker", 15),
+ effect("active.troop.damage.down", "attacker", 12),
+ effect("active.hero.health.down", "defender", 8),
+ effect("type.normal.damageTaken.up", "defender", 7)
+ ];
+ const allEffects = [...passives, ...runtimeEffects];
+ const bucketFactors = buildStaticDamageBucketFactors(fighters, allEffects);
+ const profile = buildStaticDamageProfile(fighters, allEffects);
+ const scratch = createDamageScratch();
+ const profiled = calculateIndexedDamageJob(job, fighters, allEffects, {
+ trace: true,
+ staticDamageProfile: profile,
+ scratch,
+ capToTakerTroops: false
+ });
+ const dealerBuckets = bucketFactors[job.dealerSide][job.dealerUnit];
+ const takerBuckets = bucketFactors[job.takerSide][job.takerUnit];
+ const dealer = profile[job.dealerSide][job.dealerUnit];
+ const taker = profile[job.takerSide][job.takerUnit];
- assert.ok(Math.abs(profiled.kills - baseline.kills) < 1e-12);
+ assert.equal(
+ dealer.dealerFactor.factors[0],
+ unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") /
+ unfactoredStaticProduct(dealerBuckets, "dealer", "denominator")
+ );
+ assert.equal(
+ taker.takerFactor.factors[0],
+ unfactoredStaticProduct(takerBuckets, "taker", "numerator") /
+ unfactoredStaticProduct(takerBuckets, "taker", "denominator")
+ );
+ assert.equal(
+ evaluateDamageExpression(dealer.dealerFactor, taker.takerFactor).factors[0],
+ dealer.dealerFactor.factors[0] * taker.takerFactor.factors[0]
+ );
+ const unfactoredStaticNumerator =
+ unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") *
+ unfactoredStaticProduct(takerBuckets, "taker", "numerator");
+ const unfactoredStaticDenominator =
+ unfactoredStaticProduct(dealerBuckets, "dealer", "denominator") *
+ unfactoredStaticProduct(takerBuckets, "taker", "denominator");
+ const unfactoredDamage =
+ unfactoredDynamicProduct(scratch.factors, job.kind, "numerator") * unfactoredStaticNumerator /
+ (100 * unfactoredDynamicProduct(scratch.factors, job.kind, "denominator") * unfactoredStaticDenominator);
+
+ assert.ok(Math.abs(profiled.kills - unfactoredDamage) < 1e-12);
assert.equal(profiled.trace?.atomicBuckets["player.attack"].totalPct, 50);
assert.equal(profiled.trace?.atomicBuckets["passive.attack.up"].totalPct, 20);
assert.equal(profiled.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
assert.equal(profiled.trace?.atomicBuckets["passive.health.down"].totalPct, 30);
- assert.equal(profiled.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.2);
+ assert.equal(profiled.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 15);
+ assert.equal(profiled.trace?.atomicBuckets["type.normal.damageTaken.up"].totalPct, 7);
+ assert.equal(profiled.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.2);
});
+function unfactoredStaticProduct(
+ factors: Float64Array,
+ role: BucketRole,
+ placement: BucketPlacement
+): number {
+ return STATIC_BUCKETS.reduce(
+ (product, definition, index) =>
+ definition.role === role && definition.placement === placement ? product * factors[index] : product,
+ 1
+ );
+}
+
+function unfactoredDynamicProduct(
+ factors: Float64Array,
+ kind: DamageJob["kind"],
+ placement: BucketPlacement
+): number {
+ return DYNAMIC_BUCKETS.reduce(
+ (product, definition, index) => {
+ const appliesTo = "appliesTo" in definition ? definition.appliesTo : undefined;
+ return definition.placement === placement && (!appliesTo || appliesTo === kind)
+ ? product * factors[index]
+ : product;
+ },
+ 1
+ );
+}
+
test("static damage profile preserves max stacking for duplicate passive effects", () => {
const fighters = simpleFighters();
const weaker = { ...effect("passive.attack.up", "attacker", 20), id: "weak", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };
@@ -430,8 +502,8 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [passiveHealthDown], { trace: true });
assert.equal(outcome.trace?.atomicBuckets["passive.health.down"].totalPct, 105);
- assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].factor, 2.05);
- assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].placement, "numerator");
+ assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].factor, 2.05);
+ assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].placement, "numerator");
assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);
});
@@ -598,7 +670,7 @@
};
const fighters = simpleFighters();
const infantryOutcome = calculateIndexedDamageJob(job, fighters, [infantry, lancer], { trace: true });
- const lancerOutcome = calculateIndexedDamageJob({ ...job, id: "lancer-job", attackerUnit: "lancer" }, fighters, [infantry, lancer], { trace: true });
+ const lancerOutcome = calculateIndexedDamageJob({ ...job, id: "lancer-job", dealerUnit: "lancer" }, fighters, [infantry, lancer], { trace: true });
assert.equal(infantryOutcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 40);
assert.deepEqual(infantryOutcome.usedEffectIds, ["max-infantry"]);
@@ -630,10 +702,10 @@
id: "intent",
round: 1,
source: "normal",
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
orderIndex: 0,
previousAttackCount: 0,
projectedAttackCount: 1,
@@ -671,10 +743,10 @@
id: "intent",
round: 1,
source: "normal",
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
orderIndex: 0,
previousAttackCount: 0,
projectedAttackCount: 1,
@@ -711,10 +783,10 @@
id: "intent",
round: 1,
source: "normal",
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
orderIndex: 0,
previousAttackCount: 0,
projectedAttackCount: 1,
diff --git a/simulator/src/config.test.ts b/simulator/src/config.test.ts
--- a/simulator/src/config.test.ts
+++ b/simulator/src/config.test.ts
@@ -177,7 +177,7 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /negative.*active\.hero\.attack\.down.*value/i);
});
-test("loadSimulatorConfig rejects passive effects that are not battle-start static", () => {
+test("loadSimulatorConfig rejects effects targeting static buckets unless they are fully static", () => {
const turnRoot = writeConfigWithTroopEffect({
type: "passive.attack.up",
value: 10
@@ -198,6 +198,14 @@
},
{ type: "battle_start" }
);
+ const emptyDurationRoot = writeConfigWithTroopEffect(
+ {
+ type: "passive.attack.up",
+ value: 10,
+ duration: {}
+ },
+ { type: "battle_start" }
+ );
const probabilityRoot = writeConfigWithTroopEffect(
{
type: "passive.attack.up",
@@ -206,10 +214,25 @@
{ type: "battle_start", probability: 50 }
);
- assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /passive.*battle_start/i);
- assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /passive.*battle.*duration/i);
- assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /passive.*value_evolution/i);
- assert.throws(() => loadSimulatorConfigFromDir(probabilityRoot), /passive.*probability.*deterministic/i);
+ assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /static bucket passive\.attack\.up.*battle_start/i);
+ assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /static bucket passive\.attack\.up.*duration/i);
+ assert.throws(() => loadSimulatorConfigFromDir(emptyDurationRoot), /static bucket passive\.attack\.up.*duration/i);
+ assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /static bucket passive\.attack\.up.*value_evolution/i);
+ assert.throws(() => loadSimulatorConfigFromDir(probabilityRoot), /static bucket passive\.attack\.up.*probability/i);
+});
+
+test("loadSimulatorConfig rejects skill effects targeting input-derived static buckets", () => {
+ const playerRoot = writeConfigWithTroopEffect(
+ { type: "player.attack", value: 10 },
+ { type: "battle_start" }
+ );
+ const troopsRoot = writeConfigWithTroopEffect(
+ { type: "troops.baseAttack", value: 10 },
+ { type: "battle_start" }
+ );
+
+ assert.throws(() => loadSimulatorConfigFromDir(playerRoot), /input-derived static bucket/i);
+ assert.throws(() => loadSimulatorConfigFromDir(troopsRoot), /input-derived static bucket/i);
});
test("loadSimulatorConfig rejects invalid trigger_damage_jobs selector strings", () => {
diff --git a/simulator/src/config.ts b/simulator/src/config.ts
--- a/simulator/src/config.ts
+++ b/simulator/src/config.ts
@@ -37,12 +37,11 @@
import { UNIT_TYPES } from "./types";
import type { ConfigDiagnostics, EffectIntentDefinition, SimulatorConfig, SkillFile, TriggerDamageJobDefinition } from "./types";
-import { bucketDefinition, DYNAMIC_EFFECT_BUCKETS, STATIC_PASSIVE_BUCKETS } from "./damageBuckets";
-import { assertStaticPassiveEffectDefinition, isPassiveBucket } from "./staticDamageProfile";
+import { bucketDefinition, DYNAMIC_EFFECT_BUCKETS, isPassiveBucket, PASSIVE_BUCKETS } from "./damageBuckets";
const KNOWN_EFFECT_TYPES = new Set([
...DYNAMIC_EFFECT_BUCKETS,
- ...STATIC_PASSIVE_BUCKETS,
+ ...PASSIVE_BUCKETS,
"extra_skill_attack",
"dodge",
"no_attack",
@@ -180,7 +179,7 @@
validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);
- assertStaticPassiveEffectDefinition(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
+ validateStaticBucketEffect(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);
if (!KNOWN_EFFECT_TYPES.has(type)) {
diagnostics.unsupportedEffects.push({
@@ -261,6 +260,38 @@
);
}
+// Static buckets are aggregated once per battle into the static damage profile, so any effect
+// that feeds one must itself be known at battle start, deterministic, immutable, and permanent.
+// Static buckets outside the passive family (troops/player) are input-derived and cannot be
+// produced by a skill at all.
+function validateStaticBucketEffect(
+ trigger: SkillFile["skills"][string]["trigger"],
+ effect: EffectIntentDefinition,
+ file: string,
+ skillId: string,
+ effectId: string
+): void {
+ const definition = bucketDefinition(effect.type);
+ if (definition?.phase !== "static") return;
+
+ const path = `${file}:${skillId}.${effectId}`;
+ if (!isPassiveBucket(effect.type)) {
+ throw new Error(`effect type ${effect.type} targets an input-derived static bucket and cannot be defined by a skill at ${path}`);
+ }
+ if (trigger.type !== "battle_start") {
+ throw new Error(`effect targeting static bucket ${effect.type} must use a battle_start trigger at ${path}`);
+ }
+ if (trigger.probability !== undefined) {
+ throw new Error(`effect targeting static bucket ${effect.type} cannot define trigger probability at ${path}`);
+ }
+ if (effect.value_evolution !== undefined) {
+ throw new Error(`effect targeting static bucket ${effect.type} cannot define value_evolution at ${path}`);
+ }
+ if (effect.duration !== undefined) {
+ throw new Error(`effect targeting static bucket ${effect.type} cannot define duration at ${path}`);
+ }
+}
+
function validateNativeEffectValue(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
const definition = bucketDefinition(effect.type);
if ((!definition || definition.valueType !== "pct") && !isPassiveBucket(effect.type)) return;
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -1,7 +1,6 @@
import type {
ActiveEffect,
ActiveEffectGroup,
- DamageBucketTrace,
DamageEquationTrace,
DamageJob,
ResolvedFighter,
@@ -9,21 +8,29 @@
UnitType
} from "./types";
import { UNIT_TYPES } from "./types";
-import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket, type StaticDamageBucket } from "./damageBuckets";
+import {
+ ATOMIC_BUCKETS,
+ DYNAMIC_BUCKETS,
+ EVALUATED_DAMAGE_BUCKET,
+ STATIC_BUCKETS,
+ pctBucketDelta,
+ pctBucketFactor,
+ rawBucketFactor,
+ type AtomicBucket,
+ type AggregateBucketSpec,
+ type BucketRole,
+ type BucketSpec,
+ type DamageAggregationBucketSpec,
+ type StaticDamageBucket
+} from "./damageBuckets";
import { advanceEffectAttackDelay } from "./effects";
-import { damageJobSlot, type EffectIndex } from "./effectIndex";
+import { damageJobShapeSlot, damageJobSlot, type EffectIndex } from "./effectIndex";
import type { BattleRecorder, DamageJobRecorder } from "./recorder";
-import {
- buildStaticDamageProfile,
- type StaticDamageProfile,
- type StaticDamageProfileEntry
-} from "./staticDamageProfile";
type NumericBucketId = number;
interface DamageFactorTerm {
- id: string;
bucket: NumericBucketId;
- bucketName: AtomicBucket;
+ role?: BucketRole;
placement: "numerator" | "denominator";
appliesTo?: DamageJob["kind"];
}
@@ -32,11 +39,31 @@
factors: Float64Array;
}
+interface CompiledDamageExpression {
+ numeratorSlots: Int32Array;
+ denominatorSlots: Int32Array;
+}
+
+export interface DamageBucketSet {
+ factors: Float64Array;
+ expression: CompiledDamageExpression;
+}
+
export type DamageScratch = NumericDamageBuckets;
+export type StaticDamageBucketFactors = Float64Array;
+export type StaticDamageBucketMatrix = Record<SideId, Record<UnitType, StaticDamageBucketFactors>>;
+
+/** Pre-collapsed substitutions for one fighter's possible roles in a damage job. */
+export interface StaticDamageProfileEntry {
+ dealerFactor: DamageBucketSet;
+ takerFactor: DamageBucketSet;
+}
+
+export type StaticDamageProfile = Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;
// Lean result of a single damage job: kills plus whatever the selected recorder requested.
// Usage charging happens via options.usedEffects (every mode); the heavy per-attack
-// AttackOutcome is assembled by the recorder, not here, so fast mode allocates nothing extra.
+// AttackOutcome is assembled by the recorder, not here.
export interface DamageResult {
kills: number;
appliedEffects?: DamageEquationTrace["appliedEffects"];
@@ -44,72 +71,42 @@
}
const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;
-const TROOPS_COUNT_INDEX = BUCKET_IDS["troops.count"];
-const SOURCE_EXTRA_SKILL_INDEX = BUCKET_IDS["source.extraSkill"];
-const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));
-const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");
-const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");
-const PROFILED_NUMERATOR_TERMS = DEFAULT_NUMERATOR_TERMS.filter((term) => term.bucketName !== "troops.count" && term.bucketName !== "source.extraSkill");
-const PROFILED_DENOMINATOR_TERMS = DEFAULT_DENOMINATOR_TERMS;
-const factorSlots = (terms: DamageFactorTerm[], kind: DamageJob["kind"]): Int32Array =>
- Int32Array.from(terms.filter((term) => !term.appliesTo || term.appliesTo === kind).map((term) => term.bucket));
-const DEFAULT_NUMERATOR_SLOTS = { normal: factorSlots(DEFAULT_NUMERATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_NUMERATOR_TERMS, "skill") };
-const DEFAULT_DENOMINATOR_SLOTS = { normal: factorSlots(DEFAULT_DENOMINATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_DENOMINATOR_TERMS, "skill") };
-const PROFILED_NUMERATOR_SLOTS = { normal: factorSlots(PROFILED_NUMERATOR_TERMS, "normal"), skill: factorSlots(PROFILED_NUMERATOR_TERMS, "skill") };
-const PROFILED_DENOMINATOR_SLOTS = DEFAULT_DENOMINATOR_SLOTS;
-const BUCKET_UPDATE_BY_INDEX = Uint8Array.from(
- ATOMIC_BUCKETS.map((bucket) => {
- const update = BUCKET_DEFINITIONS[bucket].update;
- return update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2;
- })
-);
-
-export class DamageAggregationError extends Error {
- readonly groupId: string;
- readonly round: number;
- readonly jobId: string;
- readonly netPct: number | undefined;
- readonly factor: number;
- readonly contributors: DamageBucketTrace["contributors"];
-
- constructor(args: {
- groupId: string;
- round: number;
- jobId: string;
- netPct?: number;
- factor: number;
- contributors: DamageBucketTrace["contributors"];
- }) {
- super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor}${args.netPct === undefined ? "" : ` netPct=${args.netPct}`}`);
- this.name = "DamageAggregationError";
- this.groupId = args.groupId;
- this.round = args.round;
- this.jobId = args.jobId;
- this.netPct = args.netPct;
- this.factor = args.factor;
- this.contributors = args.contributors;
- }
-}
+const STATIC_BUCKET_IDS = Object.fromEntries(STATIC_BUCKETS.map((bucket, index) => [bucket.path, index])) as Record<StaticDamageBucket, NumericBucketId>;
+const DYNAMIC_FACTOR_TERMS = compileDamageTerms(DYNAMIC_BUCKETS);
+const STATIC_FACTOR_TERMS = compileDamageTerms(STATIC_BUCKETS);
+const DYNAMIC_EXPRESSIONS = {
+ normal: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "normal" }),
+ skill: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "skill" })
+};
+const STATIC_EXPRESSIONS = {
+ dealer: compileDamageExpression(STATIC_FACTOR_TERMS, { role: "dealer" }),
+ taker: compileDamageExpression(STATIC_FACTOR_TERMS, { role: "taker" })
+};
+const EVALUATED_EXPRESSION = compileDamageExpression(compileDamageTerms([EVALUATED_DAMAGE_BUCKET]), {});
+const BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(DYNAMIC_BUCKETS);
+const STATIC_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(STATIC_BUCKETS);
+const EVALUATED_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates([EVALUATED_DAMAGE_BUCKET]);
+const DAMAGE_SCALE_BUCKET = createEvaluatedDamageBucket(1 / 100);
export function calculateDamageJob(
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
- activeEffects: ActiveEffect[],
- options: { recorder: BattleRecorder; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: ActiveEffect[] }
+ options: { recorder: BattleRecorder; effectIndex: EffectIndex; staticDamageProfile: StaticDamageProfile; scratch?: DamageScratch; capToTakerTroops?: boolean; usedEffects?: ActiveEffect[] }
): DamageResult {
if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
if (!options.recorder) throw new Error("calculateDamageJob requires a recorder");
+ if (!options.staticDamageProfile) throw new Error("calculateDamageJob requires a staticDamageProfile");
const recording = options.recorder.startDamageJob();
- const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);
- const attacker = fighters[job.attackerSide];
- const defender = fighters[job.defenderSide];
- const attackerTroops = job.roundStartTroops[job.attackerSide][job.attackerUnit] ?? 0;
- const defenderTroops = job.roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0;
- const minInitialArmy = Math.max(1, Math.min(totalTroops(attacker.initialTroops), totalTroops(defender.initialTroops)));
- const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));
+ const staticProfile = options.staticDamageProfile;
+ const dealer = fighters[job.dealerSide];
+ const taker = fighters[job.takerSide];
+ const dealerTroops = job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0;
+ const takerTroops = job.roundStartTroops[job.takerSide][job.takerUnit] ?? 0;
+ const minInitialArmy = Math.max(1, Math.min(totalTroops(dealer.initialTroops), totalTroops(taker.initialTroops)));
+ const armyTerm = Math.ceil(Math.sqrt(Math.max(0, dealerTroops)) * Math.sqrt(minInitialArmy));
const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();
- buckets.factors[TROOPS_COUNT_INDEX] = Math.max(0, armyTerm);
- buckets.factors[SOURCE_EXTRA_SKILL_INDEX] = Math.max(0, job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
+ applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);
+ applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
const usedEffects = options.usedEffects ?? [];
applyBucketEffects(
@@ -120,15 +117,10 @@
usedEffects
);
- const staticOffense = staticProfile.offense[job.attackerSide][job.attackerUnit];
- const staticDefense = staticProfile.defense[job.defenderSide][job.defenderUnit];
- recording.recordStaticProfile(staticOffense);
- recording.recordStaticProfile(staticDefense);
-
- const rawDamage = evaluateDefaultDamageExpression(job, buckets, staticProfile);
+ const rawDamage = evaluateDamageExpressionForJob(job, buckets, staticProfile);
const uncappedKills = Math.max(0, rawDamage);
- const kills = options.capToDefenderTroops === false ? uncappedKills : Math.min(defenderTroops, uncappedKills);
- return recording.finish({ job, factors: buckets.factors, staticOffense, staticDefense, armyTerm, rawDamage, kills });
+ const kills = options.capToTakerTroops === false ? uncappedKills : Math.min(takerTroops, uncappedKills);
+ return recording.finish({ job, factors: buckets.factors, armyTerm, rawDamage, kills });
}
function applyBucketEffects(
@@ -178,10 +170,7 @@
): number {
const appliedValuePct = selected.getCurrentValuePct(round);
const bucketIndex = selected.bucketIndex;
- const update = BUCKET_UPDATE_BY_INDEX[bucketIndex];
- if (update === 0) buckets.factors[bucketIndex] = Math.max(0, appliedValuePct);
- else if (update === 1) buckets.factors[bucketIndex] *= 1 + appliedValuePct / 100;
- else buckets.factors[bucketIndex] += appliedValuePct / 100;
+ applyBucketValue(buckets.factors, bucketIndex, appliedValuePct, BUCKET_UPDATE_BY_INDEX);
recording.recordModifier(selected, appliedValuePct);
return appliedValuePct;
}
@@ -223,17 +212,59 @@
}
}
-function factorTerm(bucket: AtomicBucket): DamageFactorTerm {
- const definition = BUCKET_DEFINITIONS[bucket];
- return {
- id: bucket,
- bucket: BUCKET_IDS[bucket],
- bucketName: bucket,
+function compileDamageTerms(definitions: readonly (BucketSpec | AggregateBucketSpec)[]): DamageFactorTerm[] {
+ return definitions.map((definition, bucket) => ({
+ bucket,
+ role: "role" in definition ? definition.role : undefined,
placement: definition.placement,
appliesTo: definition.appliesTo
+ }));
+}
+
+function compileDamageExpression(
+ terms: DamageFactorTerm[],
+ selection: {
+ role?: BucketRole;
+ kind?: DamageJob["kind"];
+ }
+): CompiledDamageExpression {
+ const selected = terms
+ .filter((term) => selection.role === undefined || term.role === selection.role)
+ .filter((term) => selection.kind === undefined || !term.appliesTo || term.appliesTo === selection.kind);
+ return {
+ numeratorSlots: Int32Array.from(selected.filter((term) => term.placement === "numerator").map((term) => term.bucket)),
+ denominatorSlots: Int32Array.from(selected.filter((term) => term.placement === "denominator").map((term) => term.bucket))
};
}
+function compileBucketUpdates(definitions: readonly DamageAggregationBucketSpec[]): Uint8Array {
+ return Uint8Array.from(
+ definitions.map(({ update, valueType }) =>
+ valueType === "factor" ? 3 : update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2
+ )
+ );
+}
+
+function applyBucketValue(factors: Float64Array, bucket: number, value: number, updates: Uint8Array): void {
+ const update = updates[bucket];
+ if (update === 0) factors[bucket] = rawBucketFactor(value);
+ else if (update === 1) factors[bucket] *= pctBucketFactor(value);
+ else if (update === 2) factors[bucket] += pctBucketDelta(value);
+ else factors[bucket] = value;
+}
+
+function applyDynamicDamageBucketValue(buckets: NumericDamageBuckets, bucket: AtomicBucket, value: number): number {
+ const bucketIndex = BUCKET_IDS[bucket];
+ applyBucketValue(buckets.factors, bucketIndex, value, BUCKET_UPDATE_BY_INDEX);
+ return buckets.factors[bucketIndex];
+}
+
+function multiplySlots(initial: number, factors: Float64Array, slots: Int32Array): number {
+ let product = initial;
+ for (const slot of slots) product *= factors[slot];
+ return product;
+}
+
function createNumericDamageBuckets(): NumericDamageBuckets {
const factors = new Float64Array(ATOMIC_BUCKETS.length);
factors.fill(1);
@@ -244,61 +275,64 @@
return createNumericDamageBuckets();
}
+/** Creates neutral factors for the closed/static bucket pool. */
+export function createStaticDamageBucketFactors(): StaticDamageBucketFactors {
+ const factors = new Float64Array(STATIC_BUCKETS.length);
+ factors.fill(1);
+ return factors;
+}
+
+/** Populates a static bucket with exactly the same metadata-driven update rule as a live bucket. */
+export function applyStaticDamageBucketValue(
+ factors: StaticDamageBucketFactors,
+ bucket: StaticDamageBucket,
+ value: number
+): number {
+ const bucketIndex = STATIC_BUCKET_IDS[bucket];
+ applyBucketValue(factors, bucketIndex, value, STATIC_BUCKET_UPDATE_BY_INDEX);
+ return factors[bucketIndex];
+}
+
+/** Selects one role's static buckets as an ordinary input to the standard evaluator. */
+export function staticDamageBucketSet(factors: StaticDamageBucketFactors, role: BucketRole): DamageBucketSet {
+ return { factors, expression: STATIC_EXPRESSIONS[role] };
+}
+
function resetDamageScratch(buckets: DamageScratch): DamageScratch {
buckets.factors.fill(1);
return buckets;
}
-function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): number {
- if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, staticProfile);
- const factors = buckets.factors;
- let numerator = 1;
- for (const slot of DEFAULT_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];
- let denominator = 1;
- for (const slot of DEFAULT_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];
- return numerator / denominator;
+/** Pure, composable reduction of bucket sets to an ordinary one-value bucket set. */
+export function evaluateDamageExpression(...inputs: DamageBucketSet[]): DamageBucketSet {
+ let factor = 1;
+ for (const input of inputs) {
+ const numerator = multiplySlots(1, input.factors, input.expression.numeratorSlots);
+ const denominator = multiplySlots(1, input.factors, input.expression.denominatorSlots);
+ factor *= numerator / denominator;
+ }
+ return createEvaluatedDamageBucket(factor);
+}
+
+function createEvaluatedDamageBucket(factor: number): DamageBucketSet {
+ const factors = new Float64Array(1);
+ applyBucketValue(factors, 0, factor, EVALUATED_BUCKET_UPDATE_BY_INDEX);
+ return { factors, expression: EVALUATED_EXPRESSION };
}
-function evaluateProfiledDamageExpression(
+function evaluateDamageExpressionForJob(
job: DamageJob,
buckets: NumericDamageBuckets,
staticProfile: StaticDamageProfile
): number {
- const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];
- const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];
- if (!offense.playerFactorsValid || !defense.playerFactorsValid) validateProfiledStaticFactors(job, staticProfile);
- const factors = buckets.factors;
- let numerator =
- factors[TROOPS_COUNT_INDEX] *
- factors[SOURCE_EXTRA_SKILL_INDEX] *
- offense.factor *
- defense.factor;
- for (const slot of PROFILED_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];
- let denominator = 100;
- for (const slot of PROFILED_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];
- return numerator / denominator;
-}
-
-function validateProfiledStaticFactors(job: DamageJob, staticProfile: StaticDamageProfile): void {
- validateProfiledPctFactor(job, "player.attacker.attack", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.attack");
- validateProfiledPctFactor(job, "player.attacker.lethality", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.lethality");
- validateProfiledPctFactor(job, "player.defender.health", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.health");
- validateProfiledPctFactor(job, "player.defender.defense", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.defense");
-}
-
-function validateProfiledPctFactor(job: DamageJob, groupId: string, entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): void {
- const term = entry.buckets[bucket];
- const totalPct = term?.totalPct ?? 0;
- const factor = 1 + totalPct / 100;
- if (factor > 0) return;
- throw new DamageAggregationError({
- groupId,
- round: job.round,
- jobId: job.id,
- netPct: totalPct,
- factor,
- contributors: term?.contributors ?? []
- });
+ const dealerFactor = staticProfile[job.dealerSide][job.dealerUnit].dealerFactor;
+ const takerFactor = staticProfile[job.takerSide][job.takerUnit].takerFactor;
+ return evaluateDamageExpression(
+ { factors: buckets.factors, expression: DYNAMIC_EXPRESSIONS[job.kind] },
+ dealerFactor,
+ takerFactor,
+ DAMAGE_SCALE_BUCKET
+ ).factors[0];
}
function totalTroops(troops: Record<UnitType, number>): number {
diff --git a/simulator/src/damageBuckets.ts b/simulator/src/damageBuckets.ts
--- a/simulator/src/damageBuckets.ts
+++ b/simulator/src/damageBuckets.ts
@@ -1,81 +1,98 @@
import type { DamageKind } from "./types";
-export type BucketRole = "attacker" | "defender";
-export type BucketValueType = "raw" | "pct";
+export type BucketRole = "dealer" | "taker";
+export type BucketValueType = "raw" | "pct" | "factor";
export type BucketUpdate = "assign_factor" | "add_pct_factor" | "multiply_pct_factor";
export type BucketPlacement = "numerator" | "denominator";
-type BucketFamily = "troops" | "player" | "passive" | "active" | "type" | "source";
+type BucketFamily = "troops" | "player" | "passive" | "active" | "type" | "source" | "aggregate";
-interface BucketSpec {
+export interface DamageAggregationBucketSpec {
path: string;
- family: BucketFamily;
- role: BucketRole;
valueType: BucketValueType;
update: BucketUpdate;
placement: BucketPlacement;
appliesTo?: DamageKind;
}
+export interface BucketSpec extends DamageAggregationBucketSpec {
+ family: Exclude<BucketFamily, "aggregate">;
+ role: BucketRole;
+}
+
+export interface AggregateBucketSpec extends DamageAggregationBucketSpec {
+ family: "aggregate";
+}
+
+// The result of evaluating any bucket set is represented as this ordinary one-value bucket.
+// It has no dealer/taker role because its input set has already resolved that distinction.
+export const EVALUATED_DAMAGE_BUCKET = {
+ path: "aggregate.factor",
+ family: "aggregate",
+ valueType: "factor",
+ update: "assign_factor",
+ placement: "numerator"
+} as const satisfies AggregateBucketSpec;
+
// Closed pools: every contributor is known while preparing the battle, so these
// buckets are aggregated once into StaticDamageProfile factors.
export const STATIC_BUCKETS = [
- { path: "troops.baseAttack", family: "troops", role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
- { path: "troops.baseLethality", family: "troops", role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
- { path: "troops.baseHealth", family: "troops", role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" },
- { path: "troops.baseDefense", family: "troops", role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" },
- { path: "player.attack", family: "player", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "player.lethality", family: "player", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "player.health", family: "player", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "player.defense", family: "player", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.attack.up", family: "passive", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "passive.attack.down", family: "passive", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.lethality.up", family: "passive", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "passive.lethality.down", family: "passive", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.health.up", family: "passive", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.health.down", family: "passive", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "passive.defense.up", family: "passive", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "passive.defense.down", family: "passive", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
+ { path: "troops.baseAttack", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },
+ { path: "troops.baseLethality", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },
+ { path: "troops.baseHealth", family: "troops", role: "taker", valueType: "raw", update: "assign_factor", placement: "denominator" },
+ { path: "troops.baseDefense", family: "troops", role: "taker", valueType: "raw", update: "assign_factor", placement: "denominator" },
+ { path: "player.attack", family: "player", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "player.lethality", family: "player", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "player.health", family: "player", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "player.defense", family: "player", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "passive.attack.up", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "passive.attack.down", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "passive.lethality.up", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "passive.lethality.down", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "passive.health.up", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "passive.health.down", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "passive.defense.up", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "passive.defense.down", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
] as const satisfies readonly BucketSpec[];
// Open pools: runtime effects can feed these buckets while the battle is running.
// Array order is the numeric slot order used by the fast damage scratch.
export const DYNAMIC_BUCKETS = [
- { path: "active.hero.attack.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.attack.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.damage.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.damage.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.damageTaken.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.damageTaken.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.defense.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.defense.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.health.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.hero.health.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.lethality.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.hero.lethality.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.attack.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.attack.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.damage.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.damage.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.damageTaken.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.damageTaken.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.defense.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.defense.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.health.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "active.troop.health.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.lethality.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- { path: "active.troop.lethality.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- { path: "source.extraSkill", family: "source", role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" },
- { path: "troops.count", family: "troops", role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
- { path: "type.all.damage.down", family: "type", role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" },
- { path: "type.all.damage.up", family: "type", role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },
- { path: "type.normal.damage.down", family: "type", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },
- { path: "type.normal.damage.up", family: "type", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
- { path: "type.normal.damageTaken.down", family: "type", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },
- { path: "type.normal.damageTaken.up", family: "type", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
- { path: "type.skill.damage.down", family: "type", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },
- { path: "type.skill.damage.up", family: "type", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },
- { path: "type.skill.damageTaken.down", family: "type", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },
- { path: "type.skill.damageTaken.up", family: "type", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" }
+ { path: "active.hero.attack.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.hero.attack.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.hero.damage.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.hero.damage.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.hero.damageTaken.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.hero.damageTaken.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.hero.defense.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.hero.defense.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.hero.health.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.hero.health.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.hero.lethality.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.hero.lethality.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.troop.attack.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.troop.attack.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.troop.damage.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.troop.damage.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.troop.damageTaken.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.troop.damageTaken.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.troop.defense.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.troop.defense.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.troop.health.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "active.troop.health.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.troop.lethality.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ { path: "active.troop.lethality.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ { path: "source.extraSkill", family: "source", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" },
+ { path: "troops.count", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },
+ { path: "type.all.damage.down", family: "type", role: "dealer", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" },
+ { path: "type.all.damage.up", family: "type", role: "dealer", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },
+ { path: "type.normal.damage.down", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },
+ { path: "type.normal.damage.up", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
+ { path: "type.normal.damageTaken.down", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },
+ { path: "type.normal.damageTaken.up", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
+ { path: "type.skill.damage.down", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },
+ { path: "type.skill.damage.up", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },
+ { path: "type.skill.damageTaken.down", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },
+ { path: "type.skill.damageTaken.up", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" }
] as const satisfies readonly BucketSpec[];
export type StaticBucketSpec = (typeof STATIC_BUCKETS)[number];
@@ -85,10 +102,18 @@
export type BucketName = AtomicBucket;
export type StaticRawBucket = Extract<StaticBucketSpec, { valueType: "raw" }>["path"];
export type StaticPlayerBucket = Extract<StaticBucketSpec, { family: "player" }>["path"];
-export type StaticPassiveBucket = Extract<StaticBucketSpec, { family: "passive" }>["path"];
+export type PassiveBucket = Extract<StaticBucketSpec, { family: "passive" }>["path"];
export type BucketPath = StaticDamageBucket | AtomicBucket;
-type BucketDefinitionFields = Omit<BucketSpec, "path">;
+type NativeBucketSpec = StaticBucketSpec | DynamicBucketSpec;
+type BucketDefinitionFields = {
+ family: NativeBucketSpec["family"];
+ role: NativeBucketSpec["role"];
+ valueType: NativeBucketSpec["valueType"];
+ update: NativeBucketSpec["update"];
+ placement: NativeBucketSpec["placement"];
+ appliesTo?: DamageKind;
+};
export type BucketDefinition =
| (BucketDefinitionFields & { path: StaticDamageBucket; phase: "static" })
| (BucketDefinitionFields & { path: AtomicBucket; phase: "dynamic" });
@@ -97,9 +122,9 @@
export const DYNAMIC_EFFECT_BUCKETS = DYNAMIC_BUCKETS
.filter((bucket) => bucket.family === "active" || bucket.family === "type")
.map((bucket) => bucket.path) as AtomicBucket[];
-export const STATIC_PASSIVE_BUCKETS = STATIC_BUCKETS
+export const PASSIVE_BUCKETS = STATIC_BUCKETS
.filter((bucket): bucket is Extract<StaticBucketSpec, { family: "passive" }> => bucket.family === "passive")
- .map((bucket) => bucket.path) as StaticPassiveBucket[];
+ .map((bucket) => bucket.path) as PassiveBucket[];
const ALL_BUCKET_DEFINITIONS: BucketDefinition[] = [
...STATIC_BUCKETS.map((bucket) => ({ ...bucket, phase: "static" as const })),
@@ -113,3 +138,27 @@
export function bucketDefinition(path: string): BucketDefinition | undefined {
return BUCKET_DEFINITIONS[path as BucketPath];
}
+
+export function isPassiveBucket(bucket: string): bucket is PassiveBucket {
+ const definition = BUCKET_DEFINITIONS[bucket as PassiveBucket];
+ return definition?.phase === "static" && definition.family === "passive";
+}
+
+export function passiveBucketRole(bucket: string): BucketRole | undefined {
+ return isPassiveBucket(bucket) ? BUCKET_DEFINITIONS[bucket].role : undefined;
+}
+
+// The one and only bucket-value conversion. A raw bucket contributes its value clamped
+// non-negative; a pct bucket contributes its percentage delta or the corresponding factor.
+// Every consumer (static profile, dynamic scratch, recorders) must use these.
+export function rawBucketFactor(raw: number): number {
+ return Math.max(0, raw);
+}
+
+export function pctBucketDelta(totalPct: number): number {
+ return totalPct / 100;
+}
+
+export function pctBucketFactor(totalPct: number): number {
+ return 1 + pctBucketDelta(totalPct);
+}
diff --git a/simulator/src/effectIndex.test.ts b/simulator/src/effectIndex.test.ts
--- a/simulator/src/effectIndex.test.ts
+++ b/simulator/src/effectIndex.test.ts
@@ -38,10 +38,10 @@
attacker: { infantry: 100, lancer: 0, marksman: 0 },
defender: { infantry: 0, lancer: 100, marksman: 0 }
},
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer"
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer"
};
assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job)].flatMap((group) => group.effects), [effect]);
@@ -128,9 +128,9 @@
attacker: { infantry: 100, lancer: 0, marksman: 0 },
defender: { infantry: 0, lancer: 100, marksman: 0 }
},
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer"
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer"
};
}
diff --git a/simulator/src/effectIndex.ts b/simulator/src/effectIndex.ts
--- a/simulator/src/effectIndex.ts
+++ b/simulator/src/effectIndex.ts
@@ -86,17 +86,17 @@
}
export function damageJobSlot(job: DamageJob): number {
- return damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit);
+ return damageJobShapeSlot(job.kind, job.dealerSide, job.dealerUnit, job.takerSide, job.takerUnit);
}
export function damageJobShapeSlot(
jobKind: DamageKind,
- attackerSide: SideId,
- attackerUnit: UnitType,
- defenderSide: SideId,
- defenderUnit: UnitType
+ dealerSide: SideId,
+ dealerUnit: UnitType,
+ takerSide: SideId,
+ takerUnit: UnitType
): number {
- return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);
+ return (((kindIndex(jobKind) * 2 + sideIndex(dealerSide)) * 3 + unitIndex(dealerUnit)) * 2 + sideIndex(takerSide)) * 3 + unitIndex(takerUnit);
}
const JOB_SHAPE_CACHE = new Map<number, Uint8Array>();
@@ -133,7 +133,7 @@
for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {
for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {
slots.push(
- definition.role === "attacker"
+ definition.role === "dealer"
? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
: damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)
);
diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts
--- a/simulator/src/effects.ts
+++ b/simulator/src/effects.ts
@@ -57,8 +57,8 @@
if (!intent) return true;
const selectors = compiledTriggerForSkill(skill);
return (
- triggerScopeMatches(selectors.source, intent.attackerSide, intent.attackerUnit) &&
- triggerScopeMatches(selectors.target, intent.defenderSide, intent.defenderUnit)
+ triggerScopeMatches(selectors.source, intent.dealerSide, intent.dealerUnit) &&
+ triggerScopeMatches(selectors.target, intent.takerSide, intent.takerUnit)
);
}
@@ -315,10 +315,10 @@
throw new Error('effect units.applies_vs cannot be "all"; use "any" for an unrestricted usage gate');
}
if ((value === "trigger.source" || value === "trigger") && attackIntent) {
- return { side: attackIntent.attackerSide, units: unitMask(attackIntent.attackerUnit) };
+ return { side: attackIntent.dealerSide, units: unitMask(attackIntent.dealerUnit) };
}
if ((value === "trigger.target" || value === "target") && attackIntent) {
- return { side: attackIntent.defenderSide, units: unitMask(attackIntent.defenderUnit) };
+ return { side: attackIntent.takerSide, units: unitMask(attackIntent.takerUnit) };
}
if (ownerSide && isRelationQualifiedSelector(value)) {
const selector = parseTriggerSelector(value, "self");
diff --git a/simulator/src/index.ts b/simulator/src/index.ts
--- a/simulator/src/index.ts
+++ b/simulator/src/index.ts
@@ -1,12 +1,11 @@
export { buildSimulatorConfig, loadSimulatorConfig } from "./config";
export type { RawSimulatorConfig } from "./config";
-export { bearFighterInput, prepareBattle, runPrepared, simulateBearBattle, signedRemainingScore } from "./simulator";
+export { prepareBattle, runPrepared, simulateBattles } from "./simulator";
export type { CompiledBattle } 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";
export type { CalibrationCaseComparison, CalibrationComparison, CalibrationComparisonRow } from "./calibration";
export type * from "./types";
diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts
--- a/simulator/src/recorder.ts
+++ b/simulator/src/recorder.ts
@@ -1,7 +1,8 @@
import type { DamageResult } from "./damage";
-import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket, type StaticDamageBucket } from "./damageBuckets";
+import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, pctBucketFactor, rawBucketFactor, type AtomicBucket, type StaticDamageBucket, type StaticPlayerBucket, type StaticRawBucket } from "./damageBuckets";
import { sourceLabel } from "./effects";
-import type { StaticDamageProfileEntry } from "./staticDamageProfile";
+import { selectPassiveContributions, unitBaseStats, unitPlayerBonuses, type PassiveContribution } from "./staticDamageProfile";
+import { UNIT_TYPES } from "./types";
import type {
ActiveEffect,
AppliedEffect,
@@ -35,8 +36,6 @@
export interface DamageRecordingResult {
job: DamageJob;
factors: Float64Array;
- staticOffense: StaticDamageProfileEntry;
- staticDefense: StaticDamageProfileEntry;
armyTerm: number;
rawDamage: number;
kills: number;
@@ -46,15 +45,46 @@
export interface DamageJobRecorder {
recordModifier(effect: ActiveEffect, valuePct: number): void;
recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): void;
- recordStaticProfile(entry: StaticDamageProfileEntry): void;
finish(result: DamageRecordingResult): DamageResult;
}
+// Recorder-side description of how the static damage factors were assembled: the same selection
+// the numeric profile uses (via selectPassiveContributions) expanded into per-bucket terms with
+// contributors. The battle runtime never sees these shapes; fast mode never builds them.
+interface StaticProfileTerm {
+ raw?: number;
+ totalPct?: number;
+ factor: number;
+ contributors: DamageBucketTrace["contributors"];
+}
+
+interface StaticProfileEntry {
+ buckets: Partial<Record<StaticDamageBucket, StaticProfileTerm>>;
+}
+
+interface StaticProfileDescription {
+ dealer: Record<SideId, Record<UnitType, StaticProfileEntry>>;
+ taker: Record<SideId, Record<UnitType, StaticProfileEntry>>;
+}
+
+// Per-(side, unit) applied-effect fragments prebuilt from the static profile description; the
+// per-job recorders splice them into each damage event instead of re-walking the description.
+interface StaticAppliedIndex {
+ dealer: Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;
+ taker: Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;
+}
+
/**
* Observes the battle without deciding combat behavior. The concrete recorder is selected once,
* before the round loop, and every simulation path emits the same observations to it.
*/
export interface BattleRecorder {
+ /**
+ * Receives the battle's fighters and setup effects before any damage job runs. Recorders that
+ * report static contributions build their description of the static profile here; the null
+ * recorder does nothing, so fast mode pays nothing.
+ */
+ recordStaticProfile(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): void;
startDamageJob(): DamageJobRecorder;
recordSkillTriggerAttempt(skill: ResolvedSkill): void;
recordSkillTriggered(skill: ResolvedSkill): void;
@@ -80,7 +110,6 @@
const NULL_DAMAGE_JOB_RECORDER: DamageJobRecorder = {
recordModifier() {},
recordRejected() {},
- recordStaticProfile() {},
finish({ kills }) {
return { kills };
}
@@ -95,6 +124,7 @@
return NULL_DAMAGE_JOB_RECORDER;
}
+ recordStaticProfile() {}
recordBattleOrder() {}
recordSkillTriggerAttempt() {}
recordSkillTriggered() {}
@@ -122,6 +152,7 @@
export class BasicInfoRecorder implements BattleRecorder {
readonly attacks: AttackOutcome[] = [];
+ protected staticApplied?: StaticAppliedIndex;
protected readonly orderEvents = new Map<string, AppliedEffect>();
protected readonly extraAttackEvents = new Map<string, AppliedEffect[]>();
protected readonly skillReports: Record<SideId, Map<string, SkillReportEntry>> = {
@@ -161,8 +192,19 @@
};
}
+ recordStaticProfile(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): void {
+ this.applyStaticDescription(buildStaticProfileDescription(fighters, setupEffects));
+ }
+
+ protected applyStaticDescription(description: StaticProfileDescription): void {
+ this.staticApplied = {
+ dealer: staticAppliedFragments(description.dealer),
+ taker: staticAppliedFragments(description.taker)
+ };
+ }
+
startDamageJob(): DamageJobRecorder {
- return new BasicDamageJobRecorder();
+ return new BasicDamageJobRecorder(this.staticApplied);
}
recordSkillTriggerAttempt(_skill: ResolvedSkill): void {}
@@ -222,10 +264,10 @@
jobId: `${intent.id}:cancelled`,
round: intent.round,
kind: "normal",
- attackerSide: intent.attackerSide,
- attackerUnit: intent.attackerUnit,
- defenderSide: intent.defenderSide,
- defenderUnit: intent.defenderUnit,
+ dealerSide: intent.dealerSide,
+ dealerUnit: intent.dealerUnit,
+ takerSide: intent.takerSide,
+ takerUnit: intent.takerUnit,
kills: 0,
counterDeltas: counterDeltas(intent, "normal_attack"),
appliedEffects: order
@@ -240,7 +282,7 @@
const sourceSkillReportKey = this.skillDamageKeys.get(job.id);
this.skillDamageKeys.delete(job.id);
if (job.kind === "skill" && sourceSkillReportKey && result.kills > 0) {
- const report = this.skillReports[job.attackerSide].get(sourceSkillReportKey);
+ const report = this.skillReports[job.dealerSide].get(sourceSkillReportKey);
if (report) report.skillKills += result.kills;
}
const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";
@@ -254,10 +296,10 @@
kind: job.kind,
sourceEffectId: job.sourceEffectId,
sourceSkillReportKey,
- attackerSide: job.attackerSide,
- attackerUnit: job.attackerUnit,
- defenderSide: job.defenderSide,
- defenderUnit: job.defenderUnit,
+ dealerSide: job.dealerSide,
+ dealerUnit: job.dealerUnit,
+ takerSide: job.takerSide,
+ takerUnit: job.takerUnit,
kills: result.kills,
counterDeltas: counterDeltas(job, cause),
appliedEffects: mergeAppliedEffects(result.appliedEffects, order, extras),
@@ -282,14 +324,20 @@
export class FullTraceRecorder extends BasicInfoRecorder {
private readonly rounds: BattleTrace["rounds"] = [];
private readonly roundJobs: DamageJob[] = [];
+ private staticDescription?: StaticProfileDescription;
constructor(
fighters: ResolvedFighter[],
private readonly makeResolved: () => BattleTrace["resolved"]
) { super(fighters); }
+ protected override applyStaticDescription(description: StaticProfileDescription): void {
+ super.applyStaticDescription(description);
+ this.staticDescription = description;
+ }
+
override startDamageJob(): DamageJobRecorder {
- return new FullDamageJobRecorder();
+ return new FullDamageJobRecorder(this.staticApplied, this.staticDescription);
}
override recordSkillTriggerAttempt(skill: ResolvedSkill): void {
@@ -318,6 +366,8 @@
class BasicDamageJobRecorder implements DamageJobRecorder {
protected readonly appliedEffects: DamageEquationTrace["appliedEffects"] = [];
+ constructor(private readonly staticApplied?: StaticAppliedIndex) {}
+
recordModifier(effect: ActiveEffect, valuePct: number): void {
if (valuePct === 0) return;
const applied = {
@@ -336,27 +386,19 @@
recordRejected(_effect: ActiveEffect, _reason: RejectedEffectReason): void {}
- recordStaticProfile(entry: StaticDamageProfileEntry): void {
- for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {
- if (!bucket.startsWith("passive.") || !term) continue;
- for (const contributor of term.contributors) {
- this.appliedEffects.push({
- kind: "modifier",
- activeEffectId: contributor.effectId,
- effectId: contributor.effectId,
- bucket,
- valuePct: contributor.valuePct,
- source: contributor.source,
- sourceSide: contributor.sourceSide,
- stackingKey: contributor.stackingKey,
- sameEffectStacking: contributor.sameEffectStacking
- });
- }
- }
+ finish({ job, kills }: DamageRecordingResult): DamageResult {
+ return { kills, appliedEffects: this.appliedEffectsFor(job) };
}
- finish({ kills }: DamageRecordingResult): DamageResult {
- return { kills, appliedEffects: this.appliedEffects };
+ // Dynamic modifiers first, then the prebuilt static passive fragments for the job's
+ // participants — the same order the per-job walk used to produce.
+ protected appliedEffectsFor(job: DamageJob): DamageEquationTrace["appliedEffects"] {
+ const statics = this.staticApplied;
+ if (!statics) return this.appliedEffects;
+ const dealer = statics.dealer[job.dealerSide][job.dealerUnit];
+ const taker = statics.taker[job.takerSide][job.takerUnit];
+ if (dealer.length === 0 && taker.length === 0) return this.appliedEffects;
+ return [...this.appliedEffects, ...dealer, ...taker];
}
}
@@ -367,6 +409,11 @@
);
private readonly rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];
+ constructor(
+ staticApplied?: StaticAppliedIndex,
+ private readonly staticDescription?: StaticProfileDescription
+ ) { super(staticApplied); }
+
override recordModifier(effect: ActiveEffect, valuePct: number): void {
this.contributors[effect.bucketIndex]?.push({
effectId: effect.source.effectId ?? effect.id,
@@ -386,18 +433,21 @@
override finish(result: DamageRecordingResult): DamageResult {
const basic = super.finish(result);
- const staticEntries = [result.staticOffense, result.staticDefense];
+ const job = result.job;
+ const staticEntries = this.staticDescription
+ ? [this.staticDescription.dealer[job.dealerSide][job.dealerUnit], this.staticDescription.taker[job.takerSide][job.takerUnit]]
+ : [];
return {
...basic,
trace: {
roundStartTroops: {
- attacker: { ...result.job.roundStartTroops.attacker },
- defender: { ...result.job.roundStartTroops.defender }
+ attacker: { ...job.roundStartTroops.attacker },
+ defender: { ...job.roundStartTroops.defender }
},
armyTerm: result.armyTerm,
atomicBuckets: toTraceBuckets(result.factors, this.contributors, staticEntries),
- aggregationGroups: buildAggregationGroups(result.job, result.factors, this.contributors, staticEntries),
- appliedEffects: this.appliedEffects,
+ aggregationGroups: buildAggregationGroups(job, result.factors, this.contributors, staticEntries),
+ appliedEffects: basic.appliedEffects ?? [],
rejectedEffects: this.rejectedEffects,
rawDamage: result.rawDamage,
finalKills: result.kills
@@ -422,11 +472,117 @@
};
}
+function buildStaticProfileDescription(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): StaticProfileDescription {
+ const description: StaticProfileDescription = {
+ dealer: {
+ attacker: buildSideEntries(fighters.attacker, "dealer"),
+ defender: buildSideEntries(fighters.defender, "dealer")
+ },
+ taker: {
+ attacker: buildSideEntries(fighters.attacker, "taker"),
+ defender: buildSideEntries(fighters.defender, "taker")
+ }
+ };
+ for (const contribution of selectPassiveContributions(setupEffects)) {
+ addPassiveTerm(description[contribution.role][contribution.side][contribution.unit], contribution);
+ }
+ return description;
+}
+
+function buildSideEntries(fighter: ResolvedFighter, role: "dealer" | "taker"): Record<UnitType, StaticProfileEntry> {
+ return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildStaticEntry(fighter, unit, role)])) as Record<UnitType, StaticProfileEntry>;
+}
+
+function buildStaticEntry(fighter: ResolvedFighter, unit: UnitType, role: "dealer" | "taker"): StaticProfileEntry {
+ const stats = unitBaseStats(fighter, unit);
+ const bonuses = unitPlayerBonuses(fighter, unit);
+ const buckets: StaticProfileEntry["buckets"] = {};
+ if (role === "dealer") {
+ setRawTerm(buckets, "troops.baseAttack", stats.attack);
+ setRawTerm(buckets, "troops.baseLethality", stats.lethality);
+ setPlayerTerm(buckets, "player.attack", bonuses.attack);
+ setPlayerTerm(buckets, "player.lethality", bonuses.lethality);
+ } else {
+ setRawTerm(buckets, "troops.baseHealth", stats.health);
+ setRawTerm(buckets, "troops.baseDefense", stats.defense);
+ setPlayerTerm(buckets, "player.health", bonuses.health);
+ setPlayerTerm(buckets, "player.defense", bonuses.defense);
+ }
+ return { buckets };
+}
+
+function setRawTerm(buckets: StaticProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {
+ buckets[bucket] = { raw, factor: rawBucketFactor(raw), contributors: [] };
+}
+
+function setPlayerTerm(buckets: StaticProfileEntry["buckets"], bucket: StaticPlayerBucket, valuePct: number): void {
+ buckets[bucket] = {
+ totalPct: valuePct,
+ factor: pctBucketFactor(valuePct),
+ contributors: [{ effectId: `input:${bucket.slice("player.".length)}`, source: "input_stats", valuePct, bucket }]
+ };
+}
+
+function addPassiveTerm(entry: StaticProfileEntry, contribution: PassiveContribution): void {
+ const { bucket, effect, valuePct } = contribution;
+ const contributor = {
+ effectId: effect.source.effectId ?? effect.id,
+ source: sourceLabel(effect),
+ sourceSide: effect.ownerSide,
+ valuePct,
+ bucket,
+ stackingKey: effect.stackingKey,
+ sameEffectStacking: effect.sameEffectStacking
+ };
+ const existing = entry.buckets[bucket];
+ if (existing) {
+ existing.totalPct = (existing.totalPct ?? 0) + valuePct;
+ existing.factor = pctBucketFactor(existing.totalPct);
+ existing.contributors.push(contributor);
+ } else {
+ entry.buckets[bucket] = { totalPct: valuePct, factor: pctBucketFactor(valuePct), contributors: [contributor] };
+ }
+}
+
+function staticAppliedFragments(
+ entries: Record<SideId, Record<UnitType, StaticProfileEntry>>
+): Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>> {
+ return Object.fromEntries(
+ (Object.entries(entries) as Array<[SideId, Record<UnitType, StaticProfileEntry>]>).map(([side, units]) => [
+ side,
+ Object.fromEntries(
+ (Object.entries(units) as Array<[UnitType, StaticProfileEntry]>).map(([unit, entry]) => [unit, passiveAppliedEffects(entry)])
+ )
+ ])
+ ) as Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;
+}
+
+function passiveAppliedEffects(entry: StaticProfileEntry): DamageEquationTrace["appliedEffects"] {
+ const applied: DamageEquationTrace["appliedEffects"] = [];
+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticProfileEntry["buckets"][StaticDamageBucket]]>) {
+ if (!bucket.startsWith("passive.") || !term) continue;
+ for (const contributor of term.contributors) {
+ applied.push({
+ kind: "modifier",
+ activeEffectId: contributor.effectId,
+ effectId: contributor.effectId,
+ bucket,
+ valuePct: contributor.valuePct,
+ source: contributor.source,
+ sourceSide: contributor.sourceSide,
+ stackingKey: contributor.stackingKey,
+ sameEffectStacking: contributor.sameEffectStacking
+ });
+ }
+ }
+ return applied;
+}
+
function buildAggregationGroups(
job: DamageJob,
factors: Float64Array,
contributors: DamageBucketTrace["contributors"][],
- staticEntries: StaticDamageProfileEntry[]
+ staticEntries: StaticProfileEntry[]
): Record<string, DamageAggregationGroupTrace> {
const groups: Record<string, DamageAggregationGroupTrace> = {};
addStaticAggregationGroups(groups, staticEntries);
@@ -458,34 +614,34 @@
function addStaticAggregationGroups(
groups: Record<string, DamageAggregationGroupTrace>,
- staticEntries: StaticDamageProfileEntry[]
+ staticEntries: StaticProfileEntry[]
): void {
- const offense = staticEntries[0];
- const defense = staticEntries[1];
- if (!offense || !defense) return;
- addStaticRawGroup(groups, "troops.baseAttack", "numerator", offense, "troops.baseAttack");
- addStaticRawGroup(groups, "troops.baseLethality", "numerator", offense, "troops.baseLethality");
- addStaticPctGroup(groups, "player.attacker.attack", "numerator", offense, "player.attack");
- addStaticPctGroup(groups, "player.attacker.lethality", "numerator", offense, "player.lethality");
- addStaticPctGroup(groups, "passive.attacker.attack.up", "numerator", offense, "passive.attack.up");
- addStaticPctGroup(groups, "passive.attacker.lethality.up", "numerator", offense, "passive.lethality.up");
- addStaticPctGroup(groups, "passive.defender.health.down", "numerator", defense, "passive.health.down");
- addStaticPctGroup(groups, "passive.defender.defense.down", "numerator", defense, "passive.defense.down");
- addStaticRawGroup(groups, "troops.baseHealth", "denominator", defense, "troops.baseHealth");
- addStaticRawGroup(groups, "troops.baseDefense", "denominator", defense, "troops.baseDefense");
- addStaticPctGroup(groups, "player.defender.health", "denominator", defense, "player.health");
- addStaticPctGroup(groups, "player.defender.defense", "denominator", defense, "player.defense");
- addStaticPctGroup(groups, "passive.attacker.attack.down", "denominator", offense, "passive.attack.down");
- addStaticPctGroup(groups, "passive.attacker.lethality.down", "denominator", offense, "passive.lethality.down");
- addStaticPctGroup(groups, "passive.defender.health.up", "denominator", defense, "passive.health.up");
- addStaticPctGroup(groups, "passive.defender.defense.up", "denominator", defense, "passive.defense.up");
+ const dealer = staticEntries[0];
+ const taker = staticEntries[1];
+ if (!dealer || !taker) return;
+ addStaticRawGroup(groups, "troops.baseAttack", "numerator", dealer, "troops.baseAttack");
+ addStaticRawGroup(groups, "troops.baseLethality", "numerator", dealer, "troops.baseLethality");
+ addStaticPctGroup(groups, "player.dealer.attack", "numerator", dealer, "player.attack");
+ addStaticPctGroup(groups, "player.dealer.lethality", "numerator", dealer, "player.lethality");
+ addStaticPctGroup(groups, "passive.dealer.attack.up", "numerator", dealer, "passive.attack.up");
+ addStaticPctGroup(groups, "passive.dealer.lethality.up", "numerator", dealer, "passive.lethality.up");
+ addStaticPctGroup(groups, "passive.taker.health.down", "numerator", taker, "passive.health.down");
+ addStaticPctGroup(groups, "passive.taker.defense.down", "numerator", taker, "passive.defense.down");
+ addStaticRawGroup(groups, "troops.baseHealth", "denominator", taker, "troops.baseHealth");
+ addStaticRawGroup(groups, "troops.baseDefense", "denominator", taker, "troops.baseDefense");
+ addStaticPctGroup(groups, "player.taker.health", "denominator", taker, "player.health");
+ addStaticPctGroup(groups, "player.taker.defense", "denominator", taker, "player.defense");
+ addStaticPctGroup(groups, "passive.dealer.attack.down", "denominator", dealer, "passive.attack.down");
+ addStaticPctGroup(groups, "passive.dealer.lethality.down", "denominator", dealer, "passive.lethality.down");
+ addStaticPctGroup(groups, "passive.taker.health.up", "denominator", taker, "passive.health.up");
+ addStaticPctGroup(groups, "passive.taker.defense.up", "denominator", taker, "passive.defense.up");
}
function addStaticRawGroup(
groups: Record<string, DamageAggregationGroupTrace>,
id: string,
placement: GroupPlacement,
- entry: StaticDamageProfileEntry,
+ entry: StaticProfileEntry,
bucket: StaticDamageBucket
): void {
const term = entry.buckets[bucket];
@@ -494,7 +650,7 @@
mode: "raw",
placement,
inputBuckets: [bucket],
- factor: Math.max(0, term?.raw ?? 0),
+ factor: term?.factor ?? 0,
contributors: term?.contributors ?? []
};
}
@@ -503,18 +659,17 @@
groups: Record<string, DamageAggregationGroupTrace>,
id: string,
placement: GroupPlacement,
- entry: StaticDamageProfileEntry,
+ entry: StaticProfileEntry,
bucket: StaticDamageBucket
): void {
const term = entry.buckets[bucket];
- const totalPct = term?.totalPct ?? 0;
groups[id] = {
id,
mode: "sum_pct",
placement,
inputBuckets: [bucket],
- totalPct,
- factor: 1 + totalPct / 100,
+ totalPct: term?.totalPct ?? 0,
+ factor: term?.factor ?? 1,
contributors: term?.contributors ?? []
};
}
@@ -522,7 +677,7 @@
function toTraceBuckets(
factors: Float64Array,
contributors: DamageBucketTrace["contributors"][],
- staticEntries: StaticDamageProfileEntry[]
+ staticEntries: StaticProfileEntry[]
): Record<string, DamageBucketTrace> {
const traced = Object.fromEntries(
ATOMIC_BUCKETS.map((bucket, index) => {
@@ -535,11 +690,11 @@
})
) as Record<string, DamageBucketTrace>;
for (const entry of staticEntries) {
- for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {
+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticProfileEntry["buckets"][StaticDamageBucket]]>) {
if (!term) continue;
traced[bucket] = term.raw !== undefined
- ? { raw: term.raw, factor: Math.max(0, term.raw), contributors: [...term.contributors] }
- : { totalPct: term.totalPct ?? 0, factor: 1 + (term.totalPct ?? 0) / 100, contributors: [...term.contributors] };
+ ? { raw: term.raw, factor: term.factor, contributors: [...term.contributors] }
+ : { totalPct: term.totalPct ?? 0, factor: term.factor, contributors: [...term.contributors] };
}
}
return traced;
@@ -555,12 +710,12 @@
}
function counterDeltas(
- attack: Pick<AttackIntent | DamageJob, "attackerSide" | "attackerUnit" | "defenderSide" | "defenderUnit">,
+ attack: Pick<AttackIntent | DamageJob, "dealerSide" | "dealerUnit" | "takerSide" | "takerUnit">,
cause: "normal_attack" | "extra_skill_attack"
): AttackOutcome["counterDeltas"] {
return [
- { side: attack.attackerSide, unit: attack.attackerUnit, counter: "attacks", by: 1, cause },
- { side: attack.defenderSide, unit: attack.defenderUnit, counter: "received_attacks", by: 1, cause }
+ { side: attack.dealerSide, unit: attack.dealerUnit, counter: "attacks", by: 1, cause },
+ { side: attack.takerSide, unit: attack.takerUnit, counter: "received_attacks", by: 1, cause }
];
}
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
@@ -6,7 +6,7 @@
import { loadSimulatorConfig } from "./config";
import { createSeededRng, chancePasses } from "./effects";
import { applyHeroGenerationStats, resolveFighter } from "./resolve";
-import { prepareBattle, runPrepared, simulateBearBattle, signedRemainingScore } from "./simulator";
+import { prepareBattle, runPrepared, simulateBattles, simulateBearBattle, signedRemainingScore } from "./simulator";
import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";
function runOnce(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}) {
@@ -37,6 +37,23 @@
assert.deepEqual(prepared.remaining, direct.remaining);
});
+test("simulateBattles returns the requested replicates and rejects invalid counts", () => {
+ const config = loadSimulatorConfig();
+ const input: BattleInput = {
+ seed: "replicates",
+ maxRounds: 1,
+ attacker: { troops: { infantry_t1: 1000 }, stats: {}, heroes: {} },
+ defender: { troops: { infantry_t1: 1000 }, stats: {}, heroes: {} }
+ };
+
+ const results = simulateBattles(input, config, { count: 3, mode: "fast" });
+
+ assert.equal(results.length, 3);
+ assert.ok(results.every((result) => result.attacks.length === 0));
+ assert.throws(() => simulateBattles(input, config, { count: 0 }), /positive integer/i);
+ assert.throws(() => simulateBattles(input, config, { count: 1.5 }), /positive integer/i);
+});
+
test("runPrepared returns structured result for a no-hero battle", () => {
const config = loadSimulatorConfig();
const result = runOnce(
@@ -100,7 +117,7 @@
})
);
- const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");
+ const attack = result.attacks.find((entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry");
assert.equal(attack?.round, 1);
assert.equal(attack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);
assert.equal(attack?.trace, undefined);
@@ -384,7 +401,7 @@
{ mode: "trace" }
);
- const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.attackerSide === "attacker");
+ const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.dealerSide === "attacker");
assert.equal(result.attacks.some((attack) => attack.kind === "skill"), false);
assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.kind === "skill"), false);
@@ -587,11 +604,11 @@
{ mode: "trace" }
);
- const lancerAttacks = result.attacks.filter((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "lancer");
- assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.defenderUnit, "marksman");
- assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.defenderUnit, "infantry");
+ const lancerAttacks = result.attacks.filter((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "lancer");
+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.takerUnit, "marksman");
+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.takerUnit, "infantry");
const round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));
- assert.equal(round4LancerAttack?.defenderUnit, "marksman");
+ assert.equal(round4LancerAttack?.takerUnit, "marksman");
assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);
});
@@ -763,7 +780,7 @@
})
);
- const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");
+ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.dealerSide === "attacker");
assert.equal(skillAttacks.length, 1);
assert.ok(skillAttacks[0].jobId.startsWith("r2:"));
});
@@ -805,7 +822,7 @@
);
const normalAttacks = result.attacks.filter(
- (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"
+ (attack) => attack.kind === "normal" && attack.dealerSide === "attacker"
);
assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);
assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
@@ -1064,7 +1081,7 @@
["r1:attacker:infantry:0:cancelled"]
);
assert.equal(
- result.attacks.some((attack) => attack.attackerSide === "defender" && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry" && attack.cancelReason === "no_attack"),
+ result.attacks.some((attack) => attack.dealerSide === "defender" && attack.takerSide === "attacker" && attack.takerUnit === "infantry" && attack.cancelReason === "no_attack"),
false
);
});
@@ -1106,7 +1123,7 @@
["r1:attacker:infantry:0:cancelled"]
);
assert.equal(
- result.attacks.some((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "infantry" && attack.cancelReason === "dodge"),
+ result.attacks.some((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "infantry" && attack.cancelReason === "dodge"),
false
);
});
@@ -1452,7 +1469,7 @@
{ mode: "trace" }
);
- const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");
+ const attack = result.attacks.find((entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry");
assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);
assert.equal(attack?.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
assert.deepEqual(
@@ -1505,7 +1522,7 @@
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.deepEqual(
- skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),
+ skillJobs.map((job) => [job.sourceEffectId, job.takerUnit]),
[
["hitLancer", "lancer"],
["hitMarksman", "marksman"]
@@ -1550,7 +1567,7 @@
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.deepEqual(
- skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),
+ skillJobs.map((job) => [job.sourceEffectId, job.takerUnit]),
[["hitAny", "lancer"]]
);
});
@@ -1660,7 +1677,7 @@
);
const defenderLosses = result.attacks
- .filter((attack) => attack.defenderSide === "defender" && attack.defenderUnit === "infantry")
+ .filter((attack) => attack.takerSide === "defender" && attack.takerUnit === "infantry")
.reduce((sum, attack) => sum + attack.kills, 0);
const doubleBlast = result.skillReport.attacker.find((entry) => entry.skillId === "DoubleBlast");
@@ -1705,12 +1722,12 @@
{ mode: "trace" }
);
const roundOneInfantryLosses = result.attacks
- .filter((attack) => attack.jobId.startsWith("r1:") && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry")
+ .filter((attack) => attack.jobId.startsWith("r1:") && attack.takerSide === "attacker" && attack.takerUnit === "infantry")
.map((attack) => attack.kills);
const roundTwo = result.trace?.rounds.find((round) => round.round === 2);
const defenderTargets = roundTwo?.intents
- .filter((intent) => intent.attackerSide === "defender")
- .map((intent) => intent.defenderUnit);
+ .filter((intent) => intent.dealerSide === "defender")
+ .map((intent) => intent.takerUnit);
assert.ok(roundOneInfantryLosses.every((kills) => !Number.isInteger(kills)));
assert.equal(roundOneInfantryLosses.reduce((sum, kills) => sum + kills, 0), 10);
@@ -1837,10 +1854,10 @@
assert.equal(normalJobs.length, 2);
assert.equal(skillJobs.length, 1);
- assert.equal(skillJobs[0]?.attackerSide, normalJobs[0]?.attackerSide);
- assert.equal(skillJobs[0]?.attackerUnit, normalJobs[0]?.attackerUnit);
- assert.equal(skillJobs[0]?.defenderSide, normalJobs[0]?.defenderSide);
- assert.equal(skillJobs[0]?.defenderUnit, normalJobs[0]?.defenderUnit);
+ assert.equal(skillJobs[0]?.dealerSide, normalJobs[0]?.dealerSide);
+ assert.equal(skillJobs[0]?.dealerUnit, normalJobs[0]?.dealerUnit);
+ assert.equal(skillJobs[0]?.takerSide, normalJobs[0]?.takerSide);
+ assert.equal(skillJobs[0]?.takerUnit, normalJobs[0]?.takerUnit);
});
test("cancelled normal attacks do not consume extra skill attack uses", () => {
@@ -1940,7 +1957,7 @@
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.deepEqual(
- skillJobs.map((job) => `${job.attackerSide}.${job.attackerUnit}->${job.defenderSide}.${job.defenderUnit}`),
+ skillJobs.map((job) => `${job.dealerSide}.${job.dealerUnit}->${job.takerSide}.${job.takerUnit}`),
["attacker.marksman->defender.infantry"]
);
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });
@@ -1995,7 +2012,7 @@
const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
assert.deepEqual(
- skillJobs.map((job) => job.defenderUnit).sort(),
+ skillJobs.map((job) => job.takerUnit).sort(),
["infantry", "infantry", "lancer", "marksman"]
);
assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "MultiTargetShot")?.triggersSeen, 1);
@@ -2157,7 +2174,7 @@
const jobs = result.trace?.rounds[0]?.jobs ?? [];
assert.deepEqual(
- jobs.map((job) => `${job.kind}:${job.attackerUnit}->${job.defenderUnit}`),
+ jobs.map((job) => `${job.kind}:${job.dealerUnit}->${job.takerUnit}`),
["normal:marksman->infantry", "normal:infantry->marksman", "normal:lancer->marksman"]
);
assert.deepEqual(result.extraSkillAttackJobsByEffect, {});
@@ -2252,9 +2269,9 @@
{ 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");
- const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");
+ const attackerAttack = result.attacks.find((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");
+ const defenderLancerAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "lancer");
+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "marksman");
assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);
assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);
@@ -2295,9 +2312,9 @@
{ 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");
- const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");
+ const attackerAttack = result.attacks.find((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");
+ const defenderLancerAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "lancer");
+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "marksman");
assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);
assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -58,6 +58,9 @@
// chargeUsedEffects (uses += 1 each) after every job in every mode.
usedEffects: ActiveEffect[];
staticDamageProfile?: StaticDamageProfile;
+ // Materialized battle_start + input passive effects; recorders derive their own static profile
+ // description from these, so the runtime carries no trace-shaped data.
+ setupEffects: ActiveEffect[];
damageScratch: DamageScratch;
rng: Rng;
skills: RuntimeSkills;
@@ -116,8 +119,8 @@
capJobKills: boolean;
commitLosses: boolean;
scoreSide?: {
- attackerSide: SideId;
- defenderSide: SideId;
+ dealerSide: SideId;
+ takerSide: SideId;
};
}
@@ -150,7 +153,7 @@
capRoundKills: false,
capJobKills: false,
commitLosses: false,
- scoreSide: { attackerSide: "attacker", defenderSide: "defender" }
+ scoreSide: { dealerSide: "attacker", takerSide: "defender" }
});
return {
...buildBattleResult(run),
@@ -161,14 +164,16 @@
/**
* A resolved battle ready to run many times with different seeds. When battle-start is deterministic,
* the entire pre-loop runtime (`template`) is seed-independent and built once; each run clones its
- * mutable state and reuses the skills + static profile by reference. For stochastic battle-start the
- * template is absent and runs reuse only the resolved fighters.
+ * mutable state and reuses the skills + static profile by reference. For stochastic battle-start,
+ * the template is absent; resolved fighters, skills, and the static profile are still reused while
+ * chance-bearing setup state is rebuilt per run.
*/
export interface CompiledBattle {
input: BattleInput;
config: SimulatorConfig;
fighters: Record<SideId, ResolvedFighter>;
template?: Runtime;
+ staticProfile: StaticDamageProfile;
runtimeSkills: RuntimeSkills;
deterministicBattleStart: boolean;
}
@@ -179,9 +184,16 @@
const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
const runtimeSkills = buildRuntimeSkills([attacker, defender]);
const deterministicBattleStart = !runtimeSkills.battleStart.some(hasChanceTrigger);
- // Deterministic battle-start => the entire pre-loop runtime is seed-independent; build it once.
- const template = deterministicBattleStart ? setupRuntime(fighters, input, "simulator-prepare", NULL_RECORDER, runtimeSkills) : undefined;
- return { input, config, fighters, template, runtimeSkills, deterministicBattleStart };
+ if (deterministicBattleStart) {
+ // The entire pre-loop runtime is seed-independent, so build it once and clone it per run.
+ const template = setupRuntime(fighters, input, "simulator-prepare", NULL_RECORDER, runtimeSkills);
+ return { input, config, fighters, template, staticProfile: template.staticDamageProfile!, runtimeSkills, deterministicBattleStart };
+ }
+ // Chance-bearing battle-start effects must be rolled per run. Build only the deterministic
+ // setup effects that feed static buckets here; do not execute and discard the stochastic setup.
+ const staticEffects = materializeStaticSetupEffects(fighters, input, runtimeSkills);
+ const staticProfile = buildStaticDamageProfile(fighters, staticEffects);
+ return { input, config, fighters, staticProfile, runtimeSkills, deterministicBattleStart };
}
export function runPrepared(compiled: CompiledBattle, seed?: string | number, options: SimulationOptions = {}): BattleResult {
@@ -191,6 +203,27 @@
return buildBattleResult(runBattle(runInput, compiled.config, options, compiled));
}
+/**
+ * Prepare the battle once and run `count` replicates, reusing the compiled fighters, skills, and
+ * static damage profile across every run. Replicate 0 runs with the input's own seed; replicate
+ * `i` runs with `${seed}#${i}` so results are reproducible and distinct.
+ */
+export function simulateBattles(
+ input: BattleInput,
+ config: SimulatorConfig,
+ options: SimulationOptions & { count?: number } = {}
+): BattleResult[] {
+ const { count = 1, ...runOptions } = options;
+ if (!Number.isInteger(count) || count < 1) {
+ throw new Error(`simulateBattles count must be a positive integer, got ${JSON.stringify(count)}`);
+ }
+ const compiled = prepareBattle(input, config);
+ const baseSeed = input.seed ?? "simulator-default";
+ return Array.from({ length: count }, (_, index) =>
+ runPrepared(compiled, index === 0 ? baseSeed : `${baseSeed}#${index}`, runOptions)
+ );
+}
+
function buildBattleResult(run: BattleRun): BattleResult {
const { fighters, runtime } = run;
return {
@@ -220,19 +253,40 @@
input: BattleInput,
seed: string | number,
recorder: BattleRecorder,
- runtimeSkills?: RuntimeSkills
+ runtimeSkills?: RuntimeSkills,
+ prebuiltProfile?: StaticDamageProfile
): Runtime {
const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), runtimeSkills);
const setupEffects = [
...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime, recorder),
- ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),
- ...addInputPassiveEffects(runtime, input.defender.passive, "defender")
+ ...createInputPassiveEffects(input.attacker.passive, "attacker"),
+ ...createInputPassiveEffects(input.defender.passive, "defender")
];
- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);
+ // Effects feeding static buckets are validated deterministic, so the profile is seed-independent
+ // and a prepared build can be reused even when other battle_start skills are stochastic.
+ runtime.staticDamageProfile = prebuiltProfile ?? buildStaticDamageProfile(fighters, setupEffects);
+ runtime.setupEffects = setupEffects;
+ recorder.recordStaticProfile(fighters, setupEffects);
runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);
return runtime;
}
+function materializeStaticSetupEffects(
+ fighters: Record<SideId, ResolvedFighter>,
+ input: BattleInput,
+ runtimeSkills: RuntimeSkills
+): ActiveEffect[] {
+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng("simulator-static-profile"), runtimeSkills);
+ const staticSkills = runtimeSkills.battleStart.filter((skill) =>
+ skill.effects.some((effect) => bucketDefinition(effect.type)?.phase === "static")
+ );
+ return [
+ ...triggerSkills("battle_start", 0, staticSkills, runtime, NULL_RECORDER),
+ ...createInputPassiveEffects(input.attacker.passive, "attacker"),
+ ...createInputPassiveEffects(input.defender.passive, "defender")
+ ];
+}
+
// Deterministic battle-start effects are materialized once in a prepared runtime. Replay only
// their observation events for each run; combat state continues to come from the cloned template.
function recordPreparedBattleStartSkills(runtime: Runtime, recorder: BattleRecorder): void {
@@ -264,6 +318,7 @@
expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),
usedEffects: [],
staticDamageProfile: template.staticDamageProfile,
+ setupEffects: template.setupEffects,
damageScratch: createDamageScratch(),
rng,
skills: template.skills,
@@ -316,6 +371,7 @@
};
const recorder = recorderFor(options, fighters);
const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));
+ recorder.recordStaticProfile(fighters, runtime.setupEffects);
recordPreparedBattleStartSkills(runtime, recorder);
return runLoop(input, fighters, runtime, recorder, options, loopOptions);
}
@@ -323,7 +379,7 @@
const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);
const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
const recorder = recorderFor(options, fighters);
- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", recorder, prepared?.runtimeSkills);
+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", recorder, prepared?.runtimeSkills, prepared?.staticProfile);
return runLoop(input, fighters, runtime, recorder, options, loopOptions);
}
@@ -348,12 +404,13 @@
dodge: options.useEffectsOnDodge ?? true,
no_attack: options.useEffectsOnNoAttack ?? true
};
+ if (!runtime.staticDamageProfile) throw new Error("runLoop requires a runtime with a static damage profile");
const damageJobOptions = {
recorder,
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
scratch: runtime.damageScratch,
- capToDefenderTroops: loopOptions.capJobKills,
+ capToTakerTroops: loopOptions.capJobKills,
usedEffects: runtime.usedEffects
};
@@ -377,7 +434,7 @@
const roundTargetDamage = emptyRoundTargetDamage();
for (const intent of intents) {
const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[
- damageJobShapeSlot("normal", intent.attackerSide, intent.attackerUnit, intent.defenderSide, intent.defenderUnit)
+ damageJobShapeSlot("normal", intent.dealerSide, intent.dealerUnit, intent.takerSide, intent.takerUnit)
] ?? [];
triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, recorder, intent);
const job = normalJob(intent, roundStartTroops);
@@ -414,9 +471,9 @@
}
recorder.recordScheduledDamageJob(job);
- const normalResult = calculateDamageJob(job, fighters, [], damageJobOptions);
+ const normalResult = calculateDamageJob(job, fighters, damageJobOptions);
if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage, recorder);
- if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {
+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {
score += normalResult.kills;
}
chargeUsedEffects(runtime);
@@ -433,7 +490,7 @@
for (const extraJob of extraSkill.jobs) {
if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
recorder.recordScheduledDamageJob(extraJob);
- const extraResult = calculateDamageJob(extraJob, fighters, [], damageJobOptions);
+ const extraResult = calculateDamageJob(extraJob, fighters, damageJobOptions);
if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage, recorder);
processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");
processedExtraJobIds.add(extraJob.id);
@@ -441,7 +498,7 @@
runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] ?? 0) + 1;
}
results.push({ job: extraJob, result: extraResult });
- if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {
+ if (loopOptions.scoreSide && extraJob.dealerSide === loopOptions.scoreSide.dealerSide && extraJob.takerSide === loopOptions.scoreSide.takerSide) {
score += extraResult.kills;
}
chargeUsedEffects(runtime);
@@ -501,16 +558,16 @@
activated.push(...triggerSkills("round_start", round, runtime.skills.roundStartGlobal, runtime, recorder));
for (const skill of runtime.skills.roundStartPerUnit) {
const side = roundTriggerSourceSide(skill);
- const defenderSide = roundTriggerTargetSide(skill, side);
+ const takerSide = roundTriggerTargetSide(skill, side);
if (!skillMatchesTrigger(skill, "round_start", round)) continue;
recorder.recordSkillTriggerAttempt(skill);
if (!chancePasses(skill, runtime.rng)) continue;
recorder.recordSkillTriggered(skill);
let orderIndex = 0;
- for (const attackerUnit of roundTriggerUnits(skill, roundStartTroops)) {
- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);
- if (!defenderUnit) continue;
- const intent = syntheticRoundIntent(round, side, attackerUnit, defenderSide, defenderUnit, orderIndex, runtime);
+ for (const dealerUnit of roundTriggerUnits(skill, roundStartTroops)) {
+ const takerUnit = chooseTakerUnit(dealerUnit, side, takerSide, roundStartTroops, runtime.effectIndex);
+ if (!takerUnit) continue;
+ const intent = syntheticRoundIntent(round, side, dealerUnit, takerSide, takerUnit, orderIndex, runtime);
if (!skillMatchesTrigger(skill, "round_start", round, intent)) continue;
for (const effectIntent of skill.effects) {
const effect = activateEffect(skill, effectIntent, round, intent);
@@ -545,26 +602,26 @@
function syntheticRoundIntent(
round: number,
- attackerSide: SideId,
- attackerUnit: UnitType,
- defenderSide: SideId,
- defenderUnit: UnitType,
+ dealerSide: SideId,
+ dealerUnit: UnitType,
+ takerSide: SideId,
+ takerUnit: UnitType,
orderIndex: number,
runtime: Runtime
): AttackIntent {
return {
- id: `r${round}:${attackerSide}:${attackerUnit}:turn:${orderIndex}`,
+ id: `r${round}:${dealerSide}:${dealerUnit}:turn:${orderIndex}`,
round,
source: "normal",
- attackerSide,
- attackerUnit,
- defenderSide,
- defenderUnit,
+ dealerSide,
+ dealerUnit,
+ takerSide,
+ takerUnit,
orderIndex,
- previousAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],
- projectedAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],
- previousReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit],
- projectedReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit]
+ previousAttackCount: runtime.counters.attacks[dealerSide][dealerUnit],
+ projectedAttackCount: runtime.counters.attacks[dealerSide][dealerUnit],
+ previousReceivedAttackCount: runtime.counters.received[takerSide][takerUnit],
+ projectedReceivedAttackCount: runtime.counters.received[takerSide][takerUnit]
};
}
@@ -598,6 +655,7 @@
expireEffectsByRound: [],
usedEffects: [],
staticDamageProfile: undefined,
+ setupEffects: [],
damageScratch: createDamageScratch(),
rng,
skills,
@@ -662,9 +720,9 @@
const attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });
for (const skill of attackDeclared) {
const trigger = compiledTriggerForSkill(skill);
- for (const attackerUnit of unitsFromMask(trigger.source.units)) {
- for (const defenderUnit of unitsFromMask(trigger.target.units)) {
- const slot = damageJobShapeSlot("normal", trigger.source.side, attackerUnit, trigger.target.side, defenderUnit);
+ for (const dealerUnit of unitsFromMask(trigger.source.units)) {
+ for (const takerUnit of unitsFromMask(trigger.target.units)) {
+ const slot = damageJobShapeSlot("normal", trigger.source.side, dealerUnit, trigger.target.side, takerUnit);
const matching = attackDeclaredByJobShape[slot];
if (matching) matching.push(skill);
else attackDeclaredByJobShape[slot] = [skill];
@@ -707,23 +765,23 @@
function potentialActivationIntents(skill: ResolvedSkill): Array<AttackIntent | undefined> {
if (skill.trigger.type === "battle_start" || (skill.trigger.type === "turn" && skill.trigger.source === undefined)) return [undefined];
const trigger = compiledTriggerForSkill(skill);
- const attackerSide = trigger.source.side;
- let defenderSide = trigger.target.side;
- if (skill.trigger.type === "turn" && defenderSide === attackerSide) defenderSide = oppositeSide(attackerSide);
- if (defenderSide === attackerSide) return [];
- const attackerUnits = unitsFromMask(trigger.source.units);
- const defenderUnits = unitsFromMask(trigger.target.units);
+ const dealerSide = trigger.source.side;
+ let takerSide = trigger.target.side;
+ if (skill.trigger.type === "turn" && takerSide === dealerSide) takerSide = oppositeSide(dealerSide);
+ if (takerSide === dealerSide) return [];
+ const dealerUnits = unitsFromMask(trigger.source.units);
+ const takerUnits = unitsFromMask(trigger.target.units);
const intents: AttackIntent[] = [];
- for (const attackerUnit of attackerUnits) {
- for (const defenderUnit of defenderUnits) {
+ for (const dealerUnit of dealerUnits) {
+ for (const takerUnit of takerUnits) {
intents.push({
id: "group-plan",
round: 1,
source: "normal",
- attackerSide,
- attackerUnit,
- defenderSide,
- defenderUnit,
+ dealerSide,
+ dealerUnit,
+ takerSide,
+ takerUnit,
orderIndex: 0,
previousAttackCount: 0,
projectedAttackCount: 1,
@@ -774,7 +832,7 @@
}
}
-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {
+function createInputPassiveEffects(passive: FighterInput["passive"], side: SideId): ActiveEffect[] {
const effects: ActiveEffect[] = [];
if (!passive) return effects;
for (const stat of ["attack", "defense", "lethality", "health"] as const) {
@@ -808,7 +866,6 @@
uses: 0,
sameEffectStacking: "add"
};
- addActiveEffect(runtime, effect);
effects.push(effect);
}
}
@@ -848,28 +905,28 @@
): AttackIntent[] {
const intents: AttackIntent[] = [];
for (const side of ["attacker", "defender"] as SideId[]) {
- const defenderSide = oppositeSide(side);
+ const takerSide = oppositeSide(side);
let orderIndex = 0;
- for (const attackerUnit of UNIT_TYPES) {
- if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;
- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);
- const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);
- if (!defenderUnit) continue;
- const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;
+ for (const dealerUnit of UNIT_TYPES) {
+ if ((roundStartTroops[side][dealerUnit] ?? 0) <= 0) continue;
+ const ordered = orderFromEffects(dealerUnit, side, runtime.effectIndex, true);
+ const takerUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, takerSide, roundStartTroops);
+ if (!takerUnit) continue;
+ const intentId = `r${round}:${side}:${dealerUnit}:${orderIndex}`;
if (ordered) {
chargeEffectUse(runtime, ordered.effect);
- recorder.recordBattleOrder(intentId, ordered.effect, defenderUnit);
+ recorder.recordBattleOrder(intentId, ordered.effect, takerUnit);
}
- const previousAttackCount = runtime.counters.attacks[side][attackerUnit];
- const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];
+ const previousAttackCount = runtime.counters.attacks[side][dealerUnit];
+ const previousReceivedAttackCount = runtime.counters.received[takerSide][takerUnit];
intents.push({
id: intentId,
round,
source: "normal",
- attackerSide: side,
- attackerUnit,
- defenderSide,
- defenderUnit,
+ dealerSide: side,
+ dealerUnit,
+ takerSide,
+ takerUnit,
orderIndex,
previousAttackCount,
projectedAttackCount: previousAttackCount + 1,
@@ -884,16 +941,15 @@
// Synthetic round-start trigger targeting reuses attack-order effects to pick a target but
// does not charge them: no real attack intent is being ordered.
-function chooseDefenderUnit(
- attackerUnit: UnitType,
- attackerSide: SideId,
- defenderSide: SideId,
+function chooseTakerUnit(
+ dealerUnit: UnitType,
+ dealerSide: SideId,
+ takerSide: SideId,
roundStartTroops: DamageJob["roundStartTroops"],
- effectIndex: EffectIndex,
- round: number
+ effectIndex: EffectIndex
): UnitType | undefined {
- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;
- return firstLivingUnit(order, defenderSide, roundStartTroops);
+ const order = orderFromEffects(dealerUnit, dealerSide, effectIndex, false)?.order ?? UNIT_TYPES;
+ return firstLivingUnit(order, takerSide, roundStartTroops);
}
function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {
@@ -901,14 +957,14 @@
}
function orderFromEffects(
- attackerUnit: UnitType,
- attackerSide: SideId,
+ dealerUnit: UnitType,
+ dealerSide: SideId,
index: EffectIndex,
advanceAttackDelay: boolean
): { order: UnitType[]; effect: ActiveEffect } | undefined {
for (const effect of index.battleOrder) {
if (effect.intent.type !== "attack_order") continue;
- if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;
+ if (effect.appliesTo.side !== dealerSide || !unitMaskHas(effect.appliesTo.units, dealerUnit)) continue;
if (Array.isArray(effect.intent.value)) {
try {
if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;
@@ -960,10 +1016,10 @@
kind: "normal",
sourceIntentId: intent.id,
roundStartTroops,
- attackerSide: intent.attackerSide,
- attackerUnit: intent.attackerUnit,
- defenderSide: intent.defenderSide,
- defenderUnit: intent.defenderUnit,
+ dealerSide: intent.dealerSide,
+ dealerUnit: intent.dealerUnit,
+ takerSide: intent.takerSide,
+ takerUnit: intent.takerUnit,
sourceMultiplier: 1
};
}
@@ -1004,10 +1060,10 @@
kind: "skill",
sourceIntentId: normalAttack.sourceIntentId,
roundStartTroops,
- attackerSide: source.side,
- attackerUnit: source.unit,
- defenderSide: target.side,
- defenderUnit: target.unit,
+ dealerSide: source.side,
+ dealerUnit: source.unit,
+ takerSide: target.side,
+ takerUnit: target.unit,
sourceEffectId,
sourceMultiplier: multiplier
};
@@ -1055,10 +1111,10 @@
function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {
return (
- effect.appliesTo.side === normalAttack.attackerSide &&
- unitMaskHas(effect.appliesTo.units, normalAttack.attackerUnit) &&
- effect.appliesVs.side === normalAttack.defenderSide &&
- unitMaskHas(effect.appliesVs.units, normalAttack.defenderUnit)
+ effect.appliesTo.side === normalAttack.dealerSide &&
+ unitMaskHas(effect.appliesTo.units, normalAttack.dealerUnit) &&
+ effect.appliesVs.side === normalAttack.takerSide &&
+ unitMaskHas(effect.appliesVs.units, normalAttack.takerUnit)
);
}
@@ -1074,17 +1130,17 @@
normalAttack: DamageJob,
roundStartTroops: DamageJob["roundStartTroops"]
): TriggerJobUnit[] {
- if (selector === "use.source") return [{ side: normalAttack.attackerSide, unit: normalAttack.attackerUnit }];
- if (selector === "use.target") return [{ side: normalAttack.defenderSide, unit: normalAttack.defenderUnit }];
+ if (selector === "use.source") return [{ side: normalAttack.dealerSide, unit: normalAttack.dealerUnit }];
+ if (selector === "use.target") return [{ side: normalAttack.takerSide, unit: normalAttack.takerUnit }];
if (selector === "effect.applies_to") return unitsFromMask(effect.appliesTo.units).map((unit) => ({ side: effect.appliesTo.side, unit }));
if (selector === "effect.applies_vs") return unitsFromMask(effect.appliesVs.units).map((unit) => ({ side: effect.appliesVs.side, unit }));
- if (selector === "enemy.living") return livingUnits(normalAttack.defenderSide, roundStartTroops);
- if (selector === "self.living") return livingUnits(normalAttack.attackerSide, roundStartTroops);
+ if (selector === "enemy.living") return livingUnits(normalAttack.takerSide, roundStartTroops);
+ if (selector === "self.living") return livingUnits(normalAttack.dealerSide, roundStartTroops);
const units = unitListFromSelector(selector);
if (!units) {
throw new Error(`trigger_damage_jobs ${role} selector is required and must be a supported selector, got ${JSON.stringify(selector)}`);
}
- const fallbackSide = role === "source" ? normalAttack.attackerSide : normalAttack.defenderSide;
+ const fallbackSide = role === "source" ? normalAttack.dealerSide : normalAttack.takerSide;
return units.map((unit) => ({ side: fallbackSide, unit }));
}
@@ -1125,8 +1181,8 @@
roundStartTroops: DamageJob["roundStartTroops"],
roundTargetDamage: Record<SideId, Record<UnitType, number>>
): boolean {
- const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);
- return available <= 0 || roundTargetDamage[job.defenderSide][job.defenderUnit] >= available;
+ const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);
+ return available <= 0 || roundTargetDamage[job.takerSide][job.takerUnit] >= available;
}
function capJobToRemainingTarget(
@@ -1136,11 +1192,11 @@
roundTargetDamage: Record<SideId, Record<UnitType, number>>,
recorder: BattleRecorder
): void {
- const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);
- const alreadyDamaged = roundTargetDamage[job.defenderSide][job.defenderUnit];
+ const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);
+ const alreadyDamaged = roundTargetDamage[job.takerSide][job.takerUnit];
const remaining = Math.max(0, available - alreadyDamaged);
result.kills = Math.min(result.kills, remaining);
- roundTargetDamage[job.defenderSide][job.defenderUnit] += result.kills;
+ roundTargetDamage[job.takerSide][job.takerUnit] += result.kills;
recorder.recordFinalKills(result);
}
@@ -1151,7 +1207,7 @@
commitRoundCounters(cancelled, results, runtime);
const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };
for (const { job, result } of results) {
- losses[job.defenderSide][job.defenderUnit] += result.kills;
+ losses[job.takerSide][job.takerUnit] += result.kills;
}
for (const side of ["attacker", "defender"] as SideId[]) {
for (const unit of UNIT_TYPES) {
@@ -1162,12 +1218,12 @@
function commitRoundCounters(cancelled: CancelledAttack[], results: DamageJobResult[], 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;
+ runtime.counters.attacks[entry.intent.dealerSide][entry.intent.dealerUnit] += 1;
+ runtime.counters.received[entry.intent.takerSide][entry.intent.takerUnit] += 1;
}
for (const { job, result } of results) {
- runtime.counters.attacks[job.attackerSide][job.attackerUnit] += 1;
- runtime.counters.received[job.defenderSide][job.defenderUnit] += 1;
+ runtime.counters.attacks[job.dealerSide][job.dealerUnit] += 1;
+ runtime.counters.received[job.takerSide][job.takerUnit] += 1;
}
}
@@ -1228,7 +1284,7 @@
function bearScore(attacks: AttackOutcome[]): number {
return attacks
- .filter((attack) => attack.attackerSide === "attacker" && attack.defenderSide === "defender")
+ .filter((attack) => attack.dealerSide === "attacker" && attack.takerSide === "defender")
.reduce((sum, attack) => sum + attack.kills, 0);
}
diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts
--- a/simulator/src/staticDamageProfile.ts
+++ b/simulator/src/staticDamageProfile.ts
@@ -1,260 +1,157 @@
-import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";
+import type { ActiveEffect, DamageBucketTrace, ResolvedFighter, SideId, StatBlock, UnitType } from "./types";
import { UNIT_TYPES, unitMaskHas } from "./types";
import {
BUCKET_DEFINITIONS,
- STATIC_BUCKETS,
- type BucketPlacement,
+ isPassiveBucket,
+ passiveBucketRole,
type BucketRole,
- type BucketValueType,
- type StaticDamageBucket,
- type StaticPassiveBucket,
- type StaticPlayerBucket,
- type StaticRawBucket
+ type PassiveBucket,
+ type StaticPlayerBucket
} from "./damageBuckets";
-import { sourceLabel } from "./effects";
-
-export interface StaticDamageProfileTerm {
- raw?: number;
- totalPct?: number;
- contributors: DamageBucketTrace["contributors"];
-}
-
-export interface StaticDamageProfileEntry {
- factor: number;
- playerFactorsValid: boolean;
- buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;
-}
-
-export interface StaticDamageProfile {
- offense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;
- defense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;
+import {
+ applyStaticDamageBucketValue,
+ createStaticDamageBucketFactors,
+ evaluateDamageExpression,
+ staticDamageBucketSet,
+ type StaticDamageBucketMatrix,
+ type StaticDamageBucketFactors,
+ type StaticDamageProfile,
+ type StaticDamageProfileEntry
+} from "./damage";
+
+export type { StaticDamageBucketMatrix, StaticDamageProfile } from "./damage";
+
+/** A player stat bonus produced a non-positive factor while building the numeric profile. */
+export class DamageAggregationError extends Error {
+ readonly groupId: string;
+ readonly netPct: number;
+ readonly factor: number;
+ readonly contributors: DamageBucketTrace["contributors"];
+
+ constructor(args: { groupId: string; netPct: number; factor: number; contributors: DamageBucketTrace["contributors"] }) {
+ super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor} netPct=${args.netPct}`);
+ this.name = "DamageAggregationError";
+ this.groupId = args.groupId;
+ this.netPct = args.netPct;
+ this.factor = args.factor;
+ this.contributors = args.contributors;
+ }
}
-interface PassiveCandidate {
+/**
+ * One passive effect selected to contribute to a static bucket for a concrete side and unit.
+ * Selection (including same-effect max stacking) lives here so the numeric profile and any
+ * recorder-side description of it can never disagree.
+ */
+export interface PassiveContribution {
+ role: BucketRole;
+ side: SideId;
+ unit: UnitType;
+ bucket: PassiveBucket;
effect: ActiveEffect;
- bucket: StaticPassiveBucket;
valuePct: number;
}
-interface PassiveCandidateGroup {
- selected: PassiveCandidate;
- candidates: PassiveCandidate[];
- entry: StaticDamageProfileEntry;
-}
-
-export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {
- const profile: StaticDamageProfile = {
- offense: {
- attacker: buildSideEntries(fighters.attacker, "offense"),
- defender: buildSideEntries(fighters.defender, "offense")
- },
- defense: {
- attacker: buildSideEntries(fighters.attacker, "defense"),
- defender: buildSideEntries(fighters.defender, "defense")
- }
+export function buildStaticDamageBucketFactors(
+ fighters: Record<SideId, ResolvedFighter>,
+ activeEffects: ActiveEffect[]
+): StaticDamageBucketMatrix {
+ const factors: Record<SideId, Record<UnitType, StaticDamageBucketFactors>> = {
+ attacker: buildSideFactors(fighters.attacker),
+ defender: buildSideFactors(fighters.defender)
};
-
- applyStaticPassives(profile, activeEffects);
- recomputeFactors(profile);
- return profile;
-}
-
-export function assertStaticPassiveEffectDefinition(
- trigger: SkillFile["skills"][string]["trigger"],
- effect: EffectIntentDefinition,
- file: string,
- skillId: string,
- effectId: string
-): void {
- if (!isPassiveBucket(effect.type)) return;
-
- const path = `${file}:${skillId}.${effectId}`;
- if (trigger.type !== "battle_start") {
- throw new Error(`passive effect ${effect.type} must use battle_start trigger at ${path}`);
- }
- if (trigger.probability !== undefined) {
- throw new Error(`passive effect ${effect.type} cannot define trigger probability at ${path}; passive effects must be deterministic`);
+ for (const contribution of selectPassiveContributions(activeEffects)) {
+ applyStaticDamageBucketValue(
+ factors[contribution.side][contribution.unit],
+ contribution.bucket,
+ contribution.valuePct
+ );
}
- if (effect.value_evolution !== undefined) {
- throw new Error(`passive effect ${effect.type} cannot define value_evolution at ${path}; passive effects must be static`);
- }
-
- const duration = effect.duration;
- if (duration === undefined) return;
- if (duration.turns !== undefined || duration.attacks !== undefined) {
- throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);
- }
-}
-
-export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {
- return BUCKET_DEFINITIONS[bucket as StaticDamageBucket]?.phase === "static";
+ return factors;
}
-export function isPassiveBucket(bucket: string): bucket is StaticPassiveBucket {
- const definition = BUCKET_DEFINITIONS[bucket as StaticPassiveBucket];
- return definition?.phase === "static" && definition.family === "passive";
-}
-
-export function staticPassiveBucketRole(bucket: string): BucketRole | undefined {
- return isPassiveBucket(bucket) ? BUCKET_DEFINITIONS[bucket].role : undefined;
-}
-
-function buildSideEntries(fighter: ResolvedFighter, role: "offense" | "defense"): Record<UnitType, StaticDamageProfileEntry> {
- return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildEntry(fighter, unit, role)])) as Record<UnitType, StaticDamageProfileEntry>;
+export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {
+ const factors = buildStaticDamageBucketFactors(fighters, activeEffects);
+ return {
+ attacker: buildProfileSide(factors.attacker),
+ defender: buildProfileSide(factors.defender)
+ };
}
-function buildEntry(fighter: ResolvedFighter, unit: UnitType, role: "offense" | "defense"): StaticDamageProfileEntry {
- const stats = fighter.troopDetails[unit]?.stats ?? fallbackStats();
- const bonuses = fighter.statBonuses[unit] ?? emptyStats();
- const buckets: StaticDamageProfileEntry["buckets"] = {};
- if (role === "offense") {
- setRaw(buckets, "troops.baseAttack", stats.attack);
- setRaw(buckets, "troops.baseLethality", stats.lethality);
- setPct(buckets, "player.attack", bonuses.attack, [{ effectId: "input:attack", source: "input_stats", valuePct: bonuses.attack, bucket: "player.attack" }]);
- setPct(buckets, "player.lethality", bonuses.lethality, [
- { effectId: "input:lethality", source: "input_stats", valuePct: bonuses.lethality, bucket: "player.lethality" }
- ]);
- } else {
- setRaw(buckets, "troops.baseHealth", stats.health);
- setRaw(buckets, "troops.baseDefense", stats.defense);
- setPct(buckets, "player.health", bonuses.health, [{ effectId: "input:health", source: "input_stats", valuePct: bonuses.health, bucket: "player.health" }]);
- setPct(buckets, "player.defense", bonuses.defense, [
- { effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }
- ]);
+function buildProfileSide(factors: Record<UnitType, StaticDamageBucketFactors>): Record<UnitType, StaticDamageProfileEntry> {
+ return Object.fromEntries(
+ UNIT_TYPES.map((unit) => [unit, {
+ dealerFactor: evaluateDamageExpression(staticDamageBucketSet(factors[unit], "dealer")),
+ takerFactor: evaluateDamageExpression(staticDamageBucketSet(factors[unit], "taker"))
+ }])
+ ) as Record<UnitType, StaticDamageProfileEntry>;
+}
+
+function applyPlayerBucket(factors: StaticDamageBucketFactors, bucket: StaticPlayerBucket, totalPct: number): void {
+ const factor = applyStaticDamageBucketValue(factors, bucket, totalPct);
+ if (factor <= 0) {
+ const stat = bucket.slice("player.".length);
+ throw new DamageAggregationError({
+ groupId: `player.${BUCKET_DEFINITIONS[bucket].role}.${stat}`,
+ netPct: totalPct,
+ factor,
+ contributors: [{ effectId: `input:${stat}`, source: "input_stats", valuePct: totalPct, bucket }]
+ });
}
- return { factor: 1, playerFactorsValid: true, buckets };
}
-function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {
- const groups = new Map<string, PassiveCandidateGroup>();
+export function selectPassiveContributions(activeEffects: ActiveEffect[]): PassiveContribution[] {
+ const contributions: PassiveContribution[] = [];
+ const maxGroups = new Map<string, PassiveContribution>();
for (const effect of activeEffects) {
const bucket = effect.intent.type;
if (!isPassiveBucket(bucket)) continue;
- const role = staticPassiveBucketRole(bucket)!;
- const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];
+ const role = passiveBucketRole(bucket)!;
+ const side = effect.appliesTo.side;
for (const unit of UNIT_TYPES) {
if (!unitMaskHas(effect.appliesTo.units, unit)) continue;
- const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };
+ const contribution: PassiveContribution = { role, side, unit, bucket, effect, valuePct: effect.getCurrentValuePct(1) };
if (effect.sameEffectStacking === "max" && effect.stackingKey) {
- const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;
- const group = groups.get(key);
- if (group) {
- group.candidates.push(candidate);
- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;
- } else {
- groups.set(key, { selected: candidate, candidates: [candidate], entry: targetEntries[unit] });
- }
+ const key = `${role}:${side}:${unit}:${bucket}:${effect.stackingKey}`;
+ const selected = maxGroups.get(key);
+ if (!selected || contribution.valuePct > selected.valuePct) maxGroups.set(key, contribution);
} else {
- addPassiveCandidate(targetEntries[unit], candidate);
+ contributions.push(contribution);
}
}
}
- for (const group of groups.values()) {
- addPassiveCandidate(group.entry, group.selected);
- }
-}
-
-function addPassiveCandidate(entry: StaticDamageProfileEntry, candidate: PassiveCandidate): void {
- addPct(entry.buckets, candidate.bucket, candidate.valuePct, {
- effectId: candidate.effect.source.effectId ?? candidate.effect.id,
- source: sourceLabel(candidate.effect),
- sourceSide: candidate.effect.ownerSide,
- valuePct: candidate.valuePct,
- bucket: candidate.bucket,
- stackingKey: candidate.effect.stackingKey,
- sameEffectStacking: candidate.effect.sameEffectStacking
- });
-}
-
-function recomputeFactors(profile: StaticDamageProfile): void {
- for (const side of ["attacker", "defender"] as SideId[]) {
- for (const unit of UNIT_TYPES) {
- const offense = profile.offense[side][unit];
- const defense = profile.defense[side][unit];
- offense.factor = offenseFactor(offense);
- defense.factor = defenseFactor(defense);
- offense.playerFactorsValid = playerPctFactorValid(offense, "player.attack") && playerPctFactorValid(offense, "player.lethality");
- defense.playerFactorsValid = playerPctFactorValid(defense, "player.health") && playerPctFactorValid(defense, "player.defense");
- }
- }
-}
-
-function playerPctFactorValid(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket): boolean {
- return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100 > 0;
-}
-
-interface StaticFactorTerm {
- bucket: StaticDamageBucket;
- valueType: BucketValueType;
- placement: BucketPlacement;
-}
-
-// The closed-pool aggregation is driven entirely by static bucket metadata (role/valueType/
-// placement); there is no second hand-written damage equation. Array order fixes the
-// numerator/denominator floating-point association.
-const STATIC_FACTOR_TERMS_BY_ROLE: Record<BucketRole, StaticFactorTerm[]> = { attacker: [], defender: [] };
-for (const definition of STATIC_BUCKETS) {
- STATIC_FACTOR_TERMS_BY_ROLE[definition.role].push({
- bucket: definition.path,
- 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 roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.attacker);
-}
-
-function defenseFactor(entry: StaticDamageProfileEntry): number {
- return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.defender);
-}
-
-function raw(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {
- return Math.max(0, entry.buckets[bucket]?.raw ?? 0);
-}
-
-function pct(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {
- return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100;
+ contributions.push(...maxGroups.values());
+ return contributions;
}
-function setRaw(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {
- buckets[bucket] = { raw, contributors: [] };
+function buildSideFactors(fighter: ResolvedFighter): Record<UnitType, StaticDamageBucketFactors> {
+ return Object.fromEntries(
+ UNIT_TYPES.map((unit) => {
+ const factors = createStaticDamageBucketFactors();
+ const stats = unitBaseStats(fighter, unit);
+ const bonuses = unitPlayerBonuses(fighter, unit);
+ applyStaticDamageBucketValue(factors, "troops.baseAttack", stats.attack);
+ applyStaticDamageBucketValue(factors, "troops.baseLethality", stats.lethality);
+ applyStaticDamageBucketValue(factors, "troops.baseHealth", stats.health);
+ applyStaticDamageBucketValue(factors, "troops.baseDefense", stats.defense);
+ applyPlayerBucket(factors, "player.attack", bonuses.attack);
+ applyPlayerBucket(factors, "player.lethality", bonuses.lethality);
+ applyPlayerBucket(factors, "player.health", bonuses.health);
+ applyPlayerBucket(factors, "player.defense", bonuses.defense);
+ return [unit, factors];
+ })
+ ) as Record<UnitType, StaticDamageBucketFactors>;
}
-function setPct(
- buckets: StaticDamageProfileEntry["buckets"],
- bucket: StaticPlayerBucket,
- valuePct: number,
- contributors: DamageBucketTrace["contributors"] = []
-): void {
- buckets[bucket] = { totalPct: valuePct, contributors };
+/** Base stats a static entry reads for a unit; units with no troops resolve to neutral 1s. */
+export function unitBaseStats(fighter: ResolvedFighter, unit: UnitType): StatBlock {
+ return fighter.troopDetails[unit]?.stats ?? FALLBACK_STATS;
}
-function addPct(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticPassiveBucket, valuePct: number, contributor: DamageBucketTrace["contributors"][number]): void {
- const existing = buckets[bucket];
- if (existing) {
- existing.totalPct = (existing.totalPct ?? 0) + valuePct;
- existing.contributors.push(contributor);
- } else {
- buckets[bucket] = { totalPct: valuePct, contributors: [contributor] };
- }
+/** Player stat bonuses a static entry reads for a unit; missing units contribute nothing. */
+export function unitPlayerBonuses(fighter: ResolvedFighter, unit: UnitType): StatBlock {
+ return fighter.statBonuses[unit] ?? EMPTY_STATS;
}
-function fallbackStats(): StatBlock {
- return { attack: 1, defense: 1, lethality: 1, health: 1 };
-}
-
-function emptyStats(): StatBlock {
- return { attack: 0, defense: 0, lethality: 0, health: 0 };
-}
+const FALLBACK_STATS: StatBlock = { attack: 1, defense: 1, lethality: 1, health: 1 };
+const EMPTY_STATS: StatBlock = { attack: 0, defense: 0, lethality: 0, health: 0 };
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
@@ -140,7 +140,7 @@
assert.equal(report.counts.executed, 1);
assert.equal(report.errors[0]?.testcase_id, "bad_aggregation");
assert.equal(report.details[0]?.errorDetails?.type, "DamageAggregationError");
- assert.equal(report.details[0]?.errorDetails?.groupId, "player.defender.health");
+ assert.equal(report.details[0]?.errorDetails?.groupId, "player.taker.health");
assert.equal(report.details[0]?.errorDetails?.factor, -0.050000000000000044);
assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");
});
diff --git a/simulator/src/testcases.ts b/simulator/src/testcases.ts
--- a/simulator/src/testcases.ts
+++ b/simulator/src/testcases.ts
@@ -10,7 +10,7 @@
} from "./calibration";
import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
import { prepareBattle, runPrepared } from "./simulator";
-import { DamageAggregationError } from "./damage";
+import { DamageAggregationError } from "./staticDamageProfile";
import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "./types";
const DEFAULT_STOCHASTIC_REPEAT = 100;
@@ -775,8 +775,6 @@
return {
type: error.name,
groupId: error.groupId,
- round: error.round,
- jobId: error.jobId,
netPct: error.netPct,
factor: error.factor,
contributors: error.contributors
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -312,10 +312,10 @@
id: string;
round: number;
source: "normal";
- attackerSide: SideId;
- attackerUnit: UnitType;
- defenderSide: SideId;
- defenderUnit: UnitType;
+ dealerSide: SideId;
+ dealerUnit: UnitType;
+ takerSide: SideId;
+ takerUnit: UnitType;
orderIndex: number;
previousAttackCount: number;
projectedAttackCount: number;
@@ -329,10 +329,10 @@
kind: DamageKind;
sourceIntentId: string;
roundStartTroops: Record<SideId, Record<UnitType, number>>;
- attackerSide: SideId;
- attackerUnit: UnitType;
- defenderSide: SideId;
- defenderUnit: UnitType;
+ dealerSide: SideId;
+ dealerUnit: UnitType;
+ takerSide: SideId;
+ takerUnit: UnitType;
sourceEffectId?: string;
sourceSkillReportKey?: string;
sourceMultiplier?: number;
@@ -417,10 +417,10 @@
kind: DamageKind;
sourceEffectId?: string;
sourceSkillReportKey?: string;
- attackerSide: SideId;
- attackerUnit: UnitType;
- defenderSide: SideId;
- defenderUnit: UnitType;
+ dealerSide: SideId;
+ dealerUnit: UnitType;
+ takerSide: SideId;
+ takerUnit: UnitType;
kills: number;
counterDeltas: CounterDelta[];
appliedEffects: AppliedEffect[];
Run B dirty state patch
diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json
--- a/simulator/config/hero_definitions/Renee.json
+++ b/simulator/config/hero_definitions/Renee.json
@@ -12,7 +12,7 @@
},
"effects": {
"NightmareTrace/1": {
- "type": "active.hero.health.down",
+ "type": "extra_skill_attack",
"value": [
40,
80,
@@ -21,8 +21,8 @@
200
],
"units": {
- "applies_to": "target",
- "applies_vs": "lancer"
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
},
"duration": {
"turns": {
@@ -33,7 +33,12 @@
"count": 1
}
},
- "same_effect_stacking": "max"
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
}
}
},
@@ -46,7 +51,7 @@
},
"effects": {
"Dreamcatcher/1": {
- "type": "active.hero.health.down",
+ "type": "extra_skill_attack",
"value": [
30,
60,
@@ -55,8 +60,8 @@
150
],
"units": {
- "applies_to": "target",
- "applies_vs": "lancer"
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
},
"duration": {
"turns": {
@@ -64,7 +69,12 @@
"delay": 1
}
},
- "same_effect_stacking": "max"
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
}
}
},
@@ -77,7 +87,7 @@
},
"effects": {
"Dreamslice/1": {
- "type": "active.hero.health.down",
+ "type": "extra_skill_attack",
"value": [
15,
30,
@@ -86,8 +96,8 @@
75
],
"units": {
- "applies_to": "target",
- "applies_vs": "any"
+ "applies_to": "all",
+ "applies_vs": "trigger.target"
},
"duration": {
"turns": {
@@ -95,7 +105,12 @@
"delay": 1
}
},
- "same_effect_stacking": "max"
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
}
}
},
diff --git a/simulator/src/fighterResolution.ts b/simulator/src/fighterResolution.ts
--- a/simulator/src/fighterResolution.ts
+++ b/simulator/src/fighterResolution.ts
@@ -189,6 +189,9 @@
let index = 0;
for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {
index += 1;
+ // Joiners contribute exactly their first expedition skill. Treat any later levels in
+ // low-level input as inert rather than rejecting an otherwise valid battle request.
+ if (instance.role === "joiner" && index > 1) continue;
const level = Number(instance.levels[`skill_${index}`] ?? instance.levels[skillId] ?? 0);
if (level <= 0) continue;
if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, engagementType)) continue;
diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts
--- a/simulator/src/simulator.test.ts
+++ b/simulator/src/simulator.test.ts
@@ -1024,6 +1024,62 @@
);
});
+test("joiner heroes contribute only their first skill when later levels are supplied", () => {
+ const config = minimalConfig({
+ Joiner: {
+ name: "Joiner",
+ skills: {
+ FirstSkill: {
+ trigger: { type: "pre_battle" },
+ effects: { first: { type: "passive.attack.up", value: 10 } }
+ },
+ SecondSkill: {
+ trigger: { type: "pre_battle" },
+ effects: { second: { type: "passive.attack.up", value: 100 } }
+ },
+ ThirdSkill: {
+ trigger: { type: "pre_battle" },
+ effects: { third: { type: "passive.lethality.up", value: 100 } }
+ },
+ FourthSkill: {
+ trigger: { type: "pre_battle" },
+ effects: { fourth: { type: "passive.lethality.up", value: 100 } }
+ }
+ }
+ }
+ });
+ const battle = (levels: Record<string, number>): BattleInput => ({
+ maxRounds: 1,
+ seed: "joiner-first-skill-only",
+ attacker: {
+ troops: { infantry_t1: 100 },
+ joiner_heroes: [{ name: "Joiner", levels }]
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ });
+
+ const firstOnly = runOnce(battle({ skill_1: 1 }), config, { mode: "trace" });
+ const laterLevelsPresent = runOnce(
+ battle({ skill_1: 1, skill_2: 1, skill_3: 1, skill_4: 1 }),
+ config,
+ { mode: "trace" }
+ );
+
+ assert.deepEqual(semanticBattleSummary(laterLevelsPresent), semanticBattleSummary(firstOnly));
+ assert.deepEqual(laterLevelsPresent.resolved.attacker.heroes[0]?.skillIds, ["FirstSkill"]);
+ assert.deepEqual(
+ laterLevelsPresent.skillReport.attacker.map((entry) => entry.skillId),
+ ["FirstSkill"]
+ );
+ const attack = laterLevelsPresent.attacks.find(
+ (entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry"
+ );
+ assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 10);
+});
+
test("joiner hero generation stats are not applied when main hero stats are enabled", () => {
const config = minimalConfig({
Main: {