WOS Sim
← Back to Runs

Compare Runs

A (baseline)
3b818046-be7b246cb3008/07/2026, 19:29:02
B (current)
793384d0-3c8ed76d7ae10/07/2026, 19:13:28
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error0.00%
Changed0
Improved0
Regressed0
Added0
Retired0

Testcase Delta

199 / 199
FileCase#AdjA%B%Δ%Status
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.59%1.59%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21-1.59%-1.59%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.17%-0.17%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/ambusher_no_marksmen.jsonambusher_no_marksmen01.87%1.87%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_minxxx_attacks_nc.jsonattdef_jasser_patrick_minxxx_attacks0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_nc.jsonattdef_jasser_patrick00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_swap_nc.jsonattdef_jasser_patrick_swap00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks1-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_test_minxxx_attacks_nc.jsonattdef_test_minxxx_attacks_nc0-0.05%0.08%0.08%0.00%unchanged
testcases/emulator_verified/attdef_test_wip_attacks_nc.jsonattdef_test_wip_attacks_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc0-3.30%-3.30%0.00%unchanged
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo00.32%0.32%0.00%unchanged
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc00.04%0.04%0.00%unchanged
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal00.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped00.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo0-0.15%-0.15%0.00%unchanged
testcases/emulator_verified/flint_solo_nc.jsonflint_solo0+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/flint_zinman_combo_nc.jsonflint_zinman_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_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.54%0.54%0.00%unchanged
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo00.08%0.08%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo10.03%0.03%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo20.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0-0.60%-0.60%0.00%unchanged
testcases/emulator_verified/logan_solo_minxxx_nc.jsonlogan_solo_minxxx00.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_solo_v2_nc.jsonlogan_solo_v200.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lynn_solo.jsonlynn_solo0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_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.20%0.20%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo00.20%0.20%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo10.02%0.02%0.00%unchanged
testcases/emulator_verified/reina_attacker_wip.jsonreina_attacker_wip00.01%0.01%0.00%unchanged
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc01.04%1.04%0.00%unchanged
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator00.14%0.14%0.00%unchanged
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/seo_yoon_solo_nc.jsonseo_yoon_solo0-0.05%0.10%0.10%0.00%unchanged
testcases/emulator_verified/sergey_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc0-1.58%-1.58%0.00%unchanged
testcases/emulator_verified/sergey_reina_combo_nc.jsonsergey_reina_combo_nc0-0.35%-0.35%0.00%unchanged
testcases/emulator_verified/sergey_solo_nc.jsonsergey_solo0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/simple_001_nc.jsonsimple_00100.00%0.00%0.00%unchanged
testcases/emulator_verified/troop_check_nc.jsontroop_check00.00%0.00%0.00%unchanged
testcases/emulator_verified/type_triangle_A_nc.jsontype_triangle_A_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/type_triangle_B_nc.jsontype_triangle_B_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo00.15%0.15%0.00%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1-1.81%-1.81%0.00%unchanged
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0-0.37%-0.37%0.00%unchanged
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo00.20%0.20%0.00%unchanged
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo00.36%0.36%0.00%unchanged
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo00.43%0.43%0.00%unchanged
testcases/emulator_verified/wos425_wip_nohero_control_nc.jsonwos425_wip_nohero_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_patrick_health_nc.jsonwos425_wip_patrick_health_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_philly_defense.jsonwos425_wip_philly_defense0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.jsonwos425_wip_sergey_damage_taken_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.jsonwos425_wip_sergey_patrick_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_8400-0.85%-0.85%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_hero_nc.jsonwos444_bradley_s3_all_troops_damage_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_hero_nc.jsonwos444_edith_s11_mark_damage_taken_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.jsonwos444_edith_s12_lancer_damage_dealt_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_hero_nc.jsonwos444_edith_s12_lancer_damage_dealt_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_hero_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_hero_nc00.11%0.11%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_bucket.jsonwos451_bradley_s21_lancer_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.jsonwos451_bradley_s21_lancer_damage_up_control0-0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_bucket.jsonwos451_bradley_s22_infantry_damage_up_bucket0-0.05%0.03%0.03%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.jsonwos451_bradley_s22_infantry_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_bucket.jsonwos451_bradley_s3_all_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.jsonwos451_bradley_s3_all_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_bucket.jsonwos451_gordon_s11_lancer_damage_up_bucket0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.jsonwos451_gordon_s11_lancer_damage_up_control0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_bucket.jsonwos451_gordon_s21_lancer_damage_up_bucket0-0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.jsonwos451_gordon_s21_lancer_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo00.00%0.00%0.00%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.05%0.27%0.27%0.00%unchanged
testcases/heroes_unittests/Hector_tc.jsondaut_viper_100.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32-0.025%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43-0.017%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_540.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_870.00%0.00%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.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 (b246cb30ed76d7ae)

ed76d7aeincpiddlyminx10/07/2026, 07:46:38

Code Changes (Run A → Run B)

Index: simulator/config/hero_definitions/Gordon.json
===================================================================
--- simulator/config/hero_definitions/Gordon.json prev run
+++ simulator/config/hero_definitions/Gordon.json this run
@@ -5,9 +5,9 @@
"skills": {
"VenomInfusion": {
"description": "Every 2 attacks, Lancers deal X% extra damage and apply poison to the target for 1 turn. Poisoned enemies deal Y% less damage.",
"trigger": {
- "type": "attack",
+ "type": "turn",
"every": 2,
"source": "lancer"
},
"effects": {
Index: simulator/config/hero_definitions/Gwen.json
===================================================================
--- simulator/config/hero_definitions/Gwen.json prev run
+++ simulator/config/hero_definitions/Gwen.json this run
@@ -112,11 +112,13 @@
"marksman"
]
},
"duration": {
- "attacks": {
- "count": 1,
+ "turns": {
"delay": 1
+ },
+ "attacks": {
+ "count": 1
}
},
"trigger_damage_jobs": [
{
Index: simulator/src/classifierDamage.test.ts
===================================================================
--- simulator/src/classifierDamage.test.ts prev run
+++ simulator/src/classifierDamage.test.ts this run
@@ -3,10 +3,10 @@
import { classifyEffectForJob } from "./classifier";
import { calculateDamageJob } from "./damage";
import { ATOMIC_BUCKETS } from "./damageBuckets";
-import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";
-import { activateEffect } from "./effects";
+import { createEffectIndex, indexEffect } from "./effectIndex";
+import { activateEffect, evolvingActiveEffectValuePct } from "./effects";
import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";
import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";
import { ALL_UNIT_MASK, unitMask } from "./types";
@@ -32,14 +32,16 @@
source: { kind: "hero_skill", side: "attacker", heroName: "Example", skillId: "Scope", effectId: "scope/1" },
intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ initialValuePct: 25,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask(["infantry"]) },
appliesVs: { side: "defender", units: unitMask(["lancer"]) },
createdRound: 1,
startRound: 1,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
};
@@ -77,14 +79,16 @@
source,
intent: { id: `${type}/1`, type, value: [valuePct] },
ownerSide,
kind: "modifier",
- valuePct,
+ initialValuePct: valuePct,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },
appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },
createdRound: 1,
startRound: 1,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
};
}
@@ -99,9 +103,8 @@
if (!options.effectIndex) {
for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);
}
const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
- removeStaticProfileBucketEffects(effectIndex);
const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });
return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };
}
@@ -417,9 +420,9 @@
test("attack-duration bucket effects are charged by the applicable attack job", () => {
const oneAttackEffect = {
...effect("active.hero.attack.up", "attacker", 100),
id: "attack-up-active",
- duration: { type: "attack" as const, value: 1 }
+ duration: { attacks: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
@@ -431,9 +434,9 @@
const turnEffect = {
...effect("active.hero.defense.down", "defender", 30),
id: "bad-luck-like",
source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },
- duration: { type: "round" as const, value: 1 }
+ duration: { turns: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });
@@ -455,9 +458,9 @@
test("effects are only charged when they participate in the calculation", () => {
const defenderOutgoingBuff = {
...effect("active.hero.lethality.up", "defender", 100),
id: "defender-outgoing-buff",
- duration: { type: "attack" as const, value: 1 }
+ duration: { attacks: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
@@ -474,9 +477,11 @@
type: "active.hero.attack.up",
value: 100,
value_evolution: { type: "pct_decay", step: "attack", value: 15 }
},
- duration: { type: "attack" as const, value: 10 },
+ duration: { attacks: { count: 10 } },
+ valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
uses: 2
};
const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
@@ -498,9 +503,11 @@
value_evolution: { type: "pct_decay", step: "turn", value: 15 }
},
createdRound: 0,
startRound: 0,
- duration: { type: "attack" as const, value: 10 }
+ valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
+ duration: { attacks: { count: 10 } }
};
const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
const secondTurn = calculateIndexedDamageJob({ ...job, round: 2 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
@@ -512,9 +519,9 @@
test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {
const weaker = {
...effect("active.hero.lethality.up", "attacker", 50),
id: "max-weaker",
- duration: { type: "attack" as const, value: 3 },
+ duration: { attacks: { count: 3 } },
stackingKey: "same-max-group",
sameEffectStacking: "max" as const
};
const strongerButDecayed = {
@@ -525,9 +532,11 @@
type: "active.hero.lethality.up",
value: 100,
value_evolution: { type: "pct_decay", step: "attack", value: 50 }
},
- duration: { type: "attack" as const, value: 3 },
+ duration: { attacks: { count: 3 } },
+ valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
uses: 1,
stackingKey: "same-max-group",
sameEffectStacking: "max" as const
};
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -8,8 +8,17 @@
import { loadSimulatorConfig } from "./config";
import { loadSimulatorConfigFromDir } from "./config-node";
import type { SkillFile } from "./types";
+test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {
+ const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];
+
+ assert.deepEqual(effect.duration, {
+ turns: { delay: 1 },
+ attacks: { count: 1 }
+ });
+});
+
test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {
const root = writeConfigWithTroopEffect({
type: "active.hero.lethality.up",
value: 10,
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -274,13 +274,13 @@
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") {
+ if (key !== "turns" && key !== "attacks") {
throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);
}
}
- for (const key of ["turns", "rounds", "attacks"] as const) {
+ for (const key of ["turns", "attacks"] as const) {
const value = duration[key];
if (value === undefined) continue;
validateNativeEffectDurationAxis(value, `${path}.${key}`);
}
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -9,12 +9,11 @@
SideId,
UnitType
} from "./types";
import { UNIT_TYPES } from "./types";
-import { classifyEffectForJob } from "./classifier";
import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";
-import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";
-import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";
+import { advanceEffectAttackDelay, sourceLabel } from "./effects";
+import { damageEffectsForJob, type EffectIndex } from "./effectIndex";
import {
buildStaticDamageProfile,
type StaticDamageBucket,
type StaticDamageProfile,
@@ -32,19 +31,13 @@
placement: GroupPlacement;
appliesTo?: DamageJob["kind"];
}
-interface BucketCandidate {
- effect: ActiveEffect;
- bucket: AtomicBucket;
- valuePct: number;
+interface MaxBucketEffectGroup {
+ selected: ActiveEffect;
+ effects: ActiveEffect[];
}
-interface MaxBucketCandidateGroup {
- selected: BucketCandidate;
- candidates: BucketCandidate[];
-}
-
interface DamageExpressionResult {
rawDamage: number;
aggregationGroups: Record<string, DamageAggregationGroupTrace>;
}
@@ -128,36 +121,19 @@
const appliedEffects: DamageEquationTrace["appliedEffects"] = [];
const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];
const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
- const candidates: BucketCandidate[] = [];
- const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
- for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
- if (!isEffectActive(candidate.effect, job.round)) continue;
- handledCandidateEffectIds?.add(candidate.effect.id);
- candidates.push({
- effect: candidate.effect,
- bucket: candidate.bucket,
- valuePct: currentEffectValuePct(candidate.effect, job.round)
- });
- }
+ applyBucketEffects(
+ damageEffectsForJob(options.effectIndex, job),
+ job.round,
+ buckets,
+ detail,
+ recordAppliedEffects,
+ appliedEffects,
+ rejectedEffects,
+ usedEffects
+ );
- if (traceEnabled) {
- for (const effect of options.effectIndex.all) {
- if (handledCandidateEffectIds?.has(effect.id)) continue;
- if (!isEffectActive(effect, job.round)) {
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });
- continue;
- }
- const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "bucket" && classification.bucket) {
- throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);
- }
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });
- }
- }
-
- 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]];
@@ -188,117 +164,123 @@
trace
};
}
-function applyBucketCandidates(
- candidates: BucketCandidate[],
+function applyBucketEffects(
+ effects: ActiveEffect[],
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;
- for (const candidate of candidates) {
- if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {
+ let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;
+ for (const effect of effects) {
+ if (effect.expired) continue;
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {
maxGroups ??= new Map();
- const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;
+ const key = `${effect.intent.type}:${effect.stackingKey}`;
const group = maxGroups.get(key);
if (group) {
- group.candidates.push(candidate);
- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;
+ group.effects.push(effect);
+ if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;
} else {
- maxGroups.set(key, { selected: candidate, candidates: [candidate] });
+ maxGroups.set(key, { selected: effect, effects: [effect] });
}
} else {
- applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
if (!maxGroups) return;
for (const group of maxGroups.values()) {
- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the
// applied percentage. Shared by the single-candidate and max-group paths.
function applySelectedBucket(
- selected: BucketCandidate,
+ selected: ActiveEffect,
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"]
): number {
const appliedValuePct = applyBucketValue(
buckets,
- selected.bucket,
- selected.valuePct,
- detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",
- detail === "full" ? sourceLabel(selected.effect) : "",
- detail === "full" ? selected.effect.ownerSide : undefined,
- selected.bucket,
- selected.effect.stackingKey,
- selected.effect.sameEffectStacking
+ selected.intent.type,
+ selected.getCurrentValuePct(round),
+ detail === "full" ? selected.source.effectId ?? selected.id : "",
+ detail === "full" ? sourceLabel(selected) : "",
+ detail === "full" ? selected.ownerSide : undefined,
+ selected.intent.type,
+ selected.stackingKey,
+ selected.sameEffectStacking
);
if (appliedValuePct !== 0 && recordAppliedEffects) {
const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
kind: "modifier",
- activeEffectId: selected.effect.id,
- effectId: selected.effect.source.effectId ?? selected.effect.id,
- bucket: selected.bucket,
+ activeEffectId: selected.id,
+ effectId: selected.source.effectId ?? selected.id,
+ bucket: selected.intent.type,
valuePct: appliedValuePct,
- source: sourceLabel(selected.effect),
- sourceSide: selected.effect.ownerSide,
- sameEffectStacking: selected.effect.sameEffectStacking
+ source: sourceLabel(selected),
+ sourceSide: selected.ownerSide,
+ sameEffectStacking: selected.sameEffectStacking
};
- if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;
+ if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;
appliedEffects.push(appliedEffect);
}
return appliedValuePct;
}
// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]
// array and no suppressed-sibling bookkeeping.
-function applyBucketCandidate(
- candidate: BucketCandidate,
+function applyBucketEffect(
+ effect: ActiveEffect,
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);
+ const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
- usedEffects.add(candidate.effect);
+ usedEffects.add(effect);
} else if (detail === "full") {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
}
}
-function applyBucketCandidateGroup(
- selected: BucketCandidate,
- candidates: BucketCandidate[],
+function applyBucketEffectGroup(
+ selected: ActiveEffect,
+ effects: ActiveEffect[],
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- const appliedValuePct = applySelectedBucket(selected, buckets, detail, recordAppliedEffects, appliedEffects);
+ const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
- for (const candidate of candidates) {
- usedEffects.add(candidate.effect);
- if (detail === "full" && candidate !== selected) {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
+ for (const effect of effects) {
+ usedEffects.add(effect);
+ if (detail === "full" && effect !== selected) {
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });
}
}
} else if (detail === "full") {
- for (const candidate of candidates) {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
+ for (const effect of effects) {
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
}
}
}
Index: simulator/src/effectIndex.test.ts
===================================================================
--- simulator/src/effectIndex.test.ts prev run
+++ simulator/src/effectIndex.test.ts this run
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";
+import { createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";
import { unitMask } from "./types";
import type { ActiveEffect, DamageJob } from "./types";
test("effect index returns bucket-tagged candidates from a direct job-shape lookup", () => {
@@ -12,14 +12,16 @@
source: { kind: "hero_skill", side: "attacker", effectId: "boost" },
intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ initialValuePct: 25,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
createdRound: 0,
startRound: 0,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
stackingKey: "stack",
sameEffectStacking: "add"
};
@@ -39,22 +41,19 @@
defenderSide: "defender",
defenderUnit: "lancer"
};
- assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);
+ assert.deepEqual(damageEffectsForJob(index, job), [effect]);
});
-test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {
+test("effect index excludes static-profile bucket effects", () => {
const index = createEffectIndex();
const passive = effect("passive.attack.up");
const active = effect("active.hero.lethality.up");
indexEffect(index, passive);
indexEffect(index, active);
- removeStaticProfileBucketEffects(index);
-
- assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);
- assert.deepEqual(index.all, [active]);
+ assert.deepEqual(damageEffectsForJob(index, job()), [active]);
});
function effect(type: string): ActiveEffect {
return {
@@ -62,14 +61,16 @@
source: { kind: "hero_skill", side: "attacker", effectId: type },
intent: { id: type, type, value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ initialValuePct: 25,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
createdRound: 0,
startRound: 0,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
stackingKey: "stack",
sameEffectStacking: "add"
};
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -1,17 +1,10 @@
import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";
import { unitsFromMask } from "./types";
-import { bucketDefinition, type AtomicBucket } from "./damageBuckets";
-import { isStaticProfileBucket } from "./staticDamageProfile";
+import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";
-export interface IndexedBucketEffect {
- effect: ActiveEffect;
- bucket: AtomicBucket;
-}
-
export interface EffectIndex {
- all: ActiveEffect[];
- damageByJobShape: Array<IndexedBucketEffect[] | undefined>;
+ damageByJobShape: Array<ActiveEffect[] | undefined>;
controls: ActiveEffect[];
extraAttacks: ActiveEffect[];
battleOrder: ActiveEffect[];
}
@@ -19,18 +12,16 @@
const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;
export function createEffectIndex(): EffectIndex {
return {
- all: [],
damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),
controls: [],
extraAttacks: [],
battleOrder: []
};
}
export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {
- index.all.push(effect);
if (effect.kind === "control") {
index.controls.push(effect);
return;
}
@@ -47,55 +38,29 @@
// Static-phase buckets (passive.*) are aggregated by the static damage profile, not the
// per-job runtime path, so they must not enter the damage job-shape index.
if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;
- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
- const appliesToUnits = unitsFromMask(effect.appliesTo.units);
- const appliesVsUnits = unitsFromMask(effect.appliesVs.units);
- for (const jobKind of jobKinds) {
- for (const appliesToUnit of appliesToUnits) {
- for (const appliesVsUnit of appliesVsUnits) {
- const slot =
- definition.role === "attacker"
- ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
- : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);
- const arr = index.damageByJobShape[slot];
- const candidate = { effect, bucket: definition.path };
- if (arr) arr.push(candidate);
- else index.damageByJobShape[slot] = [candidate];
- }
- }
+ for (const slot of shapeSlotsFor(effect, definition.path)) {
+ const arr = index.damageByJobShape[slot];
+ if (arr) arr.push(effect);
+ else index.damageByJobShape[slot] = [effect];
}
}
-export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {
- compactEffects(index.all, isActive);
- compactEffects(index.controls, isActive);
- compactEffects(index.extraAttacks, isActive);
- compactEffects(index.battleOrder, isActive);
- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
- const arr = index.damageByJobShape[slot];
- if (!arr) continue;
- compactCandidates(arr, isActive);
- if (arr.length === 0) index.damageByJobShape[slot] = undefined;
- }
+export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {
+ if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;
+ const definition = bucketDefinition(effect.intent.type);
+ return definition !== undefined && definition.valueType === "pct" && definition.phase !== "static";
}
-export function removeStaticProfileBucketEffects(index: EffectIndex): void {
- compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));
- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
- const arr = index.damageByJobShape[slot];
- if (!arr) continue;
- let write = 0;
- for (let read = 0; read < arr.length; read += 1) {
- if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }
- }
- arr.length = write;
- if (arr.length === 0) index.damageByJobShape[slot] = undefined;
- }
+export function expireEffectIndex(index: EffectIndex, effect: ActiveEffect): void {
+ effect.expired = true;
+ if (effect.kind === "control") removeStable(index.controls, effect);
+ else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);
+ else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);
}
-export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {
+export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {
return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];
}
function damageJobShapeSlot(
@@ -107,38 +72,48 @@
): number {
return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);
}
+const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();
+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));
+
+function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
+ const key =
+ ((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 +
+ (effect.appliesVs.units & 7);
+ const cached = SHAPE_SLOTS_CACHE.get(key);
+ if (cached) return cached;
+ const slots = buildShapeSlots(effect, bucket);
+ SHAPE_SLOTS_CACHE.set(key, slots);
+ return slots;
+}
+
+function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
+ const definition = bucketDefinition(bucket)!;
+ const slots: number[] = [];
+ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
+ for (const jobKind of jobKinds) {
+ for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {
+ for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {
+ slots.push(
+ definition.role === "attacker"
+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)
+ );
+ }
+ }
+ }
+ return Uint8Array.from(slots);
+}
+
function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }
function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }
function unitIndex(unit: UnitType): number {
if (unit === "infantry") return 0;
if (unit === "lancer") return 1;
return 2;
}
-function isStaticProfileEffect(effect: ActiveEffect): boolean {
- const definition = bucketDefinition(effect.intent.type);
- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));
+function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {
+ const index = effects.indexOf(effect);
+ if (index >= 0) effects.splice(index, 1);
}
-
-function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {
- let write = 0;
- for (let read = 0; read < effects.length; read += 1) {
- const effect = effects[read];
- if (!isActive(effect)) continue;
- effects[write] = effect;
- write += 1;
- }
- effects.length = write;
-}
-
-function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {
- let write = 0;
- for (let read = 0; read < candidates.length; read += 1) {
- const candidate = candidates[read];
- if (!isActive(candidate.effect)) continue;
- candidates[write] = candidate;
- write += 1;
- }
- candidates.length = write;
-}
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -3,8 +3,9 @@
ActiveEffectKind,
AttackIntent,
EffectDuration,
EffectIntentDefinition,
+ EvolvingActiveEffect,
ResolvedSkill,
ResolvedUnitScope,
SameEffectStacking,
SideId,
@@ -13,10 +14,8 @@
import { ALL_UNIT_MASK, unitMask } from "./types";
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
-type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];
-
interface CompiledTriggerSelectors {
source: ParsedTriggerSelector;
target: ParsedTriggerSelector;
}
@@ -87,16 +86,17 @@
const ownerSide = skill.side;
const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);
const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);
const duration = normalizeDuration(intent.duration);
- const delay = duration.delay ?? 0;
+ const turnDelay = Math.max(0, duration.turns?.delay ?? 0);
const effectKind = kindForIntent(intent);
if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {
throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);
}
const sourceKey = skillActivationSourceKey(skill);
- return {
+ const effect: ActiveEffect = {
id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,
+ expired: false,
source: {
kind: skill.sourceKind,
side: skill.side,
heroName: skill.heroName,
@@ -108,19 +108,33 @@
},
intent,
ownerSide,
kind: effectKind,
- valuePct: typeof intent.value === "number" ? intent.value : undefined,
+ initialValuePct: finiteNumberOrZero(intent.value),
+ getCurrentValuePct: constantActiveEffectValuePct,
appliesTo,
appliesVs,
triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,
createdRound: round,
- startRound: round + delay,
+ startRound: Math.max(1, round + turnDelay),
duration,
+ remainingAttackDelay: duration.attacks?.delay ?? 0,
uses: 0,
stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,
sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)
};
+ const evolution = intent.value_evolution;
+ if (!evolution) return effect;
+ const evolvingEffect: EvolvingActiveEffect = {
+ ...effect,
+ valueEvolution: {
+ type: evolution.type,
+ step: evolution.step,
+ amount: finiteNumberOrZero(evolution.value)
+ },
+ getCurrentValuePct: evolvingActiveEffectValuePct
+ };
+ return evolvingEffect;
}
function skillActivationSourceKey(skill: ResolvedSkill): string {
return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";
@@ -133,34 +147,60 @@
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;
- return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));
+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
+ return effect.duration.attacks !== undefined;
}
-export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
- return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");
+export function effectAttackUseLimit(effect: ActiveEffect): number | undefined {
+ const count = effect.duration.attacks?.count;
+ return count === undefined ? undefined : Math.max(1, count);
}
-export function currentEffectValuePct(effect: ActiveEffect, round: number): number {
- const baseValue = Number(effect.valuePct ?? 0);
- if (!Number.isFinite(baseValue)) return 0;
- const evolution = effect.intent.value_evolution;
- if (!evolution) return baseValue;
- const firstActiveRound = Math.max(1, effect.startRound);
- const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;
- const amount = Number(evolution.value ?? 0);
- if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;
+export function effectRoundWindow(effect: ActiveEffect): { activationRound: number; expirationRound?: number } | undefined {
+ const turns = effect.duration.turns;
+ if (!turns) return undefined;
+ return {
+ activationRound: effect.startRound,
+ ...(turns.count === undefined ? {} : { expirationRound: effect.startRound + Math.max(1, turns.count) })
+ };
+}
+
+export function isEffectAttackReady(effect: ActiveEffect): boolean {
+ return effect.remainingAttackDelay <= 0;
+}
+
+// Returns true when the effect may apply to this eligible attack. The attack
+// that consumes the final delay does not also consume the first active use.
+export function advanceEffectAttackDelay(effect: ActiveEffect): boolean {
+ const remaining = effect.remainingAttackDelay;
+ if (remaining <= 0) return true;
+ effect.remainingAttackDelay = remaining - 1;
+ return false;
+}
+
+export function constantActiveEffectValuePct(this: ActiveEffect, _round: number): number {
+ return this.initialValuePct;
+}
+
+export function evolvingActiveEffectValuePct(this: EvolvingActiveEffect, round: number): number {
+ const firstActiveRound = Math.max(1, this.startRound);
+ const evolution = this.valueEvolution;
+ const stepCount = evolution.step === "attack" ? this.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;
+ if (stepCount <= 0) return this.initialValuePct;
if (evolution.type === "pct_decay") {
- const factor = Math.max(0, 1 - amount / 100);
- return baseValue * factor ** stepCount;
+ const factor = Math.max(0, 1 - evolution.amount / 100);
+ return this.initialValuePct * factor ** stepCount;
}
- if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);
- return baseValue;
+ if (evolution.type === "fixed_decay") return Math.max(0, this.initialValuePct - stepCount * evolution.amount);
+ return this.initialValuePct;
}
+function finiteNumberOrZero(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
+}
+
export type TriggerSelectorRelation = "self" | "enemy";
export interface ParsedTriggerSelector {
relation: TriggerSelectorRelation;
@@ -236,42 +276,17 @@
return "modifier";
}
function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {
- if (!duration) return { type: "battle", value: 0 };
- const namedConstraints = normalizeNamedDurationConstraints(duration);
- if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };
- return { type: "battle", value: 0 };
+ if (!duration) return {};
+ return {
+ ...(duration.turns ? { turns: normalizeDurationAxis(duration.turns) } : {}),
+ ...(duration.attacks ? { attacks: normalizeDurationAxis(duration.attacks) } : {})
+ };
}
-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 {
+function normalizeDurationAxis(value: { count?: number; delay?: number }): { count?: number; delay?: number } {
return {
- type,
...(value.count === undefined ? {} : { count: Number(value.count) }),
...(value.delay === undefined ? {} : { delay: Number(value.delay) })
};
}
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -565,8 +565,141 @@
assert.equal(roundOneAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);
assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
});
+test("cancelled attacks do not charge attack-limited effects before their turn delay elapses", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: { DelayedAfterPause: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedAfterPause: {
+ name: "DelayedAfterPause",
+ troop_type: "infantry",
+ skills: {
+ PauseFirstRound: {
+ trigger: { type: "battle_start" },
+ effects: {
+ pause: {
+ type: "no_attack",
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ },
+ DelayedAttackBudget: {
+ trigger: { type: "turn", every: 99, first: 1 },
+ effects: {
+ delayedBoost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { delay: 1 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const roundOne = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry"));
+ const roundTwo = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
+ assert.equal(roundOne?.cancelReason, "no_attack");
+ assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+});
+
+test("attack delay skips eligible attacks before the effect can apply", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { marksman_t1: 1000000 },
+ heroes: { DelayedExtra: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedExtra: {
+ name: "DelayedExtra",
+ troop_type: "marksman",
+ skills: {
+ NextAttack: {
+ trigger: { type: "battle_start" },
+ effects: {
+ delayedExtra: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "self.marksman", applies_vs: "any" },
+ duration: { attacks: { count: 1, delay: 1 } },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");
+ assert.equal(skillAttacks.length, 1);
+ assert.ok(skillAttacks[0].jobId.startsWith("r2:"));
+});
+
+test("attack delay skips eligible damage jobs before a modifier can apply", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: { DelayedModifier: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedModifier: {
+ name: "DelayedModifier",
+ troop_type: "infantry",
+ skills: {
+ NextAttackBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ delayedBoost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { attacks: { count: 1, delay: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const normalAttacks = result.attacks.filter(
+ (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"
+ );
+ assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);
+ assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+});
+
test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
const config = loadSimulatorConfig();
const result = simulateBattle(
{
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -27,22 +27,25 @@
import { calculateDamageJob, createFastDamageScratch, type DamageResult, type DamageScratch } from "./damage";
import { createRecorder } from "./recorder";
import {
activateEffect,
+ advanceEffectAttackDelay,
chancePasses,
+ constantActiveEffectValuePct,
createSeededRng,
- currentEffectValuePct,
+ effectAttackUseLimit,
+ effectRoundWindow,
hasAttackDurationConstraint,
- isEffectActive,
+ isEffectAttackReady,
oppositeSide,
parseTriggerSelector,
sideForTriggerRelation,
skillMatchesTrigger,
sourceLabel,
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
-import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";
+import { createEffectIndex, damageEffectsForJob, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";
import { normalizeUnitType } from "./normalize";
import { emptyTroops, resolveFighter } from "./resolve";
import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";
@@ -52,10 +55,12 @@
const BEAR_TROOP_ID = "bear_infantry";
const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();
interface Runtime {
- activeEffects: ActiveEffect[];
effectIndex: EffectIndex;
+ preparedEffects: ActiveEffect[];
+ activateEffectsByRound: Array<ActiveEffect[] | undefined>;
+ expireEffectsByRound: Array<ActiveEffect[] | undefined>;
// Per-job scratch: effects that affected the job being calculated; drained by
// chargeUsedEffects (uses += 1 each) after every job in every mode.
usedEffects: Set<ActiveEffect>;
staticDamageProfile?: StaticDamageProfile;
@@ -214,29 +219,29 @@
return { ...fighter, troops: { ...fighter.initialTroops } };
}
// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage
-// profile, and drop static-profile effects from the per-job index.
+// profile. Static-phase effects never enter the per-job index.
function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {
const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));
- triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);
- addInputPassiveEffects(runtime, input.attacker.passive, "attacker");
- addInputPassiveEffects(runtime, input.defender.passive, "defender");
- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);
- removeStaticProfileBucketEffects(runtime.effectIndex);
+ const setupEffects = [
+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),
+ ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),
+ ...addInputPassiveEffects(runtime, input.defender.passive, "defender")
+ ];
+ runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);
+ runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);
return runtime;
}
-// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)
-// and the index rebuilt from the clones; skills and the static profile are shared by reference.
+// Clone a prepared template's mutable per-run effect state and rebuild the index from the
+// clones; skills and the static profile are shared by reference.
function cloneRuntime(template: Runtime, rng: Rng): Runtime {
- const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));
- const effectIndex = createEffectIndex();
- for (const effect of activeEffects) indexEffect(effectIndex, effect);
- removeStaticProfileBucketEffects(effectIndex);
- return {
- activeEffects,
- effectIndex,
+ const runtime: Runtime = {
+ effectIndex: createEffectIndex(),
+ preparedEffects: [],
+ activateEffectsByRound: [],
+ expireEffectsByRound: [],
usedEffects: new Set(),
staticDamageProfile: template.staticDamageProfile,
damageScratch: createFastDamageScratch(),
rng,
@@ -249,8 +254,12 @@
attacks: { attacker: { ...template.counters.attacks.attacker }, defender: { ...template.counters.attacks.defender } },
received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }
}
};
+ for (const templateEffect of template.preparedEffects) {
+ addActiveEffect(runtime, { ...templateEffect, expired: false, uses: 0 });
+ }
+ return runtime;
}
function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {
const cloneSide = (side: Map<string, SkillReportEntry>): Map<string, SkillReportEntry> => {
@@ -323,9 +332,9 @@
for (let round = 1; round <= maxRounds; round += 1) {
if (winnerFor(fighters)) break;
rounds = round;
const roundStartTroops = snapshotTroops(fighters);
- expireInactive(runtime, round);
+ processEffectSchedule(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops);
// Applied-effect events keyed by the intent they ordered.
const orderEvents = recorder.capturesAppliedEffects ? new Map<string, AppliedOrderEffect>() : undefined;
@@ -345,9 +354,9 @@
declaredNormalJobs.push({ intent, job });
}
for (const { intent, job } of declaredNormalJobs) {
- const controls = applicableControls(job, round, runtime);
+ const controls = applicableControls(job, runtime);
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
pendingNormalJobs.push({ intent, job, control });
} else {
@@ -363,9 +372,11 @@
if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;
if (control) {
runtime.attackControlCounts[control.reason] += 1;
- if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);
+ if (useEffectsOnCancel[control.reason]) {
+ chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);
+ }
cancelled.push({
intent,
effectId: control.effect.id,
reason: control.reason,
@@ -376,9 +387,9 @@
continue;
}
allJobs.push(job);
- const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {
+ const normalResult = calculateDamageJob(job, fighters, [], {
trace: recorder.capturesTrace,
recordAppliedEffects: recorder.capturesAppliedEffects,
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
@@ -404,9 +415,9 @@
for (const extraJob of extraSkill.jobs) {
if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
allJobs.push(extraJob);
- const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {
+ const extraResult = calculateDamageJob(extraJob, fighters, [], {
trace: recorder.capturesTrace,
recordAppliedEffects: recorder.capturesAppliedEffects,
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
@@ -428,9 +439,9 @@
}
normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));
for (const usedEffectGroup of extraSkill.usedEffectGroups) {
if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;
- for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;
+ for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);
}
}
if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);
@@ -609,10 +620,12 @@
});
}
}
return {
- activeEffects: [],
effectIndex: createEffectIndex(),
+ preparedEffects: [],
+ activateEffectsByRound: [],
+ expireEffectsByRound: [],
usedEffects: new Set(),
staticDamageProfile: undefined,
damageScratch: createFastDamageScratch(),
rng,
@@ -643,33 +656,55 @@
};
}
function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
- runtime.activeEffects.push(effect);
- indexEffect(runtime.effectIndex, effect);
+ if (!isRuntimeIndexableEffect(effect)) return;
+ const window = effectRoundWindow(effect);
+ if (!window) {
+ indexEffect(runtime.effectIndex, effect);
+ return;
+ }
+ if (window.expirationRound !== undefined) {
+ if (window.expirationRound <= window.activationRound) {
+ effect.expired = true;
+ return;
+ }
+ scheduleEffect(runtime.expireEffectsByRound, window.expirationRound, effect);
+ }
+ if (window.activationRound <= effect.createdRound) indexEffect(runtime.effectIndex, effect);
+ else scheduleEffect(runtime.activateEffectsByRound, window.activationRound, effect);
}
-function expireInactive(runtime: Runtime, round: number): void {
- let write = 0;
- for (let read = 0; read < runtime.activeEffects.length; read += 1) {
- const effect = runtime.activeEffects[read];
- if (isEffectActive(effect, round)) {
- runtime.activeEffects[write] = effect;
- write += 1;
+function scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {
+ const effects = schedule[round];
+ if (effects) effects.push(effect);
+ else schedule[round] = [effect];
+}
+
+function processEffectSchedule(runtime: Runtime, round: number): void {
+ const expiring = runtime.expireEffectsByRound[round];
+ if (expiring) {
+ for (const effect of expiring) expireActiveEffect(runtime, effect);
+ runtime.expireEffectsByRound[round] = undefined;
+ }
+ const activating = runtime.activateEffectsByRound[round];
+ if (activating) {
+ for (const effect of activating) {
+ if (!effect.expired) indexEffect(runtime.effectIndex, effect);
}
+ runtime.activateEffectsByRound[round] = undefined;
}
- runtime.activeEffects.length = write;
- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));
}
-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {
- if (!passive) return;
+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {
+ const effects: ActiveEffect[] = [];
+ if (!passive) return effects;
for (const stat of ["attack", "defense", "lethality", "health"] as const) {
for (const direction of ["up", "down"] as const) {
const valuePct = Number(passive[stat]?.[direction] ?? 0);
if (!Number.isFinite(valuePct) || valuePct <= 0) continue;
const bucket = `passive.${stat}.${direction}`;
- addActiveEffect(runtime, {
+ const effect: ActiveEffect = {
id: `${side}:input_stat:${bucket}`,
source: {
kind: "input_stat",
side,
@@ -681,19 +716,24 @@
value: valuePct
},
ownerSide: side,
kind: "modifier",
- valuePct,
+ initialValuePct: valuePct,
+ getCurrentValuePct: constantActiveEffectValuePct,
appliesTo: { side, units: ALL_UNIT_MASK },
appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },
createdRound: 0,
startRound: 0,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
- });
+ };
+ addActiveEffect(runtime, effect);
+ effects.push(effect);
}
}
+ return effects;
}
function triggerSkills(
triggerType: "battle_start" | "round_start" | "attack_declared",
@@ -731,14 +771,14 @@
const defenderSide = oppositeSide(side);
let orderIndex = 0;
for (const attackerUnit of UNIT_TYPES) {
if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;
- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);
+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);
const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);
if (!defenderUnit) continue;
const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;
if (ordered) {
- ordered.effect.uses += 1;
+ chargeEffectUse(runtime, ordered.effect);
orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });
}
const previousAttackCount = runtime.counters.attacks[side][attackerUnit];
const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];
@@ -771,9 +811,9 @@
roundStartTroops: DamageJob["roundStartTroops"],
effectIndex: EffectIndex,
round: number
): UnitType | undefined {
- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;
+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;
return firstLivingUnit(order, defenderSide, roundStartTroops);
}
function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {
@@ -783,15 +823,16 @@
function orderFromEffects(
attackerUnit: UnitType,
attackerSide: SideId,
index: EffectIndex,
- round: number
+ advanceAttackDelay: boolean
): { order: UnitType[]; effect: ActiveEffect } | undefined {
for (const effect of index.battleOrder) {
- if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;
+ if (effect.intent.type !== "attack_order") continue;
if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;
if (Array.isArray(effect.intent.value)) {
try {
+ if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;
return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };
} catch {
return undefined;
}
@@ -801,25 +842,36 @@
}
function applicableControls(
job: DamageJob,
- round: number,
runtime: Runtime
-): { dodge?: Control; no_attack?: Control } {
- const controls: { dodge?: Control; no_attack?: Control } = {};
+): ApplicableControls {
+ const controls: ApplicableControls = { attackDurationEffects: [] };
for (const effect of runtime.effectIndex.controls) {
- if (!isEffectActive(effect, round)) continue;
const classification = classifyEffectForJob(effect, job);
if (classification?.kind === "control" && classification.control) {
- controls[classification.control] = { effect, reason: classification.control };
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);
+ controls[classification.control] = {
+ effect,
+ reason: classification.control,
+ attackDurationEffects: controls.attackDurationEffects
+ };
}
}
return controls;
}
+interface ApplicableControls {
+ dodge?: Control;
+ no_attack?: Control;
+ attackDurationEffects: ActiveEffect[];
+}
+
interface Control {
effect: ActiveEffect;
reason: "dodge" | "no_attack";
+ attackDurationEffects: ActiveEffect[];
}
function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {
return {
@@ -846,9 +898,11 @@
const jobs: DamageJob[] = [];
const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];
let appliedEffects: AppliedExtraAttackEffect[] | undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
- runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
+ runtime.effectIndex.extraAttacks.filter(
+ (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)
+ ),
round
);
for (const effectGroup of effectGroups) {
const effect = effectGroup.selected;
@@ -915,9 +969,9 @@
continue;
}
const existing = selected[existingIndex];
existing.effects.push(effect);
- if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;
+ if (effect.getCurrentValuePct(round) > existing.selected.getCurrentValuePct(round)) existing.selected = effect;
}
return selected;
}
@@ -978,9 +1032,9 @@
return undefined;
}
function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {
- const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;
+ const raw = multiplier === undefined ? effect.getCurrentValuePct(round) : multiplier;
const pct = Number(raw ?? 0);
return Number.isFinite(pct) ? pct / 100 : 0;
}
@@ -1054,24 +1108,38 @@
// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in
// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.
function chargeUsedEffects(runtime: Runtime): void {
if (runtime.usedEffects.size === 0) return;
- for (const effect of runtime.usedEffects) effect.uses += 1;
+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);
runtime.usedEffects.clear();
}
+function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {
+ effect.uses += 1;
+ const limit = effectAttackUseLimit(effect);
+ if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);
+}
+
+function expireActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
+ if (effect.expired) return;
+ expireEffectIndex(runtime.effectIndex, effect);
+}
+
// A cancelled attack still charges the attacker's attack-constrained effects (the attack
// happened, it just didn't land) plus the control that cancelled it, whatever its duration.
-function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {
+function chargeCancelledAttack(
+ job: DamageJob,
+ winningControl: ActiveEffect,
+ controlEffects: ActiveEffect[],
+ runtime: Runtime
+): void {
const used = runtime.usedEffects;
- for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {
- if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);
+ for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {
+ if (effect.expired) continue;
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (hasAttackDurationConstraint(effect)) used.add(effect);
}
- for (const effect of runtime.effectIndex.controls) {
- if (!hasAttackDurationConstraint(effect)) continue;
- const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "control") used.add(effect);
- }
+ for (const effect of controlEffects) used.add(effect);
used.add(winningControl);
chargeUsedEffects(runtime);
}
Index: simulator/src/staticDamageProfile.ts
===================================================================
--- simulator/src/staticDamageProfile.ts prev run
+++ simulator/src/staticDamageProfile.ts this run
@@ -1,8 +1,8 @@
import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";
import { UNIT_TYPES, unitMaskHas } from "./types";
import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";
-import { currentEffectValuePct, sourceLabel } from "./effects";
+import { sourceLabel } from "./effects";
export interface StaticDamageProfileTerm {
raw?: number;
totalPct?: number;
@@ -89,9 +89,9 @@
}
const duration = effect.duration;
if (duration === undefined) return;
- if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {
+ if (duration.turns !== undefined || duration.attacks !== undefined) {
throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);
}
}
@@ -141,9 +141,9 @@
const role = staticPassiveBucketRole(bucket)!;
const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];
for (const unit of UNIT_TYPES) {
if (!unitMaskHas(effect.appliesTo.units, unit)) continue;
- const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };
+ const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };
if (effect.sameEffectStacking === "max" && effect.stackingKey) {
const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;
const group = groups.get(key);
if (group) {
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -73,27 +73,26 @@
export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;
export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;
-export interface EffectDuration {
- type: "battle" | "round" | "attack";
- value: number;
+export interface EffectDurationAxis {
+ count?: number;
delay?: number;
- constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;
}
+export interface EffectDuration {
+ turns?: EffectDurationAxis;
+ attacks?: EffectDurationAxis;
+}
+
export interface EffectIntentDefinition {
id: string;
type: string;
value?: unknown;
value_evolution?: { type?: string; step?: string; value?: number };
units?: Record<string, unknown>;
trigger_damage_jobs?: TriggerDamageJobDefinition[];
- duration?: {
- turns?: { count?: number; delay?: number };
- rounds?: { count?: number; delay?: number };
- attacks?: { count?: number; delay?: number };
- };
+ duration?: EffectDuration;
same_effect_stacking?: SameEffectStacking;
reason?: string;
}
@@ -241,21 +240,26 @@
}
export interface ActiveEffect {
id: string;
+ expired?: boolean;
source: EffectSource;
intent: EffectIntentDefinition;
ownerSide: SideId;
kind: ActiveEffectKind;
- valuePct?: number;
+ initialValuePct: number;
+ getCurrentValuePct(round: number): number;
// Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",
// trigger-relative selectors, or concrete unit selectors; it does not accept "all".
appliesTo: ResolvedUnitScope;
appliesVs: ResolvedUnitScope;
triggerDamageJobs?: TriggerDamageJobDefinition[];
createdRound: number;
startRound: number;
duration: EffectDuration;
+ // Eligible attacks remaining before an attacks.delay effect can apply. This is
+ // runtime state resolved from config, separate from `uses` after activation.
+ remainingAttackDelay: number;
// Times this instance affected battle mechanics (damage bucket applied, control fired,
// attack ordered, extra attack spawned). Attack duration constraints and step:"attack"
// value evolution read it; cancelled attacks still charge attack-constrained effects
// unless useEffectsOnDodge/useEffectsOnNoAttack disable that.
@@ -263,8 +267,16 @@
stackingKey?: string;
sameEffectStacking: SameEffectStacking;
}
+export interface EvolvingActiveEffect extends ActiveEffect {
+ valueEvolution: {
+ type?: string;
+ step?: string;
+ amount: number;
+ };
+}
+
export interface AttackIntent {
id: string;
round: number;
source: "normal";
Index: simulator/src/workerPool.test.ts
===================================================================
--- simulator/src/workerPool.test.ts prev run
+++ simulator/src/workerPool.test.ts this run
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { BatchWorkerPool, batchTasksByWeight, type BatchWorker } from "./workerPool";
+
+class DeferredWorker implements BatchWorker<number, number> {
+ calls: Array<{ id: number; tasks: number[] }> = [];
+ private pending: Array<{ resolve: (results: number[]) => void; reject: (error: Error) => void }> = [];
+
+ runBatch(id: number, tasks: number[]): Promise<number[]> {
+ this.calls.push({ id, tasks });
+ return new Promise((resolve, reject) => {
+ this.pending.push({ resolve, reject });
+ });
+ }
+
+ resolveNext(results: number[]): void {
+ const pending = this.pending.shift();
+ if (!pending) throw new Error("No pending worker batch");
+ pending.resolve(results);
+ }
+
+ close(): void {
+ while (this.pending.length > 0) this.pending.shift()!.reject(new Error("closed"));
+ }
+}
+
+test("BatchWorkerPool refills idle workers from the queue", async () => {
+ const workers = [new DeferredWorker(), new DeferredWorker()];
+ const pool = new BatchWorkerPool<number, number>(2, (index) => workers[index]!);
+
+ const first = pool.runBatch([1]);
+ const second = pool.runBatch([2]);
+ const third = pool.runBatch([3]);
+
+ assert.deepEqual(workers.map((worker) => worker.calls.map((call) => call.tasks)), [[[1]], [[2]]]);
+
+ workers[0]!.resolveNext([10]);
+ assert.deepEqual(await first, [10]);
+ assert.deepEqual(workers[0]!.calls.map((call) => call.tasks), [[1], [3]]);
+
+ workers[1]!.resolveNext([20]);
+ workers[0]!.resolveNext([30]);
+ assert.deepEqual(await second, [20]);
+ assert.deepEqual(await third, [30]);
+
+ await pool.close();
+});
+
+test("batchTasksByWeight groups tasks without exceeding the target when possible", () => {
+ assert.deepEqual(
+ batchTasksByWeight([1, 1, 3, 1, 1], 2, (value) => value),
+ [[1, 1], [3], [1, 1]]
+ );
+});
Index: simulator/src/workerPool.ts
===================================================================
--- simulator/src/workerPool.ts prev run
+++ simulator/src/workerPool.ts this run
@@ -0,0 +1,111 @@
+export interface BatchWorker<TTask, TResult, TProgress = never> {
+ runBatch(id: number, tasks: TTask[], onProgress?: (progress: TProgress) => void): Promise<TResult[]>;
+ close(): Promise<void> | void;
+}
+
+interface PendingBatch<TTask, TResult, TProgress> {
+ tasks: TTask[];
+ onProgress?: (progress: TProgress) => void;
+ resolve: (result: TResult[]) => void;
+ reject: (error: Error) => void;
+}
+
+interface WorkerState<TTask, TResult, TProgress> {
+ worker: BatchWorker<TTask, TResult, TProgress>;
+ idle: boolean;
+ closed: boolean;
+ inFlight?: PendingBatch<TTask, TResult, TProgress>;
+}
+
+export class BatchWorkerPool<TTask, TResult, TProgress = never> {
+ private readonly workers: WorkerState<TTask, TResult, TProgress>[];
+ private readonly queue: PendingBatch<TTask, TResult, TProgress>[] = [];
+ private nextId = 1;
+ private closed = false;
+
+ constructor(size: number, createWorker: (index: number) => BatchWorker<TTask, TResult, TProgress>) {
+ const count = Math.max(1, Math.floor(size));
+ this.workers = Array.from({ length: count }, (_, index) => ({
+ worker: createWorker(index),
+ idle: true,
+ closed: false
+ }));
+ }
+
+ runBatch(tasks: TTask[], onProgress?: (progress: TProgress) => void): Promise<TResult[]> {
+ if (this.closed) return Promise.reject(new Error("Worker pool is closed"));
+ return new Promise((resolve, reject) => {
+ this.queue.push({ tasks, onProgress, resolve, reject });
+ this.pump();
+ });
+ }
+
+ async close(): Promise<void> {
+ if (this.closed) return;
+ this.closed = true;
+ while (this.queue.length > 0) this.queue.shift()!.reject(new Error("Worker pool closed before completing queued tasks"));
+ await Promise.all(
+ this.workers.map(async (state) => {
+ state.closed = true;
+ state.idle = false;
+ if (state.inFlight) {
+ state.inFlight.reject(new Error("Worker pool closed before completing in-flight task"));
+ state.inFlight = undefined;
+ }
+ await state.worker.close();
+ })
+ );
+ }
+
+ private pump(): void {
+ if (this.closed) return;
+ for (const state of this.workers) {
+ if (!state.idle || state.closed) continue;
+ const pending = this.queue.shift();
+ if (!pending) return;
+ const id = this.nextId;
+ this.nextId += 1;
+ state.idle = false;
+ state.inFlight = pending;
+ void state.worker.runBatch(id, pending.tasks, pending.onProgress).then(
+ (result) => {
+ if (state.inFlight !== pending) return;
+ state.inFlight = undefined;
+ state.idle = true;
+ pending.resolve(result);
+ this.pump();
+ },
+ (error) => {
+ if (state.inFlight !== pending) return;
+ state.inFlight = undefined;
+ state.idle = true;
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
+ this.pump();
+ }
+ );
+ }
+ }
+}
+
+export function batchTasksByWeight<TTask>(
+ tasks: TTask[],
+ targetWeight: number,
+ getWeight: (task: TTask) => number
+): TTask[][] {
+ const batches: TTask[][] = [];
+ const maxWeight = Math.max(1, Math.floor(targetWeight));
+ let current: TTask[] = [];
+ let currentWeight = 0;
+ for (const task of tasks) {
+ const taskWeight = Math.max(1, Math.floor(getWeight(task)));
+ if (current.length > 0 && currentWeight + taskWeight > maxWeight) {
+ batches.push(current);
+ current = [];
+ currentWeight = 0;
+ }
+ current.push(task);
+ currentWeight += taskWeight;
+ }
+ if (current.length > 0) batches.push(current);
+ return batches;
+}
Show raw per-run patches
Run A dirty state patch
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
@@ -245,6 +245,86 @@
assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);
});
+test("simulateBattle skips later attacks against same-round exhausted targets only", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 100, lancer_t1: 100 },
+ stats: {
+ infantry: { attack: 100000, lethality: 100000 },
+ lancer: { attack: 100000, lethality: 100000 }
+ },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t1: 1 },
+ stats: { infantry: { attack: 100000, lethality: 100000 } },
+ heroes: {}
+ }
+ },
+ minimalConfig(),
+ { mode: "trace" }
+ );
+
+ const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");
+
+ assert.deepEqual(
+ normalAttacks.map((attack) => attack.jobId),
+ ["r1:attacker:infantry:0:normal", "r1:defender:infantry:0:normal"]
+ );
+ assert.equal(normalAttacks[0]?.kills, 1);
+ assert.equal(normalAttacks[1]?.kills, 100);
+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.id === "r1:attacker:lancer:1:normal"), false);
+ assert.equal(result.remaining.attacker.infantry, 0);
+ assert.equal(result.remaining.defender.infantry, 0);
+});
+
+test("extra skill attacks against same-round exhausted targets are skipped entirely", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ stats: { marksman: { attack: 100000, lethality: 100000 } },
+ heroes: { FollowUp: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 1 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ FollowUpShot: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.attackerSide === "attacker");
+
+ assert.equal(result.attacks.some((attack) => attack.kind === "skill"), false);
+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.kind === "skill"), false);
+ assert.deepEqual(result.extraSkillAttackJobsByEffect, {});
+ assert.equal(normalAttack?.appliedEffects.some((effect) => effect.kind === "extra_attack"), false);
+ assert.equal(result.remaining.defender.lancer, 0);
+});
+
test("simulateBattle carries fractional casualties between rounds and ceils final survivors", () => {
const config = loadSimulatorConfig();
const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/simple_001_nc.json", import.meta.url));
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -88,10 +88,15 @@
interface ExtraSkillJobsResult {
jobs: DamageJob[];
- usedEffects: ActiveEffect[];
+ usedEffectGroups: ExtraSkillUsedEffectGroup[];
appliedEffects?: AppliedExtraAttackEffect[];
}
+interface ExtraSkillUsedEffectGroup {
+ sourceEffectId: string;
+ effects: ActiveEffect[];
+}
+
interface BattleRun {
fighters: Record<SideId, ResolvedFighter>;
runtime: Runtime;
@@ -331,8 +336,9 @@
// 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 pendingNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; control?: Control }> = [];
const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];
+ const roundTargetDamage = emptyRoundTargetDamage();
for (const intent of intents) {
triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);
const job = normalJob(intent, roundStartTroops);
@@ -343,6 +349,20 @@
const controls = applicableControls(job, round, runtime);
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
+ pendingNormalJobs.push({ intent, job, control });
+ } else {
+ pendingNormalJobs.push({ intent, job });
+ }
+ }
+
+ // Phase 2: calculate each normal job; any extra_skill_attack effect it
+ // uses spawns extra DamageJobs that are calculated immediately after.
+ // chargeUsedEffects runs after each job so subsequent normal jobs see the
+ // correct uses count on any shared extra_skill_attack effects.
+ for (const { intent, job, control } of pendingNormalJobs) {
+ if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;
+
+ if (control) {
runtime.attackControlCounts[control.reason] += 1;
if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);
cancelled.push({
@@ -353,16 +373,9 @@
? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))
: NO_APPLIED_EFFECTS
});
- } else {
- pendingNormalJobs.push(job);
+ continue;
}
- }
- // Phase 2: calculate each normal job; any extra_skill_attack effect it
- // uses spawns extra DamageJobs that are calculated immediately after.
- // chargeUsedEffects runs after each job so subsequent normal jobs see the
- // correct uses count on any shared extra_skill_attack effects.
- for (const job of pendingNormalJobs) {
allJobs.push(job);
const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {
trace: recorder.capturesTrace,
@@ -373,20 +386,24 @@
capToDefenderTroops: loopOptions.capJobKills,
usedEffects: runtime.usedEffects
});
+ if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);
if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {
score += normalResult.kills;
}
chargeUsedEffects(runtime);
const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);
- for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;
- results.push({
+ const processedExtraEffectIds = new Set<string>();
+ const processedExtraJobIds = new Set<string>();
+ const normalEntry: DamageJobResult = {
job,
result: normalResult,
- extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)
- });
+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), undefined)
+ };
+ results.push(normalEntry);
for (const extraJob of extraSkill.jobs) {
+ if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
allJobs.push(extraJob);
const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {
trace: recorder.capturesTrace,
@@ -397,15 +414,25 @@
capToDefenderTroops: loopOptions.capJobKills,
usedEffects: runtime.usedEffects
});
+ if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);
+ processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");
+ processedExtraJobIds.add(extraJob.id);
+ if (extraJob.sourceEffectId) {
+ runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] ?? 0) + 1;
+ }
results.push({ job: extraJob, result: extraResult });
if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {
score += extraResult.kills;
}
chargeUsedEffects(runtime);
}
+ normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));
+ for (const usedEffectGroup of extraSkill.usedEffectGroups) {
+ if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;
+ for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;
+ }
}
- if (loopOptions.capRoundKills) capRoundKills(results, roundStartTroops);
if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);
else commitRoundCounters(cancelled, results, runtime);
@@ -817,7 +844,7 @@
capturesTrace: boolean
): ExtraSkillJobsResult {
const jobs: DamageJob[] = [];
- const usedEffects: ActiveEffect[] = [];
+ const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];
let appliedEffects: AppliedExtraAttackEffect[] | undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
@@ -825,6 +852,7 @@
);
for (const effectGroup of effectGroups) {
const effect = effectGroup.selected;
+ const sourceEffectId = effect.source.effectId ?? effect.intent.id;
const definitions = effect.triggerDamageJobs ?? [];
const firstJobIndex = jobs.length;
let definitionIndex = 0;
@@ -833,7 +861,6 @@
const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);
const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);
if (multiplier <= 0) continue;
- const sourceEffectId = effect.source.effectId ?? effect.intent.id;
const sourceSkillReportKey = reportKeyForEffectSource(effect.source);
for (const source of sources) {
if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;
@@ -853,7 +880,6 @@
sourceSkillReportKey,
sourceMultiplier: multiplier
});
- runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;
}
}
definitionIndex += 1;
@@ -861,7 +887,7 @@
if (jobs.length > firstJobIndex) {
// The whole stacking group is charged whenever the selected effect spawned jobs,
// regardless of duration constraints (extra attacks always deplete per firing).
- usedEffects.push(...effectGroup.effects);
+ usedEffectGroups.push({ sourceEffectId, effects: effectGroup.effects });
if (capturesTrace) {
(appliedEffects ??= []).push({
kind: "extra_attack",
@@ -871,7 +897,7 @@
}
}
}
- return { jobs, usedEffects, appliedEffects };
+ return { jobs, usedEffectGroups, appliedEffects };
}
function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {
@@ -958,29 +984,44 @@
return Number.isFinite(pct) ? pct / 100 : 0;
}
-// Cap each defender unit's total kills this round to the troops available at round start, applied
-// in job order. Mutates result.kills (and the trace's finalKills when present). This is
-// simulation-affecting and runs in every mode, before commit and recording.
-function capRoundKills(results: DamageJobResult[], roundStartTroops: DamageJob["roundStartTroops"]): void {
- for (const side of ["attacker", "defender"] as SideId[]) {
- for (const unit of UNIT_TYPES) {
- const matching = results.filter((entry) => entry.job.defenderSide === side && entry.job.defenderUnit === unit && entry.result.kills > 0);
- if (matching.length === 0) continue;
- const available = Math.max(0, roundStartTroops[side][unit] ?? 0);
- const totalKills = matching.reduce((sum, entry) => sum + entry.result.kills, 0);
- if (totalKills <= available) continue;
- let appliedKills = 0;
- let rawRemaining = available;
- for (const entry of matching) {
- const rawKills = entry.result.kills;
- entry.result.kills = Math.min(rawKills, Math.max(0, available - appliedKills));
- appliedKills += entry.result.kills;
- rawRemaining = Math.max(0, rawRemaining - rawKills);
- if (rawRemaining === 0) appliedKills = available;
- if (entry.result.trace) entry.result.trace.finalKills = entry.result.kills;
- }
- }
+function emptyRoundTargetDamage(): Record<SideId, Record<UnitType, number>> {
+ return { attacker: emptyTroops(), defender: emptyTroops() };
+}
+
+function targetExhausted(
+ job: DamageJob,
+ roundStartTroops: DamageJob["roundStartTroops"],
+ roundTargetDamage: Record<SideId, Record<UnitType, number>>
+): boolean {
+ const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);
+ return available <= 0 || roundTargetDamage[job.defenderSide][job.defenderUnit] >= available;
+}
+
+function capJobToRemainingTarget(
+ result: DamageResult,
+ job: DamageJob,
+ roundStartTroops: DamageJob["roundStartTroops"],
+ roundTargetDamage: Record<SideId, Record<UnitType, number>>
+): void {
+ const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);
+ const alreadyDamaged = roundTargetDamage[job.defenderSide][job.defenderUnit];
+ const remaining = Math.max(0, available - alreadyDamaged);
+ result.kills = Math.min(result.kills, remaining);
+ roundTargetDamage[job.defenderSide][job.defenderUnit] += result.kills;
+ if (result.trace) result.trace.finalKills = result.kills;
+}
+
+function filterExtraAppliedEffects(
+ events: AppliedExtraAttackEffect[] | undefined,
+ processedExtraJobIds: Set<string>
+): AppliedExtraAttackEffect[] | undefined {
+ if (!events) return undefined;
+ const filtered: AppliedExtraAttackEffect[] = [];
+ for (const event of events) {
+ const spawnedJobIds = event.spawnedJobIds.filter((jobId) => processedExtraJobIds.has(jobId));
+ if (spawnedJobIds.length > 0) filtered.push({ ...event, spawnedJobIds });
}
+ return filtered.length > 0 ? filtered : undefined;
}
// Apply the round's effects to fighter state: remove killed troops and bump attack/received
diff --git a/simulator/src/testcases.test.ts b/simulator/src/testcases.test.ts
--- a/simulator/src/testcases.test.ts
+++ b/simulator/src/testcases.test.ts
@@ -40,8 +40,8 @@
assert.equal(summary?.deterministic, false);
assert.equal(summary?.sampleCount, 5);
assert.equal(typeof summary?.game?.mu_candidate, "number");
- assert.equal(typeof summary?.baseline?.mu_candidate, "number");
- assert.deepEqual(Object.keys(summary?.game ?? {}), Object.keys(summary?.baseline ?? {}));
+ assert.equal(summary?.baseline, null);
+ assert.equal(report.counts.comparedToBaseline, 0);
assert.equal("result" in (summary as object), false);
assert.ok(detail?.result);
assert.equal(detail?.simulatorStats?.n, 5);
@@ -145,7 +145,7 @@
assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");
});
-test("runTestcases keeps executed testcase and warns when baseline snapshot row is missing", () => {
+test("runTestcases keeps executed testcase without warning when legacy baseline is unavailable", () => {
const config = loadSimulatorConfig();
const report = runTestcases({ matching: "simple_001", repeat: 1, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
const summary = Object.values(report.testcases)[0];
@@ -153,15 +153,16 @@
assert.equal(report.counts.executed, 1);
assert.equal(summary?.game?.n_candidate, 1);
assert.equal(summary?.baseline, null);
- assert.equal(report.warnings[0]?.stage, "baseline_comparison");
+ assert.equal(report.counts.comparedToBaseline, 0);
+ assert.deepEqual(report.warnings, []);
});
-test("applyComparisonQValues keeps game and baseline correction families separate", () => {
+test("applyComparisonQValues corrects game comparisons only", () => {
const firstGame = comparisonMetric(0.01);
const secondGame = comparisonMetric(0.02);
- const onlyBaseline = comparisonMetric(0.04);
+ const ignoredBaseline = comparisonMetric(0.04);
const testcases: Record<string, TestcaseSummaryEntry> = {
- "testcases/a.json#0": summaryEntry("a", 0, firstGame, onlyBaseline),
+ "testcases/a.json#0": summaryEntry("a", 0, firstGame, ignoredBaseline),
"testcases/b.json#0": summaryEntry("b", 0, secondGame, null)
};
@@ -169,7 +170,7 @@
assert.equal(firstGame.q, 0.02);
assert.equal(secondGame.q, 0.02);
- assert.equal(onlyBaseline.q, 0.04);
+ assert.equal(ignoredBaseline.q, null);
});
test("calibration lookup supports simulator symlink and source testcase path variants", () => {
@@ -185,7 +186,7 @@
}
});
-test("duplicate no-hero testcase ids align calibration rows by file and case index", () => {
+test("duplicate no-hero testcase ids keep game rows aligned by case index", () => {
const config = loadSimulatorConfig();
const report = runTestcases({ matching: "greg_mia_nohero_control_current", repeat: 1 }, config);
@@ -198,15 +199,13 @@
assert.equal(second?.testcaseId, "greg_mia_nohero_control_current");
assert.equal(first?.visibility.attacker.heroes.length, 0);
assert.equal(second?.visibility.attacker.heroes.length, 0);
- assert.equal(first?.calibration?.idx, 0);
- assert.equal(second?.calibration?.idx, 1);
- assert.equal(first?.calibration?.muGame, 3752);
- assert.equal(second?.calibration?.muGame, 3652);
- assert.equal(battleScoreDelta(first?.gameResult), first?.calibration?.muGame);
- assert.equal(battleScoreDelta(second?.gameResult), second?.calibration?.muGame);
+ assert.equal(first?.calibration, undefined);
+ assert.equal(second?.calibration, undefined);
+ assert.equal(battleScoreDelta(first?.gameResult), 3752);
+ assert.equal(battleScoreDelta(second?.gameResult), 3652);
});
-test("no-hero simple testcase loads, runs, compares to calibration, and exposes aligned core fields", () => {
+test("no-hero simple testcase loads, runs, compares to game result, and exposes aligned core fields", () => {
const config = loadSimulatorConfig();
const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
const entry = report.details[0];
@@ -215,10 +214,10 @@
assert.equal(report.counts.testcasesFound, 1);
assert.equal(summary?.testcase_id, "simple_001");
assert.equal(summary?.game?.n_reference, 1);
- assert.equal(summary?.baseline?.n_reference, 100);
+ assert.equal(summary?.baseline, null);
assert.equal(entry?.visibility.attacker.heroes.length, 0);
assert.equal(entry?.visibility.defender.heroes.length, 0);
- assert.equal(entry?.calibration?.muGame, -186);
+ assert.equal(entry?.calibration, undefined);
assert.equal(battleScoreDelta(entry?.gameResult), -186);
assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);
});
@@ -255,7 +254,7 @@
assert.ok((entry?.result?.rounds ?? 0) > 100);
});
-test("runTestcases reports a parity summary from calibration JSON", () => {
+test("runTestcases reports a game-focused parity summary without legacy baseline comparison", () => {
const config = loadSimulatorConfig();
const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
const row = Object.values(report.testcases)[0];
@@ -265,21 +264,15 @@
assert.equal(row?.testcase_id, "simple_001");
assert.equal(row?.idx, 0);
assert.equal(row?.game?.mu_reference, -186);
- assert.equal(row?.baseline?.mu_reference, -186);
- assert.equal(row?.baseline?.bias_raw, 0);
- assert.equal(row?.baseline?.sem, 0);
- assert.equal(row?.baseline?.p, null);
- assert.equal(row?.baseline?.q, null);
+ assert.equal(row?.baseline, null);
assert.equal(typeof detail?.simulatorScoreDelta, "number");
assert.equal(typeof row?.game?.bias_raw, "number");
assert.equal(row?.game?.n_candidate, 1);
assert.equal(typeof row?.game?.mu_candidate, "number");
- assert.equal(typeof row?.baseline?.bias_raw, "number");
assert.equal(typeof row?.game?.bias_raw, "number");
- assert.equal(typeof row?.baseline?.passes, "boolean");
assert.equal(typeof row?.game?.passes, "boolean");
assert.equal(report.counts.comparedToGame, 1);
- assert.equal(report.counts.comparedToBaseline, 1);
+ assert.equal(report.counts.comparedToBaseline, 0);
});
test("adaptTestcaseEntry promotes engagement_type to a top-level BattleInput key", () => {
Run B dirty state patch
diff --git a/simulator/config/hero_definitions/Gwen.json b/simulator/config/hero_definitions/Gwen.json
--- a/simulator/config/hero_definitions/Gwen.json
+++ b/simulator/config/hero_definitions/Gwen.json
@@ -113,9 +113,11 @@
]
},
"duration": {
- "attacks": {
- "count": 1,
+ "turns": {
"delay": 1
+ },
+ "attacks": {
+ "count": 1
}
},
"trigger_damage_jobs": [
diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts
--- a/simulator/src/classifierDamage.test.ts
+++ b/simulator/src/classifierDamage.test.ts
@@ -4,8 +4,8 @@
import { classifyEffectForJob } from "./classifier";
import { calculateDamageJob } from "./damage";
import { ATOMIC_BUCKETS } from "./damageBuckets";
-import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";
-import { activateEffect } from "./effects";
+import { createEffectIndex, indexEffect } from "./effectIndex";
+import { activateEffect, evolvingActiveEffectValuePct } from "./effects";
import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";
import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";
import { ALL_UNIT_MASK, unitMask } from "./types";
@@ -33,12 +33,14 @@
intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ initialValuePct: 25,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask(["infantry"]) },
appliesVs: { side: "defender", units: unitMask(["lancer"]) },
createdRound: 1,
startRound: 1,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
};
@@ -78,12 +80,14 @@
intent: { id: `${type}/1`, type, value: [valuePct] },
ownerSide,
kind: "modifier",
- valuePct,
+ initialValuePct: valuePct,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },
appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },
createdRound: 1,
startRound: 1,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
};
@@ -100,7 +104,6 @@
for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);
}
const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
- removeStaticProfileBucketEffects(effectIndex);
const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });
return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };
@@ -418,7 +421,7 @@
const oneAttackEffect = {
...effect("active.hero.attack.up", "attacker", 100),
id: "attack-up-active",
- duration: { type: "attack" as const, value: 1 }
+ duration: { attacks: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
@@ -432,7 +435,7 @@
...effect("active.hero.defense.down", "defender", 30),
id: "bad-luck-like",
source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },
- duration: { type: "round" as const, value: 1 }
+ duration: { turns: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });
@@ -456,7 +459,7 @@
const defenderOutgoingBuff = {
...effect("active.hero.lethality.up", "defender", 100),
id: "defender-outgoing-buff",
- duration: { type: "attack" as const, value: 1 }
+ duration: { attacks: { count: 1 } }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
@@ -475,7 +478,9 @@
value: 100,
value_evolution: { type: "pct_decay", step: "attack", value: 15 }
},
- duration: { type: "attack" as const, value: 10 },
+ duration: { attacks: { count: 10 } },
+ valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
uses: 2
};
@@ -499,7 +504,9 @@
},
createdRound: 0,
startRound: 0,
- duration: { type: "attack" as const, value: 10 }
+ valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
+ duration: { attacks: { count: 10 } }
};
const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
@@ -513,7 +520,7 @@
const weaker = {
...effect("active.hero.lethality.up", "attacker", 50),
id: "max-weaker",
- duration: { type: "attack" as const, value: 3 },
+ duration: { attacks: { count: 3 } },
stackingKey: "same-max-group",
sameEffectStacking: "max" as const
};
@@ -526,7 +533,9 @@
value: 100,
value_evolution: { type: "pct_decay", step: "attack", value: 50 }
},
- duration: { type: "attack" as const, value: 3 },
+ duration: { attacks: { count: 3 } },
+ valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },
+ getCurrentValuePct: evolvingActiveEffectValuePct,
uses: 1,
stackingKey: "same-max-group",
sameEffectStacking: "max" as const
diff --git a/simulator/src/config.test.ts b/simulator/src/config.test.ts
--- a/simulator/src/config.test.ts
+++ b/simulator/src/config.test.ts
@@ -9,6 +9,15 @@
import { loadSimulatorConfigFromDir } from "./config-node";
import type { SkillFile } from "./types";
+test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {
+ const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];
+
+ assert.deepEqual(effect.duration, {
+ turns: { delay: 1 },
+ attacks: { count: 1 }
+ });
+});
+
test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {
const root = writeConfigWithTroopEffect({
type: "active.hero.lethality.up",
diff --git a/simulator/src/config.ts b/simulator/src/config.ts
--- a/simulator/src/config.ts
+++ b/simulator/src/config.ts
@@ -275,11 +275,11 @@
const path = `${file}:${skillId}.${effectId}.duration`;
const duration = effect.duration as Record<string, unknown>;
for (const key of Object.keys(duration)) {
- if (key !== "turns" && key !== "rounds" && key !== "attacks") {
+ if (key !== "turns" && key !== "attacks") {
throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);
}
}
- for (const key of ["turns", "rounds", "attacks"] as const) {
+ for (const key of ["turns", "attacks"] as const) {
const value = duration[key];
if (value === undefined) continue;
validateNativeEffectDurationAxis(value, `${path}.${key}`);
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -10,10 +10,9 @@
UnitType
} from "./types";
import { UNIT_TYPES } from "./types";
-import { classifyEffectForJob } from "./classifier";
import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";
-import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";
-import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";
+import { advanceEffectAttackDelay, sourceLabel } from "./effects";
+import { damageEffectsForJob, type EffectIndex } from "./effectIndex";
import {
buildStaticDamageProfile,
type StaticDamageBucket,
@@ -33,15 +32,9 @@
appliesTo?: DamageJob["kind"];
}
-interface BucketCandidate {
- effect: ActiveEffect;
- bucket: AtomicBucket;
- valuePct: number;
-}
-
-interface MaxBucketCandidateGroup {
- selected: BucketCandidate;
- candidates: BucketCandidate[];
+interface MaxBucketEffectGroup {
+ selected: ActiveEffect;
+ effects: ActiveEffect[];
}
interface DamageExpressionResult {
@@ -129,34 +122,17 @@
const appliedEffects: DamageEquationTrace["appliedEffects"] = [];
const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];
const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
- const candidates: BucketCandidate[] = [];
- const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
- for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
- if (!isEffectActive(candidate.effect, job.round)) continue;
- handledCandidateEffectIds?.add(candidate.effect.id);
- candidates.push({
- effect: candidate.effect,
- bucket: candidate.bucket,
- valuePct: currentEffectValuePct(candidate.effect, job.round)
- });
- }
-
- if (traceEnabled) {
- for (const effect of options.effectIndex.all) {
- if (handledCandidateEffectIds?.has(effect.id)) continue;
- if (!isEffectActive(effect, job.round)) {
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });
- continue;
- }
- const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "bucket" && classification.bucket) {
- throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);
- }
- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });
- }
- }
+ applyBucketEffects(
+ damageEffectsForJob(options.effectIndex, job),
+ job.round,
+ buckets,
+ detail,
+ recordAppliedEffects,
+ appliedEffects,
+ rejectedEffects,
+ usedEffects
+ );
- applyBucketCandidates(candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
@@ -189,8 +165,9 @@
};
}
-function applyBucketCandidates(
- candidates: BucketCandidate[],
+function applyBucketEffects(
+ effects: ActiveEffect[],
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
@@ -198,32 +175,35 @@
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;
- for (const candidate of candidates) {
- if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {
+ let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;
+ for (const effect of effects) {
+ if (effect.expired) continue;
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {
maxGroups ??= new Map();
- const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;
+ const key = `${effect.intent.type}:${effect.stackingKey}`;
const group = maxGroups.get(key);
if (group) {
- group.candidates.push(candidate);
- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;
+ group.effects.push(effect);
+ if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;
} else {
- maxGroups.set(key, { selected: candidate, candidates: [candidate] });
+ maxGroups.set(key, { selected: effect, effects: [effect] });
}
} else {
- applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
if (!maxGroups) return;
for (const group of maxGroups.values()) {
- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
+ applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);
}
}
// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the
// applied percentage. Shared by the single-candidate and max-group paths.
function applySelectedBucket(
- selected: BucketCandidate,
+ selected: ActiveEffect,
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
@@ -231,27 +211,27 @@
): number {
const appliedValuePct = applyBucketValue(
buckets,
- selected.bucket,
- selected.valuePct,
- detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",
- detail === "full" ? sourceLabel(selected.effect) : "",
- detail === "full" ? selected.effect.ownerSide : undefined,
- selected.bucket,
- selected.effect.stackingKey,
- selected.effect.sameEffectStacking
+ selected.intent.type,
+ selected.getCurrentValuePct(round),
+ detail === "full" ? selected.source.effectId ?? selected.id : "",
+ detail === "full" ? sourceLabel(selected) : "",
+ detail === "full" ? selected.ownerSide : undefined,
+ selected.intent.type,
+ selected.stackingKey,
+ selected.sameEffectStacking
);
if (appliedValuePct !== 0 && recordAppliedEffects) {
const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
kind: "modifier",
- activeEffectId: selected.effect.id,
- effectId: selected.effect.source.effectId ?? selected.effect.id,
- bucket: selected.bucket,
+ activeEffectId: selected.id,
+ effectId: selected.source.effectId ?? selected.id,
+ bucket: selected.intent.type,
valuePct: appliedValuePct,
- source: sourceLabel(selected.effect),
- sourceSide: selected.effect.ownerSide,
- sameEffectStacking: selected.effect.sameEffectStacking
+ source: sourceLabel(selected),
+ sourceSide: selected.ownerSide,
+ sameEffectStacking: selected.sameEffectStacking
};
- if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;
+ if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;
appliedEffects.push(appliedEffect);
}
return appliedValuePct;
@@ -259,8 +239,9 @@
// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]
// array and no suppressed-sibling bookkeeping.
-function applyBucketCandidate(
- candidate: BucketCandidate,
+function applyBucketEffect(
+ effect: ActiveEffect,
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
@@ -268,17 +249,18 @@
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);
+ const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
- usedEffects.add(candidate.effect);
+ usedEffects.add(effect);
} else if (detail === "full") {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
}
}
-function applyBucketCandidateGroup(
- selected: BucketCandidate,
- candidates: BucketCandidate[],
+function applyBucketEffectGroup(
+ selected: ActiveEffect,
+ effects: ActiveEffect[],
+ round: number,
buckets: NumericDamageBuckets,
detail: DamageDetail,
recordAppliedEffects: boolean,
@@ -286,18 +268,18 @@
rejectedEffects: DamageEquationTrace["rejectedEffects"],
usedEffects: Set<ActiveEffect>
): void {
- const appliedValuePct = applySelectedBucket(selected, buckets, detail, recordAppliedEffects, appliedEffects);
+ const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);
if (appliedValuePct !== 0) {
// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
- for (const candidate of candidates) {
- usedEffects.add(candidate.effect);
- if (detail === "full" && candidate !== selected) {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
+ for (const effect of effects) {
+ usedEffects.add(effect);
+ if (detail === "full" && effect !== selected) {
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });
}
}
} else if (detail === "full") {
- for (const candidate of candidates) {
- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
+ for (const effect of effects) {
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });
}
}
}
diff --git a/simulator/src/effectIndex.test.ts b/simulator/src/effectIndex.test.ts
--- a/simulator/src/effectIndex.test.ts
+++ b/simulator/src/effectIndex.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";
+import { createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";
import { unitMask } from "./types";
import type { ActiveEffect, DamageJob } from "./types";
@@ -13,12 +13,14 @@
intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ initialValuePct: 25,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
createdRound: 0,
startRound: 0,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
stackingKey: "stack",
sameEffectStacking: "add"
@@ -40,20 +42,17 @@
defenderUnit: "lancer"
};
- assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);
+ assert.deepEqual(damageEffectsForJob(index, job), [effect]);
});
-test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {
+test("effect index excludes static-profile bucket effects", () => {
const index = createEffectIndex();
const passive = effect("passive.attack.up");
const active = effect("active.hero.lethality.up");
indexEffect(index, passive);
indexEffect(index, active);
- removeStaticProfileBucketEffects(index);
-
- assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);
- assert.deepEqual(index.all, [active]);
+ assert.deepEqual(damageEffectsForJob(index, job()), [active]);
});
function effect(type: string): ActiveEffect {
@@ -63,12 +62,14 @@
intent: { id: type, type, value: 25 },
ownerSide: "attacker",
kind: "modifier",
- valuePct: 25,
+ initialValuePct: 25,
+ getCurrentValuePct() { return this.initialValuePct; },
appliesTo: { side: "attacker", units: unitMask("infantry") },
appliesVs: { side: "defender", units: unitMask("lancer") },
createdRound: 0,
startRound: 0,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
stackingKey: "stack",
sameEffectStacking: "add"
diff --git a/simulator/src/effectIndex.ts b/simulator/src/effectIndex.ts
--- a/simulator/src/effectIndex.ts
+++ b/simulator/src/effectIndex.ts
@@ -1,16 +1,9 @@
import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";
import { unitsFromMask } from "./types";
-import { bucketDefinition, type AtomicBucket } from "./damageBuckets";
-import { isStaticProfileBucket } from "./staticDamageProfile";
-
-export interface IndexedBucketEffect {
- effect: ActiveEffect;
- bucket: AtomicBucket;
-}
+import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";
export interface EffectIndex {
- all: ActiveEffect[];
- damageByJobShape: Array<IndexedBucketEffect[] | undefined>;
+ damageByJobShape: Array<ActiveEffect[] | undefined>;
controls: ActiveEffect[];
extraAttacks: ActiveEffect[];
battleOrder: ActiveEffect[];
@@ -20,7 +13,6 @@
export function createEffectIndex(): EffectIndex {
return {
- all: [],
damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),
controls: [],
extraAttacks: [],
@@ -29,7 +21,6 @@
}
export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {
- index.all.push(effect);
if (effect.kind === "control") {
index.controls.push(effect);
return;
@@ -48,53 +39,27 @@
// per-job runtime path, so they must not enter the damage job-shape index.
if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;
- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
- const appliesToUnits = unitsFromMask(effect.appliesTo.units);
- const appliesVsUnits = unitsFromMask(effect.appliesVs.units);
- for (const jobKind of jobKinds) {
- for (const appliesToUnit of appliesToUnits) {
- for (const appliesVsUnit of appliesVsUnits) {
- const slot =
- definition.role === "attacker"
- ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
- : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);
- const arr = index.damageByJobShape[slot];
- const candidate = { effect, bucket: definition.path };
- if (arr) arr.push(candidate);
- else index.damageByJobShape[slot] = [candidate];
- }
- }
+ for (const slot of shapeSlotsFor(effect, definition.path)) {
+ const arr = index.damageByJobShape[slot];
+ if (arr) arr.push(effect);
+ else index.damageByJobShape[slot] = [effect];
}
}
-export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {
- compactEffects(index.all, isActive);
- compactEffects(index.controls, isActive);
- compactEffects(index.extraAttacks, isActive);
- compactEffects(index.battleOrder, isActive);
- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
- const arr = index.damageByJobShape[slot];
- if (!arr) continue;
- compactCandidates(arr, isActive);
- if (arr.length === 0) index.damageByJobShape[slot] = undefined;
- }
+export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {
+ if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;
+ const definition = bucketDefinition(effect.intent.type);
+ return definition !== undefined && definition.valueType === "pct" && definition.phase !== "static";
}
-export function removeStaticProfileBucketEffects(index: EffectIndex): void {
- compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));
- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
- const arr = index.damageByJobShape[slot];
- if (!arr) continue;
- let write = 0;
- for (let read = 0; read < arr.length; read += 1) {
- if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }
- }
- arr.length = write;
- if (arr.length === 0) index.damageByJobShape[slot] = undefined;
- }
+export function expireEffectIndex(index: EffectIndex, effect: ActiveEffect): void {
+ effect.expired = true;
+ if (effect.kind === "control") removeStable(index.controls, effect);
+ else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);
+ else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);
}
-export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {
+export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {
return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];
}
@@ -108,6 +73,38 @@
return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);
}
+const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();
+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));
+
+function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
+ const key =
+ ((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 +
+ (effect.appliesVs.units & 7);
+ const cached = SHAPE_SLOTS_CACHE.get(key);
+ if (cached) return cached;
+ const slots = buildShapeSlots(effect, bucket);
+ SHAPE_SLOTS_CACHE.set(key, slots);
+ return slots;
+}
+
+function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {
+ const definition = bucketDefinition(bucket)!;
+ const slots: number[] = [];
+ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
+ for (const jobKind of jobKinds) {
+ for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {
+ for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {
+ slots.push(
+ definition.role === "attacker"
+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)
+ );
+ }
+ }
+ }
+ return Uint8Array.from(slots);
+}
+
function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }
function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }
function unitIndex(unit: UnitType): number {
@@ -116,29 +113,7 @@
return 2;
}
-function isStaticProfileEffect(effect: ActiveEffect): boolean {
- const definition = bucketDefinition(effect.intent.type);
- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));
-}
-
-function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {
- let write = 0;
- for (let read = 0; read < effects.length; read += 1) {
- const effect = effects[read];
- if (!isActive(effect)) continue;
- effects[write] = effect;
- write += 1;
- }
- effects.length = write;
-}
-
-function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {
- let write = 0;
- for (let read = 0; read < candidates.length; read += 1) {
- const candidate = candidates[read];
- if (!isActive(candidate.effect)) continue;
- candidates[write] = candidate;
- write += 1;
- }
- candidates.length = write;
+function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {
+ const index = effects.indexOf(effect);
+ if (index >= 0) effects.splice(index, 1);
}
diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts
--- a/simulator/src/effects.ts
+++ b/simulator/src/effects.ts
@@ -4,6 +4,7 @@
AttackIntent,
EffectDuration,
EffectIntentDefinition,
+ EvolvingActiveEffect,
ResolvedSkill,
ResolvedUnitScope,
SameEffectStacking,
@@ -14,8 +15,6 @@
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
-type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];
-
interface CompiledTriggerSelectors {
source: ParsedTriggerSelector;
target: ParsedTriggerSelector;
@@ -88,14 +87,15 @@
const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);
const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);
const duration = normalizeDuration(intent.duration);
- const delay = duration.delay ?? 0;
+ const turnDelay = Math.max(0, duration.turns?.delay ?? 0);
const effectKind = kindForIntent(intent);
if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {
throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);
}
const sourceKey = skillActivationSourceKey(skill);
- return {
+ const effect: ActiveEffect = {
id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,
+ expired: false,
source: {
kind: skill.sourceKind,
side: skill.side,
@@ -109,17 +109,31 @@
intent,
ownerSide,
kind: effectKind,
- valuePct: typeof intent.value === "number" ? intent.value : undefined,
+ initialValuePct: finiteNumberOrZero(intent.value),
+ getCurrentValuePct: constantActiveEffectValuePct,
appliesTo,
appliesVs,
triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,
createdRound: round,
- startRound: round + delay,
+ startRound: Math.max(1, round + turnDelay),
duration,
+ remainingAttackDelay: duration.attacks?.delay ?? 0,
uses: 0,
stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,
sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)
};
+ const evolution = intent.value_evolution;
+ if (!evolution) return effect;
+ const evolvingEffect: EvolvingActiveEffect = {
+ ...effect,
+ valueEvolution: {
+ type: evolution.type,
+ step: evolution.step,
+ amount: finiteNumberOrZero(evolution.value)
+ },
+ getCurrentValuePct: evolvingActiveEffectValuePct
+ };
+ return evolvingEffect;
}
function skillActivationSourceKey(skill: ResolvedSkill): string {
@@ -134,30 +148,56 @@
return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
}
-export function isEffectActive(effect: ActiveEffect, round: number): boolean {
- if (round < effect.startRound) return false;
- return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));
+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
+ return effect.duration.attacks !== undefined;
}
-export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
- return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");
-}
-
-export function currentEffectValuePct(effect: ActiveEffect, round: number): number {
- const baseValue = Number(effect.valuePct ?? 0);
- if (!Number.isFinite(baseValue)) return 0;
- const evolution = effect.intent.value_evolution;
- if (!evolution) return baseValue;
- const firstActiveRound = Math.max(1, effect.startRound);
- const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;
- const amount = Number(evolution.value ?? 0);
- if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;
+export function effectAttackUseLimit(effect: ActiveEffect): number | undefined {
+ const count = effect.duration.attacks?.count;
+ return count === undefined ? undefined : Math.max(1, count);
+}
+
+export function effectRoundWindow(effect: ActiveEffect): { activationRound: number; expirationRound?: number } | undefined {
+ const turns = effect.duration.turns;
+ if (!turns) return undefined;
+ return {
+ activationRound: effect.startRound,
+ ...(turns.count === undefined ? {} : { expirationRound: effect.startRound + Math.max(1, turns.count) })
+ };
+}
+
+export function isEffectAttackReady(effect: ActiveEffect): boolean {
+ return effect.remainingAttackDelay <= 0;
+}
+
+// Returns true when the effect may apply to this eligible attack. The attack
+// that consumes the final delay does not also consume the first active use.
+export function advanceEffectAttackDelay(effect: ActiveEffect): boolean {
+ const remaining = effect.remainingAttackDelay;
+ if (remaining <= 0) return true;
+ effect.remainingAttackDelay = remaining - 1;
+ return false;
+}
+
+export function constantActiveEffectValuePct(this: ActiveEffect, _round: number): number {
+ return this.initialValuePct;
+}
+
+export function evolvingActiveEffectValuePct(this: EvolvingActiveEffect, round: number): number {
+ const firstActiveRound = Math.max(1, this.startRound);
+ const evolution = this.valueEvolution;
+ const stepCount = evolution.step === "attack" ? this.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;
+ if (stepCount <= 0) return this.initialValuePct;
if (evolution.type === "pct_decay") {
- const factor = Math.max(0, 1 - amount / 100);
- return baseValue * factor ** stepCount;
+ const factor = Math.max(0, 1 - evolution.amount / 100);
+ return this.initialValuePct * factor ** stepCount;
}
- if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);
- return baseValue;
+ if (evolution.type === "fixed_decay") return Math.max(0, this.initialValuePct - stepCount * evolution.amount);
+ return this.initialValuePct;
+}
+
+function finiteNumberOrZero(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
export type TriggerSelectorRelation = "self" | "enemy";
@@ -237,40 +277,15 @@
}
function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {
- if (!duration) return { type: "battle", value: 0 };
- const namedConstraints = normalizeNamedDurationConstraints(duration);
- if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };
- return { type: "battle", value: 0 };
-}
-
-function durationConstraints(duration: EffectDuration): EffectDurationConstraint[] {
- return duration.constraints ?? [{ type: duration.type, count: duration.value }];
-}
-
-function isDurationConstraintActive(effect: ActiveEffect, round: number, constraint: EffectDurationConstraint): boolean {
- const delay = Math.max(0, constraint.delay ?? 0);
- if (constraint.type === "battle") return true;
- if (constraint.type === "round") {
- const startRound = effect.createdRound + delay;
- if (round < startRound) return false;
- if (constraint.count === undefined) return true;
- return round < startRound + Math.max(1, constraint.count);
- }
- if (constraint.count === undefined) return true;
- return effect.uses < delay + Math.max(1, constraint.count);
-}
-
-function normalizeNamedDurationConstraints(duration: NonNullable<EffectIntentDefinition["duration"]>): EffectDurationConstraint[] {
- const constraints: EffectDurationConstraint[] = [];
- const turns = duration.turns ?? duration.rounds;
- if (turns) constraints.push(normalizeNamedDurationConstraint("round", turns));
- if (duration.attacks) constraints.push(normalizeNamedDurationConstraint("attack", duration.attacks));
- return constraints;
+ if (!duration) return {};
+ return {
+ ...(duration.turns ? { turns: normalizeDurationAxis(duration.turns) } : {}),
+ ...(duration.attacks ? { attacks: normalizeDurationAxis(duration.attacks) } : {})
+ };
}
-function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {
+function normalizeDurationAxis(value: { count?: number; delay?: number }): { count?: number; delay?: number } {
return {
- type,
...(value.count === undefined ? {} : { count: Number(value.count) }),
...(value.delay === undefined ? {} : { delay: Number(value.delay) })
};
diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts
--- a/simulator/src/simulator.test.ts
+++ b/simulator/src/simulator.test.ts
@@ -566,6 +566,139 @@
assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
});
+test("cancelled attacks do not charge attack-limited effects before their turn delay elapses", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: { DelayedAfterPause: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedAfterPause: {
+ name: "DelayedAfterPause",
+ troop_type: "infantry",
+ skills: {
+ PauseFirstRound: {
+ trigger: { type: "battle_start" },
+ effects: {
+ pause: {
+ type: "no_attack",
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ },
+ DelayedAttackBudget: {
+ trigger: { type: "turn", every: 99, first: 1 },
+ effects: {
+ delayedBoost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { delay: 1 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const roundOne = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry"));
+ const roundTwo = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
+ assert.equal(roundOne?.cancelReason, "no_attack");
+ assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+});
+
+test("attack delay skips eligible attacks before the effect can apply", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { marksman_t1: 1000000 },
+ heroes: { DelayedExtra: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedExtra: {
+ name: "DelayedExtra",
+ troop_type: "marksman",
+ skills: {
+ NextAttack: {
+ trigger: { type: "battle_start" },
+ effects: {
+ delayedExtra: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "self.marksman", applies_vs: "any" },
+ duration: { attacks: { count: 1, delay: 1 } },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");
+ assert.equal(skillAttacks.length, 1);
+ assert.ok(skillAttacks[0].jobId.startsWith("r2:"));
+});
+
+test("attack delay skips eligible damage jobs before a modifier can apply", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: { DelayedModifier: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DelayedModifier: {
+ name: "DelayedModifier",
+ troop_type: "infantry",
+ skills: {
+ NextAttackBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ delayedBoost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { attacks: { count: 1, delay: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const normalAttacks = result.attacks.filter(
+ (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"
+ );
+ assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);
+ assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);
+});
+
test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
const config = loadSimulatorConfig();
const result = simulateBattle(
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -28,11 +28,14 @@
import { createRecorder } from "./recorder";
import {
activateEffect,
+ advanceEffectAttackDelay,
chancePasses,
+ constantActiveEffectValuePct,
createSeededRng,
- currentEffectValuePct,
+ effectAttackUseLimit,
+ effectRoundWindow,
hasAttackDurationConstraint,
- isEffectActive,
+ isEffectAttackReady,
oppositeSide,
parseTriggerSelector,
sideForTriggerRelation,
@@ -41,7 +44,7 @@
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
-import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";
+import { createEffectIndex, damageEffectsForJob, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";
import { normalizeUnitType } from "./normalize";
import { emptyTroops, resolveFighter } from "./resolve";
import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";
@@ -53,8 +56,10 @@
const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();
interface Runtime {
- activeEffects: ActiveEffect[];
effectIndex: EffectIndex;
+ preparedEffects: ActiveEffect[];
+ activateEffectsByRound: Array<ActiveEffect[] | undefined>;
+ expireEffectsByRound: Array<ActiveEffect[] | undefined>;
// Per-job scratch: effects that affected the job being calculated; drained by
// chargeUsedEffects (uses += 1 each) after every job in every mode.
usedEffects: Set<ActiveEffect>;
@@ -215,27 +220,27 @@
}
// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage
-// profile, and drop static-profile effects from the per-job index.
+// profile. Static-phase effects never enter the per-job index.
function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {
const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));
- triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);
- addInputPassiveEffects(runtime, input.attacker.passive, "attacker");
- addInputPassiveEffects(runtime, input.defender.passive, "defender");
- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);
- removeStaticProfileBucketEffects(runtime.effectIndex);
+ const setupEffects = [
+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),
+ ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),
+ ...addInputPassiveEffects(runtime, input.defender.passive, "defender")
+ ];
+ runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);
+ runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);
return runtime;
}
-// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)
-// and the index rebuilt from the clones; skills and the static profile are shared by reference.
+// Clone a prepared template's mutable per-run effect state and rebuild the index from the
+// clones; skills and the static profile are shared by reference.
function cloneRuntime(template: Runtime, rng: Rng): Runtime {
- const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));
- const effectIndex = createEffectIndex();
- for (const effect of activeEffects) indexEffect(effectIndex, effect);
- removeStaticProfileBucketEffects(effectIndex);
- return {
- activeEffects,
- effectIndex,
+ const runtime: Runtime = {
+ effectIndex: createEffectIndex(),
+ preparedEffects: [],
+ activateEffectsByRound: [],
+ expireEffectsByRound: [],
usedEffects: new Set(),
staticDamageProfile: template.staticDamageProfile,
damageScratch: createFastDamageScratch(),
@@ -250,6 +255,10 @@
received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }
}
};
+ for (const templateEffect of template.preparedEffects) {
+ addActiveEffect(runtime, { ...templateEffect, expired: false, uses: 0 });
+ }
+ return runtime;
}
function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {
@@ -324,7 +333,7 @@
if (winnerFor(fighters)) break;
rounds = round;
const roundStartTroops = snapshotTroops(fighters);
- expireInactive(runtime, round);
+ processEffectSchedule(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops);
// Applied-effect events keyed by the intent they ordered.
@@ -346,7 +355,7 @@
}
for (const { intent, job } of declaredNormalJobs) {
- const controls = applicableControls(job, round, runtime);
+ const controls = applicableControls(job, runtime);
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
pendingNormalJobs.push({ intent, job, control });
@@ -364,7 +373,9 @@
if (control) {
runtime.attackControlCounts[control.reason] += 1;
- if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);
+ if (useEffectsOnCancel[control.reason]) {
+ chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);
+ }
cancelled.push({
intent,
effectId: control.effect.id,
@@ -377,7 +388,7 @@
}
allJobs.push(job);
- const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {
+ const normalResult = calculateDamageJob(job, fighters, [], {
trace: recorder.capturesTrace,
recordAppliedEffects: recorder.capturesAppliedEffects,
effectIndex: runtime.effectIndex,
@@ -405,7 +416,7 @@
for (const extraJob of extraSkill.jobs) {
if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;
allJobs.push(extraJob);
- const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {
+ const extraResult = calculateDamageJob(extraJob, fighters, [], {
trace: recorder.capturesTrace,
recordAppliedEffects: recorder.capturesAppliedEffects,
effectIndex: runtime.effectIndex,
@@ -429,7 +440,7 @@
normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));
for (const usedEffectGroup of extraSkill.usedEffectGroups) {
if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;
- for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;
+ for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);
}
}
@@ -610,8 +621,10 @@
}
}
return {
- activeEffects: [],
effectIndex: createEffectIndex(),
+ preparedEffects: [],
+ activateEffectsByRound: [],
+ expireEffectsByRound: [],
usedEffects: new Set(),
staticDamageProfile: undefined,
damageScratch: createFastDamageScratch(),
@@ -644,31 +657,53 @@
}
function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
- runtime.activeEffects.push(effect);
- indexEffect(runtime.effectIndex, effect);
+ if (!isRuntimeIndexableEffect(effect)) return;
+ const window = effectRoundWindow(effect);
+ if (!window) {
+ indexEffect(runtime.effectIndex, effect);
+ return;
+ }
+ if (window.expirationRound !== undefined) {
+ if (window.expirationRound <= window.activationRound) {
+ effect.expired = true;
+ return;
+ }
+ scheduleEffect(runtime.expireEffectsByRound, window.expirationRound, effect);
+ }
+ if (window.activationRound <= effect.createdRound) indexEffect(runtime.effectIndex, effect);
+ else scheduleEffect(runtime.activateEffectsByRound, window.activationRound, effect);
}
-function expireInactive(runtime: Runtime, round: number): void {
- let write = 0;
- for (let read = 0; read < runtime.activeEffects.length; read += 1) {
- const effect = runtime.activeEffects[read];
- if (isEffectActive(effect, round)) {
- runtime.activeEffects[write] = effect;
- write += 1;
+function scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {
+ const effects = schedule[round];
+ if (effects) effects.push(effect);
+ else schedule[round] = [effect];
+}
+
+function processEffectSchedule(runtime: Runtime, round: number): void {
+ const expiring = runtime.expireEffectsByRound[round];
+ if (expiring) {
+ for (const effect of expiring) expireActiveEffect(runtime, effect);
+ runtime.expireEffectsByRound[round] = undefined;
+ }
+ const activating = runtime.activateEffectsByRound[round];
+ if (activating) {
+ for (const effect of activating) {
+ if (!effect.expired) indexEffect(runtime.effectIndex, effect);
}
+ runtime.activateEffectsByRound[round] = undefined;
}
- runtime.activeEffects.length = write;
- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));
}
-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {
- if (!passive) return;
+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {
+ const effects: ActiveEffect[] = [];
+ if (!passive) return effects;
for (const stat of ["attack", "defense", "lethality", "health"] as const) {
for (const direction of ["up", "down"] as const) {
const valuePct = Number(passive[stat]?.[direction] ?? 0);
if (!Number.isFinite(valuePct) || valuePct <= 0) continue;
const bucket = `passive.${stat}.${direction}`;
- addActiveEffect(runtime, {
+ const effect: ActiveEffect = {
id: `${side}:input_stat:${bucket}`,
source: {
kind: "input_stat",
@@ -682,17 +717,22 @@
},
ownerSide: side,
kind: "modifier",
- valuePct,
+ initialValuePct: valuePct,
+ getCurrentValuePct: constantActiveEffectValuePct,
appliesTo: { side, units: ALL_UNIT_MASK },
appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },
createdRound: 0,
startRound: 0,
- duration: { type: "battle", value: 0 },
+ duration: {},
+ remainingAttackDelay: 0,
uses: 0,
sameEffectStacking: "add"
- });
+ };
+ addActiveEffect(runtime, effect);
+ effects.push(effect);
}
}
+ return effects;
}
function triggerSkills(
@@ -732,12 +772,12 @@
let orderIndex = 0;
for (const attackerUnit of UNIT_TYPES) {
if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;
- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);
+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);
const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);
if (!defenderUnit) continue;
const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;
if (ordered) {
- ordered.effect.uses += 1;
+ chargeEffectUse(runtime, ordered.effect);
orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });
}
const previousAttackCount = runtime.counters.attacks[side][attackerUnit];
@@ -772,7 +812,7 @@
effectIndex: EffectIndex,
round: number
): UnitType | undefined {
- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;
+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;
return firstLivingUnit(order, defenderSide, roundStartTroops);
}
@@ -784,13 +824,14 @@
attackerUnit: UnitType,
attackerSide: SideId,
index: EffectIndex,
- round: number
+ advanceAttackDelay: boolean
): { order: UnitType[]; effect: ActiveEffect } | undefined {
for (const effect of index.battleOrder) {
- if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;
+ if (effect.intent.type !== "attack_order") continue;
if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;
if (Array.isArray(effect.intent.value)) {
try {
+ if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;
return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };
} catch {
return undefined;
@@ -802,23 +843,34 @@
function applicableControls(
job: DamageJob,
- round: number,
runtime: Runtime
-): { dodge?: Control; no_attack?: Control } {
- const controls: { dodge?: Control; no_attack?: Control } = {};
+): ApplicableControls {
+ const controls: ApplicableControls = { attackDurationEffects: [] };
for (const effect of runtime.effectIndex.controls) {
- if (!isEffectActive(effect, round)) continue;
const classification = classifyEffectForJob(effect, job);
if (classification?.kind === "control" && classification.control) {
- controls[classification.control] = { effect, reason: classification.control };
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);
+ controls[classification.control] = {
+ effect,
+ reason: classification.control,
+ attackDurationEffects: controls.attackDurationEffects
+ };
}
}
return controls;
}
+interface ApplicableControls {
+ dodge?: Control;
+ no_attack?: Control;
+ attackDurationEffects: ActiveEffect[];
+}
+
interface Control {
effect: ActiveEffect;
reason: "dodge" | "no_attack";
+ attackDurationEffects: ActiveEffect[];
}
function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {
@@ -847,7 +899,9 @@
const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];
let appliedEffects: AppliedExtraAttackEffect[] | undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
- runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
+ runtime.effectIndex.extraAttacks.filter(
+ (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)
+ ),
round
);
for (const effectGroup of effectGroups) {
@@ -916,7 +970,7 @@
}
const existing = selected[existingIndex];
existing.effects.push(effect);
- if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;
+ if (effect.getCurrentValuePct(round) > existing.selected.getCurrentValuePct(round)) existing.selected = effect;
}
return selected;
}
@@ -979,7 +1033,7 @@
}
function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {
- const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;
+ const raw = multiplier === undefined ? effect.getCurrentValuePct(round) : multiplier;
const pct = Number(raw ?? 0);
return Number.isFinite(pct) ? pct / 100 : 0;
}
@@ -1055,22 +1109,36 @@
// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.
function chargeUsedEffects(runtime: Runtime): void {
if (runtime.usedEffects.size === 0) return;
- for (const effect of runtime.usedEffects) effect.uses += 1;
+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);
runtime.usedEffects.clear();
}
+function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {
+ effect.uses += 1;
+ const limit = effectAttackUseLimit(effect);
+ if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);
+}
+
+function expireActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
+ if (effect.expired) return;
+ expireEffectIndex(runtime.effectIndex, effect);
+}
+
// A cancelled attack still charges the attacker's attack-constrained effects (the attack
// happened, it just didn't land) plus the control that cancelled it, whatever its duration.
-function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {
+function chargeCancelledAttack(
+ job: DamageJob,
+ winningControl: ActiveEffect,
+ controlEffects: ActiveEffect[],
+ runtime: Runtime
+): void {
const used = runtime.usedEffects;
- for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {
- if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);
- }
- for (const effect of runtime.effectIndex.controls) {
- if (!hasAttackDurationConstraint(effect)) continue;
- const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "control") used.add(effect);
+ for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {
+ if (effect.expired) continue;
+ if (!advanceEffectAttackDelay(effect)) continue;
+ if (hasAttackDurationConstraint(effect)) used.add(effect);
}
+ for (const effect of controlEffects) used.add(effect);
used.add(winningControl);
chargeUsedEffects(runtime);
}
diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts
--- a/simulator/src/staticDamageProfile.ts
+++ b/simulator/src/staticDamageProfile.ts
@@ -1,7 +1,7 @@
import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";
import { UNIT_TYPES, unitMaskHas } from "./types";
import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";
-import { currentEffectValuePct, sourceLabel } from "./effects";
+import { sourceLabel } from "./effects";
export interface StaticDamageProfileTerm {
raw?: number;
@@ -90,7 +90,7 @@
const duration = effect.duration;
if (duration === undefined) return;
- if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {
+ if (duration.turns !== undefined || duration.attacks !== undefined) {
throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);
}
}
@@ -142,7 +142,7 @@
const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];
for (const unit of UNIT_TYPES) {
if (!unitMaskHas(effect.appliesTo.units, unit)) continue;
- const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };
+ const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };
if (effect.sameEffectStacking === "max" && effect.stackingKey) {
const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;
const group = groups.get(key);
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -74,11 +74,14 @@
export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;
export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;
-export interface EffectDuration {
- type: "battle" | "round" | "attack";
- value: number;
+export interface EffectDurationAxis {
+ count?: number;
delay?: number;
- constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;
+}
+
+export interface EffectDuration {
+ turns?: EffectDurationAxis;
+ attacks?: EffectDurationAxis;
}
export interface EffectIntentDefinition {
@@ -88,11 +91,7 @@
value_evolution?: { type?: string; step?: string; value?: number };
units?: Record<string, unknown>;
trigger_damage_jobs?: TriggerDamageJobDefinition[];
- duration?: {
- turns?: { count?: number; delay?: number };
- rounds?: { count?: number; delay?: number };
- attacks?: { count?: number; delay?: number };
- };
+ duration?: EffectDuration;
same_effect_stacking?: SameEffectStacking;
reason?: string;
}
@@ -242,11 +241,13 @@
export interface ActiveEffect {
id: string;
+ expired?: boolean;
source: EffectSource;
intent: EffectIntentDefinition;
ownerSide: SideId;
kind: ActiveEffectKind;
- valuePct?: number;
+ initialValuePct: number;
+ getCurrentValuePct(round: number): number;
// Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",
// trigger-relative selectors, or concrete unit selectors; it does not accept "all".
appliesTo: ResolvedUnitScope;
@@ -255,6 +256,9 @@
createdRound: number;
startRound: number;
duration: EffectDuration;
+ // Eligible attacks remaining before an attacks.delay effect can apply. This is
+ // runtime state resolved from config, separate from `uses` after activation.
+ remainingAttackDelay: number;
// Times this instance affected battle mechanics (damage bucket applied, control fired,
// attack ordered, extra attack spawned). Attack duration constraints and step:"attack"
// value evolution read it; cancelled attacks still charge attack-constrained effects
@@ -264,6 +268,14 @@
sameEffectStacking: SameEffectStacking;
}
+export interface EvolvingActiveEffect extends ActiveEffect {
+ valueEvolution: {
+ type?: string;
+ step?: string;
+ amount: number;
+ };
+}
+
export interface AttackIntent {
id: string;
round: number;