WOS Sim
← Back to Runs

Compare Runs

A (baseline)
2c9ddb50-a62ed76d7ae11/07/2026, 00:04:27
B (current)
4804e8bf-a550821673915/07/2026, 13:01:29
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error-0.00%
Changed3
Improved2
Regressed1
Added0
Retired0

Testcase Delta

199 / 199
FileCase#AdjA%B%Δ%Status
testcases/heroes_unittests/Reina_tc.jsondaut_viper_42-0.46%-0.43%-0.03%improved
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.17%-0.16%-0.01%improved
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc0-3.30%-3.31%+0.01%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_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/ambusher_no_marksmen.jsonambusher_no_marksmen01.87%1.87%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_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_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 (ed76d7ae08216739)

08216739gen8 hero drafts, uiimprovemetns, small other bitspiddlyminx15/07/2026, 05:21:32

Code Changes (Run A → Run B)

Index: simulator/config/hero_definitions/Gatot.json
===================================================================
--- simulator/config/hero_definitions/Gatot.json prev run
+++ simulator/config/hero_definitions/Gatot.json this run
@@ -0,0 +1,104 @@
+{
+ "name": "Gatot",
+ "hero_generation": "S8",
+ "troop_type": "infantry",
+ "skills": {
+ "GoldenGuard": {
+ "description": "Gatot commands his troops with imperial guard tactics, increasing his Infantry's Defense by X%.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "GoldenGuard/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ }
+ }
+ }
+ },
+ "KingsBestowal": {
+ "description": "The great kings bless Gatot's Infantry, granting Infantry a Shield with protection equal to Attack * X% each time they attack, for 1 turn.",
+ "trigger": {
+ "type": "attack",
+ "source": "infantry"
+ },
+ "effects": {
+ "KingsBestowal/1": {
+ "type": "active.hero.damageTaken.down",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "units": {
+ "applies_to": "trigger.source"
+ },
+ "duration": {
+ "turns": {
+ "count": 1
+ }
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "RoyalLegion": {
+ "description": "Gatot's formidable legion instills fear in enemies, reducing their Attack by X%.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "RoyalLegion/1": {
+ "type": "active.hero.attack.down",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "units": {
+ "applies_to": "enemy.any"
+ }
+ }
+ }
+ },
+ "IndestructibleCity": {
+ "description": "Widget bonus: Increase Defense by X% for all troops when defending.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "IndestructibleCity/1": {
+ "type": "passive.defense.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Hendrik.json
===================================================================
--- simulator/config/hero_definitions/Hendrik.json prev run
+++ simulator/config/hero_definitions/Hendrik.json this run
@@ -0,0 +1,107 @@
+{
+ "name": "Hendrik",
+ "hero_generation": "S8",
+ "troop_type": "marksman",
+ "skills": {
+ "WormsRavage": {
+ "description": "Captain Hendrik commands a gigantic naval shipworm to gnaw at the enemies' armor, reducing all enemy troops' Defense by X%.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "WormsRavage/1": {
+ "type": "active.hero.defense.down",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "units": {
+ "applies_to": "enemy.any"
+ }
+ }
+ }
+ },
+ "ArmorOfBarnacles": {
+ "description": "Hendrik covers all friendly Troops with a layer of hard-shelled barnacles every 4 turns, increasing their Defense by X% for 2 turns.",
+ "trigger": {
+ "type": "turn",
+ "every": 4
+ },
+ "effects": {
+ "ArmorOfBarnacles/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "duration": {
+ "turns": {
+ "count": 2
+ }
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "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"
+ },
+ "effects": {
+ "DragonsHeir/1": {
+ "type": "active.hero.damage.up",
+ "value": [
+ 8,
+ 16,
+ 24,
+ 32,
+ 40
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "attacks": {
+ "count": 1
+ }
+ }
+ }
+ }
+ },
+ "AbyssalBlessing": {
+ "description": "Widget bonus: Increase Attack by X% for all troops when rallying.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "AbyssalBlessing/1": {
+ "type": "passive.attack.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Sonya.json
===================================================================
--- simulator/config/hero_definitions/Sonya.json prev run
+++ simulator/config/hero_definitions/Sonya.json this run
@@ -0,0 +1,136 @@
+{
+ "name": "Sonya",
+ "hero_generation": "S8",
+ "troop_type": "lancer",
+ "skills": {
+ "TreasureHunter": {
+ "description": "Everyone has a share when the legendary ocean treasure is found! Sonya's promise inspires everyone, increasing Damage by X% for all troops.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "TreasureHunter/1": {
+ "type": "active.hero.damage.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ]
+ }
+ }
+ },
+ "BountyTemptation": {
+ "description": "Sonya motivates her Troops with bounty, making her Lancers deal X% more damage every 2 attacks and increasing Attack by Y% for all troops for 1 turn.",
+ "trigger": {
+ "type": "attack",
+ "every": 2,
+ "source": "lancer"
+ },
+ "effects": {
+ "BountyTemptation/1": {
+ "type": "active.hero.damage.up",
+ "value": [
+ 15,
+ 30,
+ 45,
+ 60,
+ 75
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "attacks": {
+ "count": 1
+ }
+ }
+ },
+ "BountyTemptation/2": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "duration": {
+ "turns": {
+ "count": 1
+ }
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "TorrentialImpact": {
+ "description": "Sonya's Lancers will seize every chance to launch a surprise raid like underwater currents. Her Lancers deal X% damage every 5 turns and stun the target for 1 turn.",
+ "trigger": {
+ "type": "turn",
+ "every": 5,
+ "source": "lancer"
+ },
+ "effects": {
+ "TorrentialImpact/1": {
+ "type": "active.hero.damage.up",
+ "value": [
+ 50,
+ 100,
+ 150,
+ 200,
+ 250
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "attacks": {
+ "count": 1
+ }
+ }
+ },
+ "TorrentialImpact/2": {
+ "type": "no_attack",
+ "units": {
+ "applies_to": "trigger.target",
+ "applies_vs": "trigger.source"
+ },
+ "duration": {
+ "turns": {
+ "count": 1
+ }
+ }
+ }
+ }
+ },
+ "VortexTurret": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "VortexTurret/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_generation_stats.json
===================================================================
--- simulator/config/hero_generation_stats.json prev run
+++ simulator/config/hero_generation_stats.json this run
@@ -55,6 +55,12 @@
"attack": 650.52,
"defense": 650.52,
"lethality": 160.5,
"health": 160.5
+ },
+ "S8": {
+ "attack": 780.62,
+ "defense": 780.62,
+ "lethality": 193,
+ "health": 193
}
}
Index: simulator/src/classifier.ts
===================================================================
--- simulator/src/classifier.ts prev run
+++ simulator/src/classifier.ts this run
@@ -1,12 +1,11 @@
import type { ActiveEffect, DamageJob, SideId, UnitType } from "./types";
import { unitMaskHas } from "./types";
-import { bucketDefinition, type BucketName } from "./damageBuckets";
-import { staticPassiveBucketRole, type StaticPassiveBucket } 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;
}
@@ -21,19 +20,20 @@
if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };
if (type === "attack_order") return { kind: "battle_order" };
const definition = bucketDefinition(type);
- const staticPassiveRole = staticPassiveBucketRole(type);
- if ((!definition || definition.valueType !== "pct") && !staticPassiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };
- if (staticPassiveRole) {
- if (staticPassiveRole === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };
- if (staticPassiveRole === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };
+ 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: type };
- if (definition.role === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type };
+ 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 {
@@ -44,24 +44,24 @@
return true;
}
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;
}
Index: simulator/src/classifierDamage.test.ts
===================================================================
--- simulator/src/classifierDamage.test.ts prev run
+++ simulator/src/classifierDamage.test.ts this run
@@ -1,13 +1,14 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { classifyEffectForJob } from "./classifier";
-import { calculateDamageJob } from "./damage";
-import { ATOMIC_BUCKETS } from "./damageBuckets";
-import { createEffectIndex, indexEffect } from "./effectIndex";
-import { activateEffect, evolvingActiveEffectValuePct } from "./effects";
-import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";
+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 = {
@@ -18,12 +19,12 @@
roundStartTroops: {
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
};
test("resolved effect scope matches concrete side and unit masks", () => {
@@ -50,17 +51,17 @@
assert.equal(
classifyEffectForJob(active, {
...job,
id: "job-attacker-marksman",
- attackerUnit: "marksman"
+ dealerUnit: "marksman"
})?.reason,
"not_applicable_to_job"
);
assert.equal(
classifyEffectForJob(active, {
...job,
id: "job-defender-marksman",
- defenderUnit: "marksman"
+ takerUnit: "marksman"
})?.reason,
"not_applicable_to_job"
);
});
@@ -98,20 +99,51 @@
function calculateIndexedDamageJob(
damageJob: DamageJob,
fighters: Record<"attacker" | "defender", ResolvedFighter>,
effects: ActiveEffect[],
- options: Partial<Parameters<typeof calculateDamageJob>[3]> = {}
+ options: Omit<Partial<Parameters<typeof calculateDamageJob>[2]>, "recorder"> & { recorder?: BattleRecorder; trace?: boolean } = {}
) {
- const effectIndex = options.effectIndex ?? createEffectIndex();
+ const effectIndex = options.effectIndex ?? preparedEffectIndex(effects);
if (!options.effectIndex) {
- for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);
+ for (const activeEffect of effects) {
+ if (activeEffect.damageIndexGroup) indexEffect(effectIndex, activeEffect);
+ }
}
const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
const usedEffects = options.usedEffects ?? [];
- const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });
- return { ...result, usedEffectIds: usedEffects.map((usedEffect) => usedEffect.id) };
+ 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");
@@ -125,9 +157,9 @@
assert.equal(classifyEffectForJob(effect("active.troop.lethality.up", "attacker", 10, "troop_skill"), job)?.bucket, "active.troop.lethality.up");
});
test("classifier routes the complete native bucket policy into atomic buckets", () => {
- const atomicBuckets = new Set(ATOMIC_BUCKETS);
+ const 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"],
@@ -190,9 +222,9 @@
for (const [type, expectedBucket] of passiveExpected) {
const ownerSide = type.includes(".attack.") || type.includes(".lethality.") ? "attacker" : "defender";
const bucket = classifyEffectForJob(effect(type, ownerSide), job)?.bucket;
assert.equal(bucket, expectedBucket, type);
- assert.ok(bucket !== undefined && (STATIC_PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a static passive bucket`);
+ assert.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");
});
@@ -200,24 +232,24 @@
test("passive stat bonuses only apply in the damage role that consumes that stat", () => {
const reversedJob: DamageJob = {
...job,
id: "job-reversed",
- attackerSide: "defender",
- attackerUnit: "lancer",
- defenderSide: "attacker",
- defenderUnit: "infantry"
+ 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", () => {
const attacker: ResolvedFighter = {
@@ -276,9 +308,9 @@
});
test("traced damage uses the effect index for applied bucket candidates", () => {
const indexedEffect = effect("active.hero.attack.up", "attacker", 100);
- const index = createEffectIndex();
+ const index = preparedEffectIndex([indexedEffect]);
indexEffect(index, indexedEffect);
const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true, effectIndex: index });
@@ -300,14 +332,14 @@
const outcome = calculateIndexedDamageJob(job, fighters, [passiveAttack(20), passiveAttack(10), passiveAttack(-5)], { trace: true });
assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);
assert.equal(outcome.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
- assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.3);
- assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.down"].factor, 1.05);
+ assert.equal(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;
fighters.defender.statBonuses.lancer.health = 40;
@@ -320,20 +352,91 @@
effect("passive.health.up", "defender", 15),
effect("passive.health.down", "defender", 30),
effect("passive.defense.up", "defender", 5)
];
- const baseline = calculateIndexedDamageJob(job, fighters, passives, { trace: true });
- const profile = buildStaticDamageProfile(fighters, passives);
- const profiled = calculateIndexedDamageJob(job, fighters, passives, { trace: true, staticDamageProfile: profile });
+ 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 };
const stronger = { ...effect("passive.attack.up", "attacker", 50), id: "strong", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };
@@ -398,10 +501,10 @@
const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [passiveHealthDown], { trace: true });
assert.equal(outcome.trace?.atomicBuckets["passive.health.down"].totalPct, 105);
- assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].factor, 2.05);
- assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].placement, "numerator");
+ assert.equal(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", () => {
@@ -549,8 +652,33 @@
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",
@@ -565,19 +693,20 @@
{
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",
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
orderIndex: 0,
previousAttackCount: 0,
projectedAttackCount: 1,
previousReceivedAttackCount: 0,
@@ -605,19 +734,20 @@
{
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",
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
orderIndex: 0,
previousAttackCount: 0,
projectedAttackCount: 1,
previousReceivedAttackCount: 0,
@@ -644,19 +774,20 @@
{
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",
- attackerSide: "attacker",
- attackerUnit: "infantry",
- defenderSide: "defender",
- defenderUnit: "lancer",
+ dealerSide: "attacker",
+ dealerUnit: "infantry",
+ takerSide: "defender",
+ takerUnit: "lancer",
orderIndex: 0,
previousAttackCount: 0,
projectedAttackCount: 1,
previousReceivedAttackCount: 0,
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -176,9 +176,9 @@
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
});
@@ -197,14 +197,45 @@
value_evolution: { type: "fixed_decay", step: "round", value: 1 }
},
{ type: "battle_start" }
);
+ const emptyDurationRoot = writeConfigWithTroopEffect(
+ {
+ type: "passive.attack.up",
+ value: 10,
+ duration: {}
+ },
+ { type: "battle_start" }
+ );
+ const probabilityRoot = writeConfigWithTroopEffect(
+ {
+ type: "passive.attack.up",
+ value: 10
+ },
+ { 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(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", () => {
const root = writeConfigWithTroopEffect({
type: "extra_skill_attack",
value: 100,
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -6,12 +6,14 @@
import Bahiti from "../config/hero_definitions/Bahiti.json" with { type: "json" };
import Bradley from "../config/hero_definitions/Bradley.json" with { type: "json" };
import Edith from "../config/hero_definitions/Edith.json" with { type: "json" };
import Flint from "../config/hero_definitions/Flint.json" with { type: "json" };
+import Gatot from "../config/hero_definitions/Gatot.json" with { type: "json" };
import Gordon from "../config/hero_definitions/Gordon.json" with { type: "json" };
import Greg from "../config/hero_definitions/Greg.json" with { type: "json" };
import Gwen from "../config/hero_definitions/Gwen.json" with { type: "json" };
import Hector from "../config/hero_definitions/Hector.json" with { type: "json" };
+import Hendrik from "../config/hero_definitions/Hendrik.json" with { type: "json" };
import Jasser from "../config/hero_definitions/Jasser.json" with { type: "json" };
import Jeronimo from "../config/hero_definitions/Jeronimo.json" with { type: "json" };
import Jessie from "../config/hero_definitions/Jessie.json" with { type: "json" };
import Ling from "../config/hero_definitions/Ling.json" with { type: "json" };
@@ -27,20 +29,20 @@
import Reina from "../config/hero_definitions/Reina.json" with { type: "json" };
import Renee from "../config/hero_definitions/Renee.json" with { type: "json" };
import SeoYoon from "../config/hero_definitions/Seo-yoon.json" with { type: "json" };
import Sergey from "../config/hero_definitions/Sergey.json" with { type: "json" };
+import Sonya from "../config/hero_definitions/Sonya.json" with { type: "json" };
import Wayne from "../config/hero_definitions/Wayne.json" with { type: "json" };
import WuMing from "../config/hero_definitions/WuMing.json" with { type: "json" };
import Zinman from "../config/hero_definitions/Zinman.json" with { type: "json" };
import { UNIT_TYPES } from "./types";
import type { ConfigDiagnostics, EffectIntentDefinition, SimulatorConfig, SkillFile, TriggerDamageJobDefinition } from "./types";
-import { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";
-import { assertStaticPassiveEffectDefinition, isPassiveBucket, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";
+import { bucketDefinition, DYNAMIC_EFFECT_BUCKETS, isPassiveBucket, PASSIVE_BUCKETS } from "./damageBuckets";
const KNOWN_EFFECT_TYPES = new Set([
- ...ATOMIC_BUCKETS.filter((bucket) => bucket.startsWith("active.") || bucket.startsWith("type.")),
- ...STATIC_PASSIVE_BUCKETS,
+ ...DYNAMIC_EFFECT_BUCKETS,
+ ...PASSIVE_BUCKETS,
"extra_skill_attack",
"dodge",
"no_attack",
"attack_order"
@@ -52,12 +54,14 @@
Bahiti,
Bradley,
Edith,
Flint,
+ Gatot,
Gordon,
Greg,
Gwen,
Hector,
+ Hendrik,
Jasser,
Jeronimo,
Jessie,
Ling,
@@ -73,8 +77,9 @@
Reina,
Renee,
"Seo-yoon": SeoYoon,
Sergey,
+ Sonya,
Wayne,
WuMing,
Zinman
} as unknown as Record<string, SkillFile>;
@@ -173,9 +178,9 @@
validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
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({
file,
@@ -254,8 +259,40 @@
`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.
+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;
const values = Array.isArray(effect.value) ? effect.value : [effect.value];
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -1,257 +1,178 @@
import type {
ActiveEffect,
- DamageAggregationGroupTrace,
- DamageBucketTrace,
+ ActiveEffectGroup,
DamageEquationTrace,
DamageJob,
ResolvedFighter,
SideId,
UnitType
} from "./types";
import { UNIT_TYPES } from "./types";
-import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";
-import { advanceEffectAttackDelay, sourceLabel } from "./effects";
-import { damageEffectsForJob, type EffectIndex } from "./effectIndex";
import {
- buildStaticDamageProfile,
- type StaticDamageBucket,
- type StaticDamageProfile,
- type StaticDamageProfileEntry
-} from "./staticDamageProfile";
+ 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 { damageJobShapeSlot, damageJobSlot, type EffectIndex } from "./effectIndex";
+import type { BattleRecorder, DamageJobRecorder } from "./recorder";
-type AtomicBuckets = Record<string, DamageBucketTrace>;
-type GroupPlacement = "numerator" | "denominator";
-type DamageDetail = "full" | "fast";
type NumericBucketId = number;
interface DamageFactorTerm {
- id: string;
bucket: NumericBucketId;
- bucketName: AtomicBucket;
- placement: GroupPlacement;
+ role?: BucketRole;
+ placement: "numerator" | "denominator";
appliesTo?: DamageJob["kind"];
}
-interface MaxBucketEffectGroup {
- selected: ActiveEffect;
- effects: ActiveEffect[];
+interface NumericDamageBuckets {
+ factors: Float64Array;
}
-interface DamageExpressionResult {
- rawDamage: number;
- aggregationGroups: Record<string, DamageAggregationGroupTrace>;
+interface CompiledDamageExpression {
+ numeratorSlots: Int32Array;
+ denominatorSlots: Int32Array;
}
-interface NumericDamageBuckets {
+export interface DamageBucketSet {
factors: Float64Array;
- contributors?: DamageBucketTrace["contributors"][];
+ expression: CompiledDamageExpression;
}
export type DamageScratch = NumericDamageBuckets;
+export type StaticDamageBucketFactors = Float64Array;
+export type StaticDamageBucketMatrix = Record<SideId, Record<UnitType, StaticDamageBucketFactors>>;
-// Lean result of a single damage job: kills plus, only when tracing, the recording detail.
+/** 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"];
trace?: DamageEquationTrace;
}
const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;
-const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};
-const 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;
- })
-);
+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 class DamageAggregationError extends Error {
- readonly groupId: string;
- readonly round: number;
- readonly jobId: string;
- readonly netPct: number | undefined;
- readonly factor: number;
- readonly contributors: DamageBucketTrace["contributors"];
-
- constructor(args: {
- groupId: string;
- round: number;
- jobId: string;
- netPct?: number;
- factor: number;
- contributors: DamageBucketTrace["contributors"];
- }) {
- super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor}${args.netPct === undefined ? "" : ` netPct=${args.netPct}`}`);
- this.name = "DamageAggregationError";
- this.groupId = args.groupId;
- this.round = args.round;
- this.jobId = args.jobId;
- this.netPct = args.netPct;
- this.factor = args.factor;
- this.contributors = args.contributors;
- }
-}
-
export function calculateDamageJob(
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
- activeEffects: ActiveEffect[],
- options: { trace?: boolean; recordAppliedEffects?: boolean; 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");
- // The damage math is one path; `trace` only decides whether we also capture the (expensive)
- // per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.
- const traceEnabled = options.trace === true;
- const recordAppliedEffects = traceEnabled || options.recordAppliedEffects === true;
- const detail: DamageDetail = traceEnabled ? "full" : "fast";
- const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);
- const attacker = fighters[job.attackerSide];
- const defender = fighters[job.defenderSide];
- const attackerTroops = job.roundStartTroops[job.attackerSide][job.attackerUnit] ?? 0;
- const defenderTroops = job.roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0;
- const minInitialArmy = Math.max(1, Math.min(totalTroops(attacker.initialTroops), totalTroops(defender.initialTroops)));
- const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));
- const needsTraceBuckets = traceEnabled;
- const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);
- 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);
+ 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 buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();
+ applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);
+ applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
- const appliedEffects: DamageEquationTrace["appliedEffects"] = [];
- const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];
const usedEffects = options.usedEffects ?? [];
applyBucketEffects(
- damageEffectsForJob(options.effectIndex, job),
+ options.effectIndex.damageGroupsByJobShape[damageJobSlot(job)],
job.round,
buckets,
- detail,
- recordAppliedEffects,
- appliedEffects,
- rejectedEffects,
+ recording,
usedEffects
);
- if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
- if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
-
- const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, detail, staticProfile);
+ const rawDamage = evaluateDamageExpressionForJob(job, buckets, staticProfile);
const uncappedKills = Math.max(0, rawDamage);
- const kills = options.capToDefenderTroops === false ? uncappedKills : Math.min(defenderTroops, uncappedKills);
- const trace = traceEnabled
- ? {
- roundStartTroops: {
- attacker: { ...job.roundStartTroops.attacker },
- defender: { ...job.roundStartTroops.defender }
- },
- armyTerm,
- atomicBuckets: toTraceBuckets(buckets, [
- staticProfile.offense[job.attackerSide][job.attackerUnit],
- staticProfile.defense[job.defenderSide][job.defenderUnit]
- ]),
- aggregationGroups,
- appliedEffects,
- rejectedEffects,
- rawDamage,
- finalKills: kills
- }
- : undefined;
-
- return {
- kills,
- appliedEffects: recordAppliedEffects ? appliedEffects : undefined,
- trace
- };
+ const kills = options.capToTakerTroops === false ? uncappedKills : Math.min(takerTroops, uncappedKills);
+ return recording.finish({ job, factors: buckets.factors, armyTerm, rawDamage, kills });
}
function applyBucketEffects(
- effects: ActiveEffect[],
+ groups: ActiveEffectGroup[],
round: number,
buckets: NumericDamageBuckets,
- detail: DamageDetail,
- recordAppliedEffects: boolean,
- appliedEffects: DamageEquationTrace["appliedEffects"],
- rejectedEffects: DamageEquationTrace["rejectedEffects"],
+ recording: DamageJobRecorder,
usedEffects: ActiveEffect[]
): void {
- let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;
- for (const effect of effects) {
- if (effect.expired) continue;
- if (!advanceEffectAttackDelay(effect)) continue;
- if (effect.sameEffectStacking === "max" && effect.stackingKey) {
- maxGroups ??= new Map();
- const key = `${effect.intent.type}:${effect.stackingKey}`;
- const group = maxGroups.get(key);
- if (group) {
- group.effects.push(effect);
- if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;
- } else {
- maxGroups.set(key, { selected: effect, effects: [effect] });
+ for (const group of groups) {
+ const effects = group.effects;
+ if (effects.length === 0) continue;
+ if (group.sameEffectStacking !== "max") {
+ for (const effect of effects) {
+ if (!advanceEffectAttackDelay(effect)) continue;
+ applyBucketEffect(effect, round, buckets, recording, usedEffects);
}
- } else {
- applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
+ continue;
}
+ if (effects.length === 1) {
+ const effect = effects[0];
+ if (advanceEffectAttackDelay(effect)) {
+ applyBucketEffect(effect, round, buckets, recording, usedEffects);
+ }
+ continue;
+ }
+ let selected: ActiveEffect | undefined;
+ 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) {
+ applyBucketEffectGroup(selected, eligible, round, buckets, recording, usedEffects);
+ }
}
- if (!maxGroups) return;
- for (const group of maxGroups.values()) {
- applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
- }
}
-// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the
-// applied percentage. Shared by the single-candidate and max-group paths.
+// 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.
function applySelectedBucket(
selected: ActiveEffect,
round: number,
buckets: NumericDamageBuckets,
- detail: DamageDetail,
- recordAppliedEffects: boolean,
- appliedEffects: DamageEquationTrace["appliedEffects"]
+ recording: DamageJobRecorder
): 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;
- if (detail === "full") {
- buckets.contributors?.[bucketIndex].push({
- effectId: selected.source.effectId ?? selected.id,
- source: sourceLabel(selected),
- sourceSide: selected.ownerSide,
- valuePct: appliedValuePct,
- bucket: selected.intent.type,
- stackingKey: selected.stackingKey,
- sameEffectStacking: selected.sameEffectStacking
- });
- }
- if (appliedValuePct !== 0 && recordAppliedEffects) {
- const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
- kind: "modifier",
- activeEffectId: selected.id,
- effectId: selected.source.effectId ?? selected.id,
- bucket: selected.intent.type,
- valuePct: appliedValuePct,
- source: sourceLabel(selected),
- sourceSide: selected.ownerSide,
- sameEffectStacking: selected.sameEffectStacking
- };
- if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;
- appliedEffects.push(appliedEffect);
- }
+ applyBucketValue(buckets.factors, bucketIndex, appliedValuePct, BUCKET_UPDATE_BY_INDEX);
+ recording.recordModifier(selected, appliedValuePct);
return appliedValuePct;
}
// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]
@@ -259,284 +180,161 @@
function applyBucketEffect(
effect: ActiveEffect,
round: number,
buckets: NumericDamageBuckets,
- detail: DamageDetail,
- recordAppliedEffects: boolean,
- appliedEffects: DamageEquationTrace["appliedEffects"],
- rejectedEffects: DamageEquationTrace["rejectedEffects"],
+ recording: DamageJobRecorder,
usedEffects: ActiveEffect[]
): void {
- const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);
+ const appliedValuePct = applySelectedBucket(effect, round, buckets, recording);
if (appliedValuePct !== 0) {
usedEffects.push(effect);
- } else if (detail === "full") {
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
+ } else {
+ recording.recordRejected(effect, "same_effect_max_superseded");
}
}
function applyBucketEffectGroup(
selected: ActiveEffect,
effects: ActiveEffect[],
round: number,
buckets: NumericDamageBuckets,
- detail: DamageDetail,
- recordAppliedEffects: boolean,
- appliedEffects: DamageEquationTrace["appliedEffects"],
- rejectedEffects: DamageEquationTrace["rejectedEffects"],
+ recording: DamageJobRecorder,
usedEffects: ActiveEffect[]
): void {
- const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);
+ const appliedValuePct = applySelectedBucket(selected, round, buckets, recording);
if (appliedValuePct !== 0) {
// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
for (const effect of effects) {
usedEffects.push(effect);
- if (detail === "full" && effect !== selected) {
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });
- }
+ if (effect !== selected) recording.recordRejected(effect, "same_effect_max_suppressed");
}
- } else if (detail === "full") {
- for (const effect of effects) {
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
- }
+ } else {
+ for (const effect of effects) recording.recordRejected(effect, "same_effect_max_superseded");
}
}
-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 createNumericDamageBuckets(collectTrace: boolean): NumericDamageBuckets {
- const factors = new Float64Array(ATOMIC_BUCKETS.length);
- factors.fill(1);
+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 {
- factors,
- contributors: collectTrace ? Array.from({ length: ATOMIC_BUCKETS.length }, () => []) : undefined
+ 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))
};
}
-export function createFastDamageScratch(): DamageScratch {
- return createNumericDamageBuckets(false);
+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 resetDamageScratch(buckets: DamageScratch): DamageScratch {
- buckets.factors.fill(1);
- return buckets;
+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 appendStaticProfileAppliedEffects(appliedEffects: DamageEquationTrace["appliedEffects"], entry: StaticDamageProfileEntry): void {
- for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {
- if (!bucket.startsWith("passive.") || !term) continue;
- for (const contributor of term.contributors) {
- appliedEffects.push({
- kind: "modifier",
- activeEffectId: contributor.effectId,
- effectId: contributor.effectId,
- bucket,
- valuePct: contributor.valuePct,
- source: contributor.source,
- sourceSide: contributor.sourceSide,
- stackingKey: contributor.stackingKey,
- sameEffectStacking: contributor.sameEffectStacking
- });
- }
- }
+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 evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, detail: DamageDetail, staticProfile?: StaticDamageProfile): DamageExpressionResult {
- if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, detail, 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 {
- rawDamage: numerator / denominator,
- aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets) : EMPTY_AGGREGATION_GROUPS
- };
+function multiplySlots(initial: number, factors: Float64Array, slots: Int32Array): number {
+ let product = initial;
+ for (const slot of slots) product *= factors[slot];
+ return product;
}
-function evaluateProfiledDamageExpression(
- job: DamageJob,
- buckets: NumericDamageBuckets,
- detail: DamageDetail,
- staticProfile: StaticDamageProfile
-): DamageExpressionResult {
- 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 {
- rawDamage: numerator / denominator,
- aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets, staticProfile) : EMPTY_AGGREGATION_GROUPS
- };
+function createNumericDamageBuckets(): NumericDamageBuckets {
+ const factors = new Float64Array(ATOMIC_BUCKETS.length);
+ factors.fill(1);
+ return { factors };
}
-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");
+export function createDamageScratch(): DamageScratch {
+ return createNumericDamageBuckets();
}
-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 ?? []
- });
+/** 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;
}
-function buildAggregationGroups(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): Record<string, DamageAggregationGroupTrace> {
- const aggregationGroups: Record<string, DamageAggregationGroupTrace> = {};
- if (staticProfile) addStaticAggregationGroups(aggregationGroups, job, staticProfile);
- for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS]) {
- if (term.appliesTo && term.appliesTo !== job.kind) continue;
- const definition = BUCKET_DEFINITIONS[term.bucketName];
- const factor = buckets.factors[term.bucket];
- if (definition.valueType === "raw") {
- aggregationGroups[term.id] = {
- id: term.id,
- mode: "raw",
- placement: term.placement,
- inputBuckets: [term.bucketName],
- factor,
- contributors: contributorsForTerm(term, buckets)
- };
- } else {
- const totalPct = pctFromFactor(factor);
- aggregationGroups[term.id] = {
- id: term.id,
- mode: "sum_pct",
- placement: term.placement,
- inputBuckets: [term.bucketName],
- totalPct,
- factor,
- contributors: contributorsForTerm(term, buckets)
- };
- }
- }
- return aggregationGroups;
+/** 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];
}
-function addStaticAggregationGroups(
- aggregationGroups: Record<string, DamageAggregationGroupTrace>,
- job: DamageJob,
- staticProfile: StaticDamageProfile
-): void {
- const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];
- const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];
- addStaticRawGroup(aggregationGroups, "troops.baseAttack", "numerator", offense, "troops.baseAttack");
- addStaticRawGroup(aggregationGroups, "troops.baseLethality", "numerator", offense, "troops.baseLethality");
- addStaticPctGroup(aggregationGroups, "player.attacker.attack", "numerator", offense, "player.attack");
- addStaticPctGroup(aggregationGroups, "player.attacker.lethality", "numerator", offense, "player.lethality");
- addStaticPctGroup(aggregationGroups, "passive.attacker.attack.up", "numerator", offense, "passive.attack.up");
- addStaticPctGroup(aggregationGroups, "passive.attacker.lethality.up", "numerator", offense, "passive.lethality.up");
- addStaticPctGroup(aggregationGroups, "passive.defender.health.down", "numerator", defense, "passive.health.down");
- addStaticPctGroup(aggregationGroups, "passive.defender.defense.down", "numerator", defense, "passive.defense.down");
-
- addStaticRawGroup(aggregationGroups, "troops.baseHealth", "denominator", defense, "troops.baseHealth");
- addStaticRawGroup(aggregationGroups, "troops.baseDefense", "denominator", defense, "troops.baseDefense");
- addStaticPctGroup(aggregationGroups, "player.defender.health", "denominator", defense, "player.health");
- addStaticPctGroup(aggregationGroups, "player.defender.defense", "denominator", defense, "player.defense");
- addStaticPctGroup(aggregationGroups, "passive.attacker.attack.down", "denominator", offense, "passive.attack.down");
- addStaticPctGroup(aggregationGroups, "passive.attacker.lethality.down", "denominator", offense, "passive.lethality.down");
- addStaticPctGroup(aggregationGroups, "passive.defender.health.up", "denominator", defense, "passive.health.up");
- addStaticPctGroup(aggregationGroups, "passive.defender.defense.up", "denominator", defense, "passive.defense.up");
+/** 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 addStaticRawGroup(
- aggregationGroups: Record<string, DamageAggregationGroupTrace>,
- id: string,
- placement: GroupPlacement,
- entry: StaticDamageProfileEntry,
- bucket: StaticDamageBucket
-): void {
- const term = entry.buckets[bucket];
- aggregationGroups[id] = {
- id,
- mode: "raw",
- placement,
- inputBuckets: [bucket],
- factor: Math.max(0, term?.raw ?? 0),
- contributors: term?.contributors ?? []
- };
+function resetDamageScratch(buckets: DamageScratch): DamageScratch {
+ buckets.factors.fill(1);
+ return buckets;
}
-function addStaticPctGroup(
- aggregationGroups: Record<string, DamageAggregationGroupTrace>,
- id: string,
- placement: GroupPlacement,
- entry: StaticDamageProfileEntry,
- bucket: StaticDamageBucket
-): void {
- const term = entry.buckets[bucket];
- const totalPct = term?.totalPct ?? 0;
- aggregationGroups[id] = {
- id,
- mode: "sum_pct",
- placement,
- inputBuckets: [bucket],
- totalPct,
- factor: 1 + totalPct / 100,
- contributors: term?.contributors ?? []
- };
+/** 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 contributorsForTerm(term: DamageFactorTerm, buckets: NumericDamageBuckets): DamageBucketTrace["contributors"] {
- if (!buckets.contributors) return [];
- return buckets.contributors[term.bucket] ?? [];
+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 toTraceBuckets(buckets: NumericDamageBuckets, staticEntries: StaticDamageProfileEntry[] = []): AtomicBuckets {
- const traced = Object.fromEntries(
- ATOMIC_BUCKETS.map((bucket, index) => {
- const contributors = buckets.contributors?.[index] ?? [];
- const definition = BUCKET_DEFINITIONS[bucket];
- const factor = buckets.factors[index];
- if (definition.valueType === "raw") {
- return [bucket, { raw: factor, factor, contributors: [...contributors] }];
- }
- return [bucket, { totalPct: pctFromFactor(factor), factor, contributors: [...contributors] }];
- })
- ) as AtomicBuckets;
- for (const entry of staticEntries) {
- for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {
- if (!term) continue;
- traced[bucket] =
- term.raw !== undefined
- ? { raw: term.raw, factor: Math.max(0, term.raw), contributors: [...term.contributors] }
- : { totalPct: term.totalPct ?? 0, factor: 1 + (term.totalPct ?? 0) / 100, contributors: [...term.contributors] };
- }
- }
- return traced;
+function 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];
}
function totalTroops(troops: Record<UnitType, number>): number {
return UNIT_TYPES.reduce((sum, unit) => sum + (troops[unit] ?? 0), 0);
}
-
-function pctFromFactor(factor: number): number {
- return Number(((factor - 1) * 100).toFixed(12));
-}
Index: simulator/src/damageBuckets.test.ts
===================================================================
--- simulator/src/damageBuckets.test.ts prev run
+++ simulator/src/damageBuckets.test.ts this run
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { createFastDamageScratch } from "./damage";
+import { createDamageScratch } from "./damage";
import { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";
test("active hero and troop damage aliases are supported all-damage buckets", () => {
for (const bucket of [
@@ -13,9 +13,9 @@
"active.troop.damage.up",
"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(bucketDefinition("active.hero.damage.up")?.placement, "numerator");
@@ -33,9 +33,9 @@
assert.equal(bucketDefinition("type.all.damage.down")?.appliesTo, undefined);
});
test("damage scratch stores one factor value per atomic bucket", () => {
- const scratch = createFastDamageScratch() as unknown as {
+ const scratch = createDamageScratch() as unknown as {
factors?: Float64Array;
raw?: unknown;
pct?: unknown;
rawSet?: unknown;
Index: simulator/src/damageBuckets.ts
===================================================================
--- simulator/src/damageBuckets.ts prev run
+++ simulator/src/damageBuckets.ts this run
@@ -1,188 +1,164 @@
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";
-// "static" pools are closed at compile time (base/player/passive): every contributor is
-// known at battle start, so they are aggregated once by the static damage profile.
-// "dynamic" pools are open: triggered/timed/conditional effects can feed them per job,
-// so they are aggregated in the per-job runtime scratch.
-export type BucketPhase = "static" | "dynamic";
+type BucketFamily = "troops" | "player" | "passive" | "active" | "type" | "source" | "aggregate";
-export interface BucketLeaf {
- role: BucketRole;
+export interface DamageAggregationBucketSpec {
+ path: string;
valueType: BucketValueType;
update: BucketUpdate;
placement: BucketPlacement;
appliesTo?: DamageKind;
}
-type BucketTree = {
- [key: string]: BucketLeaf | BucketTree;
-};
+export interface BucketSpec extends DamageAggregationBucketSpec {
+ family: Exclude<BucketFamily, "aggregate">;
+ role: BucketRole;
+}
-export const BUCKETS = {
- troops: {
- count: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
- baseAttack: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
- baseLethality: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },
- baseHealth: { role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" },
- baseDefense: { role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" }
- },
- player: {
- attack: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- lethality: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- health: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- defense: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- passive: {
- attack: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- lethality: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- health: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
- },
- defense: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
- }
- },
- active: {
- hero: {
- attack: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- defense: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
- },
- lethality: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- health: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
- },
- damage: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- damageTaken: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- }
- },
- troop: {
- attack: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- defense: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
- },
- lethality: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- health: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
- },
- damage: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- },
- damageTaken: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
- }
- }
- },
- type: {
- all: {
- damage: {
- up: { role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },
- down: { role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" }
- }
- },
- normal: {
- damage: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" }
- },
- damageTaken: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" }
- }
- },
- skill: {
- damage: {
- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },
- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" }
- },
- damageTaken: {
- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },
- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" }
- }
- }
- },
- source: {
- extraSkill: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" }
- }
-} as const satisfies BucketTree;
-
-export interface BucketDefinition extends BucketLeaf {
- path: string;
- phase: BucketPhase;
+export interface AggregateBucketSpec extends DamageAggregationBucketSpec {
+ family: "aggregate";
}
-// A pool is closed at compile time (static phase) when no triggered/timed/conditional
-// effect can feed it: base troop stats, player stat bonuses, and battle-start passives.
-// Everything else is an open pool aggregated per damage job.
-const STATIC_BUCKET_FAMILIES = ["troops.base", "player.", "passive."];
+// 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;
-function phaseForPath(path: string): BucketPhase {
- return STATIC_BUCKET_FAMILIES.some((family) => path.startsWith(family)) ? "static" : "dynamic";
-}
+// 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" }
+] as const satisfies readonly BucketSpec[];
-export const BUCKET_DEFINITIONS = flattenBuckets(BUCKETS);
-// ATOMIC_BUCKETS is the runtime (open-pool) bucket set used by the per-job scratch and
-// damage expression. Static-phase buckets are aggregated by the static damage profile and
-// never enter the runtime scratch, so they are intentionally excluded here.
-export const ATOMIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS)
- .filter((path) => BUCKET_DEFINITIONS[path].phase === "dynamic")
- .sort();
-export const STATIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS)
- .filter((path) => BUCKET_DEFINITIONS[path].phase === "static")
- .sort();
+// 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" }
+] as const satisfies readonly BucketSpec[];
-export type AtomicBucket = string;
+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;
+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 ATOMIC_BUCKETS = DYNAMIC_BUCKETS.map((bucket) => bucket.path) as AtomicBucket[];
+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[];
+
+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];
+ return BUCKET_DEFINITIONS[path as BucketPath];
}
-function flattenBuckets(tree: BucketTree, prefix = ""): Record<string, BucketDefinition> {
- const buckets: Record<string, BucketDefinition> = {};
- for (const [key, value] of Object.entries(tree)) {
- const path = prefix ? `${prefix}.${key}` : key;
- if (isBucketLeaf(value)) buckets[path] = { ...value, path, phase: phaseForPath(path) };
- else Object.assign(buckets, flattenBuckets(value, path));
- }
- return buckets;
+export function isPassiveBucket(bucket: string): bucket is PassiveBucket {
+ const definition = BUCKET_DEFINITIONS[bucket as PassiveBucket];
+ return definition?.phase === "static" && definition.family === "passive";
}
-function isBucketLeaf(value: BucketLeaf | BucketTree): value is BucketLeaf {
- return "role" in value && "valueType" in value && "update" in value && "placement" in value;
+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);
+}
Index: simulator/src/effectIndex.test.ts
===================================================================
--- simulator/src/effectIndex.test.ts prev run
+++ simulator/src/effectIndex.test.ts this run
@@ -1,13 +1,13 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { cloneEffectIndex, createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";
+import { createEffectIndex, damageJobSlot, damageShapeSlotsForEffect, expireEffectIndex, indexEffect } from "./effectIndex";
+import { resolvedEffectScopeKey } from "./effects";
import { unitMask } from "./types";
import type { ActiveEffect, DamageJob } from "./types";
test("effect index returns bucket-tagged candidates from a direct job-shape lookup", () => {
- const index = createEffectIndex();
const effect: ActiveEffect = {
id: "effect-1",
source: { kind: "hero_skill", side: "attacker", effectId: "boost" },
intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },
@@ -25,8 +25,9 @@
uses: 0,
stackingKey: "stack",
sameEffectStacking: "add"
};
+ const index = preparedIndex([effect]);
indexEffect(index, effect);
const job: DamageJob = {
id: "job-1",
@@ -36,44 +37,40 @@
roundStartTroops: {
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(damageEffectsForJob(index, job), [effect]);
+ assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job)].flatMap((group) => group.effects), [effect]);
});
-test("effect index excludes static-profile bucket effects", () => {
- const index = createEffectIndex();
+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");
- indexEffect(index, passive);
+ const index = preparedIndex([passive, active]);
indexEffect(index, active);
- assert.deepEqual(damageEffectsForJob(index, job()), [active]);
+ assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job())].flatMap((group) => group.effects), [active]);
});
-test("cloned effect index remaps shared effects while owning its bucket arrays", () => {
- const index = createEffectIndex();
- const original = effect("active.hero.lethality.up");
- const replacement = { ...original };
- indexEffect(index, original);
+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 index = preparedIndex([first, second]);
+ indexEffect(index, first);
+ indexEffect(index, second);
- const clone = cloneEffectIndex(index, (candidate) => {
- assert.equal(candidate, original);
- return replacement;
- });
- const originalCandidates = damageEffectsForJob(index, job());
- const clonedCandidates = damageEffectsForJob(clone, job());
+ const group = index.damageGroupsByJobShape[damageJobSlot(job())][0];
+ assert.deepEqual(group.effects, [first, second]);
+ expireEffectIndex(index, first);
- assert.notEqual(clonedCandidates, originalCandidates);
- assert.deepEqual(clonedCandidates, [replacement]);
- clonedCandidates.push(effect("active.hero.attack.up"));
- assert.deepEqual(originalCandidates, [original]);
+ assert.deepEqual(group.effects, [second]);
+ assert.equal(second.damageIndexPosition, 0);
+ assert.equal(first.damageIndexGroup, undefined);
});
function effect(type: string): ActiveEffect {
return {
@@ -96,8 +93,32 @@
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"]>>();
+ 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)}`;
+ let group = byResolvedGroup.get(key);
+ if (!group) {
+ group = {
+ effects: [],
+ bucketIndex: 0,
+ sameEffectStacking: effect.sameEffectStacking
+ };
+ byResolvedGroup.set(key, group);
+ groups.push(group);
+ for (const slot of slots) byShape[slot].push(group);
+ }
+ effect.damageIndexGroup = group;
+ }
+ return createEffectIndex(groups, byShape);
+}
+
function job(): DamageJob {
return {
id: "job-1",
round: 1,
@@ -106,10 +127,10 @@
roundStartTroops: {
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"
};
}
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -1,34 +1,54 @@
-import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";
+import type { ActiveEffect, ActiveEffectGroup, DamageJob, DamageKind, SideId, UnitType } from "./types";
import { unitsFromMask } from "./types";
import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";
export interface EffectIndex {
- damageByJobShape: Array<ActiveEffect[] | undefined>;
+ // Prepared stable graph. Repeated synchronous runs reuse these group references and
+ // reset only their live activation arrays.
+ damageGroupsByJobShape: ActiveEffectGroup[][];
+ damageGroups: ActiveEffectGroup[];
controls: ActiveEffect[];
extraAttacks: ActiveEffect[];
battleOrder: ActiveEffect[];
}
export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;
-const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));
-export function createEffectIndex(): EffectIndex {
+export function createEffectIndex(
+ damageGroups: ActiveEffectGroup[] = [],
+ damageGroupsByJobShape: ActiveEffectGroup[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => [])
+): EffectIndex {
+ for (const group of damageGroups) group.effects.length = 0;
return {
- damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),
+ damageGroupsByJobShape,
+ damageGroups,
controls: [],
extraAttacks: [],
battleOrder: []
};
}
-export function cloneEffectIndex(index: EffectIndex, cloneEffect: (effect: ActiveEffect) => ActiveEffect): EffectIndex {
- return {
- damageByJobShape: index.damageByJobShape.map((effects) => effects?.map(cloneEffect)),
+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") {
@@ -43,19 +63,13 @@
index.battleOrder.push(effect);
return;
}
- const definition = bucketDefinition(effect.intent.type);
- // Static-phase buckets (passive.*) are aggregated by the static damage profile, not the
- // per-job runtime path, so they must not enter the damage job-shape index.
- if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;
- effect.bucketIndex = BUCKET_INDEX.get(definition.path) ?? -1;
-
- for (const slot of shapeSlotsFor(effect, definition.path)) {
- const arr = index.damageByJobShape[slot];
- if (arr) arr.push(effect);
- else index.damageByJobShape[slot] = [effect];
- }
+ const group = effect.damageIndexGroup;
+ if (!group) throw new Error(`Runtime modifier ${effect.id} has no prepared damage group`);
+ 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;
@@ -67,45 +81,60 @@
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);
}
-export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {
- return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];
+export function damageJobSlot(job: DamageJob): number {
+ 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 SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();
-function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
+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 damageShapeSlotsForEffect(effect: ActiveEffect, bucketOverride?: AtomicBucket): Uint8Array {
+ const runtimeDefinition = bucketOverride === undefined ? bucketDefinition(effect.intent.type) : undefined;
+ if (!bucketOverride && (!runtimeDefinition || runtimeDefinition.valueType !== "pct" || runtimeDefinition.phase === "static")) {
+ return EMPTY_JOB_SHAPE_SLOTS;
+ }
+ const bucket = (bucketOverride ?? runtimeDefinition?.path) as AtomicBucket | 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 +
(effect.appliesVs.units & 7);
- const cached = SHAPE_SLOTS_CACHE.get(key);
+ const cached = JOB_SHAPE_CACHE.get(key);
if (cached) return cached;
const slots = buildShapeSlots(effect, bucket);
- SHAPE_SLOTS_CACHE.set(key, slots);
+ JOB_SHAPE_CACHE.set(key, slots);
return slots;
}
+const EMPTY_JOB_SHAPE_SLOTS = new Uint8Array();
+
function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
const definition = bucketDefinition(bucket)!;
const slots: number[] = [];
const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["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 === "attacker"
+ definition.role === "dealer"
? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
: damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)
);
}
@@ -125,4 +154,19 @@
function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {
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;
+ if (!group || position === undefined) return;
+ const lastPosition = group.effects.length - 1;
+ const moved = group.effects[lastPosition];
+ if (position !== lastPosition) {
+ group.effects[position] = moved;
+ moved.damageIndexPosition = position;
+ }
+ group.effects.pop();
+ effect.damageIndexGroup = undefined;
+ effect.damageIndexPosition = undefined;
+}
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -4,8 +4,9 @@
AttackIntent,
EffectDuration,
EffectIntentDefinition,
EvolvingActiveEffect,
+ ResolvedEffectIntentDefinition,
ResolvedSkill,
ResolvedUnitScope,
SameEffectStacking,
SideId,
@@ -55,10 +56,10 @@
if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every, trigger.first)) return false;
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)
);
}
export function compiledTriggerForSkill(skill: ResolvedSkill): NonNullable<ResolvedSkill["compiledTrigger"]> {
@@ -110,9 +111,9 @@
if (previous < first) return true;
return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);
}
-function compiledActivation(skill: ResolvedSkill, intent: EffectIntentDefinition): CompiledActivation {
+function compiledActivation(skill: ResolvedSkill, intent: ResolvedEffectIntentDefinition): CompiledActivation {
const cached = ACTIVATION_CACHE.get(intent);
if (cached) return cached;
const units = intent.units ?? {};
const ownerSide = skill.side;
@@ -140,15 +141,9 @@
ownerSide,
kind: effectKind,
initialValuePct: finiteNumberOrZero(intent.value),
getCurrentValuePct: evolution ? evolvingActiveEffectValuePct : constantActiveEffectValuePct,
- valueEvolution: evolution
- ? {
- type: evolution.type,
- step: evolution.step,
- amount: finiteNumberOrZero(evolution.value)
- }
- : undefined,
+ valueEvolution: evolution ? { type: evolution.type, step: evolution.step, amount: finiteNumberOrZero(evolution.value) } : undefined,
triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,
duration,
turnDelay: Math.max(0, duration.turns?.delay ?? 0),
attackDelay: duration.attacks?.delay ?? 0,
@@ -163,9 +158,9 @@
ACTIVATION_CACHE.set(intent, compiled);
return compiled;
}
-export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {
+export function activateEffect(skill: ResolvedSkill, intent: ResolvedEffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {
const compiled = compiledActivation(skill, intent);
let appliesTo = compiled.staticAppliesTo;
let appliesVs = compiled.staticAppliesVs;
if (compiled.intentScoped && attackIntent) {
@@ -191,14 +186,28 @@
duration: compiled.duration,
remainingAttackDelay: compiled.attackDelay,
uses: 0,
stackingKey: compiled.stackingKey,
- sameEffectStacking: compiled.sameEffectStacking
+ sameEffectStacking: compiled.sameEffectStacking,
+ damageIndexGroup: compiled.kind === "modifier"
+ ? intent.damageGroup ?? intent.damageGroupsByScopeKey?.[resolvedEffectScopeKey(appliesTo, appliesVs)]
+ : undefined,
+ damageIndexPosition: undefined
};
if (!compiled.valueEvolution) return effect;
return { ...effect, valueEvolution: compiled.valueEvolution } as EvolvingActiveEffect;
}
+// Flatten both resolved usage scopes into a compact preparation-time lookup key.
+// Unit masks occupy three bits, so the full key space is 2 * 8 * 2 * 8 = 256.
+export function resolvedEffectScopeKey(appliesTo: ResolvedUnitScope, appliesVs: ResolvedUnitScope): number {
+ return ((sideIndex(appliesTo.side) * 8 + appliesTo.units) * 2 + sideIndex(appliesVs.side)) * 8 + appliesVs.units;
+}
+
+function sideIndex(side: SideId): number {
+ return side === "attacker" ? 0 : 1;
+}
+
function skillActivationSourceKey(skill: ResolvedSkill): string {
return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";
}
@@ -305,12 +314,12 @@
if (role === "applies_vs" && value === "all") {
throw new Error('effect units.applies_vs cannot be "all"; use "any" for an unrestricted usage gate');
}
if ((value === "trigger.source" || value === "trigger") && attackIntent) {
- return { side: attackIntent.attackerSide, units: unitMask(attackIntent.attackerUnit) };
+ 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");
const side = sideForTriggerRelation(ownerSide, selector.relation);
Index: simulator/src/gen8Heroes.test.ts
===================================================================
--- simulator/src/gen8Heroes.test.ts prev run
+++ simulator/src/gen8Heroes.test.ts this run
@@ -0,0 +1,89 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { loadSimulatorConfig } from "./config";
+import { resolveFighter } from "./resolve";
+
+test("Gen8 heroes are registered with the shared S8 expedition stats", () => {
+ const config = loadSimulatorConfig();
+
+ assert.deepEqual(config.heroGenerationStats.S8, {
+ attack: 780.62,
+ defense: 780.62,
+ lethality: 193,
+ health: 193
+ });
+ assert.deepEqual(
+ ["Gatot", "Sonya", "Hendrik"].map((name) => [name, config.heroDefinitions[name]?.hero_generation]),
+ [
+ ["Gatot", "S8"],
+ ["Sonya", "S8"],
+ ["Hendrik", "S8"]
+ ]
+ );
+});
+
+test("Gen8 widget skills are fourth, passive, and engagement-gated", () => {
+ const config = loadSimulatorConfig();
+ const expectations = [
+ ["Gatot", "IndestructibleCity", "garrison", "passive.defense.up"],
+ ["Sonya", "VortexTurret", "garrison", "passive.lethality.up"],
+ ["Hendrik", "AbyssalBlessing", "rally", "passive.attack.up"]
+ ] as const;
+
+ for (const [heroName, skillName, engagement, effectType] of expectations) {
+ const definition = config.heroDefinitions[heroName];
+ const skillEntries = Object.entries(definition.skills);
+ assert.equal(skillEntries.length, 4);
+ assert.equal(skillEntries[3][0], skillName);
+ assert.equal(Object.values(skillEntries[3][1].effects)[0].type, effectType);
+ assert.deepEqual(skillEntries[3][1].requirements, [
+ { level: 1, type: "engagement_type", value: engagement }
+ ]);
+
+ const matchingSide = engagement === "rally" ? "attacker" : "defender";
+ const gatedOutSide = engagement === "rally" ? "defender" : "attacker";
+ const matching = resolveFighter(
+ { troops: { infantry_t1: 1 }, heroes: { [heroName]: { skill_4: 5 } } },
+ matchingSide,
+ config,
+ "rally"
+ );
+ const gatedOut = resolveFighter(
+ { troops: { infantry_t1: 1 }, heroes: { [heroName]: { skill_4: 5 } } },
+ gatedOutSide,
+ config,
+ "rally"
+ );
+ assert.deepEqual(matching.heroes[0]?.skillIds, [skillName]);
+ assert.deepEqual(gatedOut.heroes[0]?.skillIds, []);
+ }
+});
+
+test("Gen8 combat skill values and active effect buckets match their definitions", () => {
+ const config = loadSimulatorConfig();
+ const gatot = config.heroDefinitions.Gatot.skills;
+ const sonya = config.heroDefinitions.Sonya.skills;
+ const hendrik = config.heroDefinitions.Hendrik.skills;
+
+ 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.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.deepEqual(sonya.BountyTemptation.effects["BountyTemptation/1"].value, [15, 30, 45, 60, 75]);
+ 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/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");
+});
Index: simulator/src/index.ts
===================================================================
--- simulator/src/index.ts prev run
+++ simulator/src/index.ts this run
@@ -1,12 +1,11 @@
export { buildSimulatorConfig, loadSimulatorConfig } from "./config";
export type { RawSimulatorConfig } from "./config";
-export { bearFighterInput, prepareBattle, runPrepared, simulateBattle, 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";
Index: simulator/src/recorder.test.ts
===================================================================
--- simulator/src/recorder.test.ts prev run
+++ simulator/src/recorder.test.ts this run
@@ -0,0 +1,29 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ BasicInfoRecorder,
+ createRecorder,
+ FullTraceRecorder,
+ NULL_RECORDER,
+ NullRecorder
+} from "./recorder";
+
+const unusedResolved = () => {
+ throw new Error("recorder selection test does not resolve a battle");
+};
+
+test("createRecorder selects one explicit recorder implementation per simulation mode", () => {
+ const fast = createRecorder("fast", [], unusedResolved);
+ const standard = createRecorder("standard", [], unusedResolved);
+ const trace = createRecorder("trace", [], unusedResolved);
+
+ assert.equal(fast, NULL_RECORDER);
+ assert.ok(fast instanceof NullRecorder);
+ assert.ok(standard instanceof BasicInfoRecorder);
+ assert.equal(standard instanceof FullTraceRecorder, false);
+ assert.ok(trace instanceof FullTraceRecorder);
+ assert.deepEqual(fast.skillReport, { attacker: [], defender: [] });
+ assert.deepEqual(standard.skillReport, { attacker: [], defender: [] });
+ assert.deepEqual(trace.skillReport, { attacker: [], defender: [] });
+});
Index: simulator/src/recorder.ts
===================================================================
--- simulator/src/recorder.ts prev run
+++ simulator/src/recorder.ts this run
@@ -1,126 +1,766 @@
+import type { DamageResult } from "./damage";
+import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, pctBucketFactor, rawBucketFactor, type AtomicBucket, 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,
AttackIntent,
AttackOutcome,
BattleTrace,
+ DamageAggregationGroupTrace,
+ DamageBucketTrace,
+ DamageEquationTrace,
DamageJob,
+ ResolvedFighter,
+ ResolvedSkill,
SideId,
SkillReportEntry,
- SimulationMode
+ SimulationMode,
+ UnitType
} from "./types";
-import type { DamageResult } from "./damage";
+type RejectedEffectReason = "same_effect_max_suppressed" | "same_effect_max_superseded";
+type GroupPlacement = "numerator" | "denominator";
+type NumericBucketId = number;
+
+interface DamageFactorTerm {
+ id: string;
+ bucket: NumericBucketId;
+ bucketName: AtomicBucket;
+ placement: GroupPlacement;
+ appliesTo?: DamageJob["kind"];
+}
+
+export interface DamageRecordingResult {
+ job: DamageJob;
+ factors: Float64Array;
+ armyTerm: 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;
+ recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): 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 a battle and accumulates whatever the chosen mode needs to report. The battle loop
- * does all simulation-affecting work itself (kills, counters, effect usage charging); the recorder
- * only records, so swapping recorders never changes the outcome. This keeps a single, linear
- * loop with no `if (mode === ...)` branches scattered through it.
- *
- * - fast -> NULL_RECORDER: records nothing; zero per-attack allocation.
- * - standard -> RecordingRecorder: per-attack AttackOutcome[] (no equation traces).
- * - trace -> RecordingRecorder + capturesTrace: outcomes with traces + per-round trace.
+ * 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 {
- /** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */
- readonly capturesTrace: boolean;
- /** When true the loop records lightweight "effect applied" events on each AttackOutcome. */
- readonly capturesAppliedEffects: boolean;
- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;
- recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;
- recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;
+ /**
+ * 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;
+ recordSkillEffectActivated(skill: ResolvedSkill): void;
+ recordSkillDamageJob(job: DamageJob, effect: ActiveEffect): void;
+ recordBattleOrder(intentId: string, effect: ActiveEffect, chosenTarget: UnitType): void;
+ recordScheduledDamageJob(job: DamageJob): void;
+ recordExtraAttack(normalJob: DamageJob, effect: ActiveEffect, jobs: DamageJob[], firstJobIndex: number, jobCount: number, processedJobIds: Set<string>): void;
+ recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "dodge" | "no_attack"): void;
+ recordDamageJob(job: DamageJob, result: DamageResult): void;
+ recordFinalKills(result: DamageResult): void;
+ recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[]): void;
readonly attacks: AttackOutcome[];
+ readonly skillReport: Record<SideId, SkillReportEntry[]>;
readonly trace: BattleTrace | undefined;
}
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>();
-export const NULL_RECORDER: BattleRecorder = {
- capturesTrace: false,
- capturesAppliedEffects: false,
- recordCancelled() {},
- recordDamageJob() {},
- recordRound() {},
- attacks: NO_ATTACKS,
- trace: undefined
+const NULL_DAMAGE_JOB_RECORDER: DamageJobRecorder = {
+ recordModifier() {},
+ recordRejected() {},
+ finish({ kills }) {
+ return { kills };
+ }
};
+export class NullRecorder implements BattleRecorder {
+ readonly attacks = NO_ATTACKS;
+ readonly skillReport = EMPTY_SKILL_REPORT;
+ readonly trace = undefined;
+
+ startDamageJob(): DamageJobRecorder {
+ return NULL_DAMAGE_JOB_RECORDER;
+ }
+
+ recordStaticProfile() {}
+ recordBattleOrder() {}
+ recordSkillTriggerAttempt() {}
+ recordSkillTriggered() {}
+ recordSkillEffectActivated() {}
+ recordSkillDamageJob() {}
+ recordScheduledDamageJob() {}
+ recordExtraAttack() {}
+ recordCancelled() {}
+ recordDamageJob() {}
+ recordFinalKills() {}
+ recordRound() {}
+}
+
+export const NULL_RECORDER: BattleRecorder = new NullRecorder();
+
export function createRecorder(
mode: SimulationMode,
- skillReports: Record<SideId, Map<string, SkillReportEntry>>,
+ fighters: ResolvedFighter[],
makeResolved: () => BattleTrace["resolved"]
): BattleRecorder {
if (mode === "fast") return NULL_RECORDER;
- return new RecordingRecorder(skillReports, mode === "trace" ? { resolved: makeResolved(), rounds: [] } : undefined);
+ if (mode === "trace") return new FullTraceRecorder(fighters, makeResolved);
+ return new BasicInfoRecorder(fighters);
}
-class RecordingRecorder implements BattleRecorder {
+export class BasicInfoRecorder implements BattleRecorder {
readonly attacks: AttackOutcome[] = [];
- readonly trace: BattleTrace | undefined;
- readonly capturesTrace: boolean;
- readonly capturesAppliedEffects = true;
+ 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>> = {
+ attacker: new Map(),
+ defender: new Map()
+ };
+ protected readonly skillDamageKeys = new Map<string, string>();
- constructor(
- private readonly skillReports: Record<SideId, Map<string, SkillReportEntry>>,
- trace: BattleTrace | undefined
- ) {
- this.trace = trace;
- this.capturesTrace = trace !== undefined;
+ constructor(fighters: ResolvedFighter[] = []) {
+ for (const fighter of fighters) {
+ for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {
+ this.skillReports[fighter.side].set(skillReportKey(skill), {
+ sourceKind: skill.sourceKind,
+ heroName: skill.heroName,
+ troopType: skill.troopType,
+ skillId: skill.id,
+ skillName: skill.name,
+ level: skill.level,
+ triggersSeen: 0,
+ skillActivations: 0,
+ effectActivations: 0,
+ skillKills: 0,
+ unsupportedEffects: []
+ });
+ }
+ }
}
- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {
+ get trace(): BattleTrace | undefined {
+ return undefined;
+ }
+
+ get skillReport(): Record<SideId, SkillReportEntry[]> {
+ return {
+ attacker: [...this.skillReports.attacker.values()],
+ defender: [...this.skillReports.defender.values()]
+ };
+ }
+
+ 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(this.staticApplied);
+ }
+
+ recordSkillTriggerAttempt(_skill: ResolvedSkill): void {}
+
+ recordSkillTriggered(skill: ResolvedSkill): void {
+ this.incrementSkillReport(skill, "skillActivations");
+ }
+
+ recordSkillEffectActivated(skill: ResolvedSkill): void {
+ this.incrementSkillReport(skill, "effectActivations");
+ }
+
+ recordSkillDamageJob(job: DamageJob, effect: ActiveEffect): void {
+ const key = skillReportKeyForEffect(effect);
+ if (key) this.skillDamageKeys.set(job.id, key);
+ }
+
+ recordBattleOrder(intentId: string, effect: ActiveEffect, chosenTarget: UnitType): void {
+ this.orderEvents.set(intentId, {
+ kind: "battle_order",
+ ...appliedEffectBase(effect),
+ 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);
+ if (events) events.push(event);
+ else this.extraAttackEvents.set(normalJob.id, [event]);
+ }
+
+ recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "dodge" | "no_attack"): void {
+ const order = this.orderEvents.get(intent.id);
+ this.orderEvents.delete(intent.id);
this.attacks.push({
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: [
- { side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },
- { side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }
- ],
- appliedEffects,
- cancelledBy: effectId,
+ counterDeltas: counterDeltas(intent, "normal_attack"),
+ appliedEffects: order
+ ? [order, { kind: "control", ...appliedEffectBase(effect), reason }]
+ : [{ kind: "control", ...appliedEffectBase(effect), reason }],
+ cancelledBy: effect.id,
cancelReason: reason
});
}
- recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void {
- if (job.kind === "skill" && job.sourceSkillReportKey && result.kills > 0) {
- const report = this.skillReports[job.attackerSide].get(job.sourceSkillReportKey);
+ 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;
}
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: job.sourceSkillReportKey,
- attackerSide: job.attackerSide,
- attackerUnit: job.attackerUnit,
- defenderSide: job.defenderSide,
- defenderUnit: job.defenderUnit,
+ sourceSkillReportKey,
+ dealerSide: job.dealerSide,
+ dealerUnit: job.dealerUnit,
+ takerSide: job.takerSide,
+ takerUnit: job.takerUnit,
kills: result.kills,
- counterDeltas: [
- { side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause },
- { side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause }
- ],
- appliedEffects: mergeAppliedEffects(result.appliedEffects, extraAppliedEffects),
+ counterDeltas: counterDeltas(job, cause),
+ appliedEffects: mergeAppliedEffects(result.appliedEffects, order, extras),
trace: result.trace
});
}
- recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void {
- this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });
+ recordFinalKills(_result: DamageResult): void {}
+
+ recordRound(_round: number, _roundStartTroops: DamageJob["roundStartTroops"], _intents: AttackIntent[]): void {
+ this.orderEvents.clear();
+ this.extraAttackEvents.clear();
+ this.skillDamageKeys.clear();
}
+
+ protected incrementSkillReport(skill: ResolvedSkill, field: "triggersSeen" | "skillActivations" | "effectActivations"): void {
+ const report = this.skillReports[skill.side].get(skillReportKey(skill));
+ if (report) report[field] += 1;
+ }
}
-function mergeAppliedEffects(applied: AppliedEffect[] | undefined, extra: AppliedEffect[] | undefined): AppliedEffect[] {
- if (!applied?.length) return extra?.length ? extra : NO_APPLIED_EFFECTS;
- if (!extra?.length) return applied;
- return [...applied, ...extra];
+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(this.staticApplied, this.staticDescription);
+ }
+
+ override recordSkillTriggerAttempt(skill: ResolvedSkill): void {
+ this.incrementSkillReport(skill, "triggersSeen");
+ }
+
+ override recordScheduledDamageJob(job: DamageJob): void {
+ const sourceSkillReportKey = this.skillDamageKeys.get(job.id);
+ this.roundJobs.push(sourceSkillReportKey ? { ...job, sourceSkillReportKey } : job);
+ }
+
+ override recordFinalKills(result: DamageResult): void {
+ if (result.trace) result.trace.finalKills = result.kills;
+ }
+
+ override recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[]): void {
+ this.rounds.push({ round, roundStartTroops, intents, jobs: this.roundJobs.splice(0) });
+ super.recordRound(round, roundStartTroops, intents);
+ }
+
+ override get trace(): BattleTrace {
+ return { resolved: this.makeResolved(), rounds: this.rounds };
+ }
}
+
+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 = {
+ 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);
+ }
+
+ 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;
+ 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];
+ }
+}
+
+class FullDamageJobRecorder extends BasicDamageJobRecorder {
+ private readonly contributors: DamageBucketTrace["contributors"][] = Array.from(
+ { length: ATOMIC_BUCKETS.length },
+ () => []
+ );
+ 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,
+ source: sourceLabel(effect),
+ sourceSide: effect.ownerSide,
+ valuePct,
+ bucket: effect.intent.type,
+ stackingKey: effect.stackingKey,
+ sameEffectStacking: effect.sameEffectStacking
+ });
+ super.recordModifier(effect, valuePct);
+ }
+
+ override recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): void {
+ this.rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason });
+ }
+
+ override finish(result: DamageRecordingResult): DamageResult {
+ const basic = super.finish(result);
+ 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: { ...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 ?? [],
+ rejectedEffects: this.rejectedEffects,
+ rawDamage: result.rawDamage,
+ finalKills: result.kills
+ }
+ };
+ }
+}
+
+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_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");
+const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");
+
+function factorTerm(bucket: AtomicBucket): DamageFactorTerm {
+ const definition = BUCKET_DEFINITIONS[bucket];
+ return {
+ id: bucket,
+ bucket: BUCKET_IDS[bucket],
+ bucketName: bucket,
+ placement: definition.placement,
+ appliesTo: definition.appliesTo
+ };
+}
+
+function 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: 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];
+ const factor = factors[term.bucket];
+ groups[term.id] = definition.valueType === "raw"
+ ? {
+ id: term.id,
+ mode: "raw",
+ placement: term.placement,
+ inputBuckets: [term.bucketName],
+ factor,
+ contributors: contributors[term.bucket] ?? []
+ }
+ : {
+ id: term.id,
+ mode: "sum_pct",
+ placement: term.placement,
+ inputBuckets: [term.bucketName],
+ totalPct: pctFromFactor(factor),
+ factor,
+ contributors: contributors[term.bucket] ?? []
+ };
+ }
+ return groups;
+}
+
+function addStaticAggregationGroups(
+ groups: Record<string, DamageAggregationGroupTrace>,
+ staticEntries: StaticProfileEntry[]
+): void {
+ 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: StaticProfileEntry,
+ bucket: StaticDamageBucket
+): void {
+ const term = entry.buckets[bucket];
+ groups[id] = {
+ id,
+ mode: "raw",
+ placement,
+ inputBuckets: [bucket],
+ factor: term?.factor ?? 0,
+ contributors: term?.contributors ?? []
+ };
+}
+
+function addStaticPctGroup(
+ groups: Record<string, DamageAggregationGroupTrace>,
+ id: string,
+ placement: GroupPlacement,
+ entry: StaticProfileEntry,
+ bucket: StaticDamageBucket
+): void {
+ const term = entry.buckets[bucket];
+ groups[id] = {
+ id,
+ mode: "sum_pct",
+ placement,
+ inputBuckets: [bucket],
+ totalPct: term?.totalPct ?? 0,
+ factor: term?.factor ?? 1,
+ contributors: term?.contributors ?? []
+ };
+}
+
+function toTraceBuckets(
+ factors: Float64Array,
+ contributors: DamageBucketTrace["contributors"][],
+ staticEntries: StaticProfileEntry[]
+): Record<string, DamageBucketTrace> {
+ const traced = Object.fromEntries(
+ ATOMIC_BUCKETS.map((bucket, index) => {
+ const definition = BUCKET_DEFINITIONS[bucket];
+ const factor = factors[index];
+ const bucketContributors = contributors[index] ?? [];
+ return definition.valueType === "raw"
+ ? [bucket, { raw: factor, factor, contributors: [...bucketContributors] }]
+ : [bucket, { totalPct: pctFromFactor(factor), factor, contributors: [...bucketContributors] }];
+ })
+ ) as Record<string, DamageBucketTrace>;
+ for (const entry of staticEntries) {
+ 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: term.factor, contributors: [...term.contributors] }
+ : { totalPct: term.totalPct ?? 0, factor: term.factor, contributors: [...term.contributors] };
+ }
+ }
+ 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 counterDeltas(
+ attack: Pick<AttackIntent | DamageJob, "dealerSide" | "dealerUnit" | "takerSide" | "takerUnit">,
+ cause: "normal_attack" | "extra_skill_attack"
+): 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
+): AppliedEffect[] {
+ if (!applied?.length && !order && !extras?.length) return NO_APPLIED_EFFECTS;
+ if (!order && !extras?.length) return applied ?? NO_APPLIED_EFFECTS;
+ if (!applied?.length && !extras?.length) return order ? [order] : NO_APPLIED_EFFECTS;
+ if (!applied?.length && !order) return extras ?? NO_APPLIED_EFFECTS;
+ return [...(applied ?? []), ...(order ? [order] : []), ...(extras ?? [])];
+}
+
+function pctFromFactor(factor: number): number {
+ return Number(((factor - 1) * 100).toFixed(12));
+}
Index: simulator/src/resolve.ts
===================================================================
--- simulator/src/resolve.ts prev run
+++ simulator/src/resolve.ts this run
@@ -3,8 +3,9 @@
FighterInput,
HeroInputCollection,
ResolvedFighter,
ResolvedHero,
+ ResolvedEffectIntentDefinition,
ResolvedSkill,
ResolvedTroopLine,
SideId,
SimulatorConfig,
@@ -297,14 +298,15 @@
troopType?: UnitType,
heroInstanceId?: string,
heroRole?: "main" | "joiner"
): ResolvedSkill {
- const effects: EffectIntentDefinition[] = [];
+ const effects: ResolvedEffectIntentDefinition[] = [];
for (const [effectId, effect] of Object.entries(rawSkill.effects ?? {})) {
effects.push({
id: effectId,
...effect,
- value: levelSelectPreservingOrder(effect.value, level)
+ value: levelSelectPreservingOrder(effect.value, level),
+ sourceDefinition: effect
});
}
return {
id: skillId,
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -5,11 +5,15 @@
import { loadSimulatorConfig } from "./config";
import { createSeededRng, chancePasses } from "./effects";
import { applyHeroGenerationStats, resolveFighter } from "./resolve";
-import { prepareBattle, runPrepared, simulateBattle, 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 = {}) {
+ return runPrepared(prepareBattle(input, config), undefined, options);
+}
+
test("runPrepared reuses the compiled input seed when no override is supplied", () => {
const config = loadSimulatorConfig();
const stats = {
inf: { attack: 200, defense: 200, lethality: 150, health: 150 },
@@ -20,22 +24,40 @@
seed: "runprepared-seed-regression",
attacker: { troops: { infantry_t6: 5000, lancer_t6: 3000, marksman_t6: 4000 }, stats, heroes: { Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } } },
defender: { troops: { infantry_t6: 5000, lancer_t6: 3000, marksman_t6: 4000 }, stats, heroes: { Greg: { skill_1: 5, skill_2: 5 } } }
};
- const direct = simulateBattle(input, config);
+ const compiled = prepareBattle(input, config);
+ const direct = runPrepared(compiled, input.seed);
// Only meaningful if chance triggers make the round loop seed-sensitive.
assert.equal(direct.randomness.deterministic, false, "expected a stochastic battle");
- // runPrepared with no seed override must reproduce simulateBattle using the compiled input seed,
+ // runPrepared with no seed override must reproduce an explicit compiled-input seed,
// not silently fall back to the default seed.
- const prepared = runPrepared(prepareBattle(input, config));
+ const prepared = runPrepared(compiled);
assert.equal(prepared.winner, direct.winner);
assert.equal(prepared.rounds, direct.rounds);
assert.deepEqual(prepared.remaining, direct.remaining);
});
-test("simulateBattle returns structured result for a no-hero battle", () => {
+test("simulateBattles returns the requested replicates and rejects invalid counts", () => {
const config = loadSimulatorConfig();
- const result = simulateBattle(
+ 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(
{
maxRounds: 3,
attacker: {
name: "A",
@@ -62,9 +84,9 @@
assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));
});
test("standard battle outcomes include round and applied effects without full traces", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 1000 },
@@ -94,14 +116,77 @@
}
})
);
- 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);
});
+test("fast, standard, and trace recorders expose their complete output contracts", () => {
+ const input: BattleInput = {
+ maxRounds: 1,
+ seed: "recorder-contract",
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: { RecorderHero: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ }
+ };
+ const config = minimalConfig({
+ RecorderHero: {
+ name: "RecorderHero",
+ troop_type: "infantry",
+ skills: {
+ RecordedBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "active.hero.attack.up",
+ value: 25,
+ units: { applies_to: "self.infantry", applies_vs: "any" }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ const fast = runOnce(input, config, { mode: "fast" });
+ const standard = runOnce(input, config, { mode: "standard" });
+ const trace = runOnce(input, config, { mode: "trace" });
+
+ assert.deepEqual(semanticBattleSummary(fast), semanticBattleSummary(standard));
+ assert.deepEqual(semanticBattleSummary(trace), semanticBattleSummary(standard));
+ assert.deepEqual(fast.attacks, []);
+ assert.deepEqual(fast.skillReport, { attacker: [], defender: [] });
+ 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.trace, undefined);
+
+ const traceAttacksWithoutEquations = trace.attacks.map(({ trace: _equation, ...attack }) => attack);
+ const standardAttacksWithoutEquations = standard.attacks.map(({ trace: _equation, ...attack }) => attack);
+ assert.deepEqual(traceAttacksWithoutEquations, standardAttacksWithoutEquations);
+ 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);
+ assert.equal(traceSkill?.triggersSeen, 1);
+ assert.ok(trace.trace);
+ assert.deepEqual(trace.trace.resolved, trace.resolved);
+ 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);
+});
+
test("simulateBearBattle runs exactly 10 rounds and leaves the bear army unchanged", () => {
const player: FighterInput = {
name: "Player",
troops: { infantry_t1: 100 },
@@ -209,11 +294,11 @@
assert.ok(defensive.attacks.some((attack) => attack.kills > 5000));
assert.ok(damage.score > defensive.score);
});
-test("simulateBattle calculates all same-round damage from the round-start troop snapshot", () => {
+test("runPrepared calculates all same-round damage from the round-start troop snapshot", () => {
const config = loadSimulatorConfig();
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
name: "A",
@@ -244,10 +329,10 @@
assert.equal(result.trace?.rounds[0]?.roundStartTroops.defender.infantry, 1);
assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);
});
-test("simulateBattle skips later attacks against same-round exhausted targets only", () => {
- const result = simulateBattle(
+test("runPrepared skips later attacks against same-round exhausted targets only", () => {
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100, lancer_t1: 100 },
@@ -280,9 +365,9 @@
assert.equal(result.remaining.defender.infantry, 0);
});
test("extra skill attacks against same-round exhausted targets are skipped entirely", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -315,33 +400,33 @@
}),
{ 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);
assert.deepEqual(result.extraSkillAttackJobsByEffect, {});
assert.equal(normalAttack?.appliedEffects.some((effect) => effect.kind === "extra_attack"), false);
assert.equal(result.remaining.defender.lancer, 0);
});
-test("simulateBattle carries fractional casualties between rounds and ceils final survivors", () => {
+test("runPrepared carries fractional casualties between rounds and ceils final survivors", () => {
const config = loadSimulatorConfig();
const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/simple_001_nc.json", import.meta.url));
const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;
const input = testcases.find((testcase) => testcase.test_id === "simple_001");
assert.notEqual(input, undefined);
- const result = simulateBattle(input!, config, { mode: "trace" });
+ const result = runOnce(input!, config, { mode: "trace" });
assert.equal(totalRemaining(result.remaining.attacker) - totalRemaining(result.remaining.defender), -186);
assert.equal(result.remaining.defender.lancer, 186);
assert.equal(result.trace?.rounds[1]?.roundStartTroops.defender.lancer.toFixed(6), "198.003308");
});
-test("simulateBattle defaults to a 1500 round cap when no explicit maxRounds is provided", () => {
- const result = simulateBattle(
+test("runPrepared defaults to a 1500 round cap when no explicit maxRounds is provided", () => {
+ const result = runOnce(
{
attacker: { troops: {}, heroes: {} },
defender: { troops: {}, heroes: {} }
},
@@ -352,9 +437,9 @@
assert.equal(result.rounds, 1500);
});
test("round-start trigger source self.any rolls once then creates one target-locked effect per live unit type", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100, lancer_t1: 100 },
@@ -381,19 +466,61 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const report = result.skillReport.attacker.find((entry) => entry.skillId === "RoundFanout");
assert.equal(report?.triggersSeen, 1);
assert.equal(report?.skillActivations, 1);
assert.equal(report?.effectActivations, 2);
});
+test("failed probability gates are recorded as trigger attempts only in trace mode", () => {
+ const input: BattleInput = {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 100 },
+ heroes: { NeverProc: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ };
+ const config = minimalConfig({
+ NeverProc: {
+ name: "NeverProc",
+ skills: {
+ FailedBattleStart: {
+ trigger: { type: "battle_start", probability: 0 },
+ effects: {
+ buff: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ const standard = runOnce(input, config);
+ const trace = runOnce(input, config, { mode: "trace" });
+ const standardReport = standard.skillReport.attacker.find((entry) => entry.skillId === "FailedBattleStart");
+ const traceReport = trace.skillReport.attacker.find((entry) => entry.skillId === "FailedBattleStart");
+
+ assert.equal(standardReport?.triggersSeen, 0);
+ assert.equal(standardReport?.skillActivations, 0);
+ assert.equal(traceReport?.triggersSeen, 1);
+ assert.equal(traceReport?.skillActivations, 0);
+});
+
test("attack trigger source and target selectors match relative to the skill owner", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100, lancer_t1: 100 },
@@ -420,9 +547,10 @@
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const report = result.skillReport.defender.find((entry) => entry.skillId === "EnemyHitsInfantry");
assert.equal(report?.triggersSeen, 2);
@@ -430,9 +558,9 @@
assert.equal(report?.effectActivations, 2);
});
test("attack-duration effects with a round cap expire at the end of their active turn even when unused", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 4,
attacker: {
troops: { lancer_t1: 1000000 },
@@ -475,18 +603,18 @@
}),
{ 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);
});
test("attack-duration effects with a round cap expire after one applicable attack", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { infantry_t1: 1000000 },
@@ -525,9 +653,9 @@
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 = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { infantry_t1: 1000000 },
@@ -566,9 +694,9 @@
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 = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { infantry_t1: 1000000 },
@@ -617,9 +745,9 @@
assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
});
test("attack delay skips eligible attacks before the effect can apply", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { marksman_t1: 1000000 },
@@ -651,15 +779,16 @@
}
})
);
- 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:"));
});
+
test("attack delay skips eligible damage jobs before a modifier can apply", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { infantry_t1: 1000000 },
@@ -692,17 +821,18 @@
{ mode: "trace" }
);
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);
});
-test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
+
+test("runPrepared reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
const config = loadSimulatorConfig();
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
name: "A",
@@ -776,9 +906,9 @@
assert.deepEqual(bakedFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });
});
test("array joiner heroes preserve duplicate skill instances", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100 },
@@ -860,9 +990,9 @@
assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });
});
test("cancelled attacks report the winning control as an applied effect", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 10 },
@@ -914,9 +1044,9 @@
]);
});
test("no_attack applies_to cancels that unit attacking, not attacks targeting that unit", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 1000, lancer_t1: 1000 },
@@ -950,15 +1080,15 @@
result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
["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
);
});
test("dodge applies_to cancels attacks targeting that unit, not that unit attacking", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 1000 },
@@ -992,15 +1122,15 @@
result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),
["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
);
});
test("attack-declared controls from later intents affect earlier same-round attacks", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 1000 },
@@ -1077,9 +1207,9 @@
}
});
const roundTwoKills = (options: SimulationOptions): number => {
- const result = simulateBattle(input, config, options);
+ const result = runOnce(input, config, options);
const attack = result.attacks.find((entry) => entry.kind === "normal" && entry.jobId.startsWith("r2:attacker:infantry"));
assert.notEqual(attack, undefined);
return attack!.kills;
};
@@ -1089,9 +1219,9 @@
assert.ok(uncharged > charged, `expected uncharged buff to boost round 2 (${uncharged} > ${charged})`);
});
test("cancelled normal attacks advance attack counters for later frequency checks", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 4,
attacker: {
troops: { infantry_t1: 100 },
@@ -1127,9 +1257,9 @@
);
});
test("attack frequency triggers can start at a different first threshold", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 14,
attacker: {
troops: { infantry_t1: 10000 },
@@ -1165,10 +1295,10 @@
);
});
test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {
- const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });
- const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });
+ 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 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");
@@ -1178,9 +1308,9 @@
assert.equal(addRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 2);
});
test("same_effect_stacking max caps duplicate hero instances of the same skill effect", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100 },
@@ -1202,10 +1332,10 @@
assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
});
test("same_effect_stacking max caps overlapping extra skill attacks while add keeps all activations", () => {
- const maxResult = simulateBattle(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"), { mode: "trace" });
- const addResult = simulateBattle(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"), { mode: "trace" });
+ 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" });
const maxRoundTwoSkillJobs = maxResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
const addRoundTwoSkillJobs = addResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
@@ -1215,9 +1345,9 @@
assert.deepEqual(addResult.extraSkillAttackJobsByEffect, { stacking: 3 });
});
test("same_effect_stacking max consumes overlapping attack-duration extra skill effects as one group", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { infantry_t1: 10000 },
@@ -1262,9 +1392,9 @@
assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
});
test("requires_effect is ignored by native simulator effect activation", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 0,
attacker: {
troops: { infantry_t1: 10 },
@@ -1302,9 +1432,9 @@
assert.equal(report?.effectActivations, 2);
});
test("fighter passive effects are added to the static profile after battle_start effects", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100 },
@@ -1338,9 +1468,9 @@
}),
{ 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(
attack?.trace?.atomicBuckets["passive.attack.up"].contributors.map((contributor) => contributor.effectId).sort(),
@@ -1348,9 +1478,9 @@
);
});
test("extra skill attacks with array trigger damage targets hit those defender unit types", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1391,18 +1521,18 @@
);
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"]
]
);
});
test('extra skill attacks with applies_vs "any" keep current-target compatibility', () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1436,15 +1566,15 @@
);
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"]]
);
});
test("skill report attributes kills only to the skill damage source", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1494,15 +1624,19 @@
);
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 = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 1000 },
@@ -1542,9 +1676,9 @@
{ mode: "trace" }
);
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");
assert.equal(Number(defenderLosses.toFixed(6)), 10);
@@ -1558,17 +1692,17 @@
const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;
const input = testcases.find((testcase) => testcase.test_id === "sergey_solo");
if (!input) throw new Error("missing sergey_solo fixture");
- const result = simulateBattle(input, config);
+ const result = runOnce(input, config);
assert.equal(result.winner, "attacker");
assert.equal(result.remaining.attacker.marksman, 1349);
assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });
});
test("committed losses clamp exhausted floating point residue before next-round target selection", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { infantry_t1: 10, lancer_t1: 100 },
@@ -1587,14 +1721,14 @@
minimalConfig(),
{ 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);
assert.equal(roundTwo?.roundStartTroops.attacker.infantry, 0);
@@ -1603,9 +1737,9 @@
test("extra skill trigger damage jobs reject missing runtime selectors", () => {
assert.throws(
() =>
- simulateBattle(
+ runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1643,9 +1777,9 @@
test("extra skill attacks reject missing trigger damage jobs at activation", () => {
assert.throws(
() =>
- simulateBattle(
+ runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1679,9 +1813,9 @@
);
});
test("attack-triggered extra skill attacks activate then resolve trigger damage jobs on normal attack use", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1719,16 +1853,16 @@
const skillJobs = jobs.filter((job) => job.kind === "skill");
assert.equal(normalJobs.length, 2);
assert.equal(skillJobs.length, 1);
- assert.equal(skillJobs[0]?.attackerSide, normalJobs[0]?.attackerSide);
- assert.equal(skillJobs[0]?.attackerUnit, normalJobs[0]?.attackerUnit);
- assert.equal(skillJobs[0]?.defenderSide, normalJobs[0]?.defenderSide);
- assert.equal(skillJobs[0]?.defenderUnit, normalJobs[0]?.defenderUnit);
+ 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", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { marksman_t1: 100 },
@@ -1786,9 +1920,9 @@
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });
});
test("extra skill attack effects cannot be used by later enemy normal attacks", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1822,16 +1956,16 @@
);
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 });
});
test("extra skill trigger damage jobs can resolve to multiple living enemy targets without recursive attack triggers", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1877,17 +2011,17 @@
);
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);
assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "RecursiveGuard")?.triggersSeen, 1);
});
test("extra skill attack consumes one use regardless of multiple generated target jobs", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { marksman_t1: 100 },
@@ -1927,9 +2061,9 @@
test('direct simulator configs reject applies_vs "all" usage gates', () => {
assert.throws(
() =>
- simulateBattle(
+ runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -1963,9 +2097,9 @@
);
});
test("extra skill attack consumes one use when multiple same-round normal attacks match the effect", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100, marksman_t1: 100 },
@@ -2003,9 +2137,9 @@
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });
});
test("extra skill attack applies_vs must match the current normal attack target", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { marksman_t1: 100 },
@@ -2039,16 +2173,16 @@
);
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, {});
});
test("extra skill de-dupe does not suppress unrelated attack-duration effect consumption", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 2,
attacker: {
troops: { marksman_t1: 100 },
@@ -2101,9 +2235,9 @@
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });
});
test("attack-triggered source and target selectors resolve to concrete active scopes", () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100 },
@@ -2134,19 +2268,19 @@
}),
{ 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);
assert.equal(defenderMarksmanAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);
});
test('attack-triggered target selector with applies_vs "any" gates later opposing attacks', () => {
- const result = simulateBattle(
+ const result = runOnce(
{
maxRounds: 1,
attacker: {
troops: { infantry_t1: 100 },
@@ -2177,11 +2311,11 @@
}),
{ 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);
assert.equal(defenderMarksmanAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);
@@ -2216,11 +2350,11 @@
heroes: {}
}
};
- const defaultResult = simulateBattle(input, config);
- const rallyResult = simulateBattle({ ...input, engagement_type: "rally" }, config);
- const garrisonResult = simulateBattle({ ...input, engagement_type: "garrison" }, config);
+ const defaultResult = runOnce(input, config);
+ const rallyResult = runOnce({ ...input, engagement_type: "rally" }, config);
+ const garrisonResult = runOnce({ ...input, engagement_type: "garrison" }, config);
assert.equal(skillActivations(defaultResult, "RallyOnly"), 0);
assert.equal(skillActivations(defaultResult, "GarrisonOnly"), 0);
assert.equal(defaultResult.skillReport.attacker.some((entry) => entry.skillId === "RallyOnly"), false);
@@ -2263,9 +2397,9 @@
heroes: { Gated: { skill_1: 1, skill_2: 1 } }
}
};
- const result = simulateBattle(input, config);
+ const result = runOnce(input, config);
const attackerRally = result.skillReport.attacker.find((entry) => entry.skillId === "RallyOnly");
const defenderGarrison = result.skillReport.defender.find((entry) => entry.skillId === "GarrisonOnly");
@@ -2276,9 +2410,9 @@
assert.equal(result.skillReport.defender.some((entry) => entry.skillId === "RallyOnly"), false);
assert.equal(Boolean(defenderGarrison), true);
});
-test("simulateBattle identifies stochastic battles from resolved chance triggers", () => {
+test("runPrepared identifies stochastic battles from resolved chance triggers", () => {
const config = minimalConfig({
Chance: {
name: "Chance",
skills: {
@@ -2306,10 +2440,10 @@
heroes: {}
}
};
- const chanceResult = simulateBattle(input, config);
- const gatedOnlyResult = simulateBattle(
+ const chanceResult = runOnce(input, config);
+ const gatedOnlyResult = runOnce(
{
...input,
attacker: { ...input.attacker, heroes: { Chance: { skill_2: 1 } } }
},
@@ -2429,10 +2563,10 @@
}
];
for (const { name, input, config } of cases) {
- const full = simulateBattle(input, config);
- const fast = simulateBattle(input, config, { mode: "fast" });
+ const full = runOnce(input, config);
+ const fast = runOnce(input, config, { mode: "fast" });
assert.deepEqual(semanticBattleSummary(fast), semanticBattleSummary(full), name);
assert.deepEqual(fast.attacks, [], name);
assert.equal(fast.trace, undefined, name);
@@ -2456,9 +2590,9 @@
joiner_heroes: [{ name: "Norah", levels: { skill_1: 5 } }]
}
};
- const result = simulateBattle(input, config, { mode: "fast" });
+ const result = runOnce(input, config, { mode: "fast" });
const score = signedRemainingScore(result);
const expected =
result.winner === "attacker"
@@ -2498,18 +2632,18 @@
assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [0.5] } }, () => 0.005), false);
assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [100] } }, createSeededRng("x")), true);
});
-function skillActivations(result: ReturnType<typeof simulateBattle>, skillId: string): number {
+function skillActivations(result: ReturnType<typeof runOnce>, skillId: string): number {
return result.skillReport.attacker.find((entry) => entry.skillId === skillId)?.skillActivations ?? 0;
}
function totalRemaining(troops: Record<UnitType, number>): number {
return Object.values(troops).reduce((sum, count) => sum + count, 0);
}
-function semanticBattleSummary(result: ReturnType<typeof simulateBattle>): Pick<
- ReturnType<typeof simulateBattle>,
+function semanticBattleSummary(result: ReturnType<typeof runOnce>): Pick<
+ ReturnType<typeof runOnce>,
"winner" | "rounds" | "remaining" | "effectActivationCounts" | "extraSkillAttackJobsByEffect" | "attackControlCounts"
> {
return {
winner: result.winner,
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -1,32 +1,27 @@
import type {
ActiveEffect,
- AppliedControlEffect,
- AppliedEffect,
- AppliedExtraAttackEffect,
- AppliedOrderEffect,
AttackIntent,
AttackOutcome,
BearBattleResult,
BattleInput,
BattleRandomness,
BattleResult,
BattleTrace,
DamageJob,
- EffectSource,
FighterInput,
+ ResolvedEffectIntentDefinition,
ResolvedFighter,
ResolvedSkill,
SideId,
SimulationOptions,
SimulatorConfig,
- SkillReportEntry,
TriggerDamageJobSelector,
UnitType
} from "./types";
import { ALL_UNIT_MASK, UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";
-import { calculateDamageJob, createFastDamageScratch, type DamageResult, type DamageScratch } from "./damage";
-import { createRecorder } from "./recorder";
+import { calculateDamageJob, createDamageScratch, type DamageResult, type DamageScratch } from "./damage";
+import { createRecorder, NULL_RECORDER, type BattleRecorder } from "./recorder";
import {
activateEffect,
advanceEffectAttackDelay,
chancePasses,
@@ -37,33 +32,23 @@
effectRoundWindow,
hasAttackDurationConstraint,
isEffectAttackReady,
oppositeSide,
+ resolvedEffectScopeKey,
skillMatchesTrigger,
- sourceLabel,
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
-import {
- cloneEffectIndex,
- createEffectIndex,
- damageEffectsForJob,
- damageJobShapeSlot,
- DAMAGE_JOB_SHAPE_SLOTS,
- expireEffectIndex,
- indexEffect,
- isRuntimeIndexableEffect,
- type EffectIndex
-} from "./effectIndex";
+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 { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";
+import { bucketDefinition } from "./damageBuckets";
const DEFAULT_MAX_ROUNDS = 1500;
const BEAR_ROUNDS = 10;
const BEAR_DEFENSE = 36;
const BEAR_TROOP_ID = "bear_infantry";
-const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();
interface Runtime {
effectIndex: EffectIndex;
preparedEffects: ActiveEffect[];
@@ -72,12 +57,14 @@
// 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;
- skillReports: Record<SideId, Map<string, SkillReportEntry>>;
effectActivationCounts: Record<SideId, number>;
extraSkillAttackJobsByEffect: Record<string, number>;
attackControlCounts: { dodge: number; no_attack: number };
counters: {
@@ -93,8 +80,10 @@
roundStartGlobal: ResolvedSkill[];
roundStartPerUnit: ResolvedSkill[];
attackDeclared: ResolvedSkill[];
attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined>;
+ damageGroups: NonNullable<ActiveEffect["damageIndexGroup"]>[];
+ damageGroupsByJobShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][];
}
interface ExtraAttackEffectGroup {
selected: ActiveEffect;
@@ -103,14 +92,16 @@
interface ExtraSkillJobsResult {
jobs: DamageJob[];
usedEffectGroups: ExtraSkillUsedEffectGroup[];
- appliedEffects?: AppliedExtraAttackEffect[];
}
interface ExtraSkillUsedEffectGroup {
sourceEffectId: string;
+ selected: ActiveEffect;
effects: ActiveEffect[];
+ firstJobIndex: number;
+ jobCount: number;
}
interface BattleRun {
fighters: Record<SideId, ResolvedFighter>;
@@ -118,25 +109,22 @@
winner: SideId | "draw";
rounds: number;
attacks: AttackOutcome[];
trace?: BattleTrace;
+ skillReport: BattleResult["skillReport"];
score?: number;
}
interface RunLoopOptions {
capRoundKills: boolean;
capJobKills: boolean;
commitLosses: boolean;
scoreSide?: {
- attackerSide: SideId;
- defenderSide: SideId;
+ dealerSide: SideId;
+ takerSide: SideId;
};
}
-export function simulateBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}): BattleResult {
- return buildBattleResult(runBattle(input, config, options));
-}
-
export function bearFighterInput(): FighterInput {
return {
name: "Bear",
troops: { [BEAR_TROOP_ID]: 5000 },
@@ -164,9 +152,9 @@
const run = runBattle(input, configWithBearTroop(config), options, undefined, {
capRoundKills: false,
capJobKills: false,
commitLosses: false,
- scoreSide: { attackerSide: "attacker", defenderSide: "defender" }
+ scoreSide: { dealerSide: "attacker", takerSide: "defender" }
});
return {
...buildBattleResult(run),
score: run.score ?? bearScore(run.attacks)
@@ -175,27 +163,38 @@
/**
* 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;
}
export function prepareBattle(input: BattleInput, config: SimulatorConfig): CompiledBattle {
const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);
const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);
const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
- const deterministicBattleStart = !buildRuntimeSkills([attacker, defender]).battleStart.some(hasChanceTrigger);
- // Deterministic battle-start => the entire pre-loop runtime is seed-independent; build it once.
- const template = deterministicBattleStart ? setupRuntime(fighters, input, "simulator-prepare") : undefined;
- return { input, config, fighters, template, deterministicBattleStart };
+ 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
@@ -203,19 +202,37 @@
const runInput = seed === undefined ? compiled.input : { ...compiled.input, seed };
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 {
winner: run.winner,
rounds: run.rounds,
remaining: { attacker: ceilTroops(fighters.attacker.troops), defender: ceilTroops(fighters.defender.troops) },
attacks: run.attacks,
- skillReport: {
- attacker: [...runtime.skillReports.attacker.values()],
- defender: [...runtime.skillReports.defender.values()]
- },
+ skillReport: run.skillReport,
resolved: buildResolved(fighters.attacker, fighters.defender),
effectActivationCounts: runtime.effectActivationCounts,
extraSkillAttackJobsByEffect: runtime.extraSkillAttackJobsByEffect,
attackControlCounts: runtime.attackControlCounts,
@@ -230,25 +247,60 @@
}
// 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.
-function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number, collectSkillReports = true): Runtime {
- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), collectSkillReports);
+function setupRuntime(
+ fighters: Record<SideId, ResolvedFighter>,
+ input: BattleInput,
+ seed: string | number,
+ recorder: BattleRecorder,
+ 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),
- ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),
- ...addInputPassiveEffects(runtime, input.defender.passive, "defender")
+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime, recorder),
+ ...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;
}
-// Clone a prepared template's mutable per-run effect graph. The template already owns the
-// correct index topology and round schedules, so remap those references to the effect clones
-// instead of classifying and scheduling every effect again. Skills and the immutable static
-// profile are shared by reference.
-function cloneRuntime(template: Runtime, rng: Rng, collectSkillReports = true): 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;
+ recorder.recordSkillTriggerAttempt(skill);
+ if (!chancePasses(skill, runtime.rng)) continue;
+ recorder.recordSkillTriggered(skill);
+ for (const _effect of skill.effects) 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);
@@ -258,20 +310,19 @@
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, cloneEffect),
+ effectIndex: cloneEffectIndex(template.effectIndex, preparedEffects, cloneEffect),
preparedEffects,
activateEffectsByRound: cloneEffectSchedule(template.activateEffectsByRound, cloneEffect),
expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),
usedEffects: [],
staticDamageProfile: template.staticDamageProfile,
- damageScratch: createFastDamageScratch(),
+ setupEffects: template.setupEffects,
+ damageScratch: createDamageScratch(),
rng,
skills: template.skills,
- skillReports: collectSkillReports ? cloneSkillReports(template.skillReports) : { attacker: new Map(), defender: new Map() },
effectActivationCounts: { ...template.effectActivationCounts },
extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },
attackControlCounts: { ...template.attackControlCounts },
counters: {
@@ -281,24 +332,12 @@
};
return runtime;
}
-function cloneEffectSchedule(
- schedule: Array<ActiveEffect[] | undefined>,
- cloneEffect: (effect: ActiveEffect) => ActiveEffect
-): Array<ActiveEffect[] | undefined> {
+function cloneEffectSchedule(schedule: Array<ActiveEffect[] | undefined>, cloneEffect: (effect: ActiveEffect) => ActiveEffect): Array<ActiveEffect[] | undefined> {
return schedule.map((effects) => effects?.map(cloneEffect));
}
-function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {
- const cloneSide = (side: Map<string, SkillReportEntry>): Map<string, SkillReportEntry> => {
- const out = new Map<string, SkillReportEntry>();
- for (const [key, entry] of side) out.set(key, { ...entry, unsupportedEffects: [...entry.unsupportedEffects] });
- return out;
- };
- return { attacker: cloneSide(reports.attacker), defender: cloneSide(reports.defender) };
-}
-
function configWithBearTroop(config: SimulatorConfig): SimulatorConfig {
return {
...config,
troopStats: {
@@ -324,45 +363,55 @@
options: SimulationOptions,
prepared?: CompiledBattle,
loopOptions: RunLoopOptions = { capRoundKills: true, capJobKills: true, commitLosses: true }
): BattleRun {
- const collectSkillReports = (options.mode ?? "standard") !== "fast";
if (prepared?.template) {
const fighters: Record<SideId, ResolvedFighter> = {
attacker: cloneFighterForRun(prepared.fighters.attacker),
defender: cloneFighterForRun(prepared.fighters.defender)
};
- const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"), collectSkillReports);
- return runLoop(input, fighters, runtime, options, loopOptions);
+ 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 fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", collectSkillReports);
- return runLoop(input, fighters, runtime, options, loopOptions);
+ const recorder = recorderFor(options, fighters);
+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", recorder, prepared?.runtimeSkills, prepared?.staticProfile);
+ return runLoop(input, fighters, runtime, recorder, options, loopOptions);
}
+function recorderFor(options: SimulationOptions, fighters: Record<SideId, ResolvedFighter>): BattleRecorder {
+ return createRecorder(
+ options.mode ?? "standard",
+ [fighters.attacker, fighters.defender],
+ () => buildResolved(fighters.attacker, fighters.defender)
+ );
+}
+
function runLoop(
input: BattleInput,
fighters: Record<SideId, ResolvedFighter>,
runtime: Runtime,
+ recorder: BattleRecorder,
options: SimulationOptions,
loopOptions: RunLoopOptions
): BattleRun {
- const mode = options.mode ?? "standard";
- const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(fighters.attacker, fighters.defender));
const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;
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 = {
- trace: recorder.capturesTrace,
- recordAppliedEffects: recorder.capturesAppliedEffects,
+ recorder,
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,
- capToDefenderTroops: loopOptions.capJobKills,
+ scratch: runtime.damageScratch,
+ capToTakerTroops: loopOptions.capJobKills,
usedEffects: runtime.usedEffects
};
let rounds = 0;
@@ -371,14 +420,11 @@
if (winnerFor(fighters)) break;
rounds = round;
const roundStartTroops = snapshotTroops(fighters);
processEffectSchedule(runtime, round);
- triggerRoundStartSkills(round, runtime, roundStartTroops);
+ triggerRoundStartSkills(round, runtime, roundStartTroops, recorder);
- // Applied-effect events keyed by the intent they ordered.
- const orderEvents = recorder.capturesAppliedEffects ? new Map<string, AppliedOrderEffect>() : undefined;
- const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);
- const allJobs: DamageJob[] = []; // for recorder
+ const intents = resolveAttackIntents(round, runtime, roundStartTroops, recorder);
const results: DamageJobResult[] = [];
const cancelled: CancelledAttack[] = [];
// Phase 1: fire all attack_declared triggers for every intended attack before
@@ -387,11 +433,11 @@
const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];
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, intent);
+ triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, recorder, intent);
const job = normalJob(intent, roundStartTroops);
declaredNormalJobs.push({ intent, job });
}
@@ -418,64 +464,66 @@
chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);
}
cancelled.push({
intent,
- effectId: control.effect.id,
- reason: control.reason,
- appliedEffects: recorder.capturesAppliedEffects
- ? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))
- : NO_APPLIED_EFFECTS
+ control
});
continue;
}
- if (recorder.capturesTrace) allJobs.push(job);
- const normalResult = calculateDamageJob(job, fighters, [], damageJobOptions);
- if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);
- if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {
+ recorder.recordScheduledDamageJob(job);
+ const normalResult = calculateDamageJob(job, fighters, damageJobOptions);
+ if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage, recorder);
+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {
score += normalResult.kills;
}
chargeUsedEffects(runtime);
- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);
+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder);
const processedExtraEffectIds = new Set<string>();
const processedExtraJobIds = new Set<string>();
const normalEntry: DamageJobResult = {
job,
- result: normalResult,
- extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), undefined)
+ result: normalResult
};
results.push(normalEntry);
for (const extraJob of extraSkill.jobs) {
if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
- if (recorder.capturesTrace) allJobs.push(extraJob);
- const extraResult = calculateDamageJob(extraJob, fighters, [], damageJobOptions);
- if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);
+ 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.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);
}
- normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));
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
+ );
}
}
if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);
else commitRoundCounters(cancelled, results, runtime);
- for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.appliedEffects);
- for (const entry of results) recorder.recordDamageJob(entry.job, entry.result, entry.extraAppliedEffects);
- recorder.recordRound(round, roundStartTroops, intents, allJobs);
+ 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);
+ recorder.recordRound(round, roundStartTroops, intents);
}
const winner = winnerFor(fighters) ?? "draw";
return {
@@ -484,77 +532,50 @@
winner,
rounds,
attacks: recorder.attacks,
trace: recorder.trace,
+ skillReport: recorder.skillReport,
score
};
}
interface CancelledAttack {
intent: AttackIntent;
- effectId: string;
- reason: "dodge" | "no_attack";
- appliedEffects: AppliedEffect[];
+ control: Control;
}
interface DamageJobResult {
job: DamageJob;
result: DamageResult;
- extraAppliedEffects?: AppliedEffect[];
}
-const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
-
-function appendedEvent(order: AppliedOrderEffect | undefined, event: AppliedEffect): AppliedEffect[] {
- return order ? [order, event] : [event];
-}
-
-function appendedEvents(order: AppliedOrderEffect | undefined, events: AppliedEffect[] | undefined): AppliedEffect[] | undefined {
- if (!order) return events;
- return events ? [order, ...events] : [order];
-}
-
-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 appliedControlEvent(control: Control): AppliedControlEffect {
- return { kind: "control", ...appliedEffectBase(control.effect), reason: control.reason };
-}
-
function triggerRoundStartSkills(
round: number,
runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"]
+ roundStartTroops: DamageJob["roundStartTroops"],
+ recorder: BattleRecorder
): ActiveEffect[] {
const activated: ActiveEffect[] = [];
- activated.push(...triggerSkills("round_start", round, runtime.skills.roundStartGlobal, runtime));
+ 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;
- const reports = runtime.skillReports[skill.side];
- const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));
- if (report) report.triggersSeen += 1;
+ recorder.recordSkillTriggerAttempt(skill);
if (!chancePasses(skill, runtime.rng)) continue;
- if (report) report.skillActivations += 1;
+ 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);
addActiveEffect(runtime, effect);
activated.push(effect);
runtime.effectActivationCounts[skill.side] += 1;
- if (report) report.effectActivations += 1;
+ recorder.recordSkillEffectActivated(skill);
}
orderIndex += 1;
}
}
@@ -580,28 +601,28 @@
}
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]
};
}
function classifyRandomness(skills: ResolvedSkill[]): BattleRandomness {
@@ -621,39 +642,24 @@
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, collectSkillReports = true): Runtime {
- const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };
- const skills = buildRuntimeSkills(fighters);
- for (const fighter of collectSkillReports ? fighters : []) {
- for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {
- reports[fighter.side].set(reportKey(skill), {
- sourceKind: skill.sourceKind,
- heroName: skill.heroName,
- troopType: skill.troopType,
- skillId: skill.id,
- skillName: skill.name,
- level: skill.level,
- triggersSeen: 0,
- skillActivations: 0,
- effectActivations: 0,
- skillKills: 0,
- unsupportedEffects: []
- });
- }
- }
+function createRuntime(fighters: ResolvedFighter[], rng: Rng, preparedSkills?: RuntimeSkills): Runtime {
+ const skills = preparedSkills ?? buildRuntimeSkills(fighters);
return {
- effectIndex: createEffectIndex(),
+ effectIndex: createEffectIndex(
+ skills.damageGroups,
+ skills.damageGroupsByJobShape
+ ),
preparedEffects: [],
activateEffectsByRound: [],
expireEffectsByRound: [],
usedEffects: [],
staticDamageProfile: undefined,
- damageScratch: createFastDamageScratch(),
+ setupEffects: [],
+ damageScratch: createDamageScratch(),
rng,
skills,
- skillReports: reports,
effectActivationCounts: { attacker: 0, defender: 0 },
extraSkillAttackJobsByEffect: {},
attackControlCounts: { dodge: 0, no_attack: 0 },
counters: {
@@ -665,19 +671,61 @@
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 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);
- const skills = attackDeclaredByJobShape[slot];
- if (skills) skills.push(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];
}
}
}
@@ -687,12 +735,65 @@
roundStart,
roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),
roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),
attackDeclared,
- attackDeclaredByJobShape
+ 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) {
@@ -730,9 +831,9 @@
runtime.activateEffectsByRound[round] = undefined;
}
}
-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) {
for (const direction of ["up", "down"] as const) {
@@ -764,9 +865,8 @@
remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
};
- addActiveEffect(runtime, effect);
effects.push(effect);
}
}
return effects;
@@ -776,24 +876,23 @@
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;
- const reports = runtime.skillReports[skill.side];
- const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));
- if (report) report.triggersSeen += 1;
+ recorder.recordSkillTriggerAttempt(skill);
if (!chancePasses(skill, runtime.rng)) continue;
- if (report) report.skillActivations += 1;
+ 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;
- if (report) report.effectActivations += 1;
+ recorder.recordSkillEffectActivated(skill);
}
}
return activated;
}
@@ -801,34 +900,34 @@
function resolveAttackIntents(
round: number,
runtime: Runtime,
roundStartTroops: DamageJob["roundStartTroops"],
- orderEvents?: Map<string, AppliedOrderEffect>
+ recorder: BattleRecorder
): 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);
- orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: 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,
previousReceivedAttackCount,
@@ -841,33 +940,32 @@
}
// 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 {
return order.find((unit) => (roundStartTroops[side][unit] ?? 0) > 0);
}
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;
return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };
@@ -917,12 +1015,12 @@
round: intent.round,
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
};
}
@@ -930,13 +1028,12 @@
normalAttack: DamageJob,
round: number,
runtime: Runtime,
roundStartTroops: DamageJob["roundStartTroops"],
- capturesTrace: boolean
+ recorder: BattleRecorder
): ExtraSkillJobsResult {
const jobs: DamageJob[] = [];
const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];
- let appliedEffects: AppliedExtraAttackEffect[] | undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter(
(effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)
),
@@ -952,45 +1049,44 @@
const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);
const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);
const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);
if (multiplier <= 0) continue;
- const sourceSkillReportKey = reportKeyForEffectSource(effect.source);
for (const source of sources) {
if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;
for (const target of targets) {
if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;
- jobs.push({
+ const job: DamageJob = {
id: `${normalAttack.sourceIntentId}:skill:${sourceEffectId}:${definitionIndex}:${jobs.length}`,
round,
kind: "skill",
sourceIntentId: normalAttack.sourceIntentId,
roundStartTroops,
- attackerSide: source.side,
- attackerUnit: source.unit,
- defenderSide: target.side,
- defenderUnit: target.unit,
+ dealerSide: source.side,
+ dealerUnit: source.unit,
+ takerSide: target.side,
+ takerUnit: target.unit,
sourceEffectId,
- sourceSkillReportKey,
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, effects: effectGroup.effects });
- if (capturesTrace) {
- (appliedEffects ??= []).push({
- kind: "extra_attack",
- ...appliedEffectBase(effect),
- spawnedJobIds: jobs.slice(firstJobIndex).map((spawned) => spawned.id)
- });
- }
+ usedEffectGroups.push({
+ sourceEffectId,
+ selected: effect,
+ effects: effectGroup.effects,
+ firstJobIndex,
+ jobCount: jobs.length - firstJobIndex
+ });
}
}
- return { jobs, usedEffectGroups, appliedEffects };
+ return { jobs, usedEffectGroups };
}
function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {
const selected: ExtraAttackEffectGroup[] = [];
@@ -1014,12 +1110,12 @@
}
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)
);
}
interface TriggerJobUnit {
@@ -1033,19 +1129,19 @@
effect: ActiveEffect,
normalAttack: DamageJob,
roundStartTroops: DamageJob["roundStartTroops"]
): TriggerJobUnit[] {
- if (selector === "use.source") return [{ side: normalAttack.attackerSide, unit: normalAttack.attackerUnit }];
- if (selector === "use.target") return [{ side: normalAttack.defenderSide, unit: normalAttack.defenderUnit }];
+ if (selector === "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 }));
}
function livingUnits(side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): TriggerJobUnit[] {
@@ -1084,47 +1180,35 @@
job: DamageJob,
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(
result: DamageResult,
job: DamageJob,
roundStartTroops: DamageJob["roundStartTroops"],
- roundTargetDamage: Record<SideId, Record<UnitType, number>>
+ 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;
- if (result.trace) result.trace.finalKills = result.kills;
+ roundTargetDamage[job.takerSide][job.takerUnit] += result.kills;
+ recorder.recordFinalKills(result);
}
-function filterExtraAppliedEffects(
- events: AppliedExtraAttackEffect[] | undefined,
- processedExtraJobIds: Set<string>
-): AppliedExtraAttackEffect[] | undefined {
- if (!events) return undefined;
- const filtered: AppliedExtraAttackEffect[] = [];
- for (const event of events) {
- const spawnedJobIds = event.spawnedJobIds.filter((jobId) => processedExtraJobIds.has(jobId));
- if (spawnedJobIds.length > 0) filtered.push({ ...event, spawnedJobIds });
- }
- return filtered.length > 0 ? filtered : undefined;
-}
-
// 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 {
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) {
fighters[side].troops[unit] = Math.max(0, fighters[side].troops[unit] - losses[side][unit]);
@@ -1133,14 +1217,14 @@
}
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;
}
}
// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in
@@ -1170,12 +1254,13 @@
controlEffects: ActiveEffect[],
runtime: Runtime
): void {
const used = runtime.usedEffects;
- for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {
- if (effect.expired) continue;
- if (!advanceEffectAttackDelay(effect)) continue;
- if (hasAttackDurationConstraint(effect)) used.push(effect);
+ for (const group of runtime.effectIndex.damageGroupsByJobShape[damageJobSlot(job)]) {
+ for (const effect of group.effects) {
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (hasAttackDurationConstraint(effect)) used.push(effect);
+ }
}
for (const effect of controlEffects) used.push(effect);
if (!controlEffects.includes(winningControl)) used.push(winningControl);
chargeUsedEffects(runtime);
@@ -1198,9 +1283,9 @@
}
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);
}
function total(troops: Record<UnitType, number>): number {
@@ -1233,21 +1318,4 @@
diagnostics: defender.diagnostics
}
};
}
-
-function reportKey(skill: ResolvedSkill): string {
- const cached = REPORT_KEY_CACHE.get(skill);
- if (cached) return cached;
- const key = reportKeyFromParts(skill.sourceKind, skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "", skill.id);
- REPORT_KEY_CACHE.set(skill, key);
- return key;
-}
-
-function reportKeyForEffectSource(source: EffectSource): string | undefined {
- if ((source.kind !== "hero_skill" && source.kind !== "troop_skill") || !source.skillId) return undefined;
- return reportKeyFromParts(source.kind, source.heroInstanceId ?? source.heroName ?? source.troopType ?? "", source.skillId);
-}
-
-function reportKeyFromParts(sourceKind: SkillReportEntry["sourceKind"], sourceKey: string, skillId: string): string {
- return `${sourceKind}:${sourceKey}:${skillId}`;
-}
Index: simulator/src/staticDamageProfile.ts
===================================================================
--- simulator/src/staticDamageProfile.ts prev run
+++ simulator/src/staticDamageProfile.ts this run
@@ -1,286 +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, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";
-import { sourceLabel } from "./effects";
+import {
+ BUCKET_DEFINITIONS,
+ isPassiveBucket,
+ passiveBucketRole,
+ type BucketRole,
+ type PassiveBucket,
+ type StaticPlayerBucket
+} from "./damageBuckets";
+import {
+ applyStaticDamageBucketValue,
+ createStaticDamageBucketFactors,
+ evaluateDamageExpression,
+ staticDamageBucketSet,
+ type StaticDamageBucketMatrix,
+ type StaticDamageBucketFactors,
+ type StaticDamageProfile,
+ type StaticDamageProfileEntry
+} from "./damage";
-export interface StaticDamageProfileTerm {
- raw?: number;
- totalPct?: number;
- contributors: DamageBucketTrace["contributors"];
-}
+export type { StaticDamageBucketMatrix, StaticDamageProfile } from "./damage";
-export interface StaticDamageProfileEntry {
- factor: number;
- playerFactorsValid: boolean;
- buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;
-}
+/** 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"];
-export interface StaticDamageProfile {
- offense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;
- defense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;
+ 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 buildStaticDamageBucketFactors(
+ fighters: Record<SideId, ResolvedFighter>,
+ activeEffects: ActiveEffect[]
+): StaticDamageBucketMatrix {
+ const factors: Record<SideId, Record<UnitType, StaticDamageBucketFactors>> = {
+ attacker: buildSideFactors(fighters.attacker),
+ defender: buildSideFactors(fighters.defender)
+ };
+ for (const contribution of selectPassiveContributions(activeEffects)) {
+ applyStaticDamageBucketValue(
+ factors[contribution.side][contribution.unit],
+ contribution.bucket,
+ contribution.valuePct
+ );
+ }
+ return factors;
}
-export const STATIC_RAW_BUCKETS = ["troops.baseAttack", "troops.baseLethality", "troops.baseHealth", "troops.baseDefense"] as const;
-export const STATIC_PLAYER_BUCKETS = ["player.attack", "player.lethality", "player.health", "player.defense"] as const;
-export const STATIC_PASSIVE_BUCKETS = [
- "passive.attack.up",
- "passive.attack.down",
- "passive.lethality.up",
- "passive.lethality.down",
- "passive.health.up",
- "passive.health.down",
- "passive.defense.up",
- "passive.defense.down"
-] as const;
-
-export type StaticRawBucket = (typeof STATIC_RAW_BUCKETS)[number];
-export type StaticPlayerBucket = (typeof STATIC_PLAYER_BUCKETS)[number];
-export type StaticPassiveBucket = (typeof STATIC_PASSIVE_BUCKETS)[number];
-export type StaticDamageBucket = StaticRawBucket | StaticPlayerBucket | StaticPassiveBucket;
-
-// Roles are owned by the BUCKETS tree (single source of truth); derive, do not redeclare.
-const STATIC_PASSIVE_BUCKET_ROLES = Object.fromEntries(
- STATIC_PASSIVE_BUCKETS.map((bucket) => [bucket, bucketDefinition(bucket)!.role])
-) as Record<StaticPassiveBucket, BucketRole>;
-
export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {
- const profile: StaticDamageProfile = {
- offense: {
- attacker: buildSideEntries(fighters.attacker, "offense"),
- defender: buildSideEntries(fighters.defender, "offense")
- },
- defense: {
- attacker: buildSideEntries(fighters.attacker, "defense"),
- defender: buildSideEntries(fighters.defender, "defense")
- }
+ const factors = buildStaticDamageBucketFactors(fighters, activeEffects);
+ return {
+ attacker: buildProfileSide(factors.attacker),
+ defender: buildProfileSide(factors.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 (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}`);
- }
+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>;
}
-export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {
- return (STATIC_BUCKET_SET as Set<string>).has(bucket);
-}
-
-export function isPassiveBucket(bucket: string): bucket is StaticPassiveBucket {
- return (STATIC_PASSIVE_BUCKET_SET as Set<string>).has(bucket);
-}
-
-export function staticPassiveBucketRole(bucket: string): BucketRole | undefined {
- return isPassiveBucket(bucket) ? STATIC_PASSIVE_BUCKET_ROLES[bucket] : undefined;
-}
-
-function buildSideEntries(fighter: ResolvedFighter, role: "offense" | "defense"): Record<UnitType, StaticDamageProfileEntry> {
- return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildEntry(fighter, unit, role)])) as Record<UnitType, StaticDamageProfileEntry>;
-}
-
-function buildEntry(fighter: ResolvedFighter, unit: UnitType, role: "offense" | "defense"): StaticDamageProfileEntry {
- const stats = fighter.troopDetails[unit]?.stats ?? fallbackStats();
- const bonuses = fighter.statBonuses[unit] ?? emptyStats();
- const buckets: StaticDamageProfileEntry["buckets"] = {};
- if (role === "offense") {
- setRaw(buckets, "troops.baseAttack", stats.attack);
- setRaw(buckets, "troops.baseLethality", stats.lethality);
- setPct(buckets, "player.attack", bonuses.attack, [{ effectId: "input:attack", source: "input_stats", valuePct: bonuses.attack, bucket: "player.attack" }]);
- setPct(buckets, "player.lethality", bonuses.lethality, [
- { effectId: "input:lethality", source: "input_stats", valuePct: bonuses.lethality, bucket: "player.lethality" }
- ]);
- } else {
- setRaw(buckets, "troops.baseHealth", stats.health);
- setRaw(buckets, "troops.baseDefense", stats.defense);
- setPct(buckets, "player.health", bonuses.health, [{ effectId: "input:health", source: "input_stats", valuePct: bonuses.health, bucket: "player.health" }]);
- setPct(buckets, "player.defense", bonuses.defense, [
- { effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }
- ]);
+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);
- }
+ contributions.push(...maxGroups.values());
+ return contributions;
}
-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 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 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");
- }
- }
+/** 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 playerPctFactorValid(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket): boolean {
- return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100 > 0;
+/** 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;
}
-interface StaticFactorTerm {
- bucket: StaticDamageBucket;
- valueType: BucketValueType;
- placement: BucketPlacement;
-}
-
-// The closed-pool aggregation is driven entirely by BUCKETS-tree metadata (role/valueType/
-// placement); there is no second hand-written damage equation. Iteration follows tree
-// insertion order, which fixes the numerator/denominator float association — do not reorder
-// the static families in the tree without re-validating the parity gate.
-const STATIC_FACTOR_TERMS_BY_ROLE: Record<BucketRole, StaticFactorTerm[]> = { attacker: [], defender: [] };
-for (const path of Object.keys(BUCKET_DEFINITIONS)) {
- const definition = BUCKET_DEFINITIONS[path];
- if (definition.phase !== "static") continue;
- STATIC_FACTOR_TERMS_BY_ROLE[definition.role].push({
- bucket: path as StaticDamageBucket,
- valueType: definition.valueType,
- placement: definition.placement
- });
-}
-
-function roleFactor(entry: StaticDamageProfileEntry, terms: StaticFactorTerm[]): number {
- let numerator = 1;
- let denominator = 1;
- for (const term of terms) {
- const value = term.valueType === "raw" ? raw(entry, term.bucket) : pct(entry, term.bucket);
- if (term.placement === "numerator") numerator *= value;
- else denominator *= value;
- }
- return numerator / denominator;
-}
-
-function offenseFactor(entry: StaticDamageProfileEntry): number {
- return 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;
-}
-
-function setRaw(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {
- buckets[bucket] = { raw, contributors: [] };
-}
-
-function setPct(
- buckets: StaticDamageProfileEntry["buckets"],
- bucket: StaticPlayerBucket,
- valuePct: number,
- contributors: DamageBucketTrace["contributors"] = []
-): void {
- buckets[bucket] = { totalPct: valuePct, contributors };
-}
-
-function addPct(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticPassiveBucket, valuePct: number, contributor: DamageBucketTrace["contributors"][number]): void {
- const existing = buckets[bucket];
- if (existing) {
- existing.totalPct = (existing.totalPct ?? 0) + valuePct;
- existing.contributors.push(contributor);
- } else {
- buckets[bucket] = { totalPct: valuePct, contributors: [contributor] };
- }
-}
-
-function fallbackStats(): StatBlock {
- return { attack: 1, defense: 1, lethality: 1, health: 1 };
-}
-
-function emptyStats(): StatBlock {
- return { attack: 0, defense: 0, lethality: 0, health: 0 };
-}
-
-
-const STATIC_BUCKET_SET = new Set<string>([...STATIC_RAW_BUCKETS, ...STATIC_PLAYER_BUCKETS, ...STATIC_PASSIVE_BUCKETS]);
-const STATIC_PASSIVE_BUCKET_SET = new Set<string>(STATIC_PASSIVE_BUCKETS);
-
-// The typed manifest above is a convenience for literal types; the BUCKETS tree is the
-// authority for which buckets are static. Fail loudly if the two ever drift.
-{
- const manifest = [...STATIC_BUCKET_SET].sort().join(",");
- const tree = [...STATIC_BUCKETS].sort().join(",");
- if (manifest !== tree) {
- throw new Error(`static bucket manifest drifted from BUCKETS tree:\n manifest=[${manifest}]\n tree=[${tree}]`);
- }
-}
+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
@@ -139,9 +139,9 @@
assert.equal(report.counts.errors, 1);
assert.equal(report.counts.executed, 1);
assert.equal(report.errors[0]?.testcase_id, "bad_aggregation");
assert.equal(report.details[0]?.errorDetails?.type, "DamageAggregationError");
- assert.equal(report.details[0]?.errorDetails?.groupId, "player.defender.health");
+ assert.equal(report.details[0]?.errorDetails?.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");
});
Index: simulator/src/testcases.ts
===================================================================
--- simulator/src/testcases.ts prev run
+++ simulator/src/testcases.ts this run
@@ -8,10 +8,10 @@
type SampleStats,
sampleStats
} from "./calibration";
import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
-import { simulateBattle, prepareBattle, runPrepared } from "./simulator";
-import { DamageAggregationError } from "./damage";
+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;
@@ -495,10 +495,12 @@
}
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 result = simulateBattle(sampleInput(input, job.seed, job.file, job.testcaseId, job.index, iteration), config, { mode: "fast" });
+ 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);
@@ -772,10 +774,8 @@
if (error instanceof DamageAggregationError) {
return {
type: error.name,
groupId: error.groupId,
- round: error.round,
- jobId: error.jobId,
netPct: error.netPct,
factor: error.factor,
contributors: error.contributors
};
@@ -801,12 +801,8 @@
function sampleSeed(baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): string {
return `${baseSeed ?? "simulator-default"}:${relative(process.cwd(), file)}:${testcaseId}:${index}:${iteration}`;
}
-function sampleInput(input: BattleInput, baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): BattleInput {
- return { ...input, seed: sampleSeed(baseSeed ?? input.seed, file, testcaseId, index, iteration) };
-}
-
function snapshotKey(filePath: string, index: number): string {
return `${normalizeReportPath(filePath)}#${index}`;
}
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -95,8 +95,18 @@
same_effect_stacking?: SameEffectStacking;
reason?: string;
}
+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>;
+}
+
export interface TriggerDefinition {
type: string;
probability?: unknown;
first?: number;
@@ -202,9 +212,9 @@
heroRole?: "main" | "joiner";
troopType?: UnitType;
level: number;
trigger: TriggerDefinition;
- effects: EffectIntentDefinition[];
+ effects: ResolvedEffectIntentDefinition[];
compiledTrigger?: {
definition: TriggerDefinition;
side: SideId;
level: number;
@@ -246,8 +256,17 @@
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.
+export interface ActiveEffectGroup {
+ effects: ActiveEffect[];
+ bucketIndex: number;
+ sameEffectStacking: SameEffectStacking;
+}
+
export interface ActiveEffect {
id: string;
expired?: boolean;
source: EffectSource;
@@ -276,8 +295,10 @@
// unless useEffectsOnDodge/useEffectsOnNoAttack disable that.
uses: number;
stackingKey?: string;
sameEffectStacking: SameEffectStacking;
+ damageIndexGroup?: ActiveEffectGroup;
+ damageIndexPosition?: number;
}
export interface EvolvingActiveEffect extends ActiveEffect {
valueEvolution: {
@@ -290,12 +311,12 @@
export interface AttackIntent {
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;
previousReceivedAttackCount: number;
@@ -307,12 +328,12 @@
round: number;
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;
}
@@ -353,9 +374,9 @@
finalKills: number;
}
// One "this effect affected battle mechanics" event, discriminated on ActiveEffect.kind.
-// Events are built only in trace mode; the uses counter is charged in every mode.
+// 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;
@@ -395,12 +416,12 @@
round: number;
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[];
cancelledBy?: string;
@@ -414,9 +435,11 @@
troopType?: UnitType;
skillId: string;
skillName: string;
level: number;
+ /** Matching trigger opportunities, including failed probability rolls; populated only in trace mode. */
triggersSeen: number;
+ /** Successful triggers whose probability gate passed. */
skillActivations: number;
effectActivations: number;
skillKills: number;
unsupportedEffects: string[];
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/config/hero_definitions/Alonso.json b/simulator/config/hero_definitions/Alonso.json
--- a/simulator/config/hero_definitions/Alonso.json
+++ b/simulator/config/hero_definitions/Alonso.json
@@ -13,8 +13,7 @@
40,
40,
40
- ],
- "source": "self.any"
+ ]
},
"effects": {
"Onslaught/1": {
@@ -26,10 +25,6 @@
40,
50
],
- "units": {
- "applies_to": "trigger",
- "applies_vs": "trigger.target"
- },
"duration": {
"turns": {
"count": 1
diff --git a/simulator/config/hero_definitions/Gwen.json b/simulator/config/hero_definitions/Gwen.json
--- a/simulator/config/hero_definitions/Gwen.json
+++ b/simulator/config/hero_definitions/Gwen.json
@@ -113,9 +113,11 @@
]
},
"duration": {
- "attacks": {
- "count": 1,
+ "turns": {
"delay": 1
+ },
+ "attacks": {
+ "count": 1
}
},
"trigger_damage_jobs": [
diff --git a/simulator/config/hero_definitions/Mia.json b/simulator/config/hero_definitions/Mia.json
--- a/simulator/config/hero_definitions/Mia.json
+++ b/simulator/config/hero_definitions/Mia.json
@@ -30,7 +30,8 @@
},
"duration": {
"turns": {
- "count": 1
+ "count": 1,
+ "delay": 1
}
},
"same_effect_stacking": "max"
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
@@ -4,8 +4,8 @@
import { classifyEffectForJob } from "./classifier";
import { calculateDamageJob } from "./damage";
import { ATOMIC_BUCKETS } from "./damageBuckets";
-import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";
-import { activateEffect } from "./effects";
+import { createEffectIndex, indexEffect } from "./effectIndex";
+import { activateEffect, evolvingActiveEffectValuePct } from "./effects";
import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";
import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";
import { ALL_UNIT_MASK, unitMask } from "./types";
@@ -33,12 +33,15 @@
intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ 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: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
};
@@ -78,12 +81,15 @@
intent: { id: `${type}/1`, type, value: [valuePct] },
ownerSide,
kind: "modifier",
- valuePct,
+ 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: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
};
@@ -100,10 +106,9 @@
for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);
}
const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
- removeStaticProfileBucketEffects(effectIndex);
- const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
+ const usedEffects = options.usedEffects ?? [];
const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });
- return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };
+ return { ...result, usedEffectIds: usedEffects.map((usedEffect) => usedEffect.id) };
}
test("classifier routes up/down effects into neutral atomic buckets", () => {
@@ -418,7 +423,7 @@
const oneAttackEffect = {
...effect("active.hero.attack.up", "attacker", 100),
id: "attack-up-active",
- duration: { type: "attack" as const, value: 1 }
+ duration: { attacks: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
@@ -432,7 +437,7 @@
...effect("active.hero.defense.down", "defender", 30),
id: "bad-luck-like",
source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },
- duration: { type: "round" as const, value: 1 }
+ duration: { turns: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });
@@ -456,7 +461,7 @@
const defenderOutgoingBuff = {
...effect("active.hero.lethality.up", "defender", 100),
id: "defender-outgoing-buff",
- duration: { type: "attack" as const, value: 1 }
+ duration: { attacks: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
@@ -475,7 +480,9 @@
value: 100,
value_evolution: { type: "pct_decay", step: "attack", value: 15 }
},
- duration: { type: "attack" as const, value: 10 },
+ duration: { attacks: { count: 10 } },
+ valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
uses: 2
};
@@ -499,7 +506,9 @@
},
createdRound: 0,
startRound: 0,
- duration: { type: "attack" as const, value: 10 }
+ valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
+ duration: { attacks: { count: 10 } }
};
const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
@@ -513,7 +522,7 @@
const weaker = {
...effect("active.hero.lethality.up", "attacker", 50),
id: "max-weaker",
- duration: { type: "attack" as const, value: 3 },
+ duration: { attacks: { count: 3 } },
stackingKey: "same-max-group",
sameEffectStacking: "max" as const
};
@@ -526,7 +535,9 @@
value: 100,
value_evolution: { type: "pct_decay", step: "attack", value: 50 }
},
- duration: { type: "attack" as const, value: 3 },
+ duration: { attacks: { count: 3 } },
+ valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
uses: 1,
stackingKey: "same-max-group",
sameEffectStacking: "max" as const
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
@@ -9,6 +9,15 @@
import { loadSimulatorConfigFromDir } from "./config-node";
import type { SkillFile } from "./types";
+test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {
+ const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];
+
+ assert.deepEqual(effect.duration, {
+ turns: { delay: 1 },
+ attacks: { count: 1 }
+ });
+});
+
test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {
const root = writeConfigWithTroopEffect({
type: "active.hero.lethality.up",
diff --git a/simulator/src/config.ts b/simulator/src/config.ts
--- a/simulator/src/config.ts
+++ b/simulator/src/config.ts
@@ -275,11 +275,11 @@
const path = `${file}:${skillId}.${effectId}.duration`;
const duration = effect.duration as Record<string, unknown>;
for (const key of Object.keys(duration)) {
- if (key !== "turns" && key !== "rounds" && key !== "attacks") {
+ if (key !== "turns" && key !== "attacks") {
throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);
}
}
- for (const key of ["turns", "rounds", "attacks"] as const) {
+ for (const key of ["turns", "attacks"] as const) {
const value = duration[key];
if (value === undefined) continue;
validateNativeEffectDurationAxis(value, `${path}.${key}`);
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -5,15 +5,13 @@
DamageEquationTrace,
DamageJob,
ResolvedFighter,
- SameEffectStacking,
SideId,
UnitType
} from "./types";
import { UNIT_TYPES } from "./types";
-import { classifyEffectForJob } from "./classifier";
import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";
-import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";
-import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";
+import { advanceEffectAttackDelay, sourceLabel } from "./effects";
+import { damageEffectsForJob, type EffectIndex } from "./effectIndex";
import {
buildStaticDamageProfile,
type StaticDamageBucket,
@@ -33,15 +31,9 @@
appliesTo?: DamageJob["kind"];
}
-interface BucketCandidate {
- effect: ActiveEffect;
- bucket: AtomicBucket;
- valuePct: number;
-}
-
-interface MaxBucketCandidateGroup {
- selected: BucketCandidate;
- candidates: BucketCandidate[];
+interface MaxBucketEffectGroup {
+ selected: ActiveEffect;
+ effects: ActiveEffect[];
}
interface DamageExpressionResult {
@@ -67,13 +59,25 @@
const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;
const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};
-const TROOPS_COUNT_TERM = factorTerm("troops.count");
-const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");
+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;
@@ -106,7 +110,7 @@
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
activeEffects: ActiveEffect[],
- options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<ActiveEffect> }
+ options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: ActiveEffect[] }
): DamageResult {
if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
// The damage math is one path; `trace` only decides whether we also capture the (expensive)
@@ -123,47 +127,27 @@
const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));
const needsTraceBuckets = traceEnabled;
const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);
- applyBucketValue(buckets, "troops.count", armyTerm);
- applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
+ 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);
const appliedEffects: DamageEquationTrace["appliedEffects"] = [];
const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];
- const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
- const candidates: BucketCandidate[] = [];
- const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
- for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
- if (!isEffectActive(candidate.effect, job.round)) continue;
- handledCandidateEffectIds?.add(candidate.effect.id);
- candidates.push({
- effect: candidate.effect,
- bucket: candidate.bucket,
- valuePct: currentEffectValuePct(candidate.effect, job.round)
- });
- }
-
- if (traceEnabled) {
- for (const effect of options.effectIndex.all) {
- if (handledCandidateEffectIds?.has(effect.id)) continue;
- if (!isEffectActive(effect, job.round)) {
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });
- continue;
- }
- const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "bucket" && classification.bucket) {
- throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);
- }
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });
- }
- }
+ const usedEffects = options.usedEffects ?? [];
+ applyBucketEffects(
+ damageEffectsForJob(options.effectIndex, job),
+ job.round,
+ buckets,
+ detail,
+ recordAppliedEffects,
+ appliedEffects,
+ rejectedEffects,
+ usedEffects
+ );
- applyBucketCandidates(candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
- const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];
- const traceBuckets = needsTraceBuckets ? toTraceBuckets(buckets, staticTraceEntries) : undefined;
- const expressionDetail = traceEnabled ? "full" : "fast";
- const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, expressionDetail, staticProfile);
+ const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, detail, staticProfile);
const uncappedKills = Math.max(0, rawDamage);
const kills = options.capToDefenderTroops === false ? uncappedKills : Math.min(defenderTroops, uncappedKills);
const trace = traceEnabled
@@ -173,7 +157,10 @@
defender: { ...job.roundStartTroops.defender }
},
armyTerm,
- atomicBuckets: traceBuckets ?? toTraceBuckets(buckets, staticTraceEntries),
+ atomicBuckets: toTraceBuckets(buckets, [
+ staticProfile.offense[job.attackerSide][job.attackerUnit],
+ staticProfile.defense[job.defenderSide][job.defenderUnit]
+ ]),
aggregationGroups,
appliedEffects,
rejectedEffects,
@@ -189,69 +176,79 @@
};
}
-function applyBucketCandidates(
- candidates: BucketCandidate[],
+function applyBucketEffects(
+ effects: ActiveEffect[],
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- usedEffects: Set<ActiveEffect>
+ usedEffects: ActiveEffect[]
): void {
- let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;
- for (const candidate of candidates) {
- if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {
+ let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;
+ for (const effect of effects) {
+ if (effect.expired) continue;
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {
maxGroups ??= new Map();
- const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;
+ const key = `${effect.intent.type}:${effect.stackingKey}`;
const group = maxGroups.get(key);
if (group) {
- group.candidates.push(candidate);
- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;
+ group.effects.push(effect);
+ if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;
} else {
- maxGroups.set(key, { selected: candidate, candidates: [candidate] });
+ maxGroups.set(key, { selected: effect, effects: [effect] });
}
} else {
- applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
if (!maxGroups) return;
for (const group of maxGroups.values()) {
- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the
// applied percentage. Shared by the single-candidate and max-group paths.
function applySelectedBucket(
- selected: BucketCandidate,
+ selected: ActiveEffect,
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"]
): number {
- const appliedValuePct = applyBucketValue(
- buckets,
- selected.bucket,
- selected.valuePct,
- detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",
- detail === "full" ? sourceLabel(selected.effect) : "",
- detail === "full" ? selected.effect.ownerSide : undefined,
- selected.bucket,
- selected.effect.stackingKey,
- selected.effect.sameEffectStacking
- );
+ 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;
+ if (detail === "full") {
+ buckets.contributors?.[bucketIndex].push({
+ effectId: selected.source.effectId ?? selected.id,
+ source: sourceLabel(selected),
+ sourceSide: selected.ownerSide,
+ valuePct: appliedValuePct,
+ bucket: selected.intent.type,
+ stackingKey: selected.stackingKey,
+ sameEffectStacking: selected.sameEffectStacking
+ });
+ }
if (appliedValuePct !== 0 && recordAppliedEffects) {
const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
kind: "modifier",
- activeEffectId: selected.effect.id,
- effectId: selected.effect.source.effectId ?? selected.effect.id,
- bucket: selected.bucket,
+ activeEffectId: selected.id,
+ effectId: selected.source.effectId ?? selected.id,
+ bucket: selected.intent.type,
valuePct: appliedValuePct,
- source: sourceLabel(selected.effect),
- sourceSide: selected.effect.ownerSide,
- sameEffectStacking: selected.effect.sameEffectStacking
+ source: sourceLabel(selected),
+ sourceSide: selected.ownerSide,
+ sameEffectStacking: selected.sameEffectStacking
};
- if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;
+ if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;
appliedEffects.push(appliedEffect);
}
return appliedValuePct;
@@ -259,45 +256,47 @@
// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]
// array and no suppressed-sibling bookkeeping.
-function applyBucketCandidate(
- candidate: BucketCandidate,
+function applyBucketEffect(
+ effect: ActiveEffect,
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- usedEffects: Set<ActiveEffect>
+ usedEffects: ActiveEffect[]
): void {
- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);
+ const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
- usedEffects.add(candidate.effect);
+ usedEffects.push(effect);
} else if (detail === "full") {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
}
}
-function applyBucketCandidateGroup(
- selected: BucketCandidate,
- candidates: BucketCandidate[],
+function applyBucketEffectGroup(
+ selected: ActiveEffect,
+ effects: ActiveEffect[],
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- usedEffects: Set<ActiveEffect>
+ usedEffects: ActiveEffect[]
): void {
- const appliedValuePct = applySelectedBucket(selected, buckets, detail, recordAppliedEffects, appliedEffects);
+ const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
- for (const candidate of candidates) {
- usedEffects.add(candidate.effect);
- if (detail === "full" && candidate !== selected) {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
+ for (const effect of effects) {
+ usedEffects.push(effect);
+ if (detail === "full" && effect !== selected) {
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });
}
}
} else if (detail === "full") {
- for (const candidate of candidates) {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
+ for (const effect of effects) {
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
}
}
}
@@ -331,26 +330,6 @@
return buckets;
}
-function applyBucketValue(
- buckets: NumericDamageBuckets,
- bucketName: AtomicBucket,
- value: number,
- effectId = "",
- source = "",
- sourceSide: SideId | undefined = undefined,
- traceBucketName = bucketName,
- stackingKey?: string,
- sameEffectStacking: SameEffectStacking = "add"
-): number {
- const index = BUCKET_IDS[bucketName];
- const definition = BUCKET_DEFINITIONS[bucketName];
- if (definition.update === "assign_factor") buckets.factors[index] = Math.max(0, value);
- else if (definition.update === "multiply_pct_factor") buckets.factors[index] *= 1 + value / 100;
- else buckets.factors[index] += value / 100;
- if (effectId) buckets.contributors?.[index].push({ effectId, source, sourceSide, valuePct: value, bucket: traceBucketName, stackingKey, sameEffectStacking });
- return value;
-}
-
function appendStaticProfileAppliedEffects(appliedEffects: DamageEquationTrace["appliedEffects"], entry: StaticDamageProfileEntry): void {
for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {
if (!bucket.startsWith("passive.") || !term) continue;
@@ -372,10 +351,11 @@
function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, detail: DamageDetail, staticProfile?: StaticDamageProfile): DamageExpressionResult {
if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, detail, staticProfile);
+ const factors = buckets.factors;
let numerator = 1;
- for (const term of DEFAULT_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);
+ for (const slot of DEFAULT_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];
let denominator = 1;
- for (const term of DEFAULT_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);
+ for (const slot of DEFAULT_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];
return {
rawDamage: numerator / denominator,
aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets) : EMPTY_AGGREGATION_GROUPS
@@ -388,15 +368,18 @@
detail: DamageDetail,
staticProfile: StaticDamageProfile
): DamageExpressionResult {
- validateProfiledStaticFactors(job, staticProfile);
+ 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 =
- valueForTerm(TROOPS_COUNT_TERM, job, buckets) *
- valueForTerm(SOURCE_EXTRA_SKILL_TERM, job, buckets) *
- staticProfile.offense[job.attackerSide][job.attackerUnit].factor *
- staticProfile.defense[job.defenderSide][job.defenderUnit].factor;
- for (const term of PROFILED_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);
+ 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 term of PROFILED_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);
+ for (const slot of PROFILED_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];
return {
rawDamage: numerator / denominator,
aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets, staticProfile) : EMPTY_AGGREGATION_GROUPS
@@ -425,11 +408,6 @@
});
}
-function valueForTerm(term: DamageFactorTerm, job: DamageJob, buckets: NumericDamageBuckets): number {
- if (term.appliesTo && term.appliesTo !== job.kind) return 1;
- return buckets.factors[term.bucket];
-}
-
function buildAggregationGroups(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): Record<string, DamageAggregationGroupTrace> {
const aggregationGroups: Record<string, DamageAggregationGroupTrace> = {};
if (staticProfile) addStaticAggregationGroups(aggregationGroups, job, staticProfile);
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
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";
+import { cloneEffectIndex, createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";
import { unitMask } from "./types";
import type { ActiveEffect, DamageJob } from "./types";
@@ -13,12 +13,15 @@
intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ bucketIndex: -1,
+ initialValuePct: 25,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
createdRound: 0,
startRound: 0,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
stackingKey: "stack",
sameEffectStacking: "add"
@@ -40,20 +43,36 @@
defenderUnit: "lancer"
};
- assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);
+ assert.deepEqual(damageEffectsForJob(index, job), [effect]);
});
-test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {
+test("effect index excludes static-profile bucket effects", () => {
const index = createEffectIndex();
const passive = effect("passive.attack.up");
const active = effect("active.hero.lethality.up");
indexEffect(index, passive);
indexEffect(index, active);
- removeStaticProfileBucketEffects(index);
+ assert.deepEqual(damageEffectsForJob(index, job()), [active]);
+});
+
+test("cloned effect index remaps shared effects while owning its bucket arrays", () => {
+ const index = createEffectIndex();
+ const original = effect("active.hero.lethality.up");
+ const replacement = { ...original };
+ indexEffect(index, original);
+
+ const clone = cloneEffectIndex(index, (candidate) => {
+ assert.equal(candidate, original);
+ return replacement;
+ });
+ const originalCandidates = damageEffectsForJob(index, job());
+ const clonedCandidates = damageEffectsForJob(clone, job());
- assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);
- assert.deepEqual(index.all, [active]);
+ assert.notEqual(clonedCandidates, originalCandidates);
+ assert.deepEqual(clonedCandidates, [replacement]);
+ clonedCandidates.push(effect("active.hero.attack.up"));
+ assert.deepEqual(originalCandidates, [original]);
});
function effect(type: string): ActiveEffect {
@@ -63,12 +82,15 @@
intent: { id: type, type, value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ bucketIndex: -1,
+ initialValuePct: 25,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
createdRound: 0,
startRound: 0,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
stackingKey: "stack",
sameEffectStacking: "add"
diff --git a/simulator/src/effectIndex.ts b/simulator/src/effectIndex.ts
--- a/simulator/src/effectIndex.ts
+++ b/simulator/src/effectIndex.ts
@@ -1,26 +1,19 @@
import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";
import { unitsFromMask } from "./types";
-import { bucketDefinition, type AtomicBucket } from "./damageBuckets";
-import { isStaticProfileBucket } from "./staticDamageProfile";
-
-export interface IndexedBucketEffect {
- effect: ActiveEffect;
- bucket: AtomicBucket;
-}
+import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";
export interface EffectIndex {
- all: ActiveEffect[];
- damageByJobShape: Array<IndexedBucketEffect[] | undefined>;
+ damageByJobShape: Array<ActiveEffect[] | undefined>;
controls: ActiveEffect[];
extraAttacks: ActiveEffect[];
battleOrder: ActiveEffect[];
}
-const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;
+export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;
+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));
export function createEffectIndex(): EffectIndex {
return {
- all: [],
damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),
controls: [],
extraAttacks: [],
@@ -28,8 +21,16 @@
};
}
+export function cloneEffectIndex(index: EffectIndex, cloneEffect: (effect: ActiveEffect) => ActiveEffect): EffectIndex {
+ return {
+ damageByJobShape: index.damageByJobShape.map((effects) => effects?.map(cloneEffect)),
+ controls: index.controls.map(cloneEffect),
+ extraAttacks: index.extraAttacks.map(cloneEffect),
+ battleOrder: index.battleOrder.map(cloneEffect)
+ };
+}
+
export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {
- index.all.push(effect);
if (effect.kind === "control") {
index.controls.push(effect);
return;
@@ -47,58 +48,33 @@
// Static-phase buckets (passive.*) are aggregated by the static damage profile, not the
// per-job runtime path, so they must not enter the damage job-shape index.
if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;
+ effect.bucketIndex = BUCKET_INDEX.get(definition.path) ?? -1;
- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
- const appliesToUnits = unitsFromMask(effect.appliesTo.units);
- const appliesVsUnits = unitsFromMask(effect.appliesVs.units);
- for (const jobKind of jobKinds) {
- for (const appliesToUnit of appliesToUnits) {
- for (const appliesVsUnit of appliesVsUnits) {
- const slot =
- definition.role === "attacker"
- ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
- : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);
- const arr = index.damageByJobShape[slot];
- const candidate = { effect, bucket: definition.path };
- if (arr) arr.push(candidate);
- else index.damageByJobShape[slot] = [candidate];
- }
- }
+ for (const slot of shapeSlotsFor(effect, definition.path)) {
+ const arr = index.damageByJobShape[slot];
+ if (arr) arr.push(effect);
+ else index.damageByJobShape[slot] = [effect];
}
}
-export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {
- compactEffects(index.all, isActive);
- compactEffects(index.controls, isActive);
- compactEffects(index.extraAttacks, isActive);
- compactEffects(index.battleOrder, isActive);
- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
- const arr = index.damageByJobShape[slot];
- if (!arr) continue;
- compactCandidates(arr, isActive);
- if (arr.length === 0) index.damageByJobShape[slot] = undefined;
- }
+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";
}
-export function removeStaticProfileBucketEffects(index: EffectIndex): void {
- compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));
- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
- const arr = index.damageByJobShape[slot];
- if (!arr) continue;
- let write = 0;
- for (let read = 0; read < arr.length; read += 1) {
- if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }
- }
- arr.length = write;
- if (arr.length === 0) index.damageByJobShape[slot] = undefined;
- }
+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);
}
-export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {
+export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {
return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];
}
-function damageJobShapeSlot(
+export function damageJobShapeSlot(
jobKind: DamageKind,
attackerSide: SideId,
attackerUnit: UnitType,
@@ -108,6 +84,36 @@
return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);
}
+const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();
+function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
+ const key =
+ ((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 +
+ (effect.appliesVs.units & 7);
+ const cached = SHAPE_SLOTS_CACHE.get(key);
+ if (cached) return cached;
+ const slots = buildShapeSlots(effect, bucket);
+ SHAPE_SLOTS_CACHE.set(key, slots);
+ return slots;
+}
+
+function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
+ const definition = bucketDefinition(bucket)!;
+ const slots: number[] = [];
+ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["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 === "attacker"
+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)
+ );
+ }
+ }
+ }
+ return Uint8Array.from(slots);
+}
+
function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }
function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }
function unitIndex(unit: UnitType): number {
@@ -116,29 +122,7 @@
return 2;
}
-function isStaticProfileEffect(effect: ActiveEffect): boolean {
- const definition = bucketDefinition(effect.intent.type);
- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));
-}
-
-function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {
- let write = 0;
- for (let read = 0; read < effects.length; read += 1) {
- const effect = effects[read];
- if (!isActive(effect)) continue;
- effects[write] = effect;
- write += 1;
- }
- effects.length = write;
-}
-
-function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {
- let write = 0;
- for (let read = 0; read < candidates.length; read += 1) {
- const candidate = candidates[read];
- if (!isActive(candidate.effect)) continue;
- candidates[write] = candidate;
- write += 1;
- }
- candidates.length = write;
+function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {
+ const index = effects.indexOf(effect);
+ if (index >= 0) effects.splice(index, 1);
}
diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts
--- a/simulator/src/effects.ts
+++ b/simulator/src/effects.ts
@@ -4,24 +4,38 @@
AttackIntent,
EffectDuration,
EffectIntentDefinition,
+ EvolvingActiveEffect,
ResolvedSkill,
ResolvedUnitScope,
SameEffectStacking,
SideId,
UnitType
} from "./types";
-import { ALL_UNIT_MASK, unitMask } from "./types";
+import { ALL_UNIT_MASK, unitMask, unitMaskHas } from "./types";
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
-type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];
-
-interface CompiledTriggerSelectors {
- source: ParsedTriggerSelector;
- target: ParsedTriggerSelector;
+interface CompiledActivation {
+ idPrefix: string;
+ source: ActiveEffect["source"];
+ ownerSide: SideId;
+ kind: ActiveEffectKind;
+ initialValuePct: number;
+ getCurrentValuePct: ActiveEffect["getCurrentValuePct"];
+ valueEvolution?: EvolvingActiveEffect["valueEvolution"];
+ triggerDamageJobs: ActiveEffect["triggerDamageJobs"];
+ duration: EffectDuration;
+ turnDelay: number;
+ attackDelay: number;
+ stackingKey: string;
+ sameEffectStacking: SameEffectStacking;
+ staticAppliesTo: ResolvedUnitScope;
+ staticAppliesVs: ResolvedUnitScope;
+ intentScoped: boolean;
}
-const TRIGGER_SELECTOR_CACHE = new WeakMap<ResolvedSkill, CompiledTriggerSelectors>();
+const ACTIVATION_CACHE = new WeakMap<EffectIntentDefinition, CompiledActivation>();
+const INTENT_SCOPED_VALUES = new Set(["trigger.source", "trigger", "trigger.target", "target"]);
export function oppositeSide(side: SideId): SideId {
return side === "attacker" ? "defender" : "attacker";
@@ -40,32 +54,47 @@
if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every, trigger.first)) return false;
if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every, trigger.first)) return false;
if (!intent) return true;
- const selectors = compiledTriggerSelectors(skill);
+ const selectors = compiledTriggerForSkill(skill);
return (
- triggerSelectorMatches(selectors.source, skill.side, intent.attackerSide, intent.attackerUnit) &&
- triggerSelectorMatches(selectors.target, skill.side, intent.defenderSide, intent.defenderUnit)
+ triggerScopeMatches(selectors.source, intent.attackerSide, intent.attackerUnit) &&
+ triggerScopeMatches(selectors.target, intent.defenderSide, intent.defenderUnit)
);
}
-function compiledTriggerSelectors(skill: ResolvedSkill): CompiledTriggerSelectors {
- const cached = TRIGGER_SELECTOR_CACHE.get(skill);
- if (cached) return cached;
+export function compiledTriggerForSkill(skill: ResolvedSkill): NonNullable<ResolvedSkill["compiledTrigger"]> {
+ const cached = skill.compiledTrigger;
+ if (cached && cached.definition === skill.trigger && cached.side === skill.side && cached.level === skill.level) return cached;
const compiled = {
- source: parseTriggerSelector(skill.trigger.source, "self"),
- target: parseTriggerSelector(skill.trigger.target, "enemy")
+ definition: skill.trigger,
+ side: skill.side,
+ level: skill.level,
+ source: compileTriggerScope(skill, skill.trigger.source, "self"),
+ target: compileTriggerScope(skill, skill.trigger.target, "enemy"),
+ probabilityPct: resolvedProbabilityPct(skill)
};
- TRIGGER_SELECTOR_CACHE.set(skill, compiled);
+ skill.compiledTrigger = compiled;
return compiled;
}
-export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {
+function compileTriggerScope(skill: ResolvedSkill, value: unknown, defaultRelation: TriggerSelectorRelation): ResolvedUnitScope {
+ const selector = parseTriggerSelector(value, defaultRelation);
+ return {
+ side: sideForTriggerRelation(skill.side, selector.relation),
+ units: selector.units ? unitMask(selector.units) : ALL_UNIT_MASK
+ };
+}
+
+function resolvedProbabilityPct(skill: ResolvedSkill): number {
const probability = skill.trigger.probability;
- if (probability === undefined) return true;
- const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);
- if (!Number.isFinite(value) || value <= 0) return false;
+ const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability ?? 100);
+ return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
+}
+
+export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {
+ const value = compiledTriggerForSkill(skill).probabilityPct;
+ if (value <= 0) return false;
if (value >= 100) return true;
- const threshold = value / 100;
- return rng() < threshold;
+ return rng() < value / 100;
}
export function createSeededRng(seed: string | number = "simulator-default"): Rng {
@@ -82,20 +111,22 @@
return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);
}
-export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {
+function compiledActivation(skill: ResolvedSkill, intent: EffectIntentDefinition): CompiledActivation {
+ const cached = ACTIVATION_CACHE.get(intent);
+ if (cached) return cached;
const units = intent.units ?? {};
const ownerSide = skill.side;
- const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);
- const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);
+ const staticAppliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", undefined, ownerSide);
+ const staticAppliesVs = resolveUnitScope(units.applies_vs, oppositeSide(staticAppliesTo.side), "applies_vs", undefined, ownerSide);
const duration = normalizeDuration(intent.duration);
- const delay = duration.delay ?? 0;
const effectKind = kindForIntent(intent);
if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {
throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);
}
const sourceKey = skillActivationSourceKey(skill);
- return {
- id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,
+ 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,
@@ -106,20 +137,65 @@
skillName: skill.name,
effectId: intent.id
},
- intent,
ownerSide,
kind: effectKind,
- valuePct: typeof intent.value === "number" ? intent.value : undefined,
+ initialValuePct: finiteNumberOrZero(intent.value),
+ getCurrentValuePct: evolution ? evolvingActiveEffectValuePct : constantActiveEffectValuePct,
+ valueEvolution: evolution
+ ? {
+ type: evolution.type,
+ step: evolution.step,
+ amount: finiteNumberOrZero(evolution.value)
+ }
+ : undefined,
+ triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : 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:
+ (typeof units.applies_to === "string" && INTENT_SCOPED_VALUES.has(units.applies_to)) ||
+ (typeof units.applies_vs === "string" && INTENT_SCOPED_VALUES.has(units.applies_vs))
+ };
+ ACTIVATION_CACHE.set(intent, compiled);
+ return compiled;
+}
+
+export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {
+ const compiled = compiledActivation(skill, intent);
+ let appliesTo = compiled.staticAppliesTo;
+ let appliesVs = compiled.staticAppliesVs;
+ if (compiled.intentScoped && attackIntent) {
+ const units = intent.units ?? {};
+ 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,
+ intent,
+ ownerSide: compiled.ownerSide,
+ kind: compiled.kind,
+ bucketIndex: -1,
+ initialValuePct: compiled.initialValuePct,
+ getCurrentValuePct: compiled.getCurrentValuePct,
appliesTo,
appliesVs,
- triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,
+ triggerDamageJobs: compiled.triggerDamageJobs,
createdRound: round,
- startRound: round + delay,
- duration,
+ startRound: Math.max(1, round + compiled.turnDelay),
+ duration: compiled.duration,
+ remainingAttackDelay: compiled.attackDelay,
uses: 0,
- stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,
- sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)
+ stackingKey: compiled.stackingKey,
+ sameEffectStacking: compiled.sameEffectStacking
};
+ if (!compiled.valueEvolution) return effect;
+ return { ...effect, valueEvolution: compiled.valueEvolution } as EvolvingActiveEffect;
}
function skillActivationSourceKey(skill: ResolvedSkill): string {
@@ -134,30 +210,56 @@
return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
}
-export function isEffectActive(effect: ActiveEffect, round: number): boolean {
- if (round < effect.startRound) return false;
- return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));
+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
+ return effect.duration.attacks !== undefined;
}
-export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
- return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");
-}
-
-export function currentEffectValuePct(effect: ActiveEffect, round: number): number {
- const baseValue = Number(effect.valuePct ?? 0);
- if (!Number.isFinite(baseValue)) return 0;
- const evolution = effect.intent.value_evolution;
- if (!evolution) return baseValue;
- const firstActiveRound = Math.max(1, effect.startRound);
- const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;
- const amount = Number(evolution.value ?? 0);
- if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;
+export function effectAttackUseLimit(effect: ActiveEffect): number | undefined {
+ const count = effect.duration.attacks?.count;
+ return count === undefined ? undefined : Math.max(1, count);
+}
+
+export function effectRoundWindow(effect: ActiveEffect): { activationRound: number; expirationRound?: number } | undefined {
+ const turns = effect.duration.turns;
+ if (!turns) return undefined;
+ return {
+ activationRound: effect.startRound,
+ ...(turns.count === undefined ? {} : { expirationRound: effect.startRound + Math.max(1, turns.count) })
+ };
+}
+
+export function isEffectAttackReady(effect: ActiveEffect): boolean {
+ return effect.remainingAttackDelay <= 0;
+}
+
+// Returns true when the effect may apply to this eligible attack. The attack
+// that consumes the final delay does not also consume the first active use.
+export function advanceEffectAttackDelay(effect: ActiveEffect): boolean {
+ const remaining = effect.remainingAttackDelay;
+ if (remaining <= 0) return true;
+ effect.remainingAttackDelay = remaining - 1;
+ return false;
+}
+
+export function constantActiveEffectValuePct(this: ActiveEffect, _round: number): number {
+ return this.initialValuePct;
+}
+
+export function evolvingActiveEffectValuePct(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 (evolution.type === "pct_decay") {
- const factor = Math.max(0, 1 - amount / 100);
- return baseValue * factor ** stepCount;
+ const factor = Math.max(0, 1 - evolution.amount / 100);
+ return this.initialValuePct * factor ** stepCount;
}
- if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);
- return baseValue;
+ if (evolution.type === "fixed_decay") return Math.max(0, this.initialValuePct - stepCount * evolution.amount);
+ return this.initialValuePct;
+}
+
+function finiteNumberOrZero(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
export type TriggerSelectorRelation = "self" | "enemy";
@@ -183,9 +285,8 @@
return relation === "self" ? skillSide : oppositeSide(skillSide);
}
-function triggerSelectorMatches(selector: ParsedTriggerSelector, skillSide: SideId, actualSide: SideId, actualUnit: UnitType): boolean {
- if (sideForTriggerRelation(skillSide, selector.relation) !== actualSide) return false;
- return selector.units === undefined || selector.units.includes(actualUnit);
+function triggerScopeMatches(scope: ResolvedUnitScope, actualSide: SideId, actualUnit: UnitType): boolean {
+ return scope.side === actualSide && unitMaskHas(scope.units, actualUnit);
}
export function normalizeEngagementType(value: unknown): string | undefined {
@@ -237,40 +338,15 @@
}
function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {
- if (!duration) return { type: "battle", value: 0 };
- const namedConstraints = normalizeNamedDurationConstraints(duration);
- if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };
- return { type: "battle", value: 0 };
-}
-
-function durationConstraints(duration: EffectDuration): EffectDurationConstraint[] {
- return duration.constraints ?? [{ type: duration.type, count: duration.value }];
-}
-
-function isDurationConstraintActive(effect: ActiveEffect, round: number, constraint: EffectDurationConstraint): boolean {
- const delay = Math.max(0, constraint.delay ?? 0);
- if (constraint.type === "battle") return true;
- if (constraint.type === "round") {
- const startRound = effect.createdRound + delay;
- if (round < startRound) return false;
- if (constraint.count === undefined) return true;
- return round < startRound + Math.max(1, constraint.count);
- }
- if (constraint.count === undefined) return true;
- return effect.uses < delay + Math.max(1, constraint.count);
-}
-
-function normalizeNamedDurationConstraints(duration: NonNullable<EffectIntentDefinition["duration"]>): EffectDurationConstraint[] {
- const constraints: EffectDurationConstraint[] = [];
- const turns = duration.turns ?? duration.rounds;
- if (turns) constraints.push(normalizeNamedDurationConstraint("round", turns));
- if (duration.attacks) constraints.push(normalizeNamedDurationConstraint("attack", duration.attacks));
- return constraints;
+ if (!duration) return {};
+ return {
+ ...(duration.turns ? { turns: normalizeDurationAxis(duration.turns) } : {}),
+ ...(duration.attacks ? { attacks: normalizeDurationAxis(duration.attacks) } : {})
+ };
}
-function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {
+function normalizeDurationAxis(value: { count?: number; delay?: number }): { count?: number; delay?: number } {
return {
- type,
...(value.count === undefined ? {} : { count: Number(value.count) }),
...(value.delay === undefined ? {} : { delay: Number(value.delay) })
};
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
@@ -566,6 +566,139 @@
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 = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: { DelayedAfterPause: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedAfterPause: {
+ name: "DelayedAfterPause",
+ troop_type: "infantry",
+ skills: {
+ PauseFirstRound: {
+ trigger: { type: "battle_start" },
+ effects: {
+ pause: {
+ type: "no_attack",
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ },
+ DelayedAttackBudget: {
+ trigger: { type: "turn", every: 99, first: 1 },
+ effects: {
+ delayedBoost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { delay: 1 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { 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");
+ assert.equal(roundOne?.cancelReason, "no_attack");
+ assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+});
+
+test("attack delay skips eligible attacks before the effect can apply", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { marksman_t1: 1000000 },
+ heroes: { DelayedExtra: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedExtra: {
+ name: "DelayedExtra",
+ troop_type: "marksman",
+ skills: {
+ NextAttack: {
+ trigger: { type: "battle_start" },
+ effects: {
+ delayedExtra: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "self.marksman", applies_vs: "any" },
+ duration: { attacks: { count: 1, delay: 1 } },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");
+ assert.equal(skillAttacks.length, 1);
+ assert.ok(skillAttacks[0].jobId.startsWith("r2:"));
+});
+
+test("attack delay skips eligible damage jobs before a modifier can apply", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: { DelayedModifier: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedModifier: {
+ name: "DelayedModifier",
+ troop_type: "infantry",
+ skills: {
+ NextAttackBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ delayedBoost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { attacks: { count: 1, delay: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const normalAttacks = result.attacks.filter(
+ (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"
+ );
+ assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);
+ assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+});
+
test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
const config = loadSimulatorConfig();
const result = simulateBattle(
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -28,20 +28,32 @@
import { createRecorder } from "./recorder";
import {
activateEffect,
+ advanceEffectAttackDelay,
chancePasses,
+ compiledTriggerForSkill,
+ constantActiveEffectValuePct,
createSeededRng,
- currentEffectValuePct,
+ effectAttackUseLimit,
+ effectRoundWindow,
hasAttackDurationConstraint,
- isEffectActive,
+ isEffectAttackReady,
oppositeSide,
- parseTriggerSelector,
- sideForTriggerRelation,
skillMatchesTrigger,
sourceLabel,
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
-import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";
+import {
+ cloneEffectIndex,
+ createEffectIndex,
+ damageEffectsForJob,
+ damageJobShapeSlot,
+ DAMAGE_JOB_SHAPE_SLOTS,
+ expireEffectIndex,
+ indexEffect,
+ isRuntimeIndexableEffect,
+ type EffectIndex
+} from "./effectIndex";
import { normalizeUnitType } from "./normalize";
import { emptyTroops, resolveFighter } from "./resolve";
import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";
@@ -53,11 +65,13 @@
const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();
interface Runtime {
- activeEffects: ActiveEffect[];
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: Set<ActiveEffect>;
+ usedEffects: ActiveEffect[];
staticDamageProfile?: StaticDamageProfile;
damageScratch: DamageScratch;
rng: Rng;
@@ -79,6 +93,7 @@
roundStartGlobal: ResolvedSkill[];
roundStartPerUnit: ResolvedSkill[];
attackDeclared: ResolvedSkill[];
+ attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined>;
}
interface ExtraAttackEffectGroup {
@@ -215,33 +230,47 @@
}
// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage
-// profile, and drop static-profile effects from the per-job index.
-function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {
- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));
- triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);
- addInputPassiveEffects(runtime, input.attacker.passive, "attacker");
- addInputPassiveEffects(runtime, input.defender.passive, "defender");
- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);
- removeStaticProfileBucketEffects(runtime.effectIndex);
+// profile. Static-phase effects never enter the per-job index.
+function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number, collectSkillReports = true): Runtime {
+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), collectSkillReports);
+ const setupEffects = [
+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),
+ ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),
+ ...addInputPassiveEffects(runtime, input.defender.passive, "defender")
+ ];
+ runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);
+ runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);
return runtime;
}
-// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)
-// and the index rebuilt from the clones; skills and the static profile are shared by reference.
-function cloneRuntime(template: Runtime, rng: Rng): Runtime {
- const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));
- const effectIndex = createEffectIndex();
- for (const effect of activeEffects) indexEffect(effectIndex, effect);
- removeStaticProfileBucketEffects(effectIndex);
- return {
- activeEffects,
- effectIndex,
- usedEffects: new Set(),
+// Clone a prepared template's mutable per-run effect graph. The template already owns the
+// correct index topology and round schedules, so remap those references to the effect clones
+// instead of classifying and scheduling every effect again. Skills and the immutable static
+// profile are shared by reference.
+function cloneRuntime(template: Runtime, rng: Rng, collectSkillReports = true): 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, cloneEffect),
+ preparedEffects,
+ activateEffectsByRound: cloneEffectSchedule(template.activateEffectsByRound, cloneEffect),
+ expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),
+ usedEffects: [],
staticDamageProfile: template.staticDamageProfile,
damageScratch: createFastDamageScratch(),
rng,
skills: template.skills,
- skillReports: cloneSkillReports(template.skillReports),
+ skillReports: collectSkillReports ? cloneSkillReports(template.skillReports) : { attacker: new Map(), defender: new Map() },
effectActivationCounts: { ...template.effectActivationCounts },
extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },
attackControlCounts: { ...template.attackControlCounts },
@@ -250,6 +279,14 @@
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 cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {
@@ -288,18 +325,19 @@
prepared?: CompiledBattle,
loopOptions: RunLoopOptions = { capRoundKills: true, capJobKills: true, commitLosses: true }
): BattleRun {
+ const collectSkillReports = (options.mode ?? "standard") !== "fast";
if (prepared?.template) {
const fighters: Record<SideId, ResolvedFighter> = {
attacker: cloneFighterForRun(prepared.fighters.attacker),
defender: cloneFighterForRun(prepared.fighters.defender)
};
- const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));
+ const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"), collectSkillReports);
return runLoop(input, fighters, runtime, 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 fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default");
+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", collectSkillReports);
return runLoop(input, fighters, runtime, options, loopOptions);
}
@@ -317,6 +355,15 @@
dodge: options.useEffectsOnDodge ?? true,
no_attack: options.useEffectsOnNoAttack ?? true
};
+ const damageJobOptions = {
+ trace: recorder.capturesTrace,
+ recordAppliedEffects: recorder.capturesAppliedEffects,
+ effectIndex: runtime.effectIndex,
+ staticDamageProfile: runtime.staticDamageProfile,
+ scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,
+ capToDefenderTroops: loopOptions.capJobKills,
+ usedEffects: runtime.usedEffects
+ };
let rounds = 0;
let score = 0;
@@ -324,7 +371,7 @@
if (winnerFor(fighters)) break;
rounds = round;
const roundStartTroops = snapshotTroops(fighters);
- expireInactive(runtime, round);
+ processEffectSchedule(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops);
// Applied-effect events keyed by the intent they ordered.
@@ -340,13 +387,16 @@
const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];
const roundTargetDamage = emptyRoundTargetDamage();
for (const intent of intents) {
- triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);
+ const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[
+ damageJobShapeSlot("normal", intent.attackerSide, intent.attackerUnit, intent.defenderSide, intent.defenderUnit)
+ ] ?? [];
+ triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, intent);
const job = normalJob(intent, roundStartTroops);
declaredNormalJobs.push({ intent, job });
}
for (const { intent, job } of declaredNormalJobs) {
- const controls = applicableControls(job, round, runtime);
+ const controls = applicableControls(job, runtime);
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
pendingNormalJobs.push({ intent, job, control });
@@ -364,7 +414,9 @@
if (control) {
runtime.attackControlCounts[control.reason] += 1;
- if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);
+ if (useEffectsOnCancel[control.reason]) {
+ chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);
+ }
cancelled.push({
intent,
effectId: control.effect.id,
@@ -376,16 +428,8 @@
continue;
}
- allJobs.push(job);
- const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {
- trace: recorder.capturesTrace,
- recordAppliedEffects: recorder.capturesAppliedEffects,
- effectIndex: runtime.effectIndex,
- staticDamageProfile: runtime.staticDamageProfile,
- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,
- capToDefenderTroops: loopOptions.capJobKills,
- usedEffects: runtime.usedEffects
- });
+ if (recorder.capturesTrace) allJobs.push(job);
+ const normalResult = calculateDamageJob(job, fighters, [], damageJobOptions);
if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);
if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {
score += normalResult.kills;
@@ -404,16 +448,8 @@
for (const extraJob of extraSkill.jobs) {
if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
- allJobs.push(extraJob);
- const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {
- trace: recorder.capturesTrace,
- recordAppliedEffects: recorder.capturesAppliedEffects,
- effectIndex: runtime.effectIndex,
- staticDamageProfile: runtime.staticDamageProfile,
- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,
- capToDefenderTroops: loopOptions.capJobKills,
- usedEffects: runtime.usedEffects
- });
+ if (recorder.capturesTrace) allJobs.push(extraJob);
+ const extraResult = calculateDamageJob(extraJob, fighters, [], damageJobOptions);
if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);
processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");
processedExtraJobIds.add(extraJob.id);
@@ -429,7 +465,7 @@
normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));
for (const usedEffectGroup of extraSkill.usedEffectGroups) {
if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;
- for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;
+ for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);
}
}
@@ -501,7 +537,8 @@
const side = roundTriggerSourceSide(skill);
const defenderSide = roundTriggerTargetSide(skill, side);
if (!skillMatchesTrigger(skill, "round_start", round)) continue;
- const report = runtime.skillReports[skill.side].get(reportKey(skill));
+ const reports = runtime.skillReports[skill.side];
+ const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));
if (report) report.triggersSeen += 1;
if (!chancePasses(skill, runtime.rng)) continue;
if (report) report.skillActivations += 1;
@@ -529,20 +566,16 @@
}
function roundTriggerUnits(skill: ResolvedSkill, roundStartTroops: DamageJob["roundStartTroops"]): UnitType[] {
- const selector = parseTriggerSelector(skill.trigger.source, "self");
- const side = sideForTriggerRelation(skill.side, selector.relation);
- const living = UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0);
- return selector.units === undefined ? living : living.filter((unit) => selector.units?.includes(unit));
+ const source = compiledTriggerForSkill(skill).source;
+ return UNIT_TYPES.filter((unit) => (roundStartTroops[source.side][unit] ?? 0) > 0 && unitMaskHas(source.units, unit));
}
function roundTriggerSourceSide(skill: ResolvedSkill): SideId {
- const selector = parseTriggerSelector(skill.trigger.source, "self");
- return sideForTriggerRelation(skill.side, selector.relation);
+ return compiledTriggerForSkill(skill).source.side;
}
function roundTriggerTargetSide(skill: ResolvedSkill, sourceSide: SideId): SideId {
- const selector = parseTriggerSelector(skill.trigger.target, "enemy");
- const targetSide = sideForTriggerRelation(skill.side, selector.relation);
+ const targetSide = compiledTriggerForSkill(skill).target.side;
return targetSide === sourceSide ? oppositeSide(sourceSide) : targetSide;
}
@@ -589,10 +622,10 @@
return Number.isFinite(value) && value > 0 && value < 100;
}
-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {
+function createRuntime(fighters: ResolvedFighter[], rng: Rng, collectSkillReports = true): Runtime {
const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };
const skills = buildRuntimeSkills(fighters);
- for (const fighter of fighters) {
+ for (const fighter of collectSkillReports ? fighters : []) {
for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {
reports[fighter.side].set(reportKey(skill), {
sourceKind: skill.sourceKind,
@@ -610,9 +643,11 @@
}
}
return {
- activeEffects: [],
effectIndex: createEffectIndex(),
- usedEffects: new Set(),
+ preparedEffects: [],
+ activateEffectsByRound: [],
+ expireEffectsByRound: [],
+ usedEffects: [],
staticDamageProfile: undefined,
damageScratch: createFastDamageScratch(),
rng,
@@ -630,45 +665,81 @@
function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {
const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);
+ for (const skill of all) compiledTriggerForSkill(skill);
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 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);
+ const skills = attackDeclaredByJobShape[slot];
+ if (skills) skills.push(skill);
+ else attackDeclaredByJobShape[slot] = [skill];
+ }
+ }
+ }
return {
all,
battleStart,
roundStart,
roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),
roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),
- attackDeclared
+ attackDeclared,
+ attackDeclaredByJobShape
};
}
function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
- runtime.activeEffects.push(effect);
- indexEffect(runtime.effectIndex, effect);
+ 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 expireInactive(runtime: Runtime, round: number): void {
- let write = 0;
- for (let read = 0; read < runtime.activeEffects.length; read += 1) {
- const effect = runtime.activeEffects[read];
- if (isEffectActive(effect, round)) {
- runtime.activeEffects[write] = effect;
- write += 1;
+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;
}
- runtime.activeEffects.length = write;
- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));
}
-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {
- if (!passive) return;
+function addInputPassiveEffects(runtime: Runtime, 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}`;
- addActiveEffect(runtime, {
+ const effect: ActiveEffect = {
id: `${side}:input_stat:${bucket}`,
source: {
kind: "input_stat",
@@ -682,17 +753,23 @@
},
ownerSide: side,
kind: "modifier",
- valuePct,
+ 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: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
- });
+ };
+ addActiveEffect(runtime, effect);
+ effects.push(effect);
}
}
+ return effects;
}
function triggerSkills(
@@ -705,7 +782,8 @@
const activated: ActiveEffect[] = [];
for (const skill of skills) {
if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;
- const report = runtime.skillReports[skill.side].get(reportKey(skill));
+ const reports = runtime.skillReports[skill.side];
+ const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));
if (report) report.triggersSeen += 1;
if (!chancePasses(skill, runtime.rng)) continue;
if (report) report.skillActivations += 1;
@@ -732,12 +810,12 @@
let orderIndex = 0;
for (const attackerUnit of UNIT_TYPES) {
if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;
- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);
+ 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}`;
if (ordered) {
- ordered.effect.uses += 1;
+ chargeEffectUse(runtime, ordered.effect);
orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });
}
const previousAttackCount = runtime.counters.attacks[side][attackerUnit];
@@ -772,7 +850,7 @@
effectIndex: EffectIndex,
round: number
): UnitType | undefined {
- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;
+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;
return firstLivingUnit(order, defenderSide, roundStartTroops);
}
@@ -784,13 +862,14 @@
attackerUnit: UnitType,
attackerSide: SideId,
index: EffectIndex,
- round: number
+ advanceAttackDelay: boolean
): { order: UnitType[]; effect: ActiveEffect } | undefined {
for (const effect of index.battleOrder) {
- if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;
+ if (effect.intent.type !== "attack_order") continue;
if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) 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;
@@ -802,23 +881,34 @@
function applicableControls(
job: DamageJob,
- round: number,
runtime: Runtime
-): { dodge?: Control; no_attack?: Control } {
- const controls: { dodge?: Control; no_attack?: Control } = {};
+): ApplicableControls {
+ const controls: ApplicableControls = { attackDurationEffects: [] };
for (const effect of runtime.effectIndex.controls) {
- if (!isEffectActive(effect, round)) continue;
const classification = classifyEffectForJob(effect, job);
if (classification?.kind === "control" && classification.control) {
- controls[classification.control] = { effect, reason: classification.control };
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);
+ controls[classification.control] = {
+ effect,
+ reason: classification.control,
+ attackDurationEffects: controls.attackDurationEffects
+ };
}
}
return controls;
}
+interface ApplicableControls {
+ dodge?: Control;
+ no_attack?: Control;
+ attackDurationEffects: ActiveEffect[];
+}
+
interface Control {
effect: ActiveEffect;
reason: "dodge" | "no_attack";
+ attackDurationEffects: ActiveEffect[];
}
function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {
@@ -847,7 +937,9 @@
const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];
let appliedEffects: AppliedExtraAttackEffect[] | undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
- runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
+ runtime.effectIndex.extraAttacks.filter(
+ (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)
+ ),
round
);
for (const effectGroup of effectGroups) {
@@ -916,7 +1008,7 @@
}
const existing = selected[existingIndex];
existing.effects.push(effect);
- if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;
+ if (effect.getCurrentValuePct(round) > existing.selected.getCurrentValuePct(round)) existing.selected = effect;
}
return selected;
}
@@ -979,7 +1071,7 @@
}
function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {
- const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;
+ const raw = multiplier === undefined ? effect.getCurrentValuePct(round) : multiplier;
const pct = Number(raw ?? 0);
return Number.isFinite(pct) ? pct / 100 : 0;
}
@@ -1054,24 +1146,38 @@
// 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.size === 0) return;
- for (const effect of runtime.usedEffects) effect.uses += 1;
- runtime.usedEffects.clear();
+ 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, runtime: Runtime): void {
+function chargeCancelledAttack(
+ job: DamageJob,
+ winningControl: ActiveEffect,
+ controlEffects: ActiveEffect[],
+ runtime: Runtime
+): void {
const used = runtime.usedEffects;
- for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {
- if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);
- }
- for (const effect of runtime.effectIndex.controls) {
- if (!hasAttackDurationConstraint(effect)) continue;
- const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "control") used.add(effect);
+ for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {
+ if (effect.expired) continue;
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (hasAttackDurationConstraint(effect)) used.push(effect);
}
- used.add(winningControl);
+ for (const effect of controlEffects) used.push(effect);
+ if (!controlEffects.includes(winningControl)) used.push(winningControl);
chargeUsedEffects(runtime);
}
diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts
--- a/simulator/src/staticDamageProfile.ts
+++ b/simulator/src/staticDamageProfile.ts
@@ -1,7 +1,7 @@
import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";
import { UNIT_TYPES, unitMaskHas } from "./types";
import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";
-import { currentEffectValuePct, sourceLabel } from "./effects";
+import { sourceLabel } from "./effects";
export interface StaticDamageProfileTerm {
raw?: number;
@@ -11,6 +11,7 @@
export interface StaticDamageProfileEntry {
factor: number;
+ playerFactorsValid: boolean;
buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;
}
@@ -90,7 +91,7 @@
const duration = effect.duration;
if (duration === undefined) return;
- if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {
+ if (duration.turns !== undefined || duration.attacks !== undefined) {
throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);
}
}
@@ -130,7 +131,7 @@
{ effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }
]);
}
- return { factor: 1, buckets };
+ return { factor: 1, playerFactorsValid: true, buckets };
}
function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {
@@ -142,7 +143,7 @@
const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];
for (const unit of UNIT_TYPES) {
if (!unitMaskHas(effect.appliesTo.units, unit)) continue;
- const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };
+ const candidate: PassiveCandidate = { effect, bucket, 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);
@@ -177,12 +178,20 @@
function recomputeFactors(profile: StaticDamageProfile): void {
for (const side of ["attacker", "defender"] as SideId[]) {
for (const unit of UNIT_TYPES) {
- profile.offense[side][unit].factor = offenseFactor(profile.offense[side][unit]);
- profile.defense[side][unit].factor = defenseFactor(profile.defense[side][unit]);
+ 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;
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -74,11 +74,14 @@
export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;
export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;
-export interface EffectDuration {
- type: "battle" | "round" | "attack";
- value: number;
+export interface EffectDurationAxis {
+ count?: number;
delay?: number;
- constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;
+}
+
+export interface EffectDuration {
+ turns?: EffectDurationAxis;
+ attacks?: EffectDurationAxis;
}
export interface EffectIntentDefinition {
@@ -88,11 +91,7 @@
value_evolution?: { type?: string; step?: string; value?: number };
units?: Record<string, unknown>;
trigger_damage_jobs?: TriggerDamageJobDefinition[];
- duration?: {
- turns?: { count?: number; delay?: number };
- rounds?: { count?: number; delay?: number };
- attacks?: { count?: number; delay?: number };
- };
+ duration?: EffectDuration;
same_effect_stacking?: SameEffectStacking;
reason?: string;
}
@@ -205,6 +204,14 @@
level: number;
trigger: TriggerDefinition;
effects: EffectIntentDefinition[];
+ compiledTrigger?: {
+ definition: TriggerDefinition;
+ side: SideId;
+ level: number;
+ source: ResolvedUnitScope;
+ target: ResolvedUnitScope;
+ probabilityPct: number;
+ };
}
export interface ResolvedHero {
@@ -242,11 +249,16 @@
export interface ActiveEffect {
id: string;
+ expired?: boolean;
source: EffectSource;
intent: EffectIntentDefinition;
ownerSide: SideId;
kind: ActiveEffectKind;
- valuePct?: number;
+ // Numeric slot in the runtime damage scratch. Dynamic modifiers receive it
+ // when indexed; non-damage effects keep -1.
+ bucketIndex: number;
+ initialValuePct: number;
+ getCurrentValuePct(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;
@@ -255,6 +267,9 @@
createdRound: number;
startRound: number;
duration: EffectDuration;
+ // Eligible attacks remaining before an attacks.delay effect can apply. This is
+ // runtime state resolved from config, separate from `uses` after activation.
+ remainingAttackDelay: number;
// Times this instance affected battle mechanics (damage bucket applied, control fired,
// attack ordered, extra attack spawned). Attack duration constraints and step:"attack"
// value evolution read it; cancelled attacks still charge attack-constrained effects
@@ -264,6 +279,14 @@
sameEffectStacking: SameEffectStacking;
}
+export interface EvolvingActiveEffect extends ActiveEffect {
+ valueEvolution: {
+ type?: string;
+ step?: string;
+ amount: number;
+ };
+}
+
export interface AttackIntent {
id: string;
round: number;
Run B 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[];