WOS Sim
← Back to Runs

Compare Runs

A (baseline)
ea252b07-0d379ed978804/07/2026, 21:12:47
B (current)
18095737-0e1dd63eaee05/07/2026, 07:20:41
Avg Error A0.20%
Avg Error B0.19%
Δ Avg Error-0.01%
Improved4
Regressed0
Added0
Retired0

Testcase Delta

201 / 201
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0+0.05%-0.79%-0.04%-0.75%improved
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc0-0.71%0.19%-0.52%improved
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc0+0.05%-0.25%0.00%-0.25%improved
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo00.06%0.00%-0.06%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/alonso_attacker_600_all.jsonalonso_attacker_600_all0-0.14%-0.14%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer00.04%0.04%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer10.02%0.02%0.00%unchanged
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender00.14%0.14%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo1-0.26%-0.26%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2-0.19%-0.19%0.00%unchanged
testcases/emulator_verified/alonso_solo.jsonalonso_solo02.97%2.97%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201.58%1.58%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21-1.59%-1.59%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.17%-0.17%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/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.15%-3.15%0.00%unchanged
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo00.32%0.32%0.00%unchanged
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc00.04%0.04%0.00%unchanged
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal00.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped00.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo0-0.15%-0.15%0.00%unchanged
testcases/emulator_verified/flint_solo_nc.jsonflint_solo0+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/flint_zinman_combo_nc.jsonflint_zinman_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo0-1.97%-1.97%0.00%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo1-0.05%1.06%1.06%0.00%unchanged
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0-0.09%-0.09%0.00%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current10.01%0.01%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/greg_solo.jsongreg_solo00.16%0.16%0.00%unchanged
testcases/emulator_verified/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.55%0.55%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.08%-0.08%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1-0.21%-0.21%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.27%-0.27%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo10.22%0.22%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.32%-0.32%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo30.04%0.04%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo40.21%0.21%0.00%unchanged
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo00.00%0.00%0.00%unchanged
testcases/emulator_verified/molly_solo.jsonmolly_solo0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0+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.22%0.22%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.05%1.05%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_solo0-0.23%-0.23%0.00%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1-2.01%-2.01%0.00%unchanged
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0-0.37%-0.37%0.00%unchanged
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo00.18%0.18%0.00%unchanged
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo00.36%0.36%0.00%unchanged
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo00.43%0.43%0.00%unchanged
testcases/emulator_verified/wos425_wip_nohero_control_nc.jsonwos425_wip_nohero_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_patrick_health_nc.jsonwos425_wip_patrick_health_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_philly_defense.jsonwos425_wip_philly_defense0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.jsonwos425_wip_sergey_damage_taken_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.jsonwos425_wip_sergey_patrick_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo0-0.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_nc0+0.05%-0.82%-0.82%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_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc20.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_nc1+0.05%-0.41%-0.41%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc20.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_hero_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_hero_nc00.11%0.11%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_bucket.jsonwos451_bradley_s21_lancer_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.jsonwos451_bradley_s21_lancer_damage_up_control0-0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_bucket.jsonwos451_bradley_s22_infantry_damage_up_bucket0-0.05%0.03%0.03%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.jsonwos451_bradley_s22_infantry_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_bucket.jsonwos451_bradley_s3_all_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.jsonwos451_bradley_s3_all_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_bucket.jsonwos451_gordon_s11_lancer_damage_up_bucket0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.jsonwos451_gordon_s11_lancer_damage_up_control0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_bucket.jsonwos451_gordon_s21_lancer_damage_up_bucket0-0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.jsonwos451_gordon_s21_lancer_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo00.00%0.00%0.00%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.05%0.27%0.27%0.00%unchanged
testcases/heroes_unittests/Hector_tc.jsondaut_viper_100.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43-0.017%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_540.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_870.00%0.00%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.17%0.17%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21+0.05%-0.77%-0.77%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_32-0.10%-0.10%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43-0.13%-0.13%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54-0.11%-0.11%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_430.33%0.33%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_54-0.02%-0.02%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_10+0.05%-0.18%-0.18%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_42-0.46%-0.46%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_530.01%0.01%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43+0.05%-0.04%-0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65-0.006%0.04%0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76-0.004%0.04%0.04%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10-0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_320.00%0.00%0.00%unchanged

Code / Config Changes

Commits (79ed9788dd63eaee)

dd63eaeeUpdate dashboard test results databasepiddlyminx05/07/2026, 02:10:44

Code Changes (Run A → Run B)

Index: simulator/config/hero_definitions/Ahmose.json
===================================================================
--- simulator/config/hero_definitions/Ahmose.json prev run
+++ simulator/config/hero_definitions/Ahmose.json this run
@@ -6,9 +6,10 @@
"ViperFormation": {
"description": "His infantry pauses the attack once every 4 times reducing damage taken by Lancers and Marksmen by X% and Infantry by X% for 2 turns",
"trigger": {
"type": "attack",
- "every": 4,
+ "first": 4,
+ "every": 5,
"source": "infantry"
},
"effects": {
"ViperFormation/1": {
Index: simulator/config/hero_definitions/Gwen.json
===================================================================
--- simulator/config/hero_definitions/Gwen.json prev run
+++ simulator/config/hero_definitions/Gwen.json this run
@@ -5,9 +5,9 @@
"skills": {
"EagleVision": {
"description": "Increasing target's damage taken by X%",
"trigger": {
- "type": "turn",
+ "type": "attack",
"source": "self.any"
},
"effects": {
"EagleVision/1": {
@@ -31,12 +31,14 @@
}
}
},
"AirDominance": {
- "description": "Grants all troops' attack X% extra damage after every 4 attacks and causes the target to receive X% extra damage for its next attack received",
+ "description": "Grants all troops' attack X% extra damage after every 5 attacks and causes the target to receive X% extra damage for its next attack received",
"trigger": {
- "type": "attack",
- "every": 4
+ "type": "turn",
+ "first": 5,
+ "every": 6,
+ "source": "self.all"
},
"effects": {
"AirDominance/1": {
"type": "extra_skill_attack",
@@ -48,9 +50,9 @@
100
],
"units": {
"applies_to": "trigger.source",
- "applies_vs": "trigger.target"
+ "applies_vs":"trigger.target"
},
"duration": {
"attacks": {
"count": 1
@@ -58,9 +60,9 @@
},
"trigger_damage_jobs": [
{
"source": "use.source",
- "target": "effect.applies_vs"
+ "target": "use.target"
}
]
},
"AirDominance/2": {
@@ -72,14 +74,18 @@
12.5,
15
],
"units": {
- "applies_to": "target"
+ "applies_to": "trigger.target"
},
+ "same_effect_stacking": "max",
"duration": {
- "attacks": {
+ "turns": {
"count": 1,
"delay": 1
+ },
+ "attacks": {
+ "count": 1
}
}
}
}
Index: simulator/config/hero_definitions/Reina.json
===================================================================
--- simulator/config/hero_definitions/Reina.json prev run
+++ simulator/config/hero_definitions/Reina.json this run
@@ -44,9 +44,10 @@
"effects": {
"SwiftJive/1": {
"type": "dodge",
"units": {
- "applies_to": "trigger"
+ "applies_to": "target",
+ "applies_vs": "trigger.source"
},
"duration": {
"attacks": {
"count": 1
Index: simulator/src/classifier.ts
===================================================================
--- simulator/src/classifier.ts prev run
+++ simulator/src/classifier.ts this run
@@ -10,12 +10,15 @@
reason?: string;
}
export function classifyEffectForJob(effect: ActiveEffect, job: DamageJob): Classification | undefined {
- if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };
-
const type = effect.intent.type;
- if (type === "dodge" || type === "no_attack") return { kind: "control", control: type };
+ if (type === "dodge" || type === "no_attack") {
+ if (!controlEffectApplies(effect, job, type)) return { kind: "report_only", reason: "not_applicable_to_job" };
+ return { kind: "control", control: type };
+ }
+
+ if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };
if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };
if (type === "attack_order") return { kind: "battle_order" };
const definition = bucketDefinition(type);
@@ -40,8 +43,18 @@
if (!opposingUnit || !unitMaskHas(effect.appliesVs.units, opposingUnit)) return false;
return true;
}
+function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {
+ const appliesToSide = control === "no_attack" ? job.attackerSide : job.defenderSide;
+ const appliesToUnit = control === "no_attack" ? job.attackerUnit : job.defenderUnit;
+ if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;
+
+ const appliesVsSide = control === "no_attack" ? job.defenderSide : job.attackerSide;
+ const appliesVsUnit = control === "no_attack" ? job.defenderUnit : job.attackerUnit;
+ return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);
+}
+
function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {
if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";
if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";
return "wrong_side";
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -214,8 +214,14 @@
const legacyUnits = (trigger as unknown as Record<string, unknown>).units;
if (legacyUnits !== undefined) {
throw new Error(`legacy trigger units filters are not supported at ${file}:${skillId}.trigger.units; use trigger.source and trigger.target`);
}
+ if (trigger.first !== undefined && (!Number.isFinite(Number(trigger.first)) || Number(trigger.first) < 1)) {
+ throw new Error(`trigger.first must be a positive number at ${file}:${skillId}.trigger.first`);
+ }
+ if (trigger.first !== undefined && trigger.every === undefined) {
+ throw new Error(`trigger.first requires trigger.every at ${file}:${skillId}.trigger`);
+ }
}
function isTriggerRelativeUnitSelector(selector: unknown): selector is string {
return selector === "trigger" || selector === "trigger.source" || selector === "target" || selector === "trigger.target";
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -105,14 +105,15 @@
export function calculateDamageJob(
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
activeEffects: ActiveEffect[],
- options: { trace?: 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?: Set<ActiveEffect> }
): DamageResult {
if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
// The damage math is one path; `trace` only decides whether we also capture the (expensive)
// per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.
const traceEnabled = options.trace === true;
+ const recordAppliedEffects = traceEnabled || options.recordAppliedEffects === true;
const detail: DamageDetail = traceEnabled ? "full" : "fast";
const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);
const attacker = fighters[job.attackerSide];
const defender = fighters[job.defenderSide];
@@ -154,11 +155,11 @@
rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });
}
}
- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
- if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
- if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
+ 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";
@@ -182,17 +183,18 @@
: undefined;
return {
kills,
- appliedEffects: traceEnabled ? appliedEffects : undefined,
+ appliedEffects: recordAppliedEffects ? appliedEffects : undefined,
trace
};
}
function applyBucketCandidates(
candidates: BucketCandidate[],
buckets: NumericDamageBuckets,
detail: DamageDetail,
+ recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
@@ -208,14 +210,14 @@
} else {
maxGroups.set(key, { selected: candidate, candidates: [candidate] });
}
} else {
- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
if (!maxGroups) return;
for (const group of maxGroups.values()) {
- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the
@@ -223,8 +225,9 @@
function applySelectedBucket(
selected: BucketCandidate,
buckets: NumericDamageBuckets,
detail: DamageDetail,
+ recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"]
): number {
const appliedValuePct = applyBucketValue(
buckets,
@@ -236,9 +239,9 @@
selected.bucket,
selected.effect.stackingKey,
selected.effect.sameEffectStacking
);
- if (appliedValuePct !== 0 && detail === "full") {
+ if (appliedValuePct !== 0 && recordAppliedEffects) {
const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
kind: "modifier",
activeEffectId: selected.effect.id,
effectId: selected.effect.source.effectId ?? selected.effect.id,
@@ -259,13 +262,14 @@
function applyBucketCandidate(
candidate: BucketCandidate,
buckets: NumericDamageBuckets,
detail: DamageDetail,
+ recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);
+ const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
usedEffects.add(candidate.effect);
} else if (detail === "full") {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
@@ -276,13 +280,14 @@
selected: BucketCandidate,
candidates: BucketCandidate[],
buckets: NumericDamageBuckets,
detail: DamageDetail,
+ recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);
+ const appliedValuePct = applySelectedBucket(selected, 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);
@@ -556,5 +561,4 @@
function pctFromFactor(factor: number): number {
return Number(((factor - 1) * 100).toFixed(12));
}
-
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -36,10 +36,10 @@
const trigger = skill.trigger;
if (triggerType === "battle_start" && trigger.type !== "battle_start") return false;
if (triggerType === "round_start" && trigger.type !== "turn") return false;
if (triggerType === "attack_declared" && trigger.type !== "attack") return false;
- if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every)) return false;
- if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every)) 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);
return (
triggerSelectorMatches(selectors.source, skill.side, intent.attackerSide, intent.attackerUnit) &&
@@ -75,10 +75,12 @@
return state / 0x100000000;
};
}
-export function crossedFrequency(previous: number, current: number, frequency: number): boolean {
- return Math.floor(previous / frequency) < Math.floor(current / frequency);
+export function crossedFrequency(previous: number, current: number, frequency: number, first = frequency): boolean {
+ if (current < first) return false;
+ if (previous < first) return true;
+ return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);
}
export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {
const units = intent.units ?? {};
Index: simulator/src/recorder.ts
===================================================================
--- simulator/src/recorder.ts prev run
+++ simulator/src/recorder.ts this run
@@ -22,8 +22,10 @@
*/
export interface BattleRecorder {
/** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */
readonly capturesTrace: boolean;
+ /** When true the loop records lightweight "effect applied" events on each AttackOutcome. */
+ readonly capturesAppliedEffects: boolean;
recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;
recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;
recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;
readonly attacks: AttackOutcome[];
@@ -34,8 +36,9 @@
const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
export const NULL_RECORDER: BattleRecorder = {
capturesTrace: false,
+ capturesAppliedEffects: false,
recordCancelled() {},
recordDamageJob() {},
recordRound() {},
attacks: NO_ATTACKS,
@@ -54,8 +57,9 @@
class RecordingRecorder implements BattleRecorder {
readonly attacks: AttackOutcome[] = [];
readonly trace: BattleTrace | undefined;
readonly capturesTrace: boolean;
+ readonly capturesAppliedEffects = true;
constructor(
private readonly skillReports: Record<SideId, Map<string, SkillReportEntry>>,
trace: BattleTrace | undefined
@@ -66,8 +70,9 @@
recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {
this.attacks.push({
jobId: `${intent.id}:cancelled`,
+ round: intent.round,
kind: "normal",
attackerSide: intent.attackerSide,
attackerUnit: intent.attackerUnit,
defenderSide: intent.defenderSide,
@@ -90,8 +95,9 @@
}
const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";
this.attacks.push({
jobId: job.id,
+ round: job.round,
kind: job.kind,
sourceEffectId: job.sourceEffectId,
sourceSkillReportKey: job.sourceSkillReportKey,
attackerSide: job.attackerSide,
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -61,8 +61,47 @@
assert.deepEqual(result.resolved.attacker.heroes, []);
assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));
});
+test("standard battle outcomes include round and applied effects without full traces", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: { Booster: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Booster: {
+ name: "Booster",
+ troop_type: "infantry",
+ skills: {
+ BattleBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "active.hero.attack.up",
+ value: 25,
+ units: { applies_to: "self.infantry", applies_vs: "any" }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");
+ assert.equal(attack?.round, 1);
+ assert.equal(attack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);
+ assert.equal(attack?.trace, undefined);
+});
+
test("simulateBearBattle runs exactly 10 rounds and leaves the bear army unchanged", () => {
const player: FighterInput = {
name: "Player",
troops: { infantry_t1: 100 },
@@ -364,8 +403,90 @@
assert.equal(round4LancerAttack?.defenderUnit, "marksman");
assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);
});
+test("attack-duration effects with a round cap expire after one applicable attack", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: { OneHit: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ OneHit: {
+ name: "OneHit",
+ troop_type: "infantry",
+ skills: {
+ OneAttackWindow: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 3 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const roundOneAttack = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry") && attack.kind === "normal");
+ const roundTwoAttack = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
+ assert.equal(roundOneAttack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);
+ assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "boost"), false);
+});
+
+test("attack-duration effects with a round delay are not charged before the delay is over", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: { DelayedOneHit: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedOneHit: {
+ name: "DelayedOneHit",
+ troop_type: "infantry",
+ skills: {
+ DelayedAttackBudget: {
+ trigger: { type: "turn" },
+ 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 roundOneAttack = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry") && attack.kind === "normal");
+ const roundTwoAttack = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
+ assert.equal(roundOneAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);
+ assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+});
+
test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
const config = loadSimulatorConfig();
const result = simulateBattle(
{
@@ -579,8 +700,130 @@
{ side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }
]);
});
+test("no_attack applies_to cancels that unit attacking, not attacks targeting that unit", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000, lancer_t1: 1000 },
+ heroes: { StopInfantry: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000, lancer_t1: 1000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ StopInfantry: {
+ name: "StopInfantry",
+ skills: {
+ Pause: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ stop: {
+ type: "no_attack",
+ units: { applies_to: "trigger", applies_vs: "target" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
+ ["r1:attacker:infantry:0:cancelled"]
+ );
+ assert.equal(
+ result.attacks.some((attack) => attack.attackerSide === "defender" && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry" && attack.cancelReason === "no_attack"),
+ false
+ );
+});
+
+test("dodge applies_to cancels attacks targeting that unit, not that unit attacking", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t1: 1000, lancer_t1: 1000 },
+ heroes: { Dodger: { skill_1: 1 } }
+ }
+ },
+ minimalConfig({
+ Dodger: {
+ name: "Dodger",
+ skills: {
+ StepAside: {
+ trigger: { type: "attack", source: "enemy.infantry", target: "self.infantry" },
+ effects: {
+ evade: {
+ type: "dodge",
+ units: { applies_to: "target", applies_vs: "trigger" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),
+ ["r1:attacker:infantry:0:cancelled"]
+ );
+ assert.equal(
+ result.attacks.some((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "infantry" && attack.cancelReason === "dodge"),
+ false
+ );
+});
+
+test("attack-declared controls from later intents affect earlier same-round attacks", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t1: 1000 },
+ heroes: { ReactiveDodge: { skill_1: 1 } }
+ }
+ },
+ minimalConfig({
+ ReactiveDodge: {
+ name: "ReactiveDodge",
+ skills: {
+ StepAside: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ evade: {
+ type: "dodge",
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),
+ ["r1:attacker:infantry:0:cancelled"]
+ );
+});
+
test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {
// Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is
// active. By default the cancelled attack charges the buff, so round 2 lands without it;
// with useEffectsOnNoAttack:false the buff survives to boost round 2.
@@ -670,8 +913,46 @@
["r2:attacker:infantry:0:cancelled", "r4:attacker:infantry:0:cancelled"]
);
});
+test("attack frequency triggers can start at a different first threshold", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 14,
+ attacker: {
+ troops: { infantry_t1: 10000 },
+ heroes: { FirstThenEvery: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ FirstThenEvery: {
+ name: "FirstThenEvery",
+ skills: {
+ Pause: {
+ trigger: { type: "attack", first: 4, every: 5, source: "infantry" },
+ effects: {
+ cancel: {
+ type: "no_attack",
+ units: { applies_to: "trigger", applies_vs: "target" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
+ ["r4:attacker:infantry:0:cancelled", "r9:attacker:infantry:0:cancelled", "r14:attacker:infantry:0:cancelled"]
+ );
+});
+
test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {
const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });
const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -321,21 +321,26 @@
const roundStartTroops = snapshotTroops(fighters);
expireInactive(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops);
- // Trace-only: battle_order applied events keyed by the intent they ordered.
- const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;
+ // Applied-effect events keyed by the intent they ordered.
+ const orderEvents = recorder.capturesAppliedEffects ? new Map<string, AppliedOrderEffect>() : undefined;
const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);
const allJobs: DamageJob[] = []; // for recorder
const results: DamageJobResult[] = [];
const cancelled: CancelledAttack[] = [];
- // Phase 1: fire all attack_declared triggers — all ActiveEffects are
- // resolved before any damage is calculated, per the battle-core spec.
+ // Phase 1: fire all attack_declared triggers for every intended attack before
+ // evaluating any controls or damage, per the battle-core spec.
const pendingNormalJobs: DamageJob[] = [];
+ const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];
for (const intent of intents) {
triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);
const job = normalJob(intent, roundStartTroops);
+ declaredNormalJobs.push({ intent, job });
+ }
+
+ for (const { intent, job } of declaredNormalJobs) {
const controls = applicableControls(job, round, runtime);
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
runtime.attackControlCounts[control.reason] += 1;
@@ -343,9 +348,9 @@
cancelled.push({
intent,
effectId: control.effect.id,
reason: control.reason,
- appliedEffects: recorder.capturesTrace
+ appliedEffects: recorder.capturesAppliedEffects
? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))
: NO_APPLIED_EFFECTS
});
} else {
@@ -360,8 +365,9 @@
for (const job of pendingNormalJobs) {
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,
@@ -371,9 +377,9 @@
score += normalResult.kills;
}
chargeUsedEffects(runtime);
- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);
+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);
for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;
results.push({
job,
result: normalResult,
@@ -383,8 +389,9 @@
for (const extraJob of extraSkill.jobs) {
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,
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -99,8 +99,9 @@
export interface TriggerDefinition {
type: string;
probability?: unknown;
+ first?: number;
every?: number;
source?: unknown;
target?: unknown;
}
@@ -367,8 +368,9 @@
export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;
export interface AttackOutcome {
jobId: string;
+ round: number;
kind: DamageKind;
sourceEffectId?: string;
sourceSkillReportKey?: string;
attackerSide: SideId;
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/config/hero_definitions/Ahmose.json b/simulator/config/hero_definitions/Ahmose.json
--- a/simulator/config/hero_definitions/Ahmose.json
+++ b/simulator/config/hero_definitions/Ahmose.json
@@ -19,8 +19,9 @@
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
},
"ViperFormation/2": {
@@ -39,9 +40,10 @@
]
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
},
@@ -60,9 +62,10 @@
]
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -113,8 +116,9 @@
]
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
},
"BladeOfLight/2": {
@@ -130,8 +134,9 @@
"applies_to": "target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "add"
}
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
@@ -31,8 +31,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
@@ -64,9 +65,10 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -99,8 +101,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
diff --git a/simulator/config/hero_definitions/Bahiti.json b/simulator/config/hero_definitions/Bahiti.json
--- a/simulator/config/hero_definitions/Bahiti.json
+++ b/simulator/config/hero_definitions/Bahiti.json
@@ -47,8 +47,9 @@
"applies_to": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Bradley.json b/simulator/config/hero_definitions/Bradley.json
--- a/simulator/config/hero_definitions/Bradley.json
+++ b/simulator/config/hero_definitions/Bradley.json
@@ -76,8 +76,9 @@
30
],
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
}
diff --git a/simulator/config/hero_definitions/Gordon.json b/simulator/config/hero_definitions/Gordon.json
--- a/simulator/config/hero_definitions/Gordon.json
+++ b/simulator/config/hero_definitions/Gordon.json
@@ -26,8 +26,9 @@
]
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
},
"VenomInfusion/2": {
@@ -43,8 +44,9 @@
"applies_to": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
@@ -71,8 +73,9 @@
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
},
@@ -90,8 +93,9 @@
"applies_vs": "self.any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -117,8 +121,9 @@
"applies_to": "enemy.infantry"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
},
@@ -135,8 +140,9 @@
"applies_to": "enemy.marksman"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
}
diff --git a/simulator/config/hero_definitions/Greg.json b/simulator/config/hero_definitions/Greg.json
--- a/simulator/config/hero_definitions/Greg.json
+++ b/simulator/config/hero_definitions/Greg.json
@@ -31,8 +31,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 3
+ "turns": {
+ "count": 3
+ }
}
}
}
@@ -65,8 +66,9 @@
"applies_vs": "self.any"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
}
}
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
@@ -24,8 +24,9 @@
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
@@ -51,8 +52,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -60,7 +62,7 @@
"target": "effect.applies_vs"
}
]
- },
+ },
"AirDominance/2": {
"type": "active.hero.damageTaken.up",
"value": [
@@ -74,9 +76,10 @@
"applies_to": "target"
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "attacks": {
+ "count": 1,
+ "delay": 1
+ }
}
}
}
@@ -104,9 +107,10 @@
]
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "attacks": {
+ "count": 1,
+ "delay": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/config/hero_definitions/Hector.json b/simulator/config/hero_definitions/Hector.json
--- a/simulator/config/hero_definitions/Hector.json
+++ b/simulator/config/hero_definitions/Hector.json
@@ -26,8 +26,9 @@
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -55,8 +56,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 10
+ "attacks": {
+ "count": 10
+ }
},
"value_evolution": {
"type": "pct_decay",
@@ -80,8 +82,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 10
+ "attacks": {
+ "count": 10
+ }
},
"value_evolution": {
"type": "pct_decay",
@@ -118,8 +121,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/config/hero_definitions/Jeronimo.json b/simulator/config/hero_definitions/Jeronimo.json
--- a/simulator/config/hero_definitions/Jeronimo.json
+++ b/simulator/config/hero_definitions/Jeronimo.json
@@ -60,8 +60,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Lynn.json b/simulator/config/hero_definitions/Lynn.json
--- a/simulator/config/hero_definitions/Lynn.json
+++ b/simulator/config/hero_definitions/Lynn.json
@@ -26,8 +26,9 @@
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
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
@@ -29,8 +29,9 @@
"applies_to": "target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -63,8 +64,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
@@ -99,8 +101,9 @@
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
diff --git a/simulator/config/hero_definitions/Molly.json b/simulator/config/hero_definitions/Molly.json
--- a/simulator/config/hero_definitions/Molly.json
+++ b/simulator/config/hero_definitions/Molly.json
@@ -29,8 +29,9 @@
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
@@ -62,8 +63,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/config/hero_definitions/Natalia.json b/simulator/config/hero_definitions/Natalia.json
--- a/simulator/config/hero_definitions/Natalia.json
+++ b/simulator/config/hero_definitions/Natalia.json
@@ -29,8 +29,9 @@
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Norah.json b/simulator/config/hero_definitions/Norah.json
--- a/simulator/config/hero_definitions/Norah.json
+++ b/simulator/config/hero_definitions/Norah.json
@@ -74,8 +74,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
@@ -109,8 +110,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
},
"Momentum/2": {
@@ -123,9 +125,10 @@
25
],
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Philly.json b/simulator/config/hero_definitions/Philly.json
--- a/simulator/config/hero_definitions/Philly.json
+++ b/simulator/config/hero_definitions/Philly.json
@@ -58,8 +58,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -96,8 +97,9 @@
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Reina.json b/simulator/config/hero_definitions/Reina.json
--- a/simulator/config/hero_definitions/Reina.json
+++ b/simulator/config/hero_definitions/Reina.json
@@ -48,8 +48,9 @@
"applies_to": "trigger"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
@@ -84,8 +85,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json
--- a/simulator/config/hero_definitions/Renee.json
+++ b/simulator/config/hero_definitions/Renee.json
@@ -25,9 +25,13 @@
"applies_vs": "lancer"
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ },
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -55,9 +59,10 @@
"applies_vs": "lancer"
},
"duration": {
- "type": "turn",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -85,9 +90,10 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
diff --git a/simulator/config/hero_definitions/Wayne.json b/simulator/config/hero_definitions/Wayne.json
--- a/simulator/config/hero_definitions/Wayne.json
+++ b/simulator/config/hero_definitions/Wayne.json
@@ -25,8 +25,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
@@ -60,8 +61,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -86,8 +88,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -127,8 +130,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/config/troop_skills.json b/simulator/config/troop_skills.json
--- a/simulator/config/troop_skills.json
+++ b/simulator/config/troop_skills.json
@@ -97,8 +97,9 @@
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
@@ -206,8 +207,9 @@
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
@@ -249,8 +251,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -290,8 +293,9 @@
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
@@ -354,8 +358,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -403,8 +408,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -470,8 +476,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 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
@@ -101,7 +101,9 @@
}
const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
removeStaticProfileBucketEffects(effectIndex);
- return calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile });
+ 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) };
}
test("classifier routes up/down effects into neutral atomic buckets", () => {
@@ -412,7 +414,7 @@
assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);
});
-test("attack-duration bucket effects are consumed by the applicable attack job", () => {
+test("attack-duration bucket effects are charged by the applicable attack job", () => {
const oneAttackEffect = {
...effect("active.hero.attack.up", "attacker", 100),
id: "attack-up-active",
@@ -421,11 +423,11 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
- assert.ok(outcome.consumedEffectIds.includes("attack-up-active"));
+ assert.ok(outcome.usedEffectIds.includes("attack-up-active"));
assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
});
-test("turn-duration bucket effects are visible on the attack outcome without being consumed", () => {
+test("turn-duration bucket effects are charged and visible on the attack outcome", () => {
const turnEffect = {
...effect("active.hero.defense.down", "defender", 30),
id: "bad-luck-like",
@@ -435,10 +437,11 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });
- assert.equal(outcome.consumedEffectIds.includes("bad-luck-like"), false);
- assert.ok(outcome.appliedEffectIds?.includes("BadLuckStreak/1"));
+ assert.ok(outcome.usedEffectIds.includes("bad-luck-like"));
assert.deepEqual(outcome.appliedEffects, [
{
+ kind: "modifier",
+ activeEffectId: "bad-luck-like",
effectId: "BadLuckStreak/1",
bucket: "active.hero.defense.down",
valuePct: 30,
@@ -449,7 +452,7 @@
]);
});
-test("attack-duration effects are only consumed when they participate in the calculation", () => {
+test("effects are only charged when they participate in the calculation", () => {
const defenderOutgoingBuff = {
...effect("active.hero.lethality.up", "defender", 100),
id: "defender-outgoing-buff",
@@ -459,7 +462,7 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);
- assert.equal(outcome.consumedEffectIds.includes("defender-outgoing-buff"), false);
+ assert.equal(outcome.usedEffectIds.includes("defender-outgoing-buff"), false);
});
test("pct attack value evolution uses the effect use count for the current bucket value", () => {
@@ -481,7 +484,7 @@
assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);
assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);
- assert.ok(outcome.consumedEffectIds.includes("decayed-attack-up"));
+ assert.ok(outcome.usedEffectIds.includes("decayed-attack-up"));
});
test("pct turn value evolution starts decaying after the first active turn", () => {
@@ -506,7 +509,7 @@
assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);
});
-test("max-stacked attack-duration effects consume the whole eligible group and output only the max current value", () => {
+test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {
const weaker = {
...effect("active.hero.lethality.up", "attacker", 50),
id: "max-weaker",
@@ -533,7 +536,7 @@
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
- assert.deepEqual(new Set(outcome.consumedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
+ assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
});
test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {
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
@@ -71,6 +71,17 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);
});
+test("loadSimulatorConfig rejects legacy duration shape", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "trigger.source", applies_vs: "target" },
+ duration: { type: "attack", value: 1 } as never
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /duration key type.*not supported/i);
+});
+
test("simulator config source does not reference legacy effect metadata names", () => {
const legacyEffectMetadataKey = ["effect", "op"].join("_");
const source = readFileSync(fileURLToPath(new URL("./config.ts", import.meta.url)), "utf8");
@@ -166,7 +177,7 @@
{
type: "passive.attack.up",
value: 10,
- duration: { type: "round", value: 1 }
+ duration: { turns: { count: 1 } }
},
{ type: "battle_start" }
);
diff --git a/simulator/src/config.ts b/simulator/src/config.ts
--- a/simulator/src/config.ts
+++ b/simulator/src/config.ts
@@ -173,6 +173,7 @@
validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);
+ validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);
assertStaticPassiveEffectDefinition(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);
if (!KNOWN_EFFECT_TYPES.has(type)) {
@@ -263,6 +264,45 @@
}
}
+function validateNativeEffectDuration(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
+ if (effect.duration === undefined) return;
+ const path = `${file}:${skillId}.${effectId}.duration`;
+ const duration = effect.duration as Record<string, unknown>;
+ for (const key of Object.keys(duration)) {
+ if (key !== "turns" && key !== "rounds" && 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) {
+ const value = duration[key];
+ if (value === undefined) continue;
+ validateNativeEffectDurationAxis(value, `${path}.${key}`);
+ }
+}
+
+function validateNativeEffectDurationAxis(value: unknown, path: string): void {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`native effect duration axis must be an object at ${path}`);
+ }
+ const axis = value as Record<string, unknown>;
+ for (const key of Object.keys(axis)) {
+ if (key !== "count" && key !== "delay") {
+ throw new Error(`native effect duration axis key ${key} is not supported at ${path}; use count and/or delay`);
+ }
+ }
+ if (axis.count === undefined && axis.delay === undefined) {
+ throw new Error(`native effect duration axis must define count or delay at ${path}`);
+ }
+ if (axis.count !== undefined) validateIntegerRange(axis.count, 1, `${path}.count`);
+ if (axis.delay !== undefined) validateIntegerRange(axis.delay, 0, `${path}.delay`);
+}
+
+function validateIntegerRange(value: unknown, min: number, path: string): void {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min) {
+ throw new Error(`native effect duration ${path} must be an integer >= ${min}`);
+ }
+}
+
function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
const path = `${file}:${skillId}.${effectId}`;
if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -12,7 +12,7 @@
import { UNIT_TYPES } from "./types";
import { classifyEffectForJob } from "./classifier";
import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";
-import { currentEffectValuePct, isEffectActive } from "./effects";
+import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";
import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";
import {
buildStaticDamageProfile,
@@ -56,20 +56,17 @@
export type DamageScratch = NumericDamageBuckets;
-// Lean result of a single damage job: the correctness-relevant outputs (kills, consumed
-// effect bookkeeping) plus, only when tracing, the recording detail. The heavy per-attack
+// Lean result of a single damage job: kills plus, only when tracing, the recording detail.
+// Usage charging happens via options.usedEffects (every mode); the heavy per-attack
// AttackOutcome is assembled by the recorder, not here, so fast mode allocates nothing extra.
export interface DamageResult {
kills: number;
- consumedEffectIds: string[];
- appliedEffectIds?: string[];
appliedEffects?: DamageEquationTrace["appliedEffects"];
trace?: DamageEquationTrace;
}
const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;
const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};
-const EMPTY_CONSUMED_EFFECT_IDS: string[] = [];
const TROOPS_COUNT_TERM = factorTerm("troops.count");
const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");
const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));
@@ -109,7 +106,7 @@
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
activeEffects: ActiveEffect[],
- options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean }
+ options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<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)
@@ -128,9 +125,9 @@
applyBucketValue(buckets, "troops.count", armyTerm);
applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
- const appliedEffects: DamageEquationTrace["appliedEffects"] = detail === "full" ? [] : [];
- const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];
- const consumedEffectIds = new Set<string>();
+ 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)) {
@@ -158,7 +155,7 @@
}
}
- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
@@ -184,12 +181,8 @@
}
: undefined;
- const returnedConsumedEffectIds = consumedEffectIds.size === 0 ? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds];
-
return {
kills,
- consumedEffectIds: returnedConsumedEffectIds,
- appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,
appliedEffects: traceEnabled ? appliedEffects : undefined,
trace
};
@@ -201,7 +194,7 @@
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;
for (const candidate of candidates) {
@@ -216,12 +209,12 @@
maxGroups.set(key, { selected: candidate, candidates: [candidate] });
}
} else {
- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
}
}
if (!maxGroups) return;
for (const group of maxGroups.values()) {
- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
}
}
@@ -246,6 +239,8 @@
);
if (appliedValuePct !== 0 && detail === "full") {
const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
+ kind: "modifier",
+ activeEffectId: selected.effect.id,
effectId: selected.effect.source.effectId ?? selected.effect.id,
bucket: selected.bucket,
valuePct: appliedValuePct,
@@ -267,11 +262,11 @@
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);
if (appliedValuePct !== 0) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ usedEffects.add(candidate.effect);
} else if (detail === "full") {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
}
@@ -284,12 +279,13 @@
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);
if (appliedValuePct !== 0) {
+ // The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
for (const candidate of candidates) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ usedEffects.add(candidate.effect);
if (detail === "full" && candidate !== selected) {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
}
@@ -355,6 +351,8 @@
if (!bucket.startsWith("passive.") || !term) continue;
for (const contributor of term.contributors) {
appliedEffects.push({
+ kind: "modifier",
+ activeEffectId: contributor.effectId,
effectId: contributor.effectId,
bucket,
valuePct: contributor.valuePct,
@@ -560,6 +558,3 @@
return Number(((factor - 1) * 100).toFixed(12));
}
-function sourceLabel(effect: ActiveEffect): string {
- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
-}
diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts
--- a/simulator/src/effects.ts
+++ b/simulator/src/effects.ts
@@ -14,6 +14,7 @@
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
+type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];
interface CompiledTriggerSelectors {
source: ParsedTriggerSelector;
@@ -127,11 +128,17 @@
return skill.heroName ?? skill.troopType ?? "global";
}
+export function sourceLabel(effect: ActiveEffect): string {
+ return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
+}
+
export function isEffectActive(effect: ActiveEffect, round: number): boolean {
if (round < effect.startRound) return false;
- if (effect.duration.type === "battle") return true;
- if (effect.duration.type === "round") return round < effect.startRound + Math.max(1, effect.duration.value);
- return effect.uses < Math.max(1, effect.duration.value);
+ return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));
+}
+
+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
+ return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");
}
export function currentEffectValuePct(effect: ActiveEffect, round: number): number {
@@ -229,13 +236,44 @@
function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {
if (!duration) return { type: "battle", value: 0 };
- const rawType = duration.type === "turn" ? "round" : duration.type;
- if (rawType === "round" || rawType === "attack" || rawType === "battle") {
- return { type: rawType, value: Number(duration.value ?? (rawType === "battle" ? 0 : 1)), delay: Number(duration.delay ?? 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;
+}
+
+function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {
+ return {
+ type,
+ ...(value.count === undefined ? {} : { count: Number(value.count) }),
+ ...(value.delay === undefined ? {} : { delay: Number(value.delay) })
+ };
+}
+
function normalizeSameEffectStacking(value: EffectIntentDefinition["same_effect_stacking"]): SameEffectStacking {
return value === "max" ? "max" : "add";
}
diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts
--- a/simulator/src/recorder.ts
+++ b/simulator/src/recorder.ts
@@ -1,4 +1,5 @@
import type {
+ AppliedEffect,
AttackIntent,
AttackOutcome,
BattleTrace,
@@ -11,7 +12,7 @@
/**
* Observes a battle and accumulates whatever the chosen mode needs to report. The battle loop
- * does all simulation-affecting work itself (kills, counters, effect consumption); the recorder
+ * does all simulation-affecting work itself (kills, counters, effect usage charging); the recorder
* only records, so swapping recorders never changes the outcome. This keeps a single, linear
* loop with no `if (mode === ...)` branches scattered through it.
*
@@ -22,14 +23,15 @@
export interface BattleRecorder {
/** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */
readonly capturesTrace: boolean;
- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void;
- recordDamageJob(job: DamageJob, result: DamageResult): void;
+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;
+ recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;
recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;
readonly attacks: AttackOutcome[];
readonly trace: BattleTrace | undefined;
}
const NO_ATTACKS: AttackOutcome[] = [];
+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
export const NULL_RECORDER: BattleRecorder = {
capturesTrace: false,
@@ -62,7 +64,7 @@
this.capturesTrace = trace !== undefined;
}
- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void {
+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {
this.attacks.push({
jobId: `${intent.id}:cancelled`,
kind: "normal",
@@ -75,15 +77,13 @@
{ side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },
{ side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }
],
- appliedEffectIds: [],
- appliedEffects: [],
- consumedEffectIds,
+ appliedEffects,
cancelledBy: effectId,
cancelReason: reason
});
}
- recordDamageJob(job: DamageJob, result: DamageResult): void {
+ recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void {
if (job.kind === "skill" && job.sourceSkillReportKey && result.kills > 0) {
const report = this.skillReports[job.attackerSide].get(job.sourceSkillReportKey);
if (report) report.skillKills += result.kills;
@@ -103,9 +103,7 @@
{ side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause },
{ side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause }
],
- appliedEffectIds: result.appliedEffectIds ?? [],
- appliedEffects: result.appliedEffects ?? [],
- consumedEffectIds: result.consumedEffectIds,
+ appliedEffects: mergeAppliedEffects(result.appliedEffects, extraAppliedEffects),
trace: result.trace
});
}
@@ -114,3 +112,9 @@
this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });
}
}
+
+function mergeAppliedEffects(applied: AppliedEffect[] | undefined, extra: AppliedEffect[] | undefined): AppliedEffect[] {
+ if (!applied?.length) return extra?.length ? extra : NO_APPLIED_EFFECTS;
+ if (!extra?.length) return applied;
+ return [...applied, ...extra];
+}
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
@@ -7,7 +7,7 @@
import { createSeededRng, chancePasses } from "./effects";
import { applyHeroGenerationStats, resolveFighter } from "./resolve";
import { prepareBattle, runPrepared, simulateBattle, simulateBearBattle, signedRemainingScore } from "./simulator";
-import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";
+import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";
test("runPrepared reuses the compiled input seed when no override is supplied", () => {
const config = loadSimulatorConfig();
@@ -257,7 +257,7 @@
type: "active.hero.lethality.up",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
@@ -296,7 +296,7 @@
type: "active.hero.defense.up",
value: 100,
units: { applies_to: "target", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -311,6 +311,60 @@
assert.equal(report?.effectActivations, 2);
});
+test("attack-duration effects with a round cap expire at the end of their active turn even when unused", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 4,
+ attacker: {
+ troops: { lancer_t1: 1000000 },
+ heroes: { DreamLancer: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000, marksman_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DreamLancer: {
+ name: "DreamLancer",
+ troop_type: "lancer",
+ skills: {
+ EvenTurnAmbusher: {
+ trigger: { type: "turn", every: 2 },
+ effects: {
+ order: {
+ type: "attack_order",
+ value: ["marksman", "infantry", "lancer"],
+ units: { applies_to: "lancer", applies_vs: "marksman" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ },
+ NightmareTrace: {
+ trigger: { type: "turn", every: 2, source: "lancer" },
+ effects: {
+ mark: {
+ type: "active.hero.defense.down",
+ value: 100,
+ units: { applies_to: "target", applies_vs: "lancer" },
+ duration: { turns: { count: 1, delay: 1 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const lancerAttacks = result.attacks.filter((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "lancer");
+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.defenderUnit, "marksman");
+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.defenderUnit, "infantry");
+ const round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));
+ assert.equal(round4LancerAttack?.defenderUnit, "marksman");
+ assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);
+});
+
test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
const config = loadSimulatorConfig();
const result = simulateBattle(
@@ -416,7 +470,7 @@
type: "active.hero.lethality.up",
value: 10,
units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },
- duration: { type: "battle", value: 1 },
+ duration: {},
same_effect_stacking: "add"
}
}
@@ -472,7 +526,7 @@
assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });
});
-test("attack-duration effects are consumed even when a normal attack is cancelled", () => {
+test("cancelled attacks report the winning control as an applied effect", () => {
const result = simulateBattle(
{
maxRounds: 1,
@@ -496,33 +550,89 @@
type: "no_attack",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
debuff: {
type: "active.hero.attack.up",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
const cancelledAttack = cancelled!;
- assert.equal(cancelledAttack.consumedEffectIds.length, 2);
- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":cancel:")));
- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":debuff:")));
+ assert.equal(cancelledAttack.appliedEffects.length, 1);
+ const controlEvent = cancelledAttack.appliedEffects[0];
+ assert.equal(controlEvent.kind, "control");
+ assert.equal(controlEvent.kind === "control" && controlEvent.reason, "no_attack");
+ assert.ok(controlEvent.activeEffectId.includes(":cancel:"));
assert.deepEqual(cancelledAttack.counterDeltas, [
{ side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },
{ side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }
]);
});
+test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {
+ // Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is
+ // active. By default the cancelled attack charges the buff, so round 2 lands without it;
+ // with useEffectsOnNoAttack:false the buff survives to boost round 2.
+ const input = {
+ maxRounds: 2,
+ seed: "cancel-charge",
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: { SelfStunned: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ }
+ };
+ const config = minimalConfig({
+ SelfStunned: {
+ name: "SelfStunned",
+ skills: {
+ StunAndBuff: {
+ trigger: { type: "battle_start" },
+ effects: {
+ stun: {
+ type: "no_attack",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 1, delay: 1 } }
+ },
+ buff: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ const roundTwoKills = (options: SimulationOptions): number => {
+ const result = simulateBattle(input, config, options);
+ const attack = result.attacks.find((entry) => entry.kind === "normal" && entry.jobId.startsWith("r2:attacker:infantry"));
+ assert.notEqual(attack, undefined);
+ return attack!.kills;
+ };
+
+ const charged = roundTwoKills({});
+ const uncharged = roundTwoKills({ useEffectsOnNoAttack: false });
+ assert.ok(uncharged > charged, `expected uncharged buff to boost round 2 (${uncharged} > ${charged})`);
+});
+
test("cancelled normal attacks advance attack counters for later frequency checks", () => {
const result = simulateBattle(
{
@@ -546,7 +656,7 @@
cancel: {
type: "no_attack",
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -637,7 +747,7 @@
same_effect_stacking: "max",
units: { applies_to: ["infantry"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -651,9 +761,11 @@
const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));
assert.notEqual(roundTwoNormalOutcome, undefined);
- assert.equal(roundTwoNormalOutcome!.consumedEffectIds.length, 2);
+ const extraAttackEvents = roundTwoNormalOutcome!.appliedEffects.filter((event) => event.kind === "extra_attack");
+ assert.equal(extraAttackEvents.length, 1);
+ assert.equal(extraAttackEvents[0].kind === "extra_attack" && extraAttackEvents[0].spawnedJobIds.length, 1);
assert.notEqual(roundTwoSkillOutcome, undefined);
- assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 0);
+ assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
});
test("requires_effect is ignored by native simulator effect activation", () => {
@@ -767,14 +879,14 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: ["lancer"] }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
hitMarksman: {
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: ["marksman"] }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -819,7 +931,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -861,7 +973,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -877,7 +989,7 @@
type: "active.hero.defense.up",
value: 50,
units: { applies_to: "trigger.target", applies_vs: "trigger.source" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -919,14 +1031,14 @@
value: 500,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
second: {
type: "extra_skill_attack",
value: 500,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1022,7 +1134,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source" } as never],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1061,7 +1173,7 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1098,7 +1210,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1145,7 +1257,7 @@
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1161,7 +1273,7 @@
type: "no_attack",
value: 100,
units: { applies_to: "enemy.marksman", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1173,7 +1285,7 @@
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
- assert.equal(cancelled!.consumedEffectIds.some((id) => id.includes(":hitAgain:")), false);
+ assert.equal(cancelled!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
assert.deepEqual(skillJobsByRound, [0, 1]);
@@ -1205,7 +1317,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -1248,7 +1360,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
},
@@ -1260,7 +1372,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1304,7 +1416,7 @@
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -1382,7 +1494,7 @@
value: 100,
units: { applies_to: ["infantry", "marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1422,7 +1534,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: ["lancer"] },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1465,7 +1577,7 @@
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
},
@@ -1476,7 +1588,7 @@
type: "type.skill.damage.up",
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -1519,7 +1631,7 @@
type: "active.hero.lethality.down",
value: 50,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
@@ -1562,7 +1674,7 @@
type: "active.hero.lethality.down",
value: 50,
units: { applies_to: "trigger.target", applies_vs: "any" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
@@ -1779,7 +1891,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -1794,7 +1906,7 @@
pause: {
type: "no_attack",
units: { applies_to: "enemy.marksman", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1938,14 +2050,14 @@
same_effect_stacking,
units: { applies_to: ["infantry"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "turn", value: 2 }
+ duration: { turns: { count: 2 } }
}
: {
type,
value: 100,
same_effect_stacking,
units: { applies_to: ["infantry"], applies_vs: "any" },
- duration: { type: "turn", value: 2 }
+ duration: { turns: { count: 2 } }
};
return minimalConfig({
[heroName]: {
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -1,5 +1,9 @@
import type {
ActiveEffect,
+ AppliedControlEffect,
+ AppliedEffect,
+ AppliedExtraAttackEffect,
+ AppliedOrderEffect,
AttackIntent,
AttackOutcome,
BearBattleResult,
@@ -27,11 +31,13 @@
chancePasses,
createSeededRng,
currentEffectValuePct,
+ hasAttackDurationConstraint,
isEffectActive,
oppositeSide,
parseTriggerSelector,
sideForTriggerRelation,
skillMatchesTrigger,
+ sourceLabel,
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
@@ -49,7 +55,9 @@
interface Runtime {
activeEffects: ActiveEffect[];
effectIndex: EffectIndex;
- effectById: Map<string, ActiveEffect>;
+ // 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>;
staticDamageProfile?: StaticDamageProfile;
damageScratch: DamageScratch;
rng: Rng;
@@ -80,7 +88,8 @@
interface ExtraSkillJobsResult {
jobs: DamageJob[];
- consumedEffectIds: string[];
+ usedEffects: ActiveEffect[];
+ appliedEffects?: AppliedExtraAttackEffect[];
}
interface BattleRun {
@@ -222,7 +231,7 @@
return {
activeEffects,
effectIndex,
- effectById: new Map(activeEffects.map((e) => [e.id, e])),
+ usedEffects: new Set(),
staticDamageProfile: template.staticDamageProfile,
damageScratch: createFastDamageScratch(),
rng,
@@ -299,6 +308,10 @@
const mode = options.mode ?? "standard";
const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(fighters.attacker, fighters.defender));
const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;
+ const useEffectsOnCancel = {
+ dodge: options.useEffectsOnDodge ?? true,
+ no_attack: options.useEffectsOnNoAttack ?? true
+ };
let rounds = 0;
let score = 0;
@@ -309,7 +322,9 @@
expireInactive(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops);
- const intents = resolveAttackIntents(round, runtime, roundStartTroops);
+ // Trace-only: battle_order applied events keyed by the intent they ordered.
+ const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;
+ const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);
const allJobs: DamageJob[] = []; // for recorder
const results: DamageJobResult[] = [];
const cancelled: CancelledAttack[] = [];
@@ -324,9 +339,15 @@
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
runtime.attackControlCounts[control.reason] += 1;
- const consumedEffectIds = attackDurationEffectIdsForJob(job, runtime.effectIndex);
- cancelled.push({ intent, effectId: control.effect.id, reason: control.reason, consumedEffectIds });
- consumeEffects(runtime, consumedEffectIds);
+ if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);
+ cancelled.push({
+ intent,
+ effectId: control.effect.id,
+ reason: control.reason,
+ appliedEffects: recorder.capturesTrace
+ ? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))
+ : NO_APPLIED_EFFECTS
+ });
} else {
pendingNormalJobs.push(job);
}
@@ -334,7 +355,7 @@
// Phase 2: calculate each normal job; any extra_skill_attack effect it
// uses spawns extra DamageJobs that are calculated immediately after.
- // consumeEffects runs after each job so subsequent normal jobs see the
+ // chargeUsedEffects runs after each job so subsequent normal jobs see the
// correct uses count on any shared extra_skill_attack effects.
for (const job of pendingNormalJobs) {
allJobs.push(job);
@@ -343,19 +364,21 @@
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,
- capToDefenderTroops: loopOptions.capJobKills
+ capToDefenderTroops: loopOptions.capJobKills,
+ usedEffects: runtime.usedEffects
});
- results.push({ job, result: normalResult });
if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {
score += normalResult.kills;
}
- consumeEffects(runtime, normalResult.consumedEffectIds);
-
- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops);
- if (extraSkill.consumedEffectIds.length > 0) {
- normalResult.consumedEffectIds = [...normalResult.consumedEffectIds, ...extraSkill.consumedEffectIds];
- consumeEffects(runtime, extraSkill.consumedEffectIds);
- }
+ chargeUsedEffects(runtime);
+
+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);
+ for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;
+ results.push({
+ job,
+ result: normalResult,
+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)
+ });
for (const extraJob of extraSkill.jobs) {
allJobs.push(extraJob);
@@ -364,13 +387,14 @@
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,
- capToDefenderTroops: loopOptions.capJobKills
+ capToDefenderTroops: loopOptions.capJobKills,
+ usedEffects: runtime.usedEffects
});
results.push({ job: extraJob, result: extraResult });
if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {
score += extraResult.kills;
}
- consumeEffects(runtime, extraResult.consumedEffectIds);
+ chargeUsedEffects(runtime);
}
}
@@ -378,8 +402,8 @@
if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);
else commitRoundCounters(cancelled, results, runtime);
- for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.consumedEffectIds);
- for (const entry of results) recorder.recordDamageJob(entry.job, entry.result);
+ for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.appliedEffects);
+ for (const entry of results) recorder.recordDamageJob(entry.job, entry.result, entry.extraAppliedEffects);
recorder.recordRound(round, roundStartTroops, intents, allJobs);
}
@@ -399,12 +423,37 @@
intent: AttackIntent;
effectId: string;
reason: "dodge" | "no_attack";
- consumedEffectIds: string[];
+ appliedEffects: AppliedEffect[];
}
interface DamageJobResult {
job: DamageJob;
result: DamageResult;
+ extraAppliedEffects?: AppliedEffect[];
+}
+
+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
+
+function appendedEvent(order: AppliedOrderEffect | undefined, event: AppliedEffect): AppliedEffect[] {
+ return order ? [order, event] : [event];
+}
+
+function appendedEvents(order: AppliedOrderEffect | undefined, events: AppliedEffect[] | undefined): AppliedEffect[] | undefined {
+ if (!order) return events;
+ return events ? [order, ...events] : [order];
+}
+
+function appliedEffectBase(effect: ActiveEffect): { activeEffectId: string; effectId: string; source: string; sourceSide: SideId } {
+ return {
+ activeEffectId: effect.id,
+ effectId: effect.source.effectId ?? effect.id,
+ source: sourceLabel(effect),
+ sourceSide: effect.ownerSide
+ };
+}
+
+function appliedControlEvent(control: Control): AppliedControlEffect {
+ return { kind: "control", ...appliedEffectBase(control.effect), reason: control.reason };
}
function triggerRoundStartSkills(
@@ -529,7 +578,7 @@
return {
activeEffects: [],
effectIndex: createEffectIndex(),
- effectById: new Map(),
+ usedEffects: new Set(),
staticDamageProfile: undefined,
damageScratch: createFastDamageScratch(),
rng,
@@ -562,7 +611,6 @@
function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
runtime.activeEffects.push(effect);
- runtime.effectById.set(effect.id, effect);
indexEffect(runtime.effectIndex, effect);
}
@@ -641,7 +689,8 @@
function resolveAttackIntents(
round: number,
runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"]
+ roundStartTroops: DamageJob["roundStartTroops"],
+ orderEvents?: Map<string, AppliedOrderEffect>
): AttackIntent[] {
const intents: AttackIntent[] = [];
for (const side of ["attacker", "defender"] as SideId[]) {
@@ -649,12 +698,18 @@
let orderIndex = 0;
for (const attackerUnit of UNIT_TYPES) {
if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;
- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);
+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);
+ 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;
+ orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });
+ }
const previousAttackCount = runtime.counters.attacks[side][attackerUnit];
const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];
intents.push({
- id: `r${round}:${side}:${attackerUnit}:${orderIndex}`,
+ id: intentId,
round,
source: "normal",
attackerSide: side,
@@ -673,6 +728,8 @@
return intents;
}
+// Synthetic round-start trigger targeting reuses attack-order effects to pick a target but
+// does not charge them: no real attack intent is being ordered.
function chooseDefenderUnit(
attackerUnit: UnitType,
attackerSide: SideId,
@@ -681,17 +738,26 @@
effectIndex: EffectIndex,
round: number
): UnitType | undefined {
- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round) ?? UNIT_TYPES;
- return order.find((unit) => (roundStartTroops[defenderSide][unit] ?? 0) > 0);
+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;
+ return firstLivingUnit(order, defenderSide, roundStartTroops);
}
-function orderFromEffects(attackerUnit: UnitType, attackerSide: SideId, index: EffectIndex, round: number): UnitType[] | undefined {
+function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {
+ return order.find((unit) => (roundStartTroops[side][unit] ?? 0) > 0);
+}
+
+function orderFromEffects(
+ attackerUnit: UnitType,
+ attackerSide: SideId,
+ index: EffectIndex,
+ round: number
+): { order: UnitType[]; effect: ActiveEffect } | undefined {
for (const effect of index.battleOrder) {
if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;
if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;
if (Array.isArray(effect.intent.value)) {
try {
- return effect.intent.value.map((value) => normalizeUnitType(String(value)));
+ return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };
} catch {
return undefined;
}
@@ -740,10 +806,12 @@
normalAttack: DamageJob,
round: number,
runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"]
+ roundStartTroops: DamageJob["roundStartTroops"],
+ capturesTrace: boolean
): ExtraSkillJobsResult {
const jobs: DamageJob[] = [];
- const consumedEffectIds: string[] = [];
+ const usedEffects: ActiveEffect[] = [];
+ let appliedEffects: AppliedExtraAttackEffect[] | undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
round
@@ -784,10 +852,19 @@
definitionIndex += 1;
}
if (jobs.length > firstJobIndex) {
- consumedEffectIds.push(...effectGroup.effects.map((groupEffect) => groupEffect.id));
+ // The whole stacking group is charged whenever the selected effect spawned jobs,
+ // regardless of duration constraints (extra attacks always deplete per firing).
+ usedEffects.push(...effectGroup.effects);
+ if (capturesTrace) {
+ (appliedEffects ??= []).push({
+ kind: "extra_attack",
+ ...appliedEffectBase(effect),
+ spawnedJobIds: jobs.slice(firstJobIndex).map((spawned) => spawned.id)
+ });
+ }
}
}
- return { jobs, consumedEffectIds };
+ return { jobs, usedEffects, appliedEffects };
}
function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {
@@ -926,24 +1003,28 @@
}
}
-function attackDurationEffectIdsForJob(job: DamageJob, index: EffectIndex): string[] {
- const ids: string[] = [];
- for (const candidate of bucketCandidatesForJob(index, job)) {
- if (candidate.effect.duration.type === "attack") ids.push(candidate.effect.id);
- }
- for (const effect of index.controls) {
- if (effect.duration.type !== "attack") continue;
- const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "control") ids.push(effect.id);
- }
- return ids;
+// 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();
}
-function consumeEffects(runtime: Runtime, consumedEffectIds: string[]): void {
- for (const consumed of consumedEffectIds) {
- const effect = runtime.effectById.get(consumed);
- if (effect) effect.uses += 1;
+// 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 {
+ 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);
}
+ used.add(winningControl);
+ chargeUsedEffects(runtime);
}
function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {
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 } from "./effects";
+import { currentEffectValuePct, sourceLabel } from "./effects";
export interface StaticDamageProfileTerm {
raw?: number;
@@ -90,13 +90,9 @@
const duration = effect.duration;
if (duration === undefined) return;
- const durationType = duration.type ?? "battle";
- if (durationType !== "battle") {
+ if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {
throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);
}
- if (duration.delay !== undefined && duration.delay !== 0) {
- throw new Error(`passive effect ${effect.type} must use battle duration with no delay at ${path}`);
- }
}
export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {
@@ -266,9 +262,6 @@
return { attack: 0, defense: 0, lethality: 0, health: 0 };
}
-function sourceLabel(effect: ActiveEffect): string {
- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
-}
const STATIC_BUCKET_SET = new Set<string>([...STATIC_RAW_BUCKETS, ...STATIC_PLAYER_BUCKETS, ...STATIC_PASSIVE_BUCKETS]);
const STATIC_PASSIVE_BUCKET_SET = new Set<string>(STATIC_PASSIVE_BUCKETS);
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -78,6 +78,7 @@
type: "battle" | "round" | "attack";
value: number;
delay?: number;
+ constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;
}
export interface EffectIntentDefinition {
@@ -87,7 +88,11 @@
value_evolution?: { type?: string; step?: string; value?: number };
units?: Record<string, unknown>;
trigger_damage_jobs?: TriggerDamageJobDefinition[];
- duration?: { type?: string; value?: number; delay?: number };
+ duration?: {
+ turns?: { count?: number; delay?: number };
+ rounds?: { count?: number; delay?: number };
+ attacks?: { count?: number; delay?: number };
+ };
same_effect_stacking?: SameEffectStacking;
reason?: string;
}
@@ -172,6 +177,10 @@
export interface SimulationOptions {
mode?: SimulationMode;
+ // Whether a dodged / no_attack'd attack still charges (uses += 1) the attacker's
+ // attack-constrained effects, as the game does. Default true.
+ useEffectsOnDodge?: boolean;
+ useEffectsOnNoAttack?: boolean;
}
export interface ResolvedTroopLine {
@@ -245,6 +254,10 @@
createdRound: number;
startRound: number;
duration: EffectDuration;
+ // 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
+ // unless useEffectsOnDodge/useEffectsOnNoAttack disable that.
uses: number;
stackingKey?: string;
sameEffectStacking: SameEffectStacking;
@@ -310,22 +323,49 @@
armyTerm: number;
atomicBuckets: Record<string, DamageBucketTrace>;
aggregationGroups: Record<string, DamageAggregationGroupTrace>;
- appliedEffects: AppliedEffectTrace[];
+ appliedEffects: AppliedModifierEffect[];
rejectedEffects: Array<{ effectId: string; reason: string }>;
rawDamage: number;
finalKills: number;
}
-export interface AppliedEffectTrace {
+// One "this effect affected battle mechanics" event, discriminated on ActiveEffect.kind.
+// Events are built only in trace mode; the uses counter is charged in every mode.
+interface AppliedEffectBase {
+ // ActiveEffect.id — the runtime instance. Static-profile/input-stat contributors have no
+ // ActiveEffect and use the config-level effectId here too.
+ activeEffectId: string;
+ // Config-level id (source.effectId ?? ActiveEffect.id).
effectId: string;
- bucket: string;
- valuePct: number;
source: string;
sourceSide?: SideId;
+}
+
+export interface AppliedModifierEffect extends AppliedEffectBase {
+ kind: "modifier";
+ bucket: string;
+ valuePct: number;
stackingKey?: string;
sameEffectStacking?: SameEffectStacking;
}
+export interface AppliedControlEffect extends AppliedEffectBase {
+ kind: "control";
+ reason: "dodge" | "no_attack";
+}
+
+export interface AppliedOrderEffect extends AppliedEffectBase {
+ kind: "battle_order";
+ chosenTarget: UnitType;
+}
+
+export interface AppliedExtraAttackEffect extends AppliedEffectBase {
+ kind: "extra_attack";
+ spawnedJobIds: string[];
+}
+
+export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;
+
export interface AttackOutcome {
jobId: string;
kind: DamageKind;
@@ -337,9 +377,7 @@
defenderUnit: UnitType;
kills: number;
counterDeltas: CounterDelta[];
- appliedEffectIds: string[];
- appliedEffects: AppliedEffectTrace[];
- consumedEffectIds: string[];
+ appliedEffects: AppliedEffect[];
cancelledBy?: string;
cancelReason?: "dodge" | "no_attack";
trace?: DamageEquationTrace;
Run B dirty state patch
diff --git a/simulator/config/hero_definitions/Ahmose.json b/simulator/config/hero_definitions/Ahmose.json
--- a/simulator/config/hero_definitions/Ahmose.json
+++ b/simulator/config/hero_definitions/Ahmose.json
@@ -7,7 +7,8 @@
"description": "His infantry pauses the attack once every 4 times reducing damage taken by Lancers and Marksmen by X% and Infantry by X% for 2 turns",
"trigger": {
"type": "attack",
- "every": 4,
+ "first": 4,
+ "every": 5,
"source": "infantry"
},
"effects": {
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
@@ -32,10 +32,12 @@
}
},
"AirDominance": {
- "description": "Grants all troops' attack X% extra damage after every 4 attacks and causes the target to receive X% extra damage for its next attack received",
+ "description": "Grants all troops' attack X% extra damage after every 5 attacks and causes the target to receive X% extra damage for its next attack received",
"trigger": {
- "type": "attack",
- "every": 4
+ "type": "turn",
+ "first": 5,
+ "every": 6,
+ "source": "self.all"
},
"effects": {
"AirDominance/1": {
@@ -49,7 +51,7 @@
],
"units": {
"applies_to": "trigger.source",
- "applies_vs": "trigger.target"
+ "applies_vs":"trigger.target"
},
"duration": {
"attacks": {
@@ -59,7 +61,7 @@
"trigger_damage_jobs": [
{
"source": "use.source",
- "target": "effect.applies_vs"
+ "target": "use.target"
}
]
},
@@ -73,12 +75,16 @@
15
],
"units": {
- "applies_to": "target"
+ "applies_to": "trigger.target"
},
+ "same_effect_stacking": "max",
"duration": {
- "attacks": {
+ "turns": {
"count": 1,
"delay": 1
+ },
+ "attacks": {
+ "count": 1
}
}
}
diff --git a/simulator/config/hero_definitions/Reina.json b/simulator/config/hero_definitions/Reina.json
--- a/simulator/config/hero_definitions/Reina.json
+++ b/simulator/config/hero_definitions/Reina.json
@@ -45,7 +45,8 @@
"SwiftJive/1": {
"type": "dodge",
"units": {
- "applies_to": "trigger"
+ "applies_to": "target",
+ "applies_vs": "trigger.source"
},
"duration": {
"attacks": {
diff --git a/simulator/src/classifier.ts b/simulator/src/classifier.ts
--- a/simulator/src/classifier.ts
+++ b/simulator/src/classifier.ts
@@ -11,10 +11,13 @@
}
export function classifyEffectForJob(effect: ActiveEffect, job: DamageJob): Classification | undefined {
- if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };
-
const type = effect.intent.type;
- if (type === "dodge" || type === "no_attack") return { kind: "control", control: type };
+ if (type === "dodge" || type === "no_attack") {
+ if (!controlEffectApplies(effect, job, type)) return { kind: "report_only", reason: "not_applicable_to_job" };
+ return { kind: "control", control: type };
+ }
+
+ if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };
if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };
if (type === "attack_order") return { kind: "battle_order" };
@@ -41,6 +44,16 @@
return true;
}
+function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {
+ const appliesToSide = control === "no_attack" ? job.attackerSide : job.defenderSide;
+ const appliesToUnit = control === "no_attack" ? job.attackerUnit : job.defenderUnit;
+ if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;
+
+ const appliesVsSide = control === "no_attack" ? job.defenderSide : job.attackerSide;
+ const appliesVsUnit = control === "no_attack" ? job.defenderUnit : job.attackerUnit;
+ return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);
+}
+
function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {
if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";
if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";
diff --git a/simulator/src/config.ts b/simulator/src/config.ts
--- a/simulator/src/config.ts
+++ b/simulator/src/config.ts
@@ -215,6 +215,12 @@
if (legacyUnits !== undefined) {
throw new Error(`legacy trigger units filters are not supported at ${file}:${skillId}.trigger.units; use trigger.source and trigger.target`);
}
+ if (trigger.first !== undefined && (!Number.isFinite(Number(trigger.first)) || Number(trigger.first) < 1)) {
+ throw new Error(`trigger.first must be a positive number at ${file}:${skillId}.trigger.first`);
+ }
+ if (trigger.first !== undefined && trigger.every === undefined) {
+ throw new Error(`trigger.first requires trigger.every at ${file}:${skillId}.trigger`);
+ }
}
function isTriggerRelativeUnitSelector(selector: unknown): selector is string {
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -106,12 +106,13 @@
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
activeEffects: ActiveEffect[],
- options: { trace?: 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?: Set<ActiveEffect> }
): DamageResult {
if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
// The damage math is one path; `trace` only decides whether we also capture the (expensive)
// per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.
const traceEnabled = options.trace === true;
+ const recordAppliedEffects = traceEnabled || options.recordAppliedEffects === true;
const detail: DamageDetail = traceEnabled ? "full" : "fast";
const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);
const attacker = fighters[job.attackerSide];
@@ -155,9 +156,9 @@
}
}
- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
- if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
- if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
+ 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;
@@ -183,7 +184,7 @@
return {
kills,
- appliedEffects: traceEnabled ? appliedEffects : undefined,
+ appliedEffects: recordAppliedEffects ? appliedEffects : undefined,
trace
};
}
@@ -192,6 +193,7 @@
candidates: BucketCandidate[],
buckets: NumericDamageBuckets,
detail: DamageDetail,
+ recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
@@ -209,12 +211,12 @@
maxGroups.set(key, { selected: candidate, candidates: [candidate] });
}
} else {
- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
if (!maxGroups) return;
for (const group of maxGroups.values()) {
- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
@@ -224,6 +226,7 @@
selected: BucketCandidate,
buckets: NumericDamageBuckets,
detail: DamageDetail,
+ recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"]
): number {
const appliedValuePct = applyBucketValue(
@@ -237,7 +240,7 @@
selected.effect.stackingKey,
selected.effect.sameEffectStacking
);
- if (appliedValuePct !== 0 && detail === "full") {
+ if (appliedValuePct !== 0 && recordAppliedEffects) {
const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
kind: "modifier",
activeEffectId: selected.effect.id,
@@ -260,11 +263,12 @@
candidate: BucketCandidate,
buckets: NumericDamageBuckets,
detail: DamageDetail,
+ recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);
+ const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
usedEffects.add(candidate.effect);
} else if (detail === "full") {
@@ -277,11 +281,12 @@
candidates: BucketCandidate[],
buckets: NumericDamageBuckets,
detail: DamageDetail,
+ recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);
+ const appliedValuePct = applySelectedBucket(selected, 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) {
@@ -557,4 +562,3 @@
function pctFromFactor(factor: number): number {
return Number(((factor - 1) * 100).toFixed(12));
}
-
diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts
--- a/simulator/src/effects.ts
+++ b/simulator/src/effects.ts
@@ -37,8 +37,8 @@
if (triggerType === "battle_start" && trigger.type !== "battle_start") return false;
if (triggerType === "round_start" && trigger.type !== "turn") return false;
if (triggerType === "attack_declared" && trigger.type !== "attack") return false;
- if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every)) return false;
- if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every)) 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);
return (
@@ -76,8 +76,10 @@
};
}
-export function crossedFrequency(previous: number, current: number, frequency: number): boolean {
- return Math.floor(previous / frequency) < Math.floor(current / frequency);
+export function crossedFrequency(previous: number, current: number, frequency: number, first = frequency): boolean {
+ if (current < first) return false;
+ if (previous < first) return true;
+ return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);
}
export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {
diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts
--- a/simulator/src/recorder.ts
+++ b/simulator/src/recorder.ts
@@ -23,6 +23,8 @@
export interface BattleRecorder {
/** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */
readonly capturesTrace: boolean;
+ /** When true the loop records lightweight "effect applied" events on each AttackOutcome. */
+ readonly capturesAppliedEffects: boolean;
recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;
recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;
recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;
@@ -35,6 +37,7 @@
export const NULL_RECORDER: BattleRecorder = {
capturesTrace: false,
+ capturesAppliedEffects: false,
recordCancelled() {},
recordDamageJob() {},
recordRound() {},
@@ -55,6 +58,7 @@
readonly attacks: AttackOutcome[] = [];
readonly trace: BattleTrace | undefined;
readonly capturesTrace: boolean;
+ readonly capturesAppliedEffects = true;
constructor(
private readonly skillReports: Record<SideId, Map<string, SkillReportEntry>>,
@@ -67,6 +71,7 @@
recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {
this.attacks.push({
jobId: `${intent.id}:cancelled`,
+ round: intent.round,
kind: "normal",
attackerSide: intent.attackerSide,
attackerUnit: intent.attackerUnit,
@@ -91,6 +96,7 @@
const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";
this.attacks.push({
jobId: job.id,
+ round: job.round,
kind: job.kind,
sourceEffectId: job.sourceEffectId,
sourceSkillReportKey: job.sourceSkillReportKey,
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
@@ -62,6 +62,45 @@
assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));
});
+test("standard battle outcomes include round and applied effects without full traces", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: { Booster: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Booster: {
+ name: "Booster",
+ troop_type: "infantry",
+ skills: {
+ BattleBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "active.hero.attack.up",
+ value: 25,
+ units: { applies_to: "self.infantry", applies_vs: "any" }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");
+ assert.equal(attack?.round, 1);
+ assert.equal(attack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);
+ assert.equal(attack?.trace, undefined);
+});
+
test("simulateBearBattle runs exactly 10 rounds and leaves the bear army unchanged", () => {
const player: FighterInput = {
name: "Player",
@@ -662,6 +701,128 @@
]);
});
+test("no_attack applies_to cancels that unit attacking, not attacks targeting that unit", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000, lancer_t1: 1000 },
+ heroes: { StopInfantry: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000, lancer_t1: 1000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ StopInfantry: {
+ name: "StopInfantry",
+ skills: {
+ Pause: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ stop: {
+ type: "no_attack",
+ units: { applies_to: "trigger", applies_vs: "target" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
+ ["r1:attacker:infantry:0:cancelled"]
+ );
+ assert.equal(
+ result.attacks.some((attack) => attack.attackerSide === "defender" && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry" && attack.cancelReason === "no_attack"),
+ false
+ );
+});
+
+test("dodge applies_to cancels attacks targeting that unit, not that unit attacking", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t1: 1000, lancer_t1: 1000 },
+ heroes: { Dodger: { skill_1: 1 } }
+ }
+ },
+ minimalConfig({
+ Dodger: {
+ name: "Dodger",
+ skills: {
+ StepAside: {
+ trigger: { type: "attack", source: "enemy.infantry", target: "self.infantry" },
+ effects: {
+ evade: {
+ type: "dodge",
+ units: { applies_to: "target", applies_vs: "trigger" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),
+ ["r1:attacker:infantry:0:cancelled"]
+ );
+ assert.equal(
+ result.attacks.some((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "infantry" && attack.cancelReason === "dodge"),
+ false
+ );
+});
+
+test("attack-declared controls from later intents affect earlier same-round attacks", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t1: 1000 },
+ heroes: { ReactiveDodge: { skill_1: 1 } }
+ }
+ },
+ minimalConfig({
+ ReactiveDodge: {
+ name: "ReactiveDodge",
+ skills: {
+ StepAside: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ evade: {
+ type: "dodge",
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),
+ ["r1:attacker:infantry:0:cancelled"]
+ );
+});
+
test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {
// Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is
// active. By default the cancelled attack charges the buff, so round 2 lands without it;
@@ -753,6 +914,44 @@
);
});
+test("attack frequency triggers can start at a different first threshold", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 14,
+ attacker: {
+ troops: { infantry_t1: 10000 },
+ heroes: { FirstThenEvery: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ FirstThenEvery: {
+ name: "FirstThenEvery",
+ skills: {
+ Pause: {
+ trigger: { type: "attack", first: 4, every: 5, source: "infantry" },
+ effects: {
+ cancel: {
+ type: "no_attack",
+ units: { applies_to: "trigger", applies_vs: "target" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
+ ["r4:attacker:infantry:0:cancelled", "r9:attacker:infantry:0:cancelled", "r14:attacker:infantry:0:cancelled"]
+ );
+});
+
test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {
const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });
const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -322,19 +322,24 @@
expireInactive(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops);
- // Trace-only: battle_order applied events keyed by the intent they ordered.
- const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;
+ // Applied-effect events keyed by the intent they ordered.
+ const orderEvents = recorder.capturesAppliedEffects ? new Map<string, AppliedOrderEffect>() : undefined;
const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);
const allJobs: DamageJob[] = []; // for recorder
const results: DamageJobResult[] = [];
const cancelled: CancelledAttack[] = [];
- // Phase 1: fire all attack_declared triggers — all ActiveEffects are
- // resolved before any damage is calculated, per the battle-core spec.
+ // Phase 1: fire all attack_declared triggers for every intended attack before
+ // evaluating any controls or damage, per the battle-core spec.
const pendingNormalJobs: DamageJob[] = [];
+ const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];
for (const intent of intents) {
triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);
const job = normalJob(intent, roundStartTroops);
+ declaredNormalJobs.push({ intent, job });
+ }
+
+ for (const { intent, job } of declaredNormalJobs) {
const controls = applicableControls(job, round, runtime);
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
@@ -344,7 +349,7 @@
intent,
effectId: control.effect.id,
reason: control.reason,
- appliedEffects: recorder.capturesTrace
+ appliedEffects: recorder.capturesAppliedEffects
? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))
: NO_APPLIED_EFFECTS
});
@@ -361,6 +366,7 @@
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,
@@ -372,7 +378,7 @@
}
chargeUsedEffects(runtime);
- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);
+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);
for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;
results.push({
job,
@@ -384,6 +390,7 @@
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,
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -100,6 +100,7 @@
export interface TriggerDefinition {
type: string;
probability?: unknown;
+ first?: number;
every?: number;
source?: unknown;
target?: unknown;
@@ -368,6 +369,7 @@
export interface AttackOutcome {
jobId: string;
+ round: number;
kind: DamageKind;
sourceEffectId?: string;
sourceSkillReportKey?: string;