WOS Sim
← Back to Runs

Compare Runs

A (baseline)
793384d0-3c8ed76d7ae10/07/2026, 19:13:28
B (current)
2c9ddb50-a62ed76d7ae11/07/2026, 00:04:27
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error-0.00%
Changed16
Improved8
Regressed8
Added0
Retired0

Testcase Delta

199 / 199
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo0-1.97%-1.58%-0.39%improved
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21+0.05%-0.77%-0.50%-0.27%improved
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.32%-0.16%-0.16%improved
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43-0.13%-0.29%+0.16%regressed
testcases/heroes_unittests/Mia_tc.jsondaut_viper_32-0.10%-0.21%+0.11%regressed
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0-0.09%0.00%-0.09%improved
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.27%-0.18%-0.09%improved
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1-0.21%-0.13%-0.08%improved
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo1-0.05%1.06%1.12%+0.06%regressed
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0-0.07%0.01%-0.06%improved
testcases/emulator_verified/mia_solo.jsonmia_solo10.22%0.27%+0.05%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo30.04%0.09%+0.05%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo40.21%0.26%+0.05%regressed
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.17%0.21%+0.04%regressed
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo0-0.02%-0.03%+0.01%regressed
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54-0.11%-0.10%-0.01%improved
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_combo1-0.26%-0.26%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2-0.19%-0.19%0.00%unchanged
testcases/emulator_verified/alonso_solo.jsonalonso_solo02.97%2.97%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201.59%1.59%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21-1.59%-1.59%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.17%-0.17%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/ambusher_no_marksmen.jsonambusher_no_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_norah_combo_nc.jsonbahiti_norah_combo_nc0-3.30%-3.30%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_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/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/Molly_tc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_430.33%0.33%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_54-0.02%-0.02%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_10+0.05%-0.18%-0.18%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_42-0.46%-0.46%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_530.01%0.01%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43+0.05%-0.04%-0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65-0.006%0.04%0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76-0.004%0.04%0.04%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10-0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_320.00%0.00%0.00%unchanged

Code / Config Changes

Code Changes (Run A → Run B)

Index: simulator/config/hero_definitions/Alonso.json
===================================================================
--- simulator/config/hero_definitions/Alonso.json prev run
+++ simulator/config/hero_definitions/Alonso.json this run
@@ -12,10 +12,9 @@
40,
40,
40,
40
- ],
- "source": "self.any"
+ ]
},
"effects": {
"Onslaught/1": {
"type": "active.hero.lethality.up",
@@ -25,12 +24,8 @@
30,
40,
50
],
- "units": {
- "applies_to": "trigger",
- "applies_vs": "trigger.target"
- },
"duration": {
"turns": {
"count": 1
}
Index: simulator/config/hero_definitions/Mia.json
===================================================================
--- simulator/config/hero_definitions/Mia.json prev run
+++ simulator/config/hero_definitions/Mia.json this run
@@ -29,9 +29,10 @@
"applies_to": "target"
},
"duration": {
"turns": {
- "count": 1
+ "count": 1,
+ "delay": 1
}
},
"same_effect_stacking": "max"
}
Index: simulator/src/classifierDamage.test.ts
===================================================================
--- simulator/src/classifierDamage.test.ts prev run
+++ simulator/src/classifierDamage.test.ts this run
@@ -32,8 +32,9 @@
source: { kind: "hero_skill", side: "attacker", heroName: "Example", skillId: "Scope", effectId: "scope/1" },
intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
+ bucketIndex: -1,
initialValuePct: 25,
getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask(["infantry"]) },
appliesVs: { side: "defender", units: unitMask(["lancer"]) },
@@ -79,8 +80,9 @@
source,
intent: { id: `${type}/1`, type, value: [valuePct] },
ownerSide,
kind: "modifier",
+ bucketIndex: -1,
initialValuePct: valuePct,
getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },
appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },
@@ -103,11 +105,11 @@
if (!options.effectIndex) {
for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);
}
const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
- 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", () => {
assert.equal(classifyEffectForJob(effect("active.hero.health.up", "defender"), job)?.bucket, "active.hero.health.up");
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -4,9 +4,8 @@
DamageBucketTrace,
DamageEquationTrace,
DamageJob,
ResolvedFighter,
- SameEffectStacking,
SideId,
UnitType
} from "./types";
import { UNIT_TYPES } from "./types";
@@ -59,15 +58,27 @@
}
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;
readonly round: number;
@@ -98,9 +109,9 @@
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?: 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)
// per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.
@@ -115,14 +126,14 @@
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);
- 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 usedEffects = options.usedEffects ?? [];
applyBucketEffects(
damageEffectsForJob(options.effectIndex, job),
job.round,
buckets,
@@ -135,12 +146,9 @@
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
? {
@@ -148,9 +156,12 @@
attacker: { ...job.roundStartTroops.attacker },
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,
rawDamage,
@@ -172,9 +183,9 @@
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- usedEffects: Set<ActiveEffect>
+ usedEffects: ActiveEffect[]
): void {
let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;
for (const effect of effects) {
if (effect.expired) continue;
@@ -208,19 +219,25 @@
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"]
): number {
- const appliedValuePct = applyBucketValue(
- buckets,
- selected.intent.type,
- selected.getCurrentValuePct(round),
- detail === "full" ? selected.source.effectId ?? selected.id : "",
- detail === "full" ? sourceLabel(selected) : "",
- detail === "full" ? selected.ownerSide : undefined,
- selected.intent.type,
- selected.stackingKey,
- selected.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.id,
@@ -246,13 +263,13 @@
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- usedEffects: Set<ActiveEffect>
+ usedEffects: ActiveEffect[]
): void {
const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
- usedEffects.add(effect);
+ usedEffects.push(effect);
} else if (detail === "full") {
rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
}
}
@@ -265,15 +282,15 @@
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- usedEffects: Set<ActiveEffect>
+ usedEffects: ActiveEffect[]
): void {
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 effect of effects) {
- usedEffects.add(effect);
+ usedEffects.push(effect);
if (detail === "full" && effect !== selected) {
rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });
}
}
@@ -312,28 +329,8 @@
buckets.factors.fill(1);
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;
for (const contributor of term.contributors) {
@@ -353,12 +350,13 @@
}
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
};
@@ -369,17 +367,20 @@
buckets: NumericDamageBuckets,
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
};
@@ -406,13 +407,8 @@
contributors: term?.contributors ?? []
});
}
-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);
for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS]) {
Index: simulator/src/effectIndex.test.ts
===================================================================
--- simulator/src/effectIndex.test.ts prev run
+++ simulator/src/effectIndex.test.ts this run
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";
+import { cloneEffectIndex, createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";
import { unitMask } from "./types";
import type { ActiveEffect, DamageJob } from "./types";
test("effect index returns bucket-tagged candidates from a direct job-shape lookup", () => {
@@ -12,8 +12,9 @@
source: { kind: "hero_skill", side: "attacker", effectId: "boost" },
intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
+ bucketIndex: -1,
initialValuePct: 25,
getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
@@ -54,15 +55,35 @@
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.notEqual(clonedCandidates, originalCandidates);
+ assert.deepEqual(clonedCandidates, [replacement]);
+ clonedCandidates.push(effect("active.hero.attack.up"));
+ assert.deepEqual(originalCandidates, [original]);
+});
+
function effect(type: string): ActiveEffect {
return {
id: type,
source: { kind: "hero_skill", side: "attacker", effectId: type },
intent: { id: type, type, value: 25 },
ownerSide: "attacker",
kind: "modifier",
+ bucketIndex: -1,
initialValuePct: 25,
getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -8,9 +8,10 @@
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 {
damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),
@@ -19,8 +20,17 @@
battleOrder: []
};
}
+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 {
if (effect.kind === "control") {
index.controls.push(effect);
return;
@@ -37,8 +47,9 @@
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);
@@ -62,9 +73,9 @@
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,
defenderSide: SideId,
@@ -73,10 +84,8 @@
return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);
}
const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();
-const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));
-
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);
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -10,18 +10,33 @@
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;
-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";
}
@@ -38,34 +53,49 @@
if (triggerType === "attack_declared" && trigger.type !== "attack") return false;
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 {
let state = hashSeed(String(seed));
@@ -80,23 +110,24 @@
if (previous < first) return true;
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 turnDelay = Math.max(0, duration.turns?.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);
- const effect: ActiveEffect = {
- id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,
- expired: false,
+ const evolution = intent.value_evolution;
+ const compiled: CompiledActivation = {
+ idPrefix: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r`,
source: {
kind: skill.sourceKind,
side: skill.side,
heroName: skill.heroName,
@@ -105,36 +136,67 @@
skillId: skill.id,
skillName: skill.name,
effectId: intent.id
},
- intent,
ownerSide,
kind: effectKind,
initialValuePct: finiteNumberOrZero(intent.value),
- getCurrentValuePct: constantActiveEffectValuePct,
+ 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: Math.max(1, round + turnDelay),
- duration,
- remainingAttackDelay: duration.attacks?.delay ?? 0,
+ 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
};
- const evolution = intent.value_evolution;
- if (!evolution) return effect;
- const evolvingEffect: EvolvingActiveEffect = {
- ...effect,
- valueEvolution: {
- type: evolution.type,
- step: evolution.step,
- amount: finiteNumberOrZero(evolution.value)
- },
- getCurrentValuePct: evolvingActiveEffectValuePct
- };
- return evolvingEffect;
+ if (!compiled.valueEvolution) return effect;
+ return { ...effect, valueEvolution: compiled.valueEvolution } as EvolvingActiveEffect;
}
function skillActivationSourceKey(skill: ResolvedSkill): string {
return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";
@@ -222,11 +284,10 @@
export function sideForTriggerRelation(skillSide: SideId, relation: TriggerSelectorRelation): SideId {
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 {
if (typeof value !== "string") return undefined;
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -29,23 +29,32 @@
import {
activateEffect,
advanceEffectAttackDelay,
chancePasses,
+ compiledTriggerForSkill,
constantActiveEffectValuePct,
createSeededRng,
effectAttackUseLimit,
effectRoundWindow,
hasAttackDurationConstraint,
isEffectAttackReady,
oppositeSide,
- parseTriggerSelector,
- sideForTriggerRelation,
skillMatchesTrigger,
sourceLabel,
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
-import { createEffectIndex, damageEffectsForJob, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, 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";
@@ -61,9 +70,9 @@
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;
skills: RuntimeSkills;
@@ -83,8 +92,9 @@
roundStart: ResolvedSkill[];
roundStartGlobal: ResolvedSkill[];
roundStartPerUnit: ResolvedSkill[];
attackDeclared: ResolvedSkill[];
+ attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined>;
}
interface ExtraAttackEffectGroup {
selected: ActiveEffect;
@@ -220,10 +230,10 @@
}
// 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): Runtime {
- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));
+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")
@@ -232,36 +242,54 @@
runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);
return runtime;
}
-// Clone a prepared template's mutable per-run effect state and rebuild the index from the
-// clones; skills and the static profile are shared by reference.
-function cloneRuntime(template: Runtime, rng: Rng): 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 {
+ 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: createEffectIndex(),
- preparedEffects: [],
- activateEffectsByRound: [],
- expireEffectsByRound: [],
- usedEffects: new Set(),
+ 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 },
counters: {
attacks: { attacker: { ...template.counters.attacks.attacker }, defender: { ...template.counters.attacks.defender } },
received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }
}
};
- for (const templateEffect of template.preparedEffects) {
- addActiveEffect(runtime, { ...templateEffect, expired: false, uses: 0 });
- }
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>> {
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] });
@@ -296,20 +324,21 @@
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"));
+ 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);
}
function runLoop(
@@ -325,8 +354,17 @@
const useEffectsOnCancel = {
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;
for (let round = 1; round <= maxRounds; round += 1) {
@@ -348,9 +386,12 @@
const pendingNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; control?: Control }> = [];
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 });
}
@@ -386,18 +427,10 @@
});
continue;
}
- allJobs.push(job);
- const normalResult = calculateDamageJob(job, fighters, [], {
- 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;
}
@@ -414,18 +447,10 @@
results.push(normalEntry);
for (const extraJob of extraSkill.jobs) {
if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
- allJobs.push(extraJob);
- const extraResult = calculateDamageJob(extraJob, fighters, [], {
- 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);
if (extraJob.sourceEffectId) {
@@ -511,9 +536,10 @@
for (const skill of runtime.skills.roundStartPerUnit) {
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;
let orderIndex = 0;
@@ -539,22 +565,18 @@
return skill.trigger.type === "turn" && skill.trigger.source !== undefined;
}
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;
}
function syntheticRoundIntent(
@@ -599,12 +621,12 @@
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): 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,
heroName: skill.heroName,
@@ -624,9 +646,9 @@
effectIndex: createEffectIndex(),
preparedEffects: [],
activateEffectsByRound: [],
expireEffectsByRound: [],
- usedEffects: new Set(),
+ usedEffects: [],
staticDamageProfile: undefined,
damageScratch: createFastDamageScratch(),
rng,
skills,
@@ -642,18 +664,32 @@
}
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 {
@@ -716,8 +752,9 @@
value: valuePct
},
ownerSide: side,
kind: "modifier",
+ bucketIndex: -1,
initialValuePct: valuePct,
getCurrentValuePct: constantActiveEffectValuePct,
appliesTo: { side, units: ALL_UNIT_MASK },
appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },
@@ -744,9 +781,10 @@
): ActiveEffect[] {
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;
for (const effectIntent of skill.effects) {
@@ -1107,11 +1145,11 @@
// 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;
+ if (runtime.usedEffects.length === 0) return;
for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);
- runtime.usedEffects.clear();
+ runtime.usedEffects.length = 0;
}
function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {
effect.uses += 1;
@@ -1135,12 +1173,12 @@
const used = runtime.usedEffects;
for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {
if (effect.expired) continue;
if (!advanceEffectAttackDelay(effect)) continue;
- if (hasAttackDurationConstraint(effect)) used.add(effect);
+ if (hasAttackDurationConstraint(effect)) used.push(effect);
}
- for (const effect of controlEffects) used.add(effect);
- used.add(winningControl);
+ for (const effect of controlEffects) used.push(effect);
+ if (!controlEffects.includes(winningControl)) used.push(winningControl);
chargeUsedEffects(runtime);
}
function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {
Index: simulator/src/staticDamageProfile.ts
===================================================================
--- simulator/src/staticDamageProfile.ts prev run
+++ simulator/src/staticDamageProfile.ts this run
@@ -10,8 +10,9 @@
}
export interface StaticDamageProfileEntry {
factor: number;
+ playerFactorsValid: boolean;
buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;
}
export interface StaticDamageProfile {
@@ -129,9 +130,9 @@
setPct(buckets, "player.defense", bonuses.defense, [
{ 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 {
const groups = new Map<string, PassiveCandidateGroup>();
@@ -176,14 +177,22 @@
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;
placement: BucketPlacement;
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -203,8 +203,16 @@
troopType?: UnitType;
level: number;
trigger: TriggerDefinition;
effects: EffectIntentDefinition[];
+ compiledTrigger?: {
+ definition: TriggerDefinition;
+ side: SideId;
+ level: number;
+ source: ResolvedUnitScope;
+ target: ResolvedUnitScope;
+ probabilityPct: number;
+ };
}
export interface ResolvedHero {
name: string;
@@ -245,8 +253,11 @@
source: EffectSource;
intent: EffectIntentDefinition;
ownerSide: SideId;
kind: ActiveEffectKind;
+ // 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".
Show raw per-run patches
Run A dirty state patch
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/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,14 @@
intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ 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 +80,14 @@
intent: { id: `${type}/1`, type, value: [valuePct] },
ownerSide,
kind: "modifier",
- valuePct,
+ 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,7 +104,6 @@
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 result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });
return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };
@@ -418,7 +421,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 +435,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 +459,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 +478,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 +504,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 +520,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 +533,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
@@ -10,10 +10,9 @@
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 +32,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 {
@@ -129,34 +122,17 @@
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" });
- }
- }
+ 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]);
@@ -189,8 +165,9 @@
};
}
-function applyBucketCandidates(
- candidates: BucketCandidate[],
+function applyBucketEffects(
+ effects: ActiveEffect[],
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
@@ -198,32 +175,35 @@
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<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,
@@ -231,27 +211,27 @@
): 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
+ selected.intent.type,
+ selected.getCurrentValuePct(round),
+ detail === "full" ? selected.source.effectId ?? selected.id : "",
+ detail === "full" ? sourceLabel(selected) : "",
+ detail === "full" ? selected.ownerSide : undefined,
+ selected.intent.type,
+ selected.stackingKey,
+ 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,8 +239,9 @@
// 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,
@@ -268,17 +249,18 @@
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<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.add(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,
@@ -286,18 +268,18 @@
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<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.add(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" });
}
}
}
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 { createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";
import { unitMask } from "./types";
import type { ActiveEffect, DamageJob } from "./types";
@@ -13,12 +13,14 @@
intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ 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 +42,17 @@
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(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);
- assert.deepEqual(index.all, [active]);
+ assert.deepEqual(damageEffectsForJob(index, job()), [active]);
});
function effect(type: string): ActiveEffect {
@@ -63,12 +62,14 @@
intent: { id: type, type, value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ 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,16 +1,9 @@
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[];
@@ -20,7 +13,6 @@
export function createEffectIndex(): EffectIndex {
return {
- all: [],
damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),
controls: [],
extraAttacks: [],
@@ -29,7 +21,6 @@
}
export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {
- index.all.push(effect);
if (effect.kind === "control") {
index.controls.push(effect);
return;
@@ -48,53 +39,27 @@
// per-job runtime path, so they must not enter the damage job-shape index.
if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;
- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
- const appliesToUnits = unitsFromMask(effect.appliesTo.units);
- const appliesVsUnits = unitsFromMask(effect.appliesVs.units);
- 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)] ?? [];
}
@@ -108,6 +73,38 @@
return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);
}
+const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();
+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));
+
+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 +113,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,6 +4,7 @@
AttackIntent,
EffectDuration,
EffectIntentDefinition,
+ EvolvingActiveEffect,
ResolvedSkill,
ResolvedUnitScope,
SameEffectStacking,
@@ -14,8 +15,6 @@
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
-type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];
-
interface CompiledTriggerSelectors {
source: ParsedTriggerSelector;
target: ParsedTriggerSelector;
@@ -88,14 +87,15 @@
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 duration = normalizeDuration(intent.duration);
- const delay = duration.delay ?? 0;
+ const turnDelay = Math.max(0, duration.turns?.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 {
+ const effect: ActiveEffect = {
id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,
+ expired: false,
source: {
kind: skill.sourceKind,
side: skill.side,
@@ -109,17 +109,31 @@
intent,
ownerSide,
kind: effectKind,
- valuePct: typeof intent.value === "number" ? intent.value : undefined,
+ initialValuePct: finiteNumberOrZero(intent.value),
+ getCurrentValuePct: constantActiveEffectValuePct,
appliesTo,
appliesVs,
triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,
createdRound: round,
- startRound: round + delay,
+ startRound: Math.max(1, round + turnDelay),
duration,
+ remainingAttackDelay: duration.attacks?.delay ?? 0,
uses: 0,
stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,
sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)
};
+ const evolution = intent.value_evolution;
+ if (!evolution) return effect;
+ const evolvingEffect: EvolvingActiveEffect = {
+ ...effect,
+ valueEvolution: {
+ type: evolution.type,
+ step: evolution.step,
+ amount: finiteNumberOrZero(evolution.value)
+ },
+ getCurrentValuePct: evolvingActiveEffectValuePct
+ };
+ return evolvingEffect;
}
function skillActivationSourceKey(skill: ResolvedSkill): string {
@@ -134,30 +148,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";
@@ -237,40 +277,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,11 +28,14 @@
import { createRecorder } from "./recorder";
import {
activateEffect,
+ advanceEffectAttackDelay,
chancePasses,
+ constantActiveEffectValuePct,
createSeededRng,
- currentEffectValuePct,
+ effectAttackUseLimit,
+ effectRoundWindow,
hasAttackDurationConstraint,
- isEffectActive,
+ isEffectAttackReady,
oppositeSide,
parseTriggerSelector,
sideForTriggerRelation,
@@ -41,7 +44,7 @@
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
-import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";
+import { createEffectIndex, damageEffectsForJob, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";
import { normalizeUnitType } from "./normalize";
import { emptyTroops, resolveFighter } from "./resolve";
import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";
@@ -53,8 +56,10 @@
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>;
@@ -215,27 +220,27 @@
}
// 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.
+// profile. Static-phase effects never enter 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);
+ 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.
+// Clone a prepared template's mutable per-run effect state and rebuild the index 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,
+ const runtime: Runtime = {
+ effectIndex: createEffectIndex(),
+ preparedEffects: [],
+ activateEffectsByRound: [],
+ expireEffectsByRound: [],
usedEffects: new Set(),
staticDamageProfile: template.staticDamageProfile,
damageScratch: createFastDamageScratch(),
@@ -250,6 +255,10 @@
received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }
}
};
+ for (const templateEffect of template.preparedEffects) {
+ addActiveEffect(runtime, { ...templateEffect, expired: false, uses: 0 });
+ }
+ return runtime;
}
function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {
@@ -324,7 +333,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.
@@ -346,7 +355,7 @@
}
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 +373,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,
@@ -377,7 +388,7 @@
}
allJobs.push(job);
- const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {
+ const normalResult = calculateDamageJob(job, fighters, [], {
trace: recorder.capturesTrace,
recordAppliedEffects: recorder.capturesAppliedEffects,
effectIndex: runtime.effectIndex,
@@ -405,7 +416,7 @@
for (const extraJob of extraSkill.jobs) {
if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
allJobs.push(extraJob);
- const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {
+ const extraResult = calculateDamageJob(extraJob, fighters, [], {
trace: recorder.capturesTrace,
recordAppliedEffects: recorder.capturesAppliedEffects,
effectIndex: runtime.effectIndex,
@@ -429,7 +440,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);
}
}
@@ -610,8 +621,10 @@
}
}
return {
- activeEffects: [],
effectIndex: createEffectIndex(),
+ preparedEffects: [],
+ activateEffectsByRound: [],
+ expireEffectsByRound: [],
usedEffects: new Set(),
staticDamageProfile: undefined,
damageScratch: createFastDamageScratch(),
@@ -644,31 +657,53 @@
}
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 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 scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {
+ const effects = schedule[round];
+ if (effects) effects.push(effect);
+ else schedule[round] = [effect];
+}
+
+function processEffectSchedule(runtime: Runtime, round: number): void {
+ const expiring = runtime.expireEffectsByRound[round];
+ if (expiring) {
+ for (const effect of expiring) expireActiveEffect(runtime, effect);
+ runtime.expireEffectsByRound[round] = undefined;
+ }
+ const activating = runtime.activateEffectsByRound[round];
+ if (activating) {
+ for (const effect of activating) {
+ if (!effect.expired) indexEffect(runtime.effectIndex, effect);
}
+ runtime.activateEffectsByRound[round] = undefined;
}
- 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 +717,22 @@
},
ownerSide: side,
kind: "modifier",
- valuePct,
+ 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(
@@ -732,12 +772,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 +812,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 +824,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 +843,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 +899,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 +970,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 +1033,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;
}
@@ -1055,22 +1109,36 @@
// 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;
+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);
runtime.usedEffects.clear();
}
+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.add(effect);
}
+ for (const effect of controlEffects) used.add(effect);
used.add(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;
@@ -90,7 +90,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}`);
}
}
@@ -142,7 +142,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);
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;
}
@@ -242,11 +241,13 @@
export interface ActiveEffect {
id: string;
+ expired?: boolean;
source: EffectSource;
intent: EffectIntentDefinition;
ownerSide: SideId;
kind: ActiveEffectKind;
- valuePct?: 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 +256,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 +268,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/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;