WOS Sim
← Back to Runs

Compare Runs

A (baseline)
d1a3dcef-4a1dd63eaee05/07/2026, 07:30:36
B (current)
60b45ebc-903dd63eaee05/07/2026, 07:33:01
Avg Error A0.19%
Avg Error B0.19%
Δ Avg Error-0.00%
Improved1
Regressed0
Added0
Retired0

Testcase Delta

200 / 200
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc1-0.41%0.00%-0.41%improved
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1010.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1120.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1230.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1340.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1450.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_156+0.013%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_167+0.008%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_178-0.003%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_189+0.017%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_19100.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2011-0.006%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2112+0.013%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_22130.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_23140.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2415-0.008%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_25160.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2617-0.002%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_900.00%0.00%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_10+0.05%-0.32%-0.32%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_21+0.05%-0.06%-0.06%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_32+0.05%-0.38%-0.38%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_43+0.05%-0.45%-0.45%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_54+0.043%0.00%0.00%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_65+0.05%-0.07%-0.07%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_10+0.05%0.19%0.19%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_32-0.42%-0.42%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_430.02%0.02%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_540.64%0.64%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_750.16%0.16%0.00%unchanged
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_10-0.22%-0.22%0.00%unchanged
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo00.00%0.00%0.00%unchanged
testcases/emulator_verified/alonso_attacker_600_all.jsonalonso_attacker_600_all0-0.14%-0.14%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer00.04%0.04%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer10.02%0.02%0.00%unchanged
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender00.14%0.14%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_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/gwen_norah_combo_nc.jsongwen_norah_combo_nc00.19%0.19%0.00%unchanged
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0+0.05%-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo0-0.05%-0.05%0.00%unchanged
testcases/emulator_verified/hector_renee_wayne.jsonhector_renee_wayne0-0.45%-0.45%0.00%unchanged
testcases/emulator_verified/hector_solo.jsonhector_solo00.12%0.12%0.00%unchanged
testcases/emulator_verified/jasser_solo_nc.jsonjasser_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/jessie_solo_nc.jsonjessie_solo0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo1+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/logan_bahiti_combo_nc.jsonlogan_bahiti_combo_nc00.55%0.55%0.00%unchanged
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo00.08%0.08%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo10.03%0.03%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo20.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0-0.60%-0.60%0.00%unchanged
testcases/emulator_verified/logan_solo_minxxx_nc.jsonlogan_solo_minxxx00.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_solo_v2_nc.jsonlogan_solo_v200.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lynn_solo.jsonlynn_solo0-0.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_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_hero_nc.jsonwos444_bradley_s3_all_troops_damage_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_hero_nc.jsonwos444_edith_s11_mark_damage_taken_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.jsonwos444_edith_s12_lancer_damage_dealt_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_hero_nc.jsonwos444_edith_s12_lancer_damage_dealt_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_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
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc20.00%retired

Code / Config Changes

Code Changes (Run A → Run B)

Index: testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json
===================================================================
--- testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json prev run
+++ testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json this run
@@ -79,76 +79,8 @@
"marksman_t6": 2700
},
"stats": {
"inf": {
- "attack": 0.0,
- "defense": 221.7,
- "lethality": 147.3,
- "health": 155.7
- },
- "lanc": {
- "attack": 194.7,
- "defense": 193.0,
- "lethality": 157.3,
- "health": 148.4
- },
- "mark": {
- "attack": 194.7,
- "defense": 192.0,
- "lethality": 148.6,
- "health": 149.5
- }
- },
- "joiner_heroes": {}
- },
- "defender": {
- "name": "[ARK]Piddlyminxx",
- "heroes": {},
- "troops": {
- "lancer_t6": 2560
- },
- "stats": {
- "inf": {
- "attack": 0.0,
- "defense": 275.1,
- "lethality": 215.3,
- "health": 216.5
- },
- "lanc": {
- "attack": 278.8,
- "defense": 274.1,
- "lethality": 199.7,
- "health": 201.0
- },
- "mark": {
- "attack": 284.6,
- "defense": 281.6,
- "lethality": 221.8,
- "health": 216.9
- }
- },
- "joiner_heroes": {}
- },
- "game_report_result": [
- {
- "attacker": 7656,
- "defender": 0
- }
- ]
- },
- {
- "test_id": "wos444_gordon_s22_all_enemy_damage_dealt_control_nc",
- "description": "WOS-444 exact no-hero control for Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
- "attacker": {
- "name": "[BBQ]XxWIPxX",
- "heroes": {},
- "troops": {
- "infantry_t6": 2700,
- "lancer_t6": 2700,
- "marksman_t6": 2700
- },
- "stats": {
- "inf": {
"attack": 221.4,
"defense": 221.7,
"lethality": 147.3,
"health": 155.7
@@ -206,5 +138,5 @@
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
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
@@ -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;
diff --git a/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json b/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json
--- a/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json
+++ b/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json
@@ -1,74 +1,4 @@
[
- {
- "test_id": "wos444_bradley_s3_all_troops_damage_control_nc",
- "description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
- "attacker": {
- "name": "[BBQ]XxWIPxX",
- "heroes": {},
- "troops": {
- "infantry_t6": 2520,
- "lancer_t6": 2520,
- "marksman_t6": 2520
- },
- "stats": {
- "inf": {
- "attack": 0.0,
- "defense": 221.7,
- "lethality": 147.3,
- "health": 155.7
- },
- "lanc": {
- "attack": 194.7,
- "defense": 193.0,
- "lethality": 157.3,
- "health": 148.4
- },
- "mark": {
- "attack": 194.7,
- "defense": 192.0,
- "lethality": 148.6,
- "health": 149.5
- }
- },
- "joiner_heroes": {}
- },
- "defender": {
- "name": "[ARK]Piddlyminxx",
- "heroes": {},
- "troops": {
- "infantry_t6": 900,
- "lancer_t6": 900,
- "marksman_t6": 900
- },
- "stats": {
- "inf": {
- "attack": 0.0,
- "defense": 275.1,
- "lethality": 215.3,
- "health": 216.5
- },
- "lanc": {
- "attack": 278.8,
- "defense": 274.1,
- "lethality": 199.7,
- "health": 201.0
- },
- "mark": {
- "attack": 284.6,
- "defense": 281.6,
- "lethality": 221.8,
- "health": 216.9
- }
- },
- "joiner_heroes": {}
- },
- "game_report_result": [
- {
- "attacker": 5848,
- "defender": 0
- }
- ]
- },
{
"test_id": "wos444_bradley_s3_all_troops_damage_control_nc",
"description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
@@ -209,4 +139,4 @@
}
]
}
-]
\ No newline at end of file
+]
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;
diff --git a/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json b/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json
--- a/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json
+++ b/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json
@@ -1,74 +1,4 @@
[
- {
- "test_id": "wos444_bradley_s3_all_troops_damage_control_nc",
- "description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
- "attacker": {
- "name": "[BBQ]XxWIPxX",
- "heroes": {},
- "troops": {
- "infantry_t6": 2520,
- "lancer_t6": 2520,
- "marksman_t6": 2520
- },
- "stats": {
- "inf": {
- "attack": 0.0,
- "defense": 221.7,
- "lethality": 147.3,
- "health": 155.7
- },
- "lanc": {
- "attack": 194.7,
- "defense": 193.0,
- "lethality": 157.3,
- "health": 148.4
- },
- "mark": {
- "attack": 194.7,
- "defense": 192.0,
- "lethality": 148.6,
- "health": 149.5
- }
- },
- "joiner_heroes": {}
- },
- "defender": {
- "name": "[ARK]Piddlyminxx",
- "heroes": {},
- "troops": {
- "infantry_t6": 900,
- "lancer_t6": 900,
- "marksman_t6": 900
- },
- "stats": {
- "inf": {
- "attack": 0.0,
- "defense": 275.1,
- "lethality": 215.3,
- "health": 216.5
- },
- "lanc": {
- "attack": 278.8,
- "defense": 274.1,
- "lethality": 199.7,
- "health": 201.0
- },
- "mark": {
- "attack": 284.6,
- "defense": 281.6,
- "lethality": 221.8,
- "health": 216.9
- }
- },
- "joiner_heroes": {}
- },
- "game_report_result": [
- {
- "attacker": 5848,
- "defender": 0
- }
- ]
- },
{
"test_id": "wos444_bradley_s3_all_troops_damage_control_nc",
"description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
@@ -209,4 +139,4 @@
}
]
}
-]
\ No newline at end of file
+]
diff --git a/testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json b/testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json
--- a/testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json
+++ b/testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json
@@ -67,74 +67,6 @@
}
]
},
- {
- "test_id": "wos444_gordon_s22_all_enemy_damage_dealt_control_nc",
- "description": "WOS-444 exact no-hero control for Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
- "attacker": {
- "name": "[BBQ]XxWIPxX",
- "heroes": {},
- "troops": {
- "infantry_t6": 2700,
- "lancer_t6": 2700,
- "marksman_t6": 2700
- },
- "stats": {
- "inf": {
- "attack": 0.0,
- "defense": 221.7,
- "lethality": 147.3,
- "health": 155.7
- },
- "lanc": {
- "attack": 194.7,
- "defense": 193.0,
- "lethality": 157.3,
- "health": 148.4
- },
- "mark": {
- "attack": 194.7,
- "defense": 192.0,
- "lethality": 148.6,
- "health": 149.5
- }
- },
- "joiner_heroes": {}
- },
- "defender": {
- "name": "[ARK]Piddlyminxx",
- "heroes": {},
- "troops": {
- "lancer_t6": 2560
- },
- "stats": {
- "inf": {
- "attack": 0.0,
- "defense": 275.1,
- "lethality": 215.3,
- "health": 216.5
- },
- "lanc": {
- "attack": 278.8,
- "defense": 274.1,
- "lethality": 199.7,
- "health": 201.0
- },
- "mark": {
- "attack": 284.6,
- "defense": 281.6,
- "lethality": 221.8,
- "health": 216.9
- }
- },
- "joiner_heroes": {}
- },
- "game_report_result": [
- {
- "attacker": 7656,
- "defender": 0
- }
- ]
- },
{
"test_id": "wos444_gordon_s22_all_enemy_damage_dealt_control_nc",
"description": "WOS-444 exact no-hero control for Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
@@ -207,4 +139,4 @@
}
]
}
-]
\ No newline at end of file
+]