WOS Sim
← Back to Runs

Compare Runs

A (baseline)
6dbe4393-2267130304825/08/2026, 19:58:46
B (current)
9ee426b7-faace9afe1e29/08/2026, 18:22:13
Avg Error A0.18%
Avg Error B0.19%
Δ Avg Error+0.02%
Changed70
Improved33
Regressed36
Added1
Retired0

Testcase Delta

353 / 353
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21—-1.49%-1.04%-0.45%improved
testcases/emulator_verified/sergey_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc0—-1.36%-1.03%-0.33%improved
testcases/emulator_verified/alonso_solo.jsonalonso_solo0—2.83%2.52%-0.31%improved
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo0—-0.65%-0.92%+0.27%regressed
testcases/emulator_verified/renee_charge_single_target_bucket_nc.jsonrenee_charge_single_target_bucket_nc0—-0.25%0.00%-0.25%improved
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc0—-3.36%-3.13%-0.23%improved
testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2200-t9-lancers-vs-146-t1-fc10-lancers0—1.98%2.20%+0.22%regressed
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1+0.05%-2.21%-2.02%-0.19%regressed
testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2201-t9-lancers-vs-146-t1-fc10-lancers0—-0.73%-0.58%-0.15%improved
testcases/emulator_verified/logan_bahiti_combo_nc.jsonlogan_bahiti_combo_nc0—0.54%0.43%-0.11%improved
testcases/emulator_verified/sergey_reina_combo_nc.jsonsergey_reina_combo_nc0—-0.35%-0.24%-0.11%improved
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo0—0.26%0.35%+0.09%regressed
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo0—0.10%0.19%+0.09%regressed
testcases/emulator_verified/renee_gwen_bucket_retry_nc.jsonrenee_gwen_bucket_retry_nc0-0.05%0.21%0.29%+0.08%regressed
testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2100-t9-lancers-vs-146-t1-fc10-lancers0—0.55%0.47%-0.08%improved
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v20—1.65%1.58%-0.07%improved
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc0—0.46%0.53%+0.07%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo4—0.23%0.30%+0.07%regressed
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_8400—-0.85%-0.92%+0.07%regressed
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender0—0.30%0.35%+0.05%regressed
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0—-0.65%-0.60%-0.05%improved
testcases/emulator_verified/mia_solo.jsonmia_solo1—0.24%0.19%-0.05%improved
testcases/emulator_verified/norah_solo.jsonnorah_solo0—-0.29%-0.24%-0.05%improved
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc0—1.01%1.06%+0.05%regressed
testcases/emulator_verified/greg_solo.jsongreg_solo0—0.15%0.11%-0.04%improved
testcases/emulator_verified/renee_wayne_mixed_510_vs_888.jsonrenee_wayne_mixed_510_vs_8880—-0.04%-0.08%+0.04%regressed
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_hero_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_hero_nc0—0.12%-0.16%+0.04%regressed
testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.jsons9-3000-t9-lancers-vs-146-t1-fc10-lancers0—0.15%0.11%-0.04%improved
testcases/emulator_verified/alonso_attacker_600_all.jsonalonso_attacker_600_all0—-0.12%-0.15%+0.03%regressed
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo0—-0.04%-0.01%-0.03%improved
testcases/emulator_verified/mia_solo.jsonmia_solo0—-0.20%-0.23%+0.03%regressed
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0+0.05%-1.08%-1.05%-0.03%improved
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0—0.40%0.43%+0.03%regressed
testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.jsons9-1950-t9-lancers-vs-146-t1-fc10-lancers0—-0.07%-0.10%+0.03%regressed
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2—-0.19%-0.17%-0.02%improved
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0—-0.01%-0.03%+0.02%regressed
testcases/emulator_verified/hector_solo.jsonhector_solo0—0.11%0.13%+0.02%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo3—0.04%0.02%-0.02%improved
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo0—0.02%0.00%-0.02%improved
testcases/emulator_verified/molly_solo.jsonmolly_solo0—-0.06%-0.08%+0.02%regressed
testcases/emulator_verified/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo0—0.08%0.06%-0.02%improved
testcases/emulator_verified/norah_s2_splash_A.jsonnorah_s2_splash_A0—0.21%0.19%-0.02%improved
testcases/emulator_verified/norah_s2_splash_D.jsonnorah_s2_splash_D0—0.01%0.03%+0.02%regressed
testcases/emulator_verified/norah_zinman_combo_nc.jsonnorah_zinman_combo_nc0—0.07%0.05%-0.02%improved
testcases/emulator_verified/renee_ahmose_damage_taken_overlap_attacker_nc.jsonrenee_ahmose_damage_taken_overlap_attacker_nc0-0.05%-0.11%0.09%-0.02%improved
testcases/gatot_verified/s4-fc9-gatot-90000.jsons4-fc9-gatot-900000—-0.11%-0.09%-0.02%improved
testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.jsons9-1500-t9-lancers-vs-146-t1-fc10-lancers0—0.00%-0.02%+0.02%regressed
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer0—0.04%0.05%+0.01%regressed
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0—-0.02%-0.03%+0.01%regressed
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0—0.05%0.06%+0.01%regressed
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0—0.00%-0.01%+0.01%regressed
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current0—0.00%0.01%+0.01%regressed
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current1—0.01%0.02%+0.01%regressed
testcases/emulator_verified/hector_renee_wayne.jsonhector_renee_wayne0—-0.46%-0.45%-0.01%improved
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo2—0.00%0.01%+0.01%regressed
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0—0.01%-0.02%+0.01%regressed
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1—-0.13%-0.12%-0.01%improved
testcases/emulator_verified/mia_solo.jsonmia_solo2—-0.15%-0.16%+0.01%regressed
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo0—-0.14%-0.13%-0.01%improved
testcases/emulator_verified/philly_solo.jsonphilly_solo1—0.02%0.03%+0.01%regressed
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip0—-0.02%-0.01%-0.01%improved
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0—-0.39%-0.38%-0.01%improved
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo0—0.14%0.15%+0.01%regressed
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo0—-0.05%-0.06%+0.01%regressed
testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.jsons33-current-500-marksman-wayne3-vs-5000-gatot3-inf0—0.10%0.09%-0.01%improved
testcases/gatot_verified/s4-fc9-gatot-40000.jsons4-fc9-gatot-400000—0.14%0.15%+0.01%regressed
testcases/gatot_verified/s5-fc9-gatot-lumak-20000.jsons5-fc9-gatot-lumak-200000—0.01%0.00%-0.01%improved
testcases/gatot_verified/s8-11548-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-11548-t9-marksmen-vs-one-t1-fc10-infantry0-0.05%0.76%0.75%-0.01%improved
testcases/gatot_verified/s8-20000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-20000-t9-marksmen-vs-one-t1-fc10-infantry0—0.00%0.01%+0.01%regressed
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_10—0.18%0.18%0.00%unchanged
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo0+0.05%-0.41%-0.41%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer1—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/army_term_precision_002i_003l.jsonarmy_term_precision_002i_003l0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_minxxx_attacks_nc.jsonattdef_jasser_patrick_minxxx_attacks0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_nc.jsonattdef_jasser_patrick0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_swap_nc.jsonattdef_jasser_patrick_swap0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks1—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_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc0—0.04%0.04%0.00%unchanged
testcases/emulator_verified/damage_precision_325l_008i_325m.jsondamage_precision_325l_008i_325m0-0.05%0.15%0.15%0.00%unchanged
testcases/emulator_verified/damage_precision_325l_009i_325m.jsondamage_precision_325l_009i_325m0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/damage_precision_875i_026i_875m.jsondamage_precision_875i_026i_875m0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped0-0.05%0.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_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/gordon_wayne_s1_extra_overlap_lvl2.jsongordon_wayne_s1_extra_overlap_nc0-0.05%0.24%0.24%0.00%unchanged
testcases/emulator_verified/gordon_wayne_s1_extra_overlap_nc.jsongordon_wayne_s1_extra_overlap_nc0-0.05%1.03%1.03%0.00%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current0+0.05%0.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_current2—-0.02%-0.02%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/jasser_solo_nc.jsonjasser_solo0+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/jessie_solo_nc.jsonjessie_solo0+0.027%-0.24%-0.24%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_gwen_combo_nc.jsonlogan_gwen_combo_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo0—0.08%0.08%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo1—0.03%0.03%0.00%unchanged
testcases/emulator_verified/logan_solo_minxxx_nc.jsonlogan_solo_minxxx0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_solo_v2_nc.jsonlogan_solo_v20—0.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.02%-0.02%0.00%unchanged
testcases/emulator_verified/lynn_solo.jsonlynn_solo0—-0.06%-0.06%0.00%unchanged
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0—-0.01%-0.01%0.00%unchanged
testcases/emulator_verified/norah_s2_inf_only_B_nc.jsonnorah_s2_inf_only_B0+0.05%-0.24%-0.24%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_B_nc.jsonnorah_s2_splash_B0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_C_nc.jsonnorah_s2_splash_C0+0.05%-0.06%-0.06%0.00%unchanged
testcases/emulator_verified/patrick_solo_nc.jsonpatrick_solo0+0.004%-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo1—0.20%0.20%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo0—0.16%-0.16%0.00%changed
testcases/emulator_verified/plateau_boundary_700m_006i_700l.jsonplateau_boundary_700m_006i_700l0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/plateau_boundary_700m_007i_700l.jsonplateau_boundary_700m_007i_700l0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/plateau_boundary_700m_008i_700l.jsonplateau_boundary_700m_008i_700l0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/plateau_boundary_700m_009i_700l.jsonplateau_boundary_700m_009i_700l0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/reina_attacker_wip.jsonreina_attacker_wip0—0.01%0.01%0.00%unchanged
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator0—0.15%0.15%0.00%unchanged
testcases/emulator_verified/renee_hendrik_defense_bucket_nc.jsonrenee_hendrik_defense_bucket_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/retarget_lancer_screen_nc.jsonretarget_lancer_screen_nc0—0.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_solo_nc.jsonsergey_solo0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/simple_001_nc.jsonsimple_0010—0.00%0.00%0.00%unchanged
testcases/emulator_verified/troop_check_nc.jsontroop_check0—0.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_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo0—0.17%0.17%0.00%unchanged
testcases/emulator_verified/wos425_wip_nohero_control_nc.jsonwos425_wip_nohero_control_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_patrick_health_nc.jsonwos425_wip_patrick_health_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_philly_defense.jsonwos425_wip_philly_defense0—-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.jsonwos425_wip_sergey_damage_taken_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.jsonwos425_wip_sergey_patrick_combo_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc1+0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_hero_nc.jsonwos444_bradley_s3_all_troops_damage_hero_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_hero_nc.jsonwos444_edith_s11_mark_damage_taken_hero_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.jsonwos444_edith_s12_lancer_damage_dealt_control_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_hero_nc.jsonwos444_edith_s12_lancer_damage_dealt_hero_nc0+0.05%0.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_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc1—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_bucket.jsonwos451_bradley_s21_lancer_damage_up_bucket0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.jsonwos451_bradley_s21_lancer_damage_up_control0—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.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.jsonwos451_bradley_s22_infantry_damage_up_control0+0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_bucket.jsonwos451_bradley_s3_all_damage_up_bucket0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.jsonwos451_bradley_s3_all_damage_up_control0—0.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.03%0.03%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.jsonwos451_gordon_s11_lancer_damage_up_control0—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.05%0.04%0.04%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.jsonwos451_gordon_s21_lancer_damage_up_control0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_bradley_current_nc.jsonwu_ming_bradley_current_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_marksman_vs_infantry_nc.jsonwu_ming_marksman_vs_infantry_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_rounding_followup_nc.jsonwu_ming_rounding_followup_nc0-0.05%0.07%0.07%0.00%unchanged
testcases/emulator_verified/wu_ming_s1_isolation_nc.jsonwu_ming_s1_isolation_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_s2_isolation_nc.jsonwu_ming_s2_isolation_nc0+0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_current_nc.jsonwu_ming_solo_current_nc0+0.05%-1.42%-1.42%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-4900-vs-1000.jsons1-4900-vs-10000—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-4950-vs-1000.jsons1-4950-vs-10000-0.015%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-5000-vs-1000.jsons1-5000-vs-10000-0.009%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-5050-vs-1000.jsons1-5050-vs-10000-0.004%0.02%0.02%0.00%unchanged
testcases/gatot_verified/s10-bradley-1000-inf-125-marksman-vs-5000-inf.jsons10-bradley-1000-inf-125-marksman-vs-5000-inf0-0.016%-0.02%-0.02%0.00%unchanged
testcases/gatot_verified/s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman.jsons15.2-secondary-heroes-1000-inf-125-lancer-125-marksman0+0.05%-0.19%-0.19%0.00%unchanged
testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.jsons15.3-1000-inf-0-lancer-1000-marksman0-0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.jsons15.3-1000-inf-1000-lancer-0-marksman0-0.05%0.01%0.01%0.00%unchanged
testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.jsons15.3-1000-inf-1000-lancer-1000-marksman0-0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s15.3-2000-inf-0-lancer-1000-marksman.jsons15.3-2000-inf-0-lancer-1000-marksman0-0.032%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s15.3-2000-inf-1000-lancer-0-marksman.jsons15.3-2000-inf-1000-lancer-0-marksman0-0.043%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s16-1500-inf-1500-marksman-vs-1000-inf.jsons16-1500-inf-1500-marksman-vs-1000-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-1000-inf.jsons16-2000-inf-2000-marksman-vs-1000-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-5000-inf.jsons16-2000-inf-2000-marksman-vs-5000-inf0-0.004%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s16-7500-inf-vs-5000-inf.jsons16-7500-inf-vs-5000-inf0+0.013%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s2-health-1.1-4950-vs-1000.jsons2-health-1.1-4950-vs-10000-0.015%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s2-health-1.1-5000-vs-1000.jsons2-health-1.1-5000-vs-10000-0.009%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s2-health-1.1-5050-vs-1000.jsons2-health-1.1-5050-vs-10000-0.005%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s2-health-1.1-5100-vs-1000.jsons2-health-1.1-5100-vs-10000-0.002%-0.02%-0.02%0.00%unchanged
testcases/gatot_verified/s2-health-1.1-5150-vs-1000.jsons2-health-1.1-5150-vs-10000—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s25-1000-inf-3000-marksman-vs-5000-inf.jsons25-1000-inf-3000-marksman-vs-5000-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-2000-inf.jsons25-2000-inf-2000-marksman-vs-2000-inf0+0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-3000-inf.jsons25-2000-inf-2000-marksman-vs-3000-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-4000-inf.jsons25-2000-inf-2000-marksman-vs-4000-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.jsons25-3000-inf-1000-marksman-vs-5000-inf0+0.019%0.01%0.01%0.00%unchanged
testcases/gatot_verified/s3-5000-inf-vs-1000-inf-10-lancer.jsons3-5000-inf-vs-1000-inf-10-lancer0-0.05%7.04%7.04%0.00%unchanged
testcases/gatot_verified/s31-current-2000-inf-2000-marksman-vs-5000-inf.jsons31-current-2000-inf-2000-marksman-vs-5000-inf0+0.025%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s32-current-750-marksman-wayne-vs-3000-inf.jsons32-current-750-marksman-wayne-vs-3000-inf0-0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf.jsons34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf.jsons35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf.jsons36-current-600-lancer-600-marksman-vs-1100-gatot3-inf0-0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf.jsons37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf.jsons38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf0-0.05%0.04%0.04%0.00%unchanged
testcases/gatot_verified/s39-1000-lancer-400-marksman-vs-500-gatot3-inf.jsons39-1000-lancer-400-marksman-vs-500-gatot3-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s4-fc9-gatot-10000.jsons4-fc9-gatot-100000—0.01%0.01%0.00%unchanged
testcases/gatot_verified/s4-fc9-gatot-20000.jsons4-fc9-gatot-200000—0.01%0.01%0.00%unchanged
testcases/gatot_verified/s40-2000-inf-1000-marksman-vs-5000-inf.jsons40-2000-inf-1000-marksman-vs-5000-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s41-300-inf-200-lancer-600-marksman-vs-500-inf.jsons41-300-inf-200-lancer-600-marksman-vs-500-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf.jsons42-current-wayne-skill-shield-scope-200-inf-vs-450-inf0—-0.02%-0.02%0.00%unchanged
testcases/gatot_verified/s43-current-reciprocal-shield-cap-450-inf-vs-200-inf.jsons43-current-reciprocal-shield-cap-450-inf-vs-200-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf.jsons44-current-reciprocal-shield-cap-2500-inf-vs-500-inf0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s5-fc9-gatot-lumak-40000.jsons5-fc9-gatot-lumak-400000—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s5-fc9-gatot-lumak-90000.jsons5-fc9-gatot-lumak-900000—0.01%0.01%0.00%unchanged
testcases/gatot_verified/s7-10000-t6-marksmen-vs-one-t1-fc10-infantry.jsons7-10000-t6-marksmen-vs-one-t1-fc10-infantry0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-10000-t9-marksmen-vs-one-t1-fc10-infantry0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-11791-t9-marksmen-vs-one-t1-fc10-infantry0-0.05%0.54%0.54%0.00%unchanged
testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-12000-t9-marksmen-vs-one-t1-fc10-infantry0—0.36%0.36%0.00%unchanged
testcases/gatot_verified/s8-25000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-25000-t9-marksmen-vs-one-t1-fc10-infantry0—0.14%0.14%0.00%unchanged
testcases/gatot_verified/s8-30116-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-30116-t9-marksmen-vs-one-t1-fc10-infantry0—0.02%0.02%0.00%unchanged
testcases/gatot_verified/s8-30139-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-30139-t9-marksmen-vs-one-t1-fc10-infantry0—-0.02%-0.02%0.00%unchanged
testcases/gatot_verified/s8-30179-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-30179-t9-marksmen-vs-one-t1-fc10-infantry0—0.04%0.04%0.00%unchanged
testcases/gatot_verified/s8-30205-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-30205-t9-marksmen-vs-one-t1-fc10-infantry0—-0.01%-0.01%0.00%unchanged
testcases/gatot_verified/s8-30228-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-30228-t9-marksmen-vs-one-t1-fc10-infantry0—-0.02%-0.02%0.00%unchanged
testcases/gatot_verified/s8-33427-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-33427-t9-marksmen-vs-one-t1-fc10-infantry0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s8-33452-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-33452-t9-marksmen-vs-one-t1-fc10-infantry0—0.00%0.00%0.00%unchanged
testcases/gatot_verified/s8-33481-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-33481-t9-marksmen-vs-one-t1-fc10-infantry0—0.01%0.01%0.00%unchanged
testcases/gatot_verified/s8-33510-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-33510-t9-marksmen-vs-one-t1-fc10-infantry0—0.01%0.01%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_101—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_112—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_123—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_134—-0.04%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_145—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_156—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_167—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_178—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_189—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1910—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2011—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2112—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2213—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2314—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2415—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2516—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2617—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_90—0.00%——retired
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__101——0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__112——0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__123+0.033%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__134+0.05%—-0.04%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__145+0.01%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__156+0.013%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__167+0.029%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__178+0.014%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__189+0.025%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__1910——0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2011+0.043%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2112+0.017%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2213+0.015%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2314——0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2415+0.05%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2516+0.008%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2617+0.015%—0.00%—added
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__90+0.025%—0.00%—added
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_10—-0.38%——retired
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_21—-0.06%——retired
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_32—-0.48%——retired
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_43—-0.56%——retired
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_54—-0.15%——retired
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_65—-0.07%——retired
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_10+0.05%—-0.38%—added
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_21+0.05%—-0.06%—added
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_32+0.05%—-0.48%—added
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_43+0.05%—-0.56%—added
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_54+0.05%—-0.15%—added
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_65+0.05%—-0.07%—added
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_10—0.06%——retired
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_21—0.00%——retired
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_32—-0.40%——retired
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_43—0.02%——retired
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_54—0.57%——retired
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_75—0.17%——retired
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_10+0.041%—0.06%—added
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_21——0.00%—added
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_32——-0.40%—added
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_43——0.02%—added
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_54——0.50%—added
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_65——0.17%—added
testcases/emulator_verified/ambusher_logan_renee_vs_logan_patrick_nc.jsonambusher_logan_renee_vs_logan_patrick_nc0——-6.04%—added
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10—-0.01%——retired
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21—0.37%——retired
testcases/heroes_unittests/Flint_tc.jsonflint_tc_10+0.05%—-0.01%—added
testcases/heroes_unittests/Flint_tc.jsonflint_tc_21-0.05%—0.37%—added
testcases/heroes_unittests/Hector_tc.jsondaut_viper_10—-0.01%——retired
testcases/heroes_unittests/Hector_tc.jsonhector_tc_10——-0.01%—added
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10—-0.06%——retired
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_21—-0.07%——retired
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32—-0.09%——retired
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43—-0.28%——retired
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_54—-0.19%——retired
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65—-0.06%——retired
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76—-0.05%——retired
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_87—0.00%——retired
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_10+0.05%—-0.06%—added
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_21+0.05%—-0.07%—added
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_32+0.05%—-0.09%—added
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_43+0.05%—-0.28%—added
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_54+0.05%—-0.19%—added
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_65+0.05%—-0.06%—added
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_76+0.05%—-0.05%—added
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_87——0.00%—added
testcases/heroes_unittests/Mia_tc.jsondaut_viper_10—0.21%——retired
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21—-0.68%——retired
testcases/heroes_unittests/Mia_tc.jsondaut_viper_32—0.07%——retired
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43—-0.38%——retired
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54—-0.08%——retired
testcases/heroes_unittests/Mia_tc.jsonmia_tc_10——0.21%—added
testcases/heroes_unittests/Mia_tc.jsonmia_tc_21+0.05%—-0.68%—added
testcases/heroes_unittests/Mia_tc.jsonmia_tc_32——0.07%—added
testcases/heroes_unittests/Mia_tc.jsonmia_tc_43——-0.36%—added
testcases/heroes_unittests/Mia_tc.jsonmia_tc_54——-0.07%—added
testcases/heroes_unittests/Molly_tc.jsondaut_viper_10—-0.06%——retired
testcases/heroes_unittests/Molly_tc.jsondaut_viper_21—-0.07%——retired
testcases/heroes_unittests/Molly_tc.jsondaut_viper_32—-0.09%——retired
testcases/heroes_unittests/Molly_tc.jsondaut_viper_43—0.33%——retired
testcases/heroes_unittests/Molly_tc.jsondaut_viper_54—-0.01%——retired
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_10+0.05%—-0.06%—added
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_21+0.05%—-0.07%—added
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_32+0.05%—-0.09%—added
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_43——0.35%—added
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_54——-0.02%—added
testcases/heroes_unittests/Reina_tc.jsondaut_viper_10—0.00%——retired
testcases/heroes_unittests/Reina_tc.jsondaut_viper_21—0.00%——retired
testcases/heroes_unittests/Reina_tc.jsondaut_viper_42—-0.74%——retired
testcases/heroes_unittests/Reina_tc.jsondaut_viper_53—0.01%——retired
testcases/heroes_unittests/Reina_tc.jsonreina_tc_10——0.00%—added
testcases/heroes_unittests/Reina_tc.jsonreina_tc_21——0.00%—added
testcases/heroes_unittests/Reina_tc.jsonreina_tc_42——-0.64%—added
testcases/heroes_unittests/Reina_tc.jsonreina_tc_53——0.01%—added
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10—-0.06%——retired
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32—-0.09%——retired
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43—-0.04%——retired
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54—-0.08%——retired
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61—-0.07%——retired
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65—0.00%——retired
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76—0.00%——retired
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_10+0.05%—-0.06%—added
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_32+0.05%—-0.09%—added
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_43+0.05%—-0.04%—added
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_54+0.05%—-0.08%—added
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_61+0.05%—-0.07%—added
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_65+0.05%—0.00%—added
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_76+0.033%—0.00%—added
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10—0.00%——retired
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21—0.00%——retired
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_32—0.00%——retired
testcases/heroes_unittests/Zinman_tc.jsonzinman_tc_10-0.05%—0.00%—added
testcases/heroes_unittests/Zinman_tc.jsonzinman_tc_21+0.007%—0.00%—added
testcases/heroes_unittests/Zinman_tc.jsonzinman_tc_32——0.00%—added

Code / Config Changes

Commits (71303048 → ce9afe1e)

ce9afe1eBetter handling of random extra backgroud etcpiddlyminx29/08/2026, 02:07:28

Code Changes (Run A → Run B)

Index: simulator/config/hero_definitions/README.md
===================================================================
--- simulator/config/hero_definitions/README.md prev run
+++ simulator/config/hero_definitions/README.md this run
@@ -209,13 +209,13 @@
| --- | --- | --- |
| `type` | One supported modifier/special-effect string, or omitted on a child-bearing carrier | Chooses the mechanic and, for modifiers, the damage-equation bucket. |
| `value` | Usually a number or per-level numeric array | Percentage magnitude for modifiers; damage multiplier percentage for `extra_skill_attack`; fixed unit order for `attack_order`. |
| `units` | `{ applies_to?, applies_vs? }` | Resolves which troop lines may receive/use the effect and which opposing troop lines it applies against. |
-| `duration` | `{ turns?, attacks? }` | Optional round window and/or use limit. Omitted means permanent, except that `extra_skill_attack` defaults to one turn and one attack. |
+| `duration` | `{ turns?, attacks? }` | Optional round window and/or use limit. Omitted means permanent, except that extra-attack effects default to one turn and one attack. |
| `same_effect_stacking` | `add` or `max` | Controls overlap between live activations of the same modifier definition and scope. Omitted means `add`. |
| `requires_effect` | Effect ID string | Applies a runtime damage modifier only while the named effect is applicable to the same damage job. |
| `value_evolution` | Evolution object | Optionally changes the effect value as rounds or uses advance. |
-| `trigger_damage_jobs` | Non-empty array of job definitions | Required for `extra_skill_attack`; describes the actual skill-damage jobs it emits. |
+| `trigger_damage_jobs` | Non-empty array of job definitions | Required for `extra_skill_attack`; describes the damage jobs it emits. |
| `trigger_effects` | Object keyed by child effect ID | Defines children materialized only from an actual parent use. |
### `value` and level selection
@@ -226,9 +226,9 @@
```
At level 3 this resolves to 15. A scalar number gives the same value at every level. Numeric arrays are selected by level before effects reach the runtime.
-`dodge` and `no_attack` ignore `value`; their probability belongs on the skill trigger. `extra_skill_attack` is not covered by the percentage-bucket value validator: its resolved `value` is converted to a number, and a missing, non-finite, zero, or negative value produces no damage jobs.
+`dodge` and `no_attack` ignore `value`; their probability belongs on the skill trigger. Extra-attack effects are not covered by the percentage-bucket value validator: their resolved `value` is converted to a number, and a missing, non-finite, zero, or negative value produces no damage jobs.
## Modifier effect types
Modifier names identify a damage-equation bucket. The general forms are:
@@ -404,13 +404,13 @@
## Special effect types
### `extra_skill_attack`
-This creates an active effect that can be used by eligible normal attacks. `units.applies_to`/`applies_vs` gate the parent normal attack; they do not by themselves define the generated jobs. After the parent normal job is calculated, each `trigger_damage_jobs` entry expands into zero or more immediate `kind: "skill"` jobs.
+This creates an active effect that can be used by eligible normal attacks. `units.applies_to`/`applies_vs` gate the parent normal attack; they do not by themselves define the generated jobs. After the parent normal job is calculated, each `trigger_damage_jobs` entry expands into zero or more generated jobs.
When `duration` is omitted, the active effect defaults to one turn and one applicable attack. Declare `duration` only when the extra attack needs a different lifetime or delay.
-`value` becomes the generated job's source multiplier: `100` means `1.0`, `25` means `0.25`, and `200` means `2.0`. The result is a **new full damage calculation**, not that percentage of the parent normal job's final kills or raw damage. It uses the generated source/target pair, round-start troop counts, the ordinary static factors, general active modifiers, and skill-only type modifiers. Normal-only type modifiers do not apply.
+`value` becomes the generated job's source multiplier: `100` means `1.0`, `25` means `0.25`, and `200` means `2.0`. The result is a **new full damage calculation**, not that percentage of the parent normal job's final kills or raw damage. The multiplier is independent of `damage_kind`; that field alone decides whether normal-only, skill-only, or neither family of type modifiers applies.
Requirements:
- `trigger_damage_jobs` must be a non-empty array;
@@ -428,8 +428,10 @@
"target": "enemy.living"
}
```
+An optional `damage_kind` may be `normal`, `skill`, or `extra`, and defaults to `skill`. This is the only field that classifies the generated damage. `extra` receives common damage buckets but neither normal-only nor skill-only buckets, and it is not attributed to `skillKills`.
+
Supported `source` and `target` selectors are:
| Selector | Meaning |
| --- | --- |
@@ -447,8 +449,12 @@
The job selector determines the actual side as well as the unit. The runtime does not enforce that a generated source and target are opponents, so scopes misconfigured onto the same side can generate friendly-fire or same-side jobs.
The extra-attack effect is charged once for the parent normal attack if at least one of its jobs runs. If its `value` is non-positive, every job lacks living source/target troops, or every job is skipped because its target is already exhausted, the effect is not charged and can remain available.
+When an `extra_skill_attack` is materialized as a carrier's `trigger_effects` child, its completed damage result is captured in the carrier-use round and stored on that child ActiveEffect. The child can deliver that fixed result only when a later normal attack matches its resolved source and target scope. Delivery does not run the damage equation again; if the matching attack never occurs during the child's duration, the pending damage expires with the effect.
+
+Renee's Nightmare Trace uses this form with a one-turn delay: the even-round Lancer attack locks the target and calculates the damage, then the following odd-round Lancer attack delivers it. The precise capture ordering and treatment of defensive state are provisional reverse-engineering assumptions, not confirmed game mechanics.
+
All jobs emitted by one use read the parent extra-attack effect's same current `value`; its use/evolution is charged only after those jobs finish. Other attack-limited **modifier** effects are different: each generated skill job is a separate modifier use, and modifiers are charged after each job, so a one-job modifier can expire before the second target in the same extra attack is calculated.
### `dodge`
Index: simulator/config/hero_definitions/Renee.json
===================================================================
--- simulator/config/hero_definitions/Renee.json prev run
+++ simulator/config/hero_definitions/Renee.json this run
@@ -20,19 +20,25 @@
"attacks": { "count": 1 }
},
"trigger_effects": {
"NightmareTrace/1": {
- "type": "active.hero.damageTaken.up",
+ "type": "extra_skill_attack",
"value": [40, 80, 120, 160, 200],
"units": {
- "applies_to": "parent.use.target",
- "applies_vs": "parent.use.source"
+ "applies_to": "parent.use.source",
+ "applies_vs": "parent.use.target"
},
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target",
+ "damage_kind": "normal"
+ }
+ ],
"duration": {
"turns": { "delay": 1, "count": 1 },
"attacks": { "count": 1 }
- },
- "same_effect_stacking": "max"
+ }
}
}
}
}
@@ -54,13 +60,13 @@
"attacks": { "count": 1 }
},
"trigger_effects": {
"Dreamcatcher/1": {
- "type": "active.hero.damageTaken.up",
+ "type": "type.marked_target.damage.up",
"value": [30, 60, 90, 120, 150],
"units": {
- "applies_to": "parent.use.target",
- "applies_vs": "parent.use.source"
+ "applies_to": "parent.use.source",
+ "applies_vs": "parent.use.target"
},
"duration": {
"turns": { "delay": 1, "count": 1 }
},
@@ -87,13 +93,13 @@
"attacks": { "count": 1 }
},
"trigger_effects": {
"Dreamslice/1": {
- "type": "active.hero.damageTaken.up",
+ "type": "type.marked_target.damage.up",
"value": [15, 30, 45, 60, 75],
"units": {
- "applies_to": "parent.use.target",
- "applies_vs": "any"
+ "applies_to": "all",
+ "applies_vs": "parent.use.target"
},
"duration": {
"turns": { "delay": 1, "count": 1 }
},
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -293,8 +293,56 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /unknown trigger_damage_jobs key multiplier/i);
});
+test("loadSimulatorConfig accepts extra damage jobs and rejects removed delay metadata or unknown kinds", () => {
+ const validExtra = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "extra" }]
+ });
+ const removedDelay = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", delivery_delay_turns: 1 }]
+ });
+ const invalidKind = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "mystery" }]
+ });
+
+ assert.doesNotThrow(() => loadSimulatorConfigFromDir(validExtra));
+ assert.throws(() => loadSimulatorConfigFromDir(removedDelay), /unknown trigger_damage_jobs key delivery_delay_turns/i);
+ assert.throws(() => loadSimulatorConfigFromDir(invalidKind), /damage_kind.*normal.*skill.*extra/i);
+});
+
+test("loadSimulatorConfig reports the removed extra_attack effect type as unsupported", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "extra_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "extra" }]
+ });
+
+ const config = loadSimulatorConfigFromDir(root);
+ assert.equal(config.diagnostics.unsupportedEffects.some((effect) => effect.type === "extra_attack"), true);
+});
+
+test("loadSimulatorConfig rejects per-job skill reporting overrides", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", reports_skill_kills: false } as never]
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /unknown trigger_damage_jobs key reports_skill_kills/i);
+});
+
test("loadSimulatorConfig requires trigger_damage_jobs source and target", () => {
const missingSourceRoot = writeConfigWithTroopEffect({
type: "extra_skill_attack",
value: 100,
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -430,9 +430,9 @@
function validateTriggerDamageJobShape(job: unknown, path: string, jobIndex: number): asserts job is TriggerDamageJobDefinition {
if (!job || typeof job !== "object" || Array.isArray(job)) {
throw new Error(`trigger_damage_jobs entry must be an object at ${path}.trigger_damage_jobs[${jobIndex}]`);
}
- const allowedKeys = new Set(["source", "target"]);
+ const allowedKeys = new Set(["source", "target", "damage_kind"]);
for (const key of Object.keys(job)) {
if (!allowedKeys.has(key)) {
throw new Error(`unknown trigger_damage_jobs key ${key} at ${path}.trigger_damage_jobs[${jobIndex}]`);
}
@@ -443,8 +443,11 @@
}
if (record.target === undefined) {
throw new Error(`trigger_damage_jobs entry requires target at ${path}.trigger_damage_jobs[${jobIndex}]`);
}
+ if (record.damage_kind !== undefined && record.damage_kind !== "normal" && record.damage_kind !== "skill" && record.damage_kind !== "extra") {
+ throw new Error(`trigger_damage_jobs damage_kind must be "normal", "skill", or "extra" at ${path}.trigger_damage_jobs[${jobIndex}]`);
+ }
}
function validateTriggerDamageJobSelector(
selector: TriggerDamageJobDefinition["source"],
Index: simulator/src/damage.test.ts
===================================================================
--- simulator/src/damage.test.ts prev run
+++ simulator/src/damage.test.ts this run
@@ -169,8 +169,21 @@
assert.ok(Math.abs((outcome.trace?.rawDamage ?? 0) - 12.3456) < 1e-12);
assert.ok(Math.abs(outcome.kills - 12.3456) < 1e-12);
});
+test("generated-job source multiplier is independent of damage kind", () => {
+ for (const kind of ["normal", "skill", "extra"] as const) {
+ const outcome = calculateIndexedDamageJob(
+ { ...job, kind, sourceMultiplier: 1.6 },
+ simpleFighters(),
+ [],
+ { trace: true, minInitialArmy: 1000, capToTakerTroops: false }
+ );
+
+ assert.ok(Math.abs(outcome.kills - 16) < 1e-12, kind);
+ }
+});
+
test("damage calculation does not quantize values near the three-decimal grid", () => {
const outcome = calculateIndexedDamageJob(
{ ...job, kind: "skill", sourceMultiplier: 2.007 },
simpleFighters(),
@@ -451,9 +464,9 @@
assert.ok(Math.abs(damageTakenUp.kills - baseline.kills * 1.25) < 1e-12);
assert.ok(Math.abs(damageTakenDown.kills - baseline.kills / 1.25) < 1e-12);
});
-test("a durationless shield subtracts its full raw value from every normal and skill damage job", () => {
+test("a durationless shield subtracts its full raw value from normal, skill, and extra damage jobs", () => {
const fighters = simpleFighters();
const shield = {
...effect("active.hero.shield", "defender", 25),
kind: "shield" as const,
@@ -465,12 +478,16 @@
const secondNormal = calculateIndexedDamageJob(job, fighters, [shield], { trace: true });
const skillJob = { ...job, kind: "skill" as const, sourceMultiplier: 1 };
const skillBaseline = calculateIndexedDamageJob(skillJob, fighters, [], { trace: true });
const skill = calculateIndexedDamageJob(skillJob, fighters, [shield], { trace: true });
+ const extraJob = { ...job, kind: "extra" as const, sourceMultiplier: 1 };
+ const extraBaseline = calculateIndexedDamageJob(extraJob, fighters, [], { trace: true });
+ const extra = calculateIndexedDamageJob(extraJob, fighters, [shield], { trace: true });
assert.equal(firstNormal.kills, Math.max(0, normalBaseline.kills - 25));
assert.equal(secondNormal.kills, firstNormal.kills);
assert.equal(skill.kills, Math.max(0, skillBaseline.kills - 25));
+ assert.equal(extra.kills, Math.max(0, extraBaseline.kills - 25));
assert.equal(firstNormal.trace?.damageBeforeOffsets, normalBaseline.kills);
assert.equal(firstNormal.trace?.offsetDamage, 25);
assert.equal(firstNormal.trace?.aggregationGroups["active.hero.shield"].placement, "post_subtract");
assert.deepEqual(firstNormal.appliedEffects, [{
@@ -591,21 +608,26 @@
assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].placement, "numerator");
assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);
});
-test("pass-specific buckets only apply to matching damage job kind", () => {
+test("normal- and skill-specific buckets exclude extra damage", () => {
const normalEffect = effect("type.normal.damage.up", "attacker", 100);
const skillEffect = effect("type.skill.damage.up", "attacker", 100);
const fighters = simpleFighters();
const normalOutcome = calculateIndexedDamageJob(job, fighters, [normalEffect, skillEffect], { trace: true });
const skillOutcome = calculateIndexedDamageJob({ ...job, kind: "skill", sourceMultiplier: 1 }, fighters, [normalEffect, skillEffect], {
trace: true
});
+ const extraOutcome = calculateIndexedDamageJob({ ...job, kind: "extra", sourceMultiplier: 1 }, fighters, [normalEffect, skillEffect], {
+ trace: true
+ });
assert.equal(normalOutcome.trace?.atomicBuckets["type.normal.damage.up"].totalPct, 100);
assert.equal(normalOutcome.trace?.aggregationGroups["type.skill.damage.up"], undefined);
assert.equal(skillOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);
assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);
+ assert.equal(extraOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);
+ assert.equal(extraOutcome.trace?.aggregationGroups["type.skill.damage.up"], undefined);
});
test("attack-duration bucket effects are charged by the applicable attack job", () => {
const oneAttackEffect = {
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -3,8 +3,9 @@
ActiveEffectGroup,
AppliedEffect,
DamageEquationTrace,
DamageJob,
+ DamageResult,
ResolvedFighter,
SideId,
UnitType
} from "./types";
@@ -67,19 +68,16 @@
// Lean result of a single damage job: kills plus whatever the selected recorder requested.
// Usage charging happens via options.usedEffects (every mode); the heavy per-attack
// AttackOutcome is assembled by the recorder, not here.
-export interface DamageResult {
- kills: number;
- appliedEffects?: AppliedEffect[];
- trace?: DamageEquationTrace;
-}
+export type { DamageResult } from "./types";
const DYNAMIC_FACTOR_TERMS = compileDamageTerms(DYNAMIC_BUCKETS);
const STATIC_FACTOR_TERMS = compileDamageTerms(STATIC_BUCKETS);
const DYNAMIC_EXPRESSIONS = {
normal: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "normal" }),
- skill: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "skill" })
+ skill: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "skill" }),
+ extra: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "extra" })
};
const STATIC_EXPRESSIONS = {
dealer: compileDamageExpression(STATIC_FACTOR_TERMS, { jobSide: "dealer" }),
taker: compileDamageExpression(STATIC_FACTOR_TERMS, { jobSide: "taker" })
@@ -139,9 +137,9 @@
const initialArmy = options.minInitialArmy ?? minInitialArmy(fighters);
const armyTerm = ceilIgnoringFloatResidue(Math.sqrt(dealerTroops) * Math.sqrt(initialArmy));
const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();
applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);
- applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
+ applyDynamicDamageBucketValue(buckets, "source.multiplier", job.sourceMultiplier ?? 1);
const usedEffects = options.usedEffects ?? [];
const primaryUsedEffects = options.primaryUsedEffects ?? usedEffects;
const jobSlot = damageJobSlot(job);
Index: simulator/src/damageBuckets.ts
===================================================================
--- simulator/src/damageBuckets.ts prev run
+++ simulator/src/damageBuckets.ts this run
@@ -61,14 +61,15 @@
{ name: "active.troop.defense.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
{ name: "active.troop.health.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
{ name: "active.troop.health.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
{ name: "active.troop.shield", jobSide: "taker", update: "add_raw", placement: "post_subtract", effectBucket: true },
- { name: "source.extraSkill", jobSide: "dealer", update: "assign_factor", placement: "numerator", damageKind: "skill" },
+ { name: "source.multiplier", jobSide: "dealer", update: "assign_factor", placement: "numerator" },
{ name: "troops.count", jobSide: "dealer", update: "assign_factor", placement: "numerator" },
{ name: "type.all.damage.down", jobSide: "dealer", update: "multiply_pct_factor", placement: "denominator", effectBucket: true },
{ name: "type.all.damage.up", jobSide: "dealer", update: "multiply_pct_factor", placement: "numerator", effectBucket: true },
{ name: "type.single_target.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
{ name: "type.single_target.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "type.marked_target.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
{ name: "type.normal.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", damageKind: "normal", effectBucket: true },
{ name: "type.normal.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", damageKind: "normal", effectBucket: true },
{ name: "type.normal.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", damageKind: "normal", effectBucket: true },
{ name: "type.normal.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", damageKind: "normal", effectBucket: true },
Index: simulator/src/effectIndex.test.ts
===================================================================
--- simulator/src/effectIndex.test.ts prev run
+++ simulator/src/effectIndex.test.ts this run
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { createEffectIndex, damageJobSlot, damageShapeSlotsForEffect, expireEffectIndex, indexEffect } from "./effectIndex";
+import { createEffectIndex, damageJobSlot, damageShapeSlotsForEffect, DAMAGE_JOB_SHAPE_SLOTS, expireEffectIndex, indexEffect } from "./effectIndex";
import { resolvedEffectScopeKey } from "./effects";
import { unitMask } from "./types";
import type { ActiveEffect, DamageJob } from "./types";
@@ -90,9 +90,9 @@
}
function preparedIndex(effects: ActiveEffect[]): ReturnType<typeof createEffectIndex> {
const groups: NonNullable<ActiveEffect["effectGroup"]>[] = [];
- const byShape: NonNullable<ActiveEffect["effectGroup"]>[][] = Array.from({ length: 72 }, () => []);
+ const byShape: NonNullable<ActiveEffect["effectGroup"]>[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => []);
const byResolvedGroup = new Map<string, NonNullable<ActiveEffect["effectGroup"]>>();
for (const effect of effects) {
const slots = damageShapeSlotsForEffect(effect);
if (slots.length === 0) continue;
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -37,9 +37,10 @@
battleOrder: ActiveEffect[];
carriers: ActiveEffect[];
}
-export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;
+const DAMAGE_KINDS: DamageKind[] = ["normal", "skill", "extra"];
+export const DAMAGE_JOB_SHAPE_SLOTS = DAMAGE_KINDS.length * 2 * 3 * 2 * 3;
export function createEffectIndex(
effectGroups: ActiveEffectGroup[] = [],
damageGroupsByJobShape: ActiveEffectGroup[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => [])
@@ -136,9 +137,9 @@
function buildShapeSlots(effect: ActiveEffect, bucket: DynamicDamageBucket): Uint8Array {
const definition = dynamicBucketDefinition(bucket)!;
const slots: number[] = [];
- const jobKinds: DamageKind[] = definition.damageKind ? [definition.damageKind] : ["normal", "skill"];
+ const jobKinds = definition.damageKind ? [definition.damageKind] : DAMAGE_KINDS;
for (const jobKind of jobKinds) {
for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {
for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {
slots.push(
@@ -151,9 +152,9 @@
}
return Uint8Array.from(slots);
}
-function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }
+function kindIndex(kind: DamageKind): number { return DAMAGE_KINDS.indexOf(kind); }
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;
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -128,9 +128,9 @@
const staticAppliesVs = resolveUnitScope(units.applies_vs, oppositeSide(staticAppliesTo.side), "applies_vs", undefined, ownerSide);
const effectKind = kindForIntent(intent);
const duration = normalizeDuration(intent.duration, effectKind);
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`);
+ throw new Error(`${intent.type} effect ${intent.id} requires at least one trigger_damage_jobs entry`);
}
const evolution = intent.value_evolution;
const compiled: CompiledActivation = {
source: {
Index: simulator/src/extraAttacks.ts
===================================================================
--- simulator/src/extraAttacks.ts prev run
+++ simulator/src/extraAttacks.ts this run
@@ -22,77 +22,150 @@
type RunLoopOptions,
type Runtime
} from "./runtime";
-// Spawn, calculate, and charge the extra_skill_attack attacks riding on one normal attack.
+// Spawn, calculate, and charge extra attacks riding on one normal attack.
// Returns the finalized kills from every generated job in this normal attack's
-// cluster. Each applicable effect expands its trigger_damage_jobs into jobs that run immediately, in
-// place; the effect is charged one use only when at least one of its own jobs actually ran.
+// cluster. A triggered child can instead carry damage captured in an earlier round;
+// that fixed result is emitted only when the child's locked normal attack matches.
+// The effect is charged one use only when at least one of its own jobs actually ran.
// Spawning reads only round-start state and the effect's own gates, so running earlier
// effects' jobs first cannot change what later effects spawn.
-export function processExtraSkillAttacks(
+export function processExtraAttacks(
normalAttack: DamageJob,
intent: AttackIntent,
runtime: Runtime,
fighters: Record<SideId, ResolvedFighter>,
damageJobOptions: DamageJobOptions,
roundTargetDamage: Record<SideId, Record<UnitType, number>>,
loopOptions: RunLoopOptions,
results: DamageJobResult[]
-): number {
- if (runtime.effectIndex.extraAttacks.length === 0) return 0;
+): { totalKills: number; skillKills: number } {
+ if (runtime.effectIndex.extraAttacks.length === 0) return { totalKills: 0, skillKills: 0 };
const { round, roundStartTroops } = normalAttack;
const recorder = damageJobOptions.recorder;
- let triggeredKills = 0;
+ let totalKills = 0;
+ let skillKills = 0;
// Snapshot the applicable effects: charging an effect below may expire it out of the live index.
const effects = runtime.effectIndex.extraAttacks.filter(
(effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)
);
for (const effect of effects) {
const sourceEffectId = effect.source.effectId ?? effect.intent.id;
let processedJobCount = 0;
let firstProcessedJob: DamageJob | undefined;
+ if (effect.pendingDamageJobs) {
+ for (const pending of effect.pendingDamageJobs) {
+ const deliveryJob: DamageJob = {
+ ...pending.job,
+ round,
+ calculationRound: pending.job.round
+ };
+ if (loopOptions.capRoundKills && targetExhausted(deliveryJob, roundStartTroops, roundTargetDamage)) continue;
+ recorder.recordScheduledDamageJob(deliveryJob);
+ if (loopOptions.capRoundKills) {
+ capJobToRemainingTarget(pending.result, deliveryJob, roundStartTroops, roundTargetDamage, recorder);
+ } else if (loopOptions.capJobKills) {
+ pending.result.kills = Math.min(
+ pending.result.kills,
+ Math.max(0, roundStartTroops[deliveryJob.takerSide][deliveryJob.takerUnit] ?? 0)
+ );
+ recorder.recordFinalKills(pending.result);
+ }
+ results.push({ job: deliveryJob, result: pending.result, intent });
+ totalKills += pending.result.kills;
+ processedJobCount += 1;
+ firstProcessedJob ??= deliveryJob;
+ }
+ } else {
+ for (const definition of effect.triggerDamageJobs ?? []) {
+ const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);
+ const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);
+ const multiplier = effect.getCurrentValue(round) / 100;
+ if (multiplier <= 0) continue;
+ for (const source of sources) {
+ if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;
+ for (const target of targets) {
+ if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;
+ const job: DamageJob = {
+ round,
+ kind: definition.damage_kind ?? "skill",
+ roundStartTroops,
+ dealerSide: source.side,
+ dealerUnit: source.unit,
+ takerSide: target.side,
+ takerUnit: target.unit,
+ sourceEffectId,
+ sourceMultiplier: multiplier
+ };
+ if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;
+ recorder.recordScheduledDamageJob(job);
+ const result = calculateDamageJob(job, fighters, damageJobOptions);
+ if (job.kind === "skill") recorder.recordSkillDamageJob(job, effect);
+ if (loopOptions.capRoundKills) {
+ capJobToRemainingTarget(result, job, roundStartTroops, roundTargetDamage, recorder);
+ }
+ results.push({ job, result, intent });
+ totalKills += result.kills;
+ if (job.kind === "skill") {
+ skillKills += result.kills;
+ runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;
+ }
+ processedJobCount += 1;
+ firstProcessedJob ??= job;
+ chargeUsedEffectsForJob(runtime, job, recorder);
+ }
+ }
+ }
+ }
+ if (processedJobCount > 0) {
+ materializeTriggeredEffects(effect, round, effectUseIntent(firstProcessedJob!), runtime, recorder);
+ chargeEffectUse(runtime, effect);
+ recorder.recordExtraAttack(normalAttack, effect, processedJobCount);
+ }
+ }
+ return { totalKills, skillKills };
+}
+
+export function captureTriggeredExtraDamage(
+ effects: ActiveEffect[],
+ normalAttack: DamageJob,
+ fighters: Record<SideId, ResolvedFighter>,
+ damageJobOptions: DamageJobOptions,
+ runtime: Runtime
+): void {
+ const { round, roundStartTroops } = normalAttack;
+ for (const effect of effects) {
+ if (effect.expired || effect.intent.type !== "extra_skill_attack" || effect.pendingDamageJobs) continue;
+ const multiplier = effect.getCurrentValue(round) / 100;
+ if (multiplier <= 0) continue;
+ const pending = [];
for (const definition of effect.triggerDamageJobs ?? []) {
const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);
const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);
- const multiplier = effect.getCurrentValue(round) / 100;
- if (multiplier <= 0) continue;
for (const source of sources) {
if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;
for (const target of targets) {
if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;
const job: DamageJob = {
round,
- kind: "skill",
+ kind: definition.damage_kind ?? "skill",
roundStartTroops,
dealerSide: source.side,
dealerUnit: source.unit,
takerSide: target.side,
takerUnit: target.unit,
- sourceEffectId,
+ sourceEffectId: effect.source.effectId ?? effect.intent.id,
sourceMultiplier: multiplier
};
- recorder.recordSkillDamageJob(job, effect);
- if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;
- recorder.recordScheduledDamageJob(job);
- const result = calculateDamageJob(job, fighters, damageJobOptions);
- if (loopOptions.capRoundKills) capJobToRemainingTarget(result, job, roundStartTroops, roundTargetDamage, recorder);
- processedJobCount += 1;
- firstProcessedJob ??= job;
- runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;
- results.push({ job, result, intent });
- triggeredKills += result.kills;
- chargeUsedEffectsForJob(runtime, job, recorder);
+ const result = calculateDamageJob(job, fighters, { ...damageJobOptions, capToTakerTroops: false });
+ pending.push({ job, result });
+ chargeUsedEffectsForJob(runtime, job, damageJobOptions.recorder);
}
}
}
- if (processedJobCount > 0) {
- materializeTriggeredEffects(effect, round, effectUseIntent(firstProcessedJob!), runtime, recorder);
- chargeEffectUse(runtime, effect);
- recorder.recordExtraAttack(normalAttack, effect, processedJobCount);
- }
+ if (pending.length > 0) effect.pendingDamageJobs = pending;
}
- return triggeredKills;
}
function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {
return (
Index: simulator/src/recorder.ts
===================================================================
--- simulator/src/recorder.ts prev run
+++ simulator/src/recorder.ts this run
@@ -309,8 +309,9 @@
this.extraAttackEvents.delete(job);
const appliedEffects = mergeAppliedEffects(result.appliedEffects, order, extras);
this.attacks.push({
round: job.round,
+ ...(job.calculationRound === undefined ? {} : { calculationRound: job.calculationRound }),
kind: job.kind,
sourceEffectId: job.sourceEffectId,
dealerSide: job.dealerSide,
dealerUnit: job.dealerUnit,
Index: simulator/src/runtime.ts
===================================================================
--- simulator/src/runtime.ts prev run
+++ simulator/src/runtime.ts this run
@@ -272,17 +272,20 @@
round: number,
useIntent: AttackIntent,
runtime: Runtime,
recorder: BattleRecorder
-): void {
+): ActiveEffect[] {
const skill = parent.sourceSkill;
- if (!skill || !parent.triggerEffects?.length) return;
+ if (!skill || !parent.triggerEffects?.length) return [];
+ const children: ActiveEffect[] = [];
for (const childIntent of parent.triggerEffects) {
const child = activateEffect(skill, childIntent, round, useIntent);
addActiveEffect(runtime, child);
+ children.push(child);
runtime.effectActivationCounts[skill.side] += 1;
recorder.recordSkillEffectActivated(skill);
}
+ return children;
}
/** Materialize selected parents' children after the job, then charge all participating effects. */
export function chargeUsedEffectsForJob(
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -362,8 +362,27 @@
assert.ok(defensive.attacks.some((attack) => attack.kills > 5000));
assert.ok(damage.score > defensive.score);
});
+test("simulateBearBattle includes carried extra damage in score without treating it as skill kills", () => {
+ const result = simulateBearBattle(
+ {
+ troops: { lancer_t1: 1000 },
+ stats: {},
+ heroes: { Renee: { skill_1: 1 } }
+ },
+ loadSimulatorConfig(),
+ "bear-renee-extra"
+ );
+
+ const attackerDamage = result.attacks
+ .filter((attack) => attack.dealerSide === "attacker")
+ .reduce((total, attack) => total + attack.kills, 0);
+ assert.equal(result.score, attackerDamage);
+ assert.equal(result.attacks.some((attack) => attack.kind === "extra"), true);
+ assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "NightmareTrace")?.skillKills, 0);
+});
+
test("runPrepared calculates all same-round damage from the round-start troop snapshot", () => {
const config = loadSimulatorConfig();
const result = runOnce(
{
@@ -727,8 +746,167 @@
assert.equal(round4LancerAttack?.takerUnit, "marksman");
assert.equal((round4LancerAttack?.appliedEffects ?? []).some((effect) => effect.effectId === "mark"), false);
});
+test("carried extra damage expires when the next normal attack does not match its locked target", () => {
+ const result = runOnce(
+ {
+ maxRounds: 3,
+ attacker: {
+ troops: { lancer_t1: 1000000 },
+ heroes: { DreamLancer: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000, marksman_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DreamLancer: {
+ name: "DreamLancer",
+ troop_type: "lancer",
+ skills: {
+ EvenTurnAmbusher: {
+ trigger: { type: "turn", every: 2 },
+ effects: {
+ order: {
+ type: "attack_order",
+ value: ["marksman", "infantry", "lancer"],
+ units: { applies_to: "lancer", applies_vs: "marksman" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ },
+ NightmareTrace: {
+ trigger: { type: "turn", every: 2 },
+ effects: {
+ mark: {
+ units: { applies_to: "self.lancer", applies_vs: "enemy.any" },
+ duration: { turns: { count: 1 }, attacks: { count: 1 } },
+ trigger_effects: {
+ delayed: {
+ type: "extra_skill_attack",
+ value: 40,
+ units: { applies_to: "parent.use.source", applies_vs: "parent.use.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "extra" }],
+ duration: { turns: { delay: 1, count: 1 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const lancerAttacks = result.attacks.filter((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "lancer");
+ assert.equal(lancerAttacks.find((attack) => attack.round === 2 && attack.kind === "normal")?.takerUnit, "marksman");
+ assert.equal(lancerAttacks.find((attack) => attack.round === 3 && attack.kind === "normal")?.takerUnit, "infantry");
+ assert.equal(lancerAttacks.some((attack) => attack.sourceEffectId === "delayed"), false);
+});
+
+test("Renee snapshots Nightmare Trace as extra damage on the even turn and only delivers it next turn", () => {
+ const result = runOnce(
+ {
+ maxRounds: 3,
+ attacker: {
+ troops: { lancer_t1: 1000000 },
+ heroes: { Renee: { skill_1: 1, skill_2: 1, skill_3: 1 } }
+ },
+ defender: {
+ troops: { marksman_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ loadSimulatorConfig(),
+ { mode: "trace" }
+ );
+
+ const attackerJobs = result.attacks.filter((attack) => attack.dealerSide === "attacker");
+ const roundTwoNormal = attackerJobs.find((attack) => attack.round === 2 && attack.kind === "normal");
+ const delayed = attackerJobs.find((attack) => attack.sourceEffectId === "NightmareTrace/1");
+ const roundThreeNormal = attackerJobs.find((attack) => attack.round === 3 && attack.kind === "normal");
+
+ assert.equal(attackerJobs.some((attack) => attack.round === 2 && attack.kind === "skill"), false);
+ assert.equal(delayed?.kind, "extra");
+ assert.equal(delayed?.round, 3);
+ assert.equal(delayed?.calculationRound, 2);
+ assert.ok(Math.abs(delayed!.kills - roundTwoNormal!.kills * 0.4) < 1e-9);
+ assert.equal(delayed?.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 10);
+ assert.equal(delayed?.trace?.atomicBuckets["type.marked_target.damage.up"].totalPct, 0);
+ assert.equal(roundThreeNormal?.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 10);
+ assert.equal(roundThreeNormal?.trace?.atomicBuckets["type.marked_target.damage.up"].totalPct, 45);
+
+ const nightmareTrace = result.skillReport.attacker.find((entry) => entry.skillId === "NightmareTrace");
+ assert.equal(nightmareTrace?.skillKills, 0);
+});
+
+test("Wu Ming skill-damage amplification does not affect Renee's carried extra damage", () => {
+ const result = runOnce(
+ {
+ maxRounds: 3,
+ attacker: {
+ troops: { lancer_t1: 1000000 },
+ heroes: {
+ WuMing: { skill_2: 1, skill_3: 1 },
+ Renee: { skill_1: 1 }
+ }
+ },
+ defender: {
+ troops: { marksman_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ loadSimulatorConfig(),
+ { mode: "trace" }
+ );
+
+ const delayed = result.attacks.find((attack) => attack.sourceEffectId === "NightmareTrace/1");
+ assert.equal(delayed?.kind, "extra");
+ assert.equal(delayed?.trace?.atomicBuckets["active.hero.damage.up"].totalPct, 4);
+ assert.equal(delayed?.trace?.aggregationGroups["type.skill.damage.up"], undefined);
+ assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "NightmareTrace")?.skillKills, 0);
+});
+
+test("Renee's marked-target bucket multiplies separately from the single-target counterfactual", () => {
+ const base = loadSimulatorConfig();
+ const heroDefinitions = structuredClone(base.heroDefinitions);
+ const reneeSkills = heroDefinitions.Renee.skills;
+ for (const [skillId, effectId] of [["Dreamcatcher", "Dreamcatcher/1"], ["Dreamslice", "Dreamslice/1"]] as const) {
+ reneeSkills[skillId].effects[`${skillId}/mark`].trigger_effects![effectId].type = "type.single_target.damage.up";
+ }
+ const singleTargetConfig = buildSimulatorConfig({
+ heroGenerationStats: base.heroGenerationStats,
+ troopSkills: base.troopSkills,
+ heroDefinitions
+ });
+ const input: BattleInput = {
+ maxRounds: 3,
+ attacker: {
+ troops: { lancer_t1: 1000000 },
+ heroes: { Renee: { skill_1: 1, skill_2: 1, skill_3: 1 } }
+ },
+ defender: {
+ troops: { marksman_t1: 1000000 },
+ heroes: {}
+ }
+ };
+
+ const shared = runOnce(input, singleTargetConfig, { mode: "trace" });
+ const separate = runOnce(input, base, { mode: "trace" });
+ const sharedNormal = shared.attacks.find((attack) => attack.round === 3 && attack.kind === "normal" && attack.dealerSide === "attacker")!;
+ const separateNormal = separate.attacks.find((attack) => attack.round === 3 && attack.kind === "normal" && attack.dealerSide === "attacker")!;
+ const separateDelayed = separate.attacks.find((attack) => attack.sourceEffectId === "NightmareTrace/1")!;
+
+ assert.equal(sharedNormal.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 55);
+ assert.equal(separateNormal.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 10);
+ assert.equal(separateNormal.trace?.atomicBuckets["type.marked_target.damage.up"].totalPct, 45);
+ assert.ok(Math.abs(separateNormal.kills / sharedNormal.kills - (1.1 * 1.45) / 1.55) < 1e-12);
+ assert.equal(separateDelayed.trace?.atomicBuckets["type.marked_target.damage.up"].totalPct, 0);
+});
+
test("attack-duration effects with a round cap expire after one applicable attack", () => {
const result = runOnce(
{
maxRounds: 2,
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -47,9 +47,9 @@
type DamageJobResult,
type RunLoopOptions,
type Runtime
} from "./runtime";
-import { processExtraSkillAttacks } from "./extraAttacks";
+import { captureTriggeredExtraDamage, processExtraAttacks } from "./extraAttacks";
// Re-exported so the public battle API stays importable from one module.
export { prepareBattle, type CompiledBattle } from "./prepare";
import { emptyTroops, resolveFighter } from "./fighterResolution";
@@ -256,9 +256,8 @@
const intents: AttackIntent[] = [];
const results: DamageJobResult[] = [];
const cancelled: CancelledAttack[] = [];
const roundTargetDamage = emptyRoundTargetDamage();
-
// Resolve each normal attack as one procedural cluster. Later attacks observe effects
// produced by earlier attacks; no synthetic global attack-declaration phase exists.
const orderIndexBySide: Record<SideId, number> = { attacker: 0, defender: 0 };
for (const dealerUnit of UNIT_TYPES) {
@@ -306,9 +305,9 @@
const deferredEffects = matchingTriggerSkills
? triggerAttackSkills(round, matchingTriggerSkills, runtime, recorder, intent)
: undefined;
const dodge = applicableControl(job, runtime, "dodge");
- fireApplicableCarriers(round, job, intent, runtime, recorder);
+ const triggeredChildren = fireApplicableCarriers(round, job, intent, runtime, recorder);
let normalKills = 0;
if (dodge) {
runtime.attackControlCounts.dodge += 1;
@@ -325,20 +324,21 @@
chargeUsedEffectsForJob(runtime, job, recorder);
results.push({ job, result: normalResult, intent });
}
+ captureTriggeredExtraDamage(triggeredChildren, job, fighters, damageJobOptions, runtime);
advanceNormalAttackCounters(intent, runtime);
- const triggeredKills = processExtraSkillAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);
+ const extraAttacks = processExtraAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);
if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {
- score += triggeredKills;
+ score += extraAttacks.totalKills;
}
if (deferredEffects) {
materializeDeferredEffects(
deferredEffects,
round,
intent,
normalKills,
- triggeredKills,
+ extraAttacks.skillKills,
sourceAttackProtectionBasis(job, fighters, initialArmyCount),
runtime,
recorder
);
@@ -507,11 +507,12 @@
job: DamageJob,
intent: AttackIntent,
runtime: Runtime,
recorder: BattleRecorder
-): void {
+): ActiveEffect[] {
const carriers = runtime.effectIndex.carriers;
const originalCount = carriers.length;
+ const children: ActiveEffect[] = [];
let index = 0;
for (let inspected = 0; inspected < originalCount; inspected += 1) {
const carrier = carriers[index];
if (!carrier) break;
@@ -520,15 +521,16 @@
carrier.appliesVs.side === job.takerSide &&
unitMaskHas(carrier.appliesVs.units, job.takerUnit) &&
advanceEffectAttackDelay(carrier);
if (matches) {
- materializeTriggeredEffects(carrier, round, intent, runtime, recorder);
+ children.push(...materializeTriggeredEffects(carrier, round, intent, runtime, recorder));
chargeEffectUse(runtime, carrier);
}
// chargeEffectUse may have removed the current carrier. Keep the same index
// in that case so the shifted original entry is inspected next.
if (carriers[index] === carrier) index += 1;
}
+ return children;
}
function advanceNormalAttackCounters(intent: AttackIntent, runtime: Runtime): void {
runtime.counters.attacks[intent.dealerSide][intent.dealerUnit] += 1;
Index: simulator/src/tooling/parityMetrics.ts
===================================================================
--- simulator/src/tooling/parityMetrics.ts prev run
+++ simulator/src/tooling/parityMetrics.ts this run
@@ -51,9 +51,9 @@
if (options.deterministic) {
statType = "deterministic";
passes = Math.abs(biasPct) <= deterministicLimit;
- } else if (options.candidate.sigma === 0) {
+ } else if (options.candidate.sigma === 0 && options.reference.sigma === 0) {
statType = "zero_var";
passes = Math.abs(biasPct) <= deterministicLimit;
} else if (options.reference.n <= 1) {
statType = "single_obs";
@@ -62,12 +62,23 @@
p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
} else {
statType = "t";
- sem = options.candidate.sigma * Math.sqrt(1 / Math.max(options.candidate.n, 1) + 1 / options.reference.n);
- stat = sem === 0 ? null : round(biasRaw / sem, 4);
- p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
- passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
+ const candidateVariance = (options.candidate.sigma ** 2) / Math.max(options.candidate.n, 1);
+ const referenceVariance = (options.reference.sigma ** 2) / options.reference.n;
+ sem = Math.sqrt(candidateVariance + referenceVariance);
+ const rawStat = sem === 0 ? null : biasRaw / sem;
+ const degreesOfFreedom = welchDegreesOfFreedom(
+ candidateVariance,
+ options.candidate.n,
+ referenceVariance,
+ options.reference.n
+ );
+ const rawP = rawStat === null ? null : studentTwoSidedP(rawStat, degreesOfFreedom);
+ stat = rawStat === null ? null : round(rawStat, 4);
+ p = rawP === null ? null : round(rawP, 6);
+ const alpha = 2 * (1 - normalCdf(zThreshold));
+ passes = rawP === null || rawP >= alpha || Math.abs(biasPct) <= minBiasPct;
}
return {
n_candidate: options.candidate.n,
@@ -103,8 +114,88 @@
function normalCdf(x: number): number {
return 0.5 * (1 + erf(x / Math.SQRT2));
}
+function welchDegreesOfFreedom(candidateVariance: number, candidateN: number, referenceVariance: number, referenceN: number): number {
+ const numerator = (candidateVariance + referenceVariance) ** 2;
+ const denominator = varianceDegreesTerm(candidateVariance, candidateN) + varianceDegreesTerm(referenceVariance, referenceN);
+ return denominator === 0 ? Number.POSITIVE_INFINITY : numerator / denominator;
+}
+
+function varianceDegreesTerm(variance: number, n: number): number {
+ return n <= 1 ? 0 : (variance ** 2) / (n - 1);
+}
+
+function studentTwoSidedP(stat: number, degreesOfFreedom: number): number {
+ if (!Number.isFinite(degreesOfFreedom)) return 2 * (1 - normalCdf(Math.abs(stat)));
+ const x = degreesOfFreedom / (degreesOfFreedom + stat ** 2);
+ return regularizedIncompleteBeta(x, degreesOfFreedom / 2, 0.5);
+}
+
+function regularizedIncompleteBeta(x: number, a: number, b: number): number {
+ if (x <= 0) return 0;
+ if (x >= 1) return 1;
+ const scale = Math.exp(logGamma(a + b) - logGamma(a) - logGamma(b) + a * Math.log(x) + b * Math.log1p(-x));
+ if (x < (a + 1) / (a + b + 2)) return (scale * betaContinuedFraction(x, a, b)) / a;
+ return 1 - (scale * betaContinuedFraction(1 - x, b, a)) / b;
+}
+
+function betaContinuedFraction(x: number, a: number, b: number): number {
+ const maxIterations = 200;
+ const epsilon = 3e-14;
+ const minimum = 1e-300;
+ const qab = a + b;
+ const qap = a + 1;
+ const qam = a - 1;
+ let c = 1;
+ let d = 1 - (qab * x) / qap;
+ if (Math.abs(d) < minimum) d = minimum;
+ d = 1 / d;
+ let result = d;
+
+ for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
+ const even = iteration * 2;
+ let coefficient = (iteration * (b - iteration) * x) / ((qam + even) * (a + even));
+ d = 1 + coefficient * d;
+ if (Math.abs(d) < minimum) d = minimum;
+ c = 1 + coefficient / c;
+ if (Math.abs(c) < minimum) c = minimum;
+ d = 1 / d;
+ result *= d * c;
+
+ coefficient = -((a + iteration) * (qab + iteration) * x) / ((a + even) * (qap + even));
+ d = 1 + coefficient * d;
+ if (Math.abs(d) < minimum) d = minimum;
+ c = 1 + coefficient / c;
+ if (Math.abs(c) < minimum) c = minimum;
+ d = 1 / d;
+ const delta = d * c;
+ result *= delta;
+ if (Math.abs(delta - 1) < epsilon) break;
+ }
+
+ return result;
+}
+
+function logGamma(value: number): number {
+ const coefficients = [
+ 676.5203681218851,
+ -1259.1392167224028,
+ 771.3234287776531,
+ -176.6150291621406,
+ 12.507343278686905,
+ -0.13857109526572012,
+ 9.984369578019572e-6,
+ 1.5056327351493116e-7
+ ];
+ if (value < 0.5) return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * value)) - logGamma(1 - value);
+ let series = 0.9999999999998099;
+ const shifted = value - 1;
+ for (let index = 0; index < coefficients.length; index += 1) series += coefficients[index] / (shifted + index + 1);
+ const t = shifted + coefficients.length - 0.5;
+ return 0.5 * Math.log(2 * Math.PI) + (shifted + 0.5) * Math.log(t) - t + Math.log(series);
+}
+
function erf(x: number): number {
const sign = x < 0 ? -1 : 1;
const ax = Math.abs(x);
const t = 1 / (1 + 0.3275911 * ax);
Index: simulator/src/tooling/testcases.test.ts
===================================================================
--- simulator/src/tooling/testcases.test.ts prev run
+++ simulator/src/tooling/testcases.test.ts this run
@@ -403,8 +403,24 @@
assert.equal(metrics.sem, 3.79);
assert.equal(metrics.stat, -4.4459);
});
+test("compareOutcomeDistribution includes noisy game samples in a Welch t comparison", () => {
+ const metrics = compareOutcomeDistribution({
+ candidate: { n: 100, mu: -1149.6, sigma: 1578.2 },
+ reference: { n: 10, mu: 179.3, sigma: 2006.6 },
+ initialTroops: 17_500,
+ deterministic: false,
+ thresholds: { z_threshold: 2, min_bias_pct: 0.5 }
+ });
+
+ assert.equal(metrics.stat_type, "t");
+ assert.equal(metrics.sem, 653.87);
+ assert.equal(metrics.stat, -2.0323);
+ assert.equal(metrics.p, 0.069131);
+ assert.equal(metrics.passes, true);
+});
+
test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {
const rows = [
{ p: 0.01, q: null },
{ p: 0.03, q: null },
Index: simulator/src/tooling/testcases.ts
===================================================================
--- simulator/src/tooling/testcases.ts prev run
+++ simulator/src/tooling/testcases.ts this run
@@ -10,9 +10,9 @@
} from "./calibration";
import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
import { prepareBattle, runPrepared } from "../simulator";
import { DamageAggregationError } from "../staticDamageProfile";
-import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "../types";
+import type { BattleInput, BattleResult, FighterInput, SimulationMode, SimulatorConfig, StatBlock, UnitType } from "../types";
const DEFAULT_STOCHASTIC_REPEAT = 100;
const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;
const STAT_ROUNDING_SCAN_STEPS = 50;
@@ -145,8 +145,10 @@
index: number;
input: BattleInput;
repeat: number;
seed?: string | number;
+ includeSamples?: boolean;
+ simulationMode?: SimulationMode;
}
export interface TestcaseExecutionResult {
testcaseId: string;
@@ -618,9 +620,13 @@
const sampleDeltas: number[] = [];
// Resolve the battle once and reuse it across every seeded sample of this case.
const compiled = prepareBattle(job.input, config);
const baseSeed = job.seed ?? job.input.seed;
- const sample = (iteration: number) => runPrepared(compiled, sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration));
+ const sample = (iteration: number) => runPrepared(
+ compiled,
+ sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration),
+ job.simulationMode ? { mode: job.simulationMode } : {}
+ );
let result = sample(0);
const firstScore = battleScoreDelta(result);
if (firstScore !== undefined) {
samples.push(firstScore);
@@ -644,9 +650,9 @@
index: job.index,
result,
deterministic: result.randomness.deterministic,
sampleCount,
- simulatorStats: sampleStats(samples),
+ simulatorStats: sampleStats(samples, { includeSamples: job.includeSamples }),
simulatorScoreDelta: battleScoreDelta(result),
simulatorSampleOutcomes: sampleOutcomes,
simulatorSampleDeltas: sampleDeltas,
diagnostics: [...result.resolved.attacker.diagnostics, ...result.resolved.defender.diagnostics]
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -1,7 +1,7 @@
export type SideId = "attacker" | "defender";
export type UnitType = "infantry" | "lancer" | "marksman";
-export type DamageKind = "normal" | "skill";
+export type DamageKind = "normal" | "skill" | "extra";
export type UnitMask = number;
export type ActiveEffectKind = "modifier" | "shield" | "extra_attack" | "control" | "battle_order" | "carrier";
export type SameEffectStacking = "add" | "max";
@@ -31,8 +31,10 @@
export interface TriggerDamageJobDefinition {
source: TriggerDamageJobSelector;
target: TriggerDamageJobSelector;
+ /** Damage classification used when the job is calculated. Defaults to skill. */
+ damage_kind?: DamageKind;
}
export function unitMask(units: UnitType | UnitType[]): UnitMask {
const list = Array.isArray(units) ? units : [units];
@@ -321,8 +323,10 @@
// unless useEffectsOnDodge/useEffectsOnNoAttack disable that.
uses: number;
sameEffectStacking: SameEffectStacking;
triggerEffects?: ResolvedEffectIntentDefinition[];
+ /** Completed damage captured when this runtime effect was materialized and emitted on its later matching attack. */
+ pendingDamageJobs?: PendingDamageJob[];
effectGroup?: ActiveEffectGroup;
effectGroupPosition?: number;
}
@@ -349,8 +353,10 @@
}
export interface DamageJob {
round: number;
+ /** Present when a delayed job was calculated in an earlier round. */
+ calculationRound?: number;
kind: DamageKind;
roundStartTroops: Record<SideId, Record<UnitType, number>>;
dealerSide: SideId;
dealerUnit: UnitType;
@@ -359,8 +365,19 @@
sourceEffectId?: string;
sourceMultiplier?: number;
}
+export interface DamageResult {
+ kills: number;
+ appliedEffects?: AppliedEffect[];
+ trace?: DamageEquationTrace;
+}
+
+export interface PendingDamageJob {
+ job: DamageJob;
+ result: DamageResult;
+}
+
export interface CounterDelta {
side: SideId;
unit: UnitType;
counter: "attacks" | "received_attacks";
@@ -455,8 +472,10 @@
export type AppliedEffect = AppliedPercentageEffectSummary | AppliedShieldEffectSummary | DetailedAppliedEffect;
export interface AttackOutcome {
round: number;
+ /** Present when this damage was snapshotted in an earlier round. */
+ calculationRound?: number;
kind: DamageKind;
sourceEffectId?: string;
dealerSide: SideId;
dealerUnit: UnitType;
Index: testcases/1-testcases_no-heroes_t6_single-type_nc.json
===================================================================
--- testcases/1-testcases_no-heroes_t6_single-type_nc.json prev run
+++ testcases/1-testcases_no-heroes_t6_single-type_nc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_9",
+ "test_id": "no_heroes_single_t6__9",
"time": "2025-07-13 - 12:02",
"attacker": {
"name": "daut",
"stats": {
@@ -67,9 +67,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_10",
+ "test_id": "no_heroes_single_t6__10",
"time": "2025-07-13 - 12:07",
"attacker": {
"name": "daut",
"stats": {
@@ -135,9 +135,9 @@
"defender": 1618
}
},
{
- "test_id": "daut_viper_11",
+ "test_id": "no_heroes_single_t6__11",
"time": "2025-07-13 - 12:08",
"attacker": {
"name": "daut",
"stats": {
@@ -203,9 +203,9 @@
"defender": 376
}
},
{
- "test_id": "daut_viper_12",
+ "test_id": "no_heroes_single_t6__12",
"time": "2025-07-13 - 12:15",
"attacker": {
"name": "daut",
"stats": {
@@ -271,9 +271,9 @@
"defender": 6
}
},
{
- "test_id": "daut_viper_13",
+ "test_id": "no_heroes_single_t6__13",
"time": "2025-07-13 - 12:21",
"attacker": {
"name": "daut",
"stats": {
@@ -339,9 +339,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_14",
+ "test_id": "no_heroes_single_t6__14",
"time": "2025-07-13 - 12:24",
"attacker": {
"name": "daut",
"stats": {
@@ -407,9 +407,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_15",
+ "test_id": "no_heroes_single_t6__15",
"time": "2025-07-13 - 12:27",
"attacker": {
"name": "daut",
"stats": {
@@ -475,9 +475,9 @@
"defender": 254
}
},
{
- "test_id": "daut_viper_16",
+ "test_id": "no_heroes_single_t6__16",
"time": "2025-07-13 - 12:29",
"attacker": {
"name": "daut",
"stats": {
@@ -543,9 +543,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_17",
+ "test_id": "no_heroes_single_t6__17",
"time": "2025-07-13 - 12:32",
"attacker": {
"name": "daut",
"stats": {
@@ -613,9 +613,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_18",
+ "test_id": "no_heroes_single_t6__18",
"time": "2025-07-13 - 12:34",
"attacker": {
"name": "daut",
"stats": {
@@ -683,9 +683,9 @@
"defender": 177
}
},
{
- "test_id": "daut_viper_19",
+ "test_id": "no_heroes_single_t6__19",
"time": "2025-07-13 - 12:36",
"attacker": {
"name": "daut",
"stats": {
@@ -753,9 +753,9 @@
"defender": 489
}
},
{
- "test_id": "daut_viper_20",
+ "test_id": "no_heroes_single_t6__20",
"time": "2025-07-13 - 12:39",
"attacker": {
"name": "daut",
"stats": {
@@ -823,9 +823,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_21",
+ "test_id": "no_heroes_single_t6__21",
"time": "2025-07-13 - 12:42",
"attacker": {
"name": "daut",
"stats": {
@@ -893,9 +893,9 @@
"defender": 193
}
},
{
- "test_id": "daut_viper_22",
+ "test_id": "no_heroes_single_t6__22",
"time": "2025-07-13 - 12:43",
"attacker": {
"name": "daut",
"stats": {
@@ -963,9 +963,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_23",
+ "test_id": "no_heroes_single_t6__23",
"time": "2025-07-13 - 12:45",
"attacker": {
"name": "daut",
"stats": {
@@ -1033,9 +1033,9 @@
"defender": 277
}
},
{
- "test_id": "daut_viper_24",
+ "test_id": "no_heroes_single_t6__24",
"time": "2025-07-13 - 12:47",
"attacker": {
"name": "daut",
"stats": {
@@ -1103,9 +1103,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_25",
+ "test_id": "no_heroes_single_t6__25",
"time": "2025-07-13 - 12:50",
"attacker": {
"name": "daut",
"stats": {
@@ -1173,9 +1173,9 @@
"defender": 45
}
},
{
- "test_id": "daut_viper_26",
+ "test_id": "no_heroes_single_t6__26",
"time": "2025-07-13 - 12:54",
"attacker": {
"name": "daut",
"stats": {
Index: testcases/2-testcases_no-heroes_t6_mixed_nc.json
===================================================================
--- testcases/2-testcases_no-heroes_t6_mixed_nc.json prev run
+++ testcases/2-testcases_no-heroes_t6_mixed_nc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "no_heroes_t6_mixed_1",
"time": "2025-07-12 - 22:59",
"attacker": {
"name": "daut",
"stats": {
@@ -77,9 +77,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "no_heroes_t6_mixed_2",
"time": "2025-07-18 - 02:17",
"attacker": {
"name": "daut",
"heroes": {},
@@ -155,9 +155,9 @@
"defender": 61
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "no_heroes_t6_mixed_3",
"time": "2025-07-18 - 02:30",
"attacker": {
"name": "daut",
"heroes": {},
@@ -233,9 +233,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "no_heroes_t6_mixed_4",
"time": "2025-07-18 - 03:17",
"attacker": {
"name": "daut",
"heroes": {},
@@ -311,9 +311,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "no_heroes_t6_mixed_5",
"time": "2025-07-18 - 03:24",
"attacker": {
"name": "daut",
"heroes": {},
@@ -389,9 +389,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_6",
+ "test_id": "no_heroes_t6_mixed_6",
"time": "2025-07-18 - 03:33",
"attacker": {
"name": "daut",
"heroes": {},
Index: testcases/3-testcases_mixed-heroes-not-verified.json
===================================================================
--- testcases/3-testcases_mixed-heroes-not-verified.json prev run
+++ testcases/3-testcases_mixed-heroes-not-verified.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "no_heroes_t6_mixed_2x_1",
"time": "2025-07-12 - 23:09",
"attacker": {
"name": "daut2x",
"stats": {
@@ -84,9 +84,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "no_heroes_t6_mixed_2x_2",
"time": "2025-07-12 - 23:17",
"attacker": {
"name": "daut2x",
"stats": {
@@ -166,9 +166,9 @@
"defender": 3729
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "no_heroes_t6_mixed_2x_3",
"time": "2025-07-13 - 13:45",
"attacker": {
"name": "daut2x",
"stats": {
@@ -280,9 +280,9 @@
"defender": 2823
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "no_heroes_t6_mixed_2x_4",
"time": "2025-07-13 - 16:51",
"attacker": {
"name": "daut2x",
"stats": {
@@ -392,9 +392,9 @@
"defender": 519
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "no_heroes_t6_mixed_2x_5",
"time": "2025-07-13 - 16:57",
"attacker": {
"name": "daut2x",
"heroes": {
@@ -504,9 +504,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_7",
+ "test_id": "no_heroes_t6_mixed_2x_6",
"time": "2025-07-13 - 17:03",
"attacker": {
"name": "daut2x",
"heroes": {
Index: testcases/emulator_verified/ambusher_logan_renee_vs_logan_patrick_nc.json
===================================================================
--- testcases/emulator_verified/ambusher_logan_renee_vs_logan_patrick_nc.json prev run
+++ testcases/emulator_verified/ambusher_logan_renee_vs_logan_patrick_nc.json this run
@@ -0,0 +1,429 @@
+[
+ {
+ "test_id": "ambusher_logan_renee_vs_logan_patrick_nc",
+ "description": "Ambusher capture over 10 battles. MiniClaw2 attacks with Logan 1/0/0 and Renee 1/0/0 using 6000 T9 Infantry and 1500 T9 Lancers. [LAB]MiniClaw defends with Logan 1/1/0 and Patrick 1/1 using 5000 T6 Infantry and 5000 T6 Marksmen. Stats are the displayed battle-report bonuses; per-trial skill counts and troop outcomes are retained in metadata.",
+ "attacker": {
+ "name": "MiniClaw2",
+ "heroes": {
+ "Logan": {
+ "skill_1": 1,
+ "skill_2": 0,
+ "skill_3": 0
+ },
+ "Renee": {
+ "skill_1": 1,
+ "skill_2": 0,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "infantry_t9": 6000,
+ "lancer_t9": 1500
+ },
+ "stats": {
+ "inf": {
+ "attack": 78.9,
+ "defense": 80.1,
+ "lethality": 36.0,
+ "health": 36.0
+ },
+ "lanc": {
+ "attack": 110.5,
+ "defense": 111.7,
+ "lethality": 54.0,
+ "health": 54.0
+ },
+ "mark": {
+ "attack": 42.3,
+ "defense": 43.5,
+ "lethality": 36.0,
+ "health": 36.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[LAB]MiniClaw",
+ "heroes": {
+ "Logan": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ },
+ "Patrick": {
+ "skill_1": 1,
+ "skill_2": 1
+ }
+ },
+ "troops": {
+ "infantry_t6": 5000,
+ "marksman_t6": 5000
+ },
+ "stats": {
+ "inf": {
+ "attack": 141.3,
+ "defense": 145.3,
+ "lethality": 72.2,
+ "health": 72.2
+ },
+ "lanc": {
+ "attack": 148.0,
+ "defense": 152.0,
+ "lethality": 72.2,
+ "health": 72.2
+ },
+ "mark": {
+ "attack": 81.5,
+ "defense": 85.5,
+ "lethality": 72.2,
+ "health": 72.2
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 2057,
+ "defender": 0
+ },
+ {
+ "attacker": 581,
+ "defender": 9
+ },
+ {
+ "attacker": 0,
+ "defender": 2668
+ },
+ {
+ "attacker": 1547,
+ "defender": 0
+ },
+ {
+ "attacker": 2428,
+ "defender": 0
+ },
+ {
+ "attacker": 2199,
+ "defender": 0
+ },
+ {
+ "attacker": 0,
+ "defender": 1731
+ },
+ {
+ "attacker": 1178,
+ "defender": 0
+ },
+ {
+ "attacker": 0,
+ "defender": 1580
+ },
+ {
+ "attacker": 0,
+ "defender": 2209
+ }
+ ],
+ "metadata": {
+ "reported_initial_totals": {
+ "attacker": 7500,
+ "defender": 10000
+ },
+ "observed_battle_outcomes": [
+ {
+ "trial": 1,
+ "skill_counts": {
+ "renee_s1": 53,
+ "ambusher": 23
+ },
+ "attacker": {
+ "survivors": 2057,
+ "infantry": {
+ "kills": 491,
+ "survivors": 557
+ },
+ "lancer": {
+ "kills": 3009,
+ "survivors": 1500
+ }
+ },
+ "defender": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 523,
+ "survivors": 0
+ },
+ "marksman": {
+ "kills": 1383,
+ "survivors": 0
+ }
+ }
+ },
+ {
+ "trial": 2,
+ "skill_counts": {
+ "renee_s1": 58,
+ "ambusher": 21
+ },
+ "attacker": {
+ "survivors": 581,
+ "infantry": {
+ "kills": 421,
+ "survivors": 9
+ },
+ "lancer": {
+ "kills": 3079,
+ "survivors": 581
+ }
+ },
+ "defender": {
+ "survivors": 9,
+ "infantry": {
+ "kills": 600,
+ "survivors": 0
+ },
+ "marksman": {
+ "kills": 1822,
+ "survivors": 0
+ }
+ }
+ },
+ {
+ "trial": 3,
+ "skill_counts": {
+ "renee_s1": 45,
+ "ambusher": 13
+ },
+ "attacker": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 381,
+ "survivors": 0
+ },
+ "lancer": {
+ "kills": 2187,
+ "survivors": 0
+ }
+ },
+ "defender": {
+ "survivors": 2668,
+ "infantry": {
+ "kills": 531,
+ "survivors": 714
+ },
+ "marksman": {
+ "kills": 2094,
+ "survivors": 1954
+ }
+ }
+ },
+ {
+ "trial": 4,
+ "skill_counts": {
+ "renee_s1": 53,
+ "ambusher": 22
+ },
+ "attacker": {
+ "survivors": 1547,
+ "infantry": {
+ "kills": 453,
+ "survivors": 47
+ },
+ "lancer": {
+ "kills": 3047,
+ "survivors": 1500
+ }
+ },
+ "defender": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 524,
+ "survivors": 0
+ },
+ "marksman": {
+ "kills": 1560,
+ "survivors": 0
+ }
+ }
+ },
+ {
+ "trial": 5,
+ "skill_counts": {
+ "renee_s1": 52,
+ "ambusher": 23
+ },
+ "attacker": {
+ "survivors": 2428,
+ "infantry": {
+ "kills": 498,
+ "survivors": 928
+ },
+ "lancer": {
+ "kills": 3092,
+ "survivors": 1500
+ }
+ },
+ "defender": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 525,
+ "survivors": 0
+ },
+ "marksman": {
+ "kills": 1251,
+ "survivors": 0
+ }
+ }
+ },
+ {
+ "trial": 6,
+ "skill_counts": {
+ "renee_s1": 53,
+ "ambusher": 23
+ },
+ "attacker": {
+ "survivors": 2199,
+ "infantry": {
+ "kills": 497,
+ "survivors": 699
+ },
+ "lancer": {
+ "kills": 3003,
+ "survivors": 1500
+ }
+ },
+ "defender": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 528,
+ "survivors": 0
+ },
+ "marksman": {
+ "kills": 1328,
+ "survivors": 0
+ }
+ }
+ },
+ {
+ "trial": 7,
+ "skill_counts": {
+ "renee_s1": 47,
+ "ambusher": 16
+ },
+ "attacker": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 384,
+ "survivors": 0
+ },
+ "lancer": {
+ "kills": 2511,
+ "survivors": 0
+ }
+ },
+ "defender": {
+ "survivors": 1731,
+ "infantry": {
+ "kills": 566,
+ "survivors": 805
+ },
+ "marksman": {
+ "kills": 2059,
+ "survivors": 926
+ }
+ }
+ },
+ {
+ "trial": 8,
+ "skill_counts": {
+ "renee_s1": 55,
+ "ambusher": 23
+ },
+ "attacker": {
+ "survivors": 1178,
+ "infantry": {
+ "kills": 424,
+ "survivors": 0
+ },
+ "lancer": {
+ "kills": 3076,
+ "survivors": 1178
+ }
+ },
+ "defender": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 567,
+ "survivors": 0
+ },
+ "marksman": {
+ "kills": 1646,
+ "survivors": 0
+ }
+ }
+ },
+ {
+ "trial": 9,
+ "skill_counts": {
+ "renee_s1": 51,
+ "ambusher": 17
+ },
+ "attacker": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 415,
+ "survivors": 0
+ },
+ "lancer": {
+ "kills": 2533,
+ "survivors": 0
+ }
+ },
+ "defender": {
+ "survivors": 1580,
+ "infantry": {
+ "kills": 557,
+ "survivors": 329
+ },
+ "marksman": {
+ "kills": 2068,
+ "survivors": 1251
+ }
+ }
+ },
+ {
+ "trial": 10,
+ "skill_counts": {
+ "renee_s1": 47,
+ "ambusher": 14
+ },
+ "attacker": {
+ "survivors": 0,
+ "infantry": {
+ "kills": 382,
+ "survivors": 0
+ },
+ "lancer": {
+ "kills": 2346,
+ "survivors": 0
+ }
+ },
+ "defender": {
+ "survivors": 2209,
+ "infantry": {
+ "kills": 555,
+ "survivors": 690
+ },
+ "marksman": {
+ "kills": 2070,
+ "survivors": 1519
+ }
+ }
+ }
+ ],
+ "data_quality_notes": [
+ "Trial 2's supplied attacker troop survivors sum to 590 (9 Infantry + 581 Lancers), while its supplied attacker total is 581.",
+ "Trial 2's supplied defender troop survivors sum to 0, while its supplied defender total is 9. Values are preserved exactly as supplied."
+ ]
+ }
+ }
+]
Index: testcases/heroes_unittests/Flint_tc.json
===================================================================
--- testcases/heroes_unittests/Flint_tc.json prev run
+++ testcases/heroes_unittests/Flint_tc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "flint_tc_1",
"time": "2025-07-20 - 03:49",
"attacker": {
"name": "daut",
"heroes": {},
@@ -104,9 +104,9 @@
}
]
},
{
- "test_id": "daut_viper_2",
+ "test_id": "flint_tc_2",
"time": "2025-07-20 - 04:07",
"attacker": {
"name": "daut",
"heroes": {},
Index: testcases/heroes_unittests/Hector_tc.json
===================================================================
--- testcases/heroes_unittests/Hector_tc.json prev run
+++ testcases/heroes_unittests/Hector_tc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "hector_tc_1",
"time": "2025-07-21 - 01:06",
"attacker": {
"name": "daut",
"heroes": {},
Index: testcases/heroes_unittests/Jessie_tc_nc.json
===================================================================
--- testcases/heroes_unittests/Jessie_tc_nc.json prev run
+++ testcases/heroes_unittests/Jessie_tc_nc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "jessie_tc_1",
"time": "2025-07-18 - 02:17",
"attacker": {
"name": "daut",
"heroes": {},
@@ -77,9 +77,9 @@
"defender": 61
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "jessie_tc_2",
"time": "2025-07-18 - 03:33",
"attacker": {
"name": "daut",
"heroes": {},
@@ -155,9 +155,9 @@
"defender": 89
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "jessie_tc_3",
"time": "2025-07-18 - 02:34",
"attacker": {
"name": "daut",
"heroes": {},
@@ -223,9 +223,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "jessie_tc_4",
"time": "2025-07-18 - 02:38",
"attacker": {
"name": "daut",
"heroes": {},
@@ -299,9 +299,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "jessie_tc_5",
"time": "2025-07-18 - 02:40",
"attacker": {
"name": "daut",
"heroes": {
@@ -375,9 +375,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_6",
+ "test_id": "jessie_tc_6",
"time": "2025-07-18 - 02:44",
"attacker": {
"name": "daut",
"heroes": {},
@@ -460,9 +460,9 @@
"defender": 96
}
},
{
- "test_id": "daut_viper_7",
+ "test_id": "jessie_tc_7",
"time": "2025-07-18 - 02:47",
"attacker": {
"name": "daut",
"heroes": {},
@@ -545,9 +545,9 @@
"defender": 74
}
},
{
- "test_id": "daut_viper_8",
+ "test_id": "jessie_tc_8",
"time": "2025-07-18 - 02:57",
"attacker": {
"name": "daut",
"heroes": {
Index: testcases/heroes_unittests/Mia_tc.json
===================================================================
--- testcases/heroes_unittests/Mia_tc.json prev run
+++ testcases/heroes_unittests/Mia_tc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "mia_tc_1",
"time": "2025-07-18 - 23:40",
"attacker": {
"name": "daut",
"heroes": {},
@@ -84,9 +84,9 @@
"defender": 181
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "mia_tc_2",
"time": "2025-07-18 - 23:41",
"attacker": {
"name": "daut",
"heroes": {},
@@ -195,9 +195,9 @@
}
]
},
{
- "test_id": "daut_viper_3",
+ "test_id": "mia_tc_3",
"time": "2025-07-19 - 01:02",
"attacker": {
"name": "daut",
"heroes": {
@@ -300,9 +300,9 @@
}
]
},
{
- "test_id": "daut_viper_4",
+ "test_id": "mia_tc_4",
"time": "2025-07-20 - 02:42",
"attacker": {
"name": "daut",
"heroes": {
@@ -383,9 +383,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "mia_tc_5",
"time": "2025-07-20 - 02:48",
"attacker": {
"name": "daut",
"heroes": {
Index: testcases/heroes_unittests/Molly_tc.json
===================================================================
--- testcases/heroes_unittests/Molly_tc.json prev run
+++ testcases/heroes_unittests/Molly_tc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "molly_tc_1",
"time": "2025-07-18 - 02:17",
"attacker": {
"name": "daut",
"heroes": {},
@@ -77,9 +77,9 @@
"defender": 61
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "molly_tc_2",
"time": "2025-07-18 - 03:33",
"attacker": {
"name": "daut",
"heroes": {},
@@ -155,9 +155,9 @@
"defender": 89
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "molly_tc_3",
"time": "2025-07-18 - 02:34",
"attacker": {
"name": "daut",
"heroes": {},
@@ -223,9 +223,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "molly_tc_4",
"time": "2025-07-18 - 04:43",
"attacker": {
"name": "daut",
"heroes": {
@@ -308,9 +308,9 @@
"defender": 68
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "molly_tc_5",
"time": "2025-07-18 - 05:37",
"attacker": {
"name": "daut",
"heroes": {},
Index: testcases/heroes_unittests/Reina_tc.json
===================================================================
--- testcases/heroes_unittests/Reina_tc.json prev run
+++ testcases/heroes_unittests/Reina_tc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "reina_tc_1",
"time": "2025-07-18 - 18:45",
"attacker": {
"name": "daut2x",
"heroes": {},
@@ -82,9 +82,9 @@
"defender": 62
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "reina_tc_2",
"time": "2025-07-18 - 18:55",
"attacker": {
"name": "daut2x",
"heroes": {},
@@ -169,9 +169,9 @@
"defender": 40
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "reina_tc_4",
"time": "2025-07-18 - 20:11",
"attacker": {
"name": "daut",
"heroes": {},
@@ -276,9 +276,9 @@
}
]
},
{
- "test_id": "daut_viper_5",
+ "test_id": "reina_tc_5",
"time": "2025-07-18 - 20:16",
"attacker": {
"name": "daut",
"heroes": {},
Index: testcases/heroes_unittests/Seo-yoon_tc_nc.json
===================================================================
--- testcases/heroes_unittests/Seo-yoon_tc_nc.json prev run
+++ testcases/heroes_unittests/Seo-yoon_tc_nc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "seoyoon_tc_1",
"time": "2025-07-18 - 02:17",
"attacker": {
"name": "daut",
"heroes": {},
@@ -77,9 +77,9 @@
"defender": 61
}
},
{
- "test_id": "daut_viper_6",
+ "test_id": "seoyoon_tc_6",
"time": "2025-07-18 - 03:33",
"attacker": {
"name": "daut",
"heroes": {},
@@ -155,9 +155,9 @@
"defender": 89
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "seoyoon_tc_3",
"time": "2025-07-18 - 02:34",
"attacker": {
"name": "daut",
"heroes": {},
@@ -223,9 +223,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "seoyoon_tc_4",
"time": "2025-07-18 - 04:20",
"attacker": {
"name": "daut",
"heroes": {
@@ -306,9 +306,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "seoyoon_tc_5",
"time": "2025-07-18 - 04:24",
"attacker": {
"name": "daut",
"heroes": {},
@@ -389,9 +389,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_6",
+ "test_id": "seoyoon_tc_6",
"time": "2025-07-18 - 04:28",
"attacker": {
"name": "daut",
"heroes": {
@@ -477,9 +477,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_7",
+ "test_id": "seoyoon_tc_7",
"time": "2025-07-18 - 04:36",
"attacker": {
"name": "daut",
"heroes": {
Index: testcases/heroes_unittests/Zinman_tc.json
===================================================================
--- testcases/heroes_unittests/Zinman_tc.json prev run
+++ testcases/heroes_unittests/Zinman_tc.json this run
@@ -1,7 +1,7 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "zinman_tc_1",
"time": "2025-07-18 - 23:01",
"attacker": {
"name": "daut",
"heroes": {},
@@ -76,9 +76,9 @@
"defender": 122
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "zinman_tc_2",
"time": "2025-07-18 - 23:16",
"attacker": {
"name": "daut",
"heroes": {},
@@ -162,9 +162,9 @@
"defender": 0
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "zinman_tc_3",
"time": "2025-07-18 - 23:31",
"attacker": {
"name": "daut",
"heroes": {},
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/config/hero_definitions/README.md b/simulator/config/hero_definitions/README.md
--- a/simulator/config/hero_definitions/README.md
+++ b/simulator/config/hero_definitions/README.md
@@ -69,8 +69,8 @@
When enabled:
-- the stat block from `hero_generation_stats.json` is added to every troop type's player stats;
-- generation stats from all **main** heroes are summed;
+- each main hero's generation stat block is added only to the player-stat block for that hero's troop type;
+- generation stats from multiple main heroes of the same troop type are summed in that troop type's block;
- joiner heroes do not contribute generation stats;
- skill level does not change the generation stat block.
Run B dirty state patch
diff --git a/simulator/src/tooling/parityMetrics.ts b/simulator/src/tooling/parityMetrics.ts
--- a/simulator/src/tooling/parityMetrics.ts
+++ b/simulator/src/tooling/parityMetrics.ts
@@ -52,7 +52,7 @@
if (options.deterministic) {
statType = "deterministic";
passes = Math.abs(biasPct) <= deterministicLimit;
- } else if (options.candidate.sigma === 0) {
+ } else if (options.candidate.sigma === 0 && options.reference.sigma === 0) {
statType = "zero_var";
passes = Math.abs(biasPct) <= deterministicLimit;
} else if (options.reference.n <= 1) {
@@ -63,10 +63,21 @@
passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
} else {
statType = "t";
- sem = options.candidate.sigma * Math.sqrt(1 / Math.max(options.candidate.n, 1) + 1 / options.reference.n);
- stat = sem === 0 ? null : round(biasRaw / sem, 4);
- p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
- passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
+ const candidateVariance = (options.candidate.sigma ** 2) / Math.max(options.candidate.n, 1);
+ const referenceVariance = (options.reference.sigma ** 2) / options.reference.n;
+ sem = Math.sqrt(candidateVariance + referenceVariance);
+ const rawStat = sem === 0 ? null : biasRaw / sem;
+ const degreesOfFreedom = welchDegreesOfFreedom(
+ candidateVariance,
+ options.candidate.n,
+ referenceVariance,
+ options.reference.n
+ );
+ const rawP = rawStat === null ? null : studentTwoSidedP(rawStat, degreesOfFreedom);
+ stat = rawStat === null ? null : round(rawStat, 4);
+ p = rawP === null ? null : round(rawP, 6);
+ const alpha = 2 * (1 - normalCdf(zThreshold));
+ passes = rawP === null || rawP >= alpha || Math.abs(biasPct) <= minBiasPct;
}
return {
@@ -104,6 +115,86 @@
return 0.5 * (1 + erf(x / Math.SQRT2));
}
+function welchDegreesOfFreedom(candidateVariance: number, candidateN: number, referenceVariance: number, referenceN: number): number {
+ const numerator = (candidateVariance + referenceVariance) ** 2;
+ const denominator = varianceDegreesTerm(candidateVariance, candidateN) + varianceDegreesTerm(referenceVariance, referenceN);
+ return denominator === 0 ? Number.POSITIVE_INFINITY : numerator / denominator;
+}
+
+function varianceDegreesTerm(variance: number, n: number): number {
+ return n <= 1 ? 0 : (variance ** 2) / (n - 1);
+}
+
+function studentTwoSidedP(stat: number, degreesOfFreedom: number): number {
+ if (!Number.isFinite(degreesOfFreedom)) return 2 * (1 - normalCdf(Math.abs(stat)));
+ const x = degreesOfFreedom / (degreesOfFreedom + stat ** 2);
+ return regularizedIncompleteBeta(x, degreesOfFreedom / 2, 0.5);
+}
+
+function regularizedIncompleteBeta(x: number, a: number, b: number): number {
+ if (x <= 0) return 0;
+ if (x >= 1) return 1;
+ const scale = Math.exp(logGamma(a + b) - logGamma(a) - logGamma(b) + a * Math.log(x) + b * Math.log1p(-x));
+ if (x < (a + 1) / (a + b + 2)) return (scale * betaContinuedFraction(x, a, b)) / a;
+ return 1 - (scale * betaContinuedFraction(1 - x, b, a)) / b;
+}
+
+function betaContinuedFraction(x: number, a: number, b: number): number {
+ const maxIterations = 200;
+ const epsilon = 3e-14;
+ const minimum = 1e-300;
+ const qab = a + b;
+ const qap = a + 1;
+ const qam = a - 1;
+ let c = 1;
+ let d = 1 - (qab * x) / qap;
+ if (Math.abs(d) < minimum) d = minimum;
+ d = 1 / d;
+ let result = d;
+
+ for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
+ const even = iteration * 2;
+ let coefficient = (iteration * (b - iteration) * x) / ((qam + even) * (a + even));
+ d = 1 + coefficient * d;
+ if (Math.abs(d) < minimum) d = minimum;
+ c = 1 + coefficient / c;
+ if (Math.abs(c) < minimum) c = minimum;
+ d = 1 / d;
+ result *= d * c;
+
+ coefficient = -((a + iteration) * (qab + iteration) * x) / ((a + even) * (qap + even));
+ d = 1 + coefficient * d;
+ if (Math.abs(d) < minimum) d = minimum;
+ c = 1 + coefficient / c;
+ if (Math.abs(c) < minimum) c = minimum;
+ d = 1 / d;
+ const delta = d * c;
+ result *= delta;
+ if (Math.abs(delta - 1) < epsilon) break;
+ }
+
+ return result;
+}
+
+function logGamma(value: number): number {
+ const coefficients = [
+ 676.5203681218851,
+ -1259.1392167224028,
+ 771.3234287776531,
+ -176.6150291621406,
+ 12.507343278686905,
+ -0.13857109526572012,
+ 9.984369578019572e-6,
+ 1.5056327351493116e-7
+ ];
+ if (value < 0.5) return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * value)) - logGamma(1 - value);
+ let series = 0.9999999999998099;
+ const shifted = value - 1;
+ for (let index = 0; index < coefficients.length; index += 1) series += coefficients[index] / (shifted + index + 1);
+ const t = shifted + coefficients.length - 0.5;
+ return 0.5 * Math.log(2 * Math.PI) + (shifted + 0.5) * Math.log(t) - t + Math.log(series);
+}
+
function erf(x: number): number {
const sign = x < 0 ? -1 : 1;
const ax = Math.abs(x);
diff --git a/simulator/src/tooling/testcases.test.ts b/simulator/src/tooling/testcases.test.ts
--- a/simulator/src/tooling/testcases.test.ts
+++ b/simulator/src/tooling/testcases.test.ts
@@ -404,6 +404,22 @@
assert.equal(metrics.stat, -4.4459);
});
+test("compareOutcomeDistribution includes noisy game samples in a Welch t comparison", () => {
+ const metrics = compareOutcomeDistribution({
+ candidate: { n: 100, mu: -1149.6, sigma: 1578.2 },
+ reference: { n: 10, mu: 179.3, sigma: 2006.6 },
+ initialTroops: 17_500,
+ deterministic: false,
+ thresholds: { z_threshold: 2, min_bias_pct: 0.5 }
+ });
+
+ assert.equal(metrics.stat_type, "t");
+ assert.equal(metrics.sem, 653.87);
+ assert.equal(metrics.stat, -2.0323);
+ assert.equal(metrics.p, 0.069131);
+ assert.equal(metrics.passes, true);
+});
+
test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {
const rows = [
{ p: 0.01, q: null },
diff --git a/simulator/src/tooling/testcases.ts b/simulator/src/tooling/testcases.ts
--- a/simulator/src/tooling/testcases.ts
+++ b/simulator/src/tooling/testcases.ts
@@ -11,7 +11,7 @@
import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
import { prepareBattle, runPrepared } from "../simulator";
import { DamageAggregationError } from "../staticDamageProfile";
-import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "../types";
+import type { BattleInput, BattleResult, FighterInput, SimulationMode, SimulatorConfig, StatBlock, UnitType } from "../types";
const DEFAULT_STOCHASTIC_REPEAT = 100;
const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;
@@ -146,6 +146,8 @@
input: BattleInput;
repeat: number;
seed?: string | number;
+ includeSamples?: boolean;
+ simulationMode?: SimulationMode;
}
export interface TestcaseExecutionResult {
@@ -619,7 +621,11 @@
// Resolve the battle once and reuse it across every seeded sample of this case.
const compiled = prepareBattle(job.input, config);
const baseSeed = job.seed ?? job.input.seed;
- const sample = (iteration: number) => runPrepared(compiled, sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration));
+ const sample = (iteration: number) => runPrepared(
+ compiled,
+ sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration),
+ job.simulationMode ? { mode: job.simulationMode } : {}
+ );
let result = sample(0);
const firstScore = battleScoreDelta(result);
if (firstScore !== undefined) {
@@ -645,7 +651,7 @@
result,
deterministic: result.randomness.deterministic,
sampleCount,
- simulatorStats: sampleStats(samples),
+ simulatorStats: sampleStats(samples, { includeSamples: job.includeSamples }),
simulatorScoreDelta: battleScoreDelta(result),
simulatorSampleOutcomes: sampleOutcomes,
simulatorSampleDeltas: sampleDeltas,
diff --git a/testcases/1-testcases_no-heroes_t6_single-type_nc.json b/testcases/1-testcases_no-heroes_t6_single-type_nc.json
--- a/testcases/1-testcases_no-heroes_t6_single-type_nc.json
+++ b/testcases/1-testcases_no-heroes_t6_single-type_nc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_9",
+ "test_id": "no_heroes_single_t6__9",
"time": "2025-07-13 - 12:02",
"attacker": {
"name": "daut",
@@ -68,7 +68,7 @@
}
},
{
- "test_id": "daut_viper_10",
+ "test_id": "no_heroes_single_t6__10",
"time": "2025-07-13 - 12:07",
"attacker": {
"name": "daut",
@@ -136,7 +136,7 @@
}
},
{
- "test_id": "daut_viper_11",
+ "test_id": "no_heroes_single_t6__11",
"time": "2025-07-13 - 12:08",
"attacker": {
"name": "daut",
@@ -204,7 +204,7 @@
}
},
{
- "test_id": "daut_viper_12",
+ "test_id": "no_heroes_single_t6__12",
"time": "2025-07-13 - 12:15",
"attacker": {
"name": "daut",
@@ -272,7 +272,7 @@
}
},
{
- "test_id": "daut_viper_13",
+ "test_id": "no_heroes_single_t6__13",
"time": "2025-07-13 - 12:21",
"attacker": {
"name": "daut",
@@ -340,7 +340,7 @@
}
},
{
- "test_id": "daut_viper_14",
+ "test_id": "no_heroes_single_t6__14",
"time": "2025-07-13 - 12:24",
"attacker": {
"name": "daut",
@@ -408,7 +408,7 @@
}
},
{
- "test_id": "daut_viper_15",
+ "test_id": "no_heroes_single_t6__15",
"time": "2025-07-13 - 12:27",
"attacker": {
"name": "daut",
@@ -476,7 +476,7 @@
}
},
{
- "test_id": "daut_viper_16",
+ "test_id": "no_heroes_single_t6__16",
"time": "2025-07-13 - 12:29",
"attacker": {
"name": "daut",
@@ -544,7 +544,7 @@
}
},
{
- "test_id": "daut_viper_17",
+ "test_id": "no_heroes_single_t6__17",
"time": "2025-07-13 - 12:32",
"attacker": {
"name": "daut",
@@ -614,7 +614,7 @@
}
},
{
- "test_id": "daut_viper_18",
+ "test_id": "no_heroes_single_t6__18",
"time": "2025-07-13 - 12:34",
"attacker": {
"name": "daut",
@@ -684,7 +684,7 @@
}
},
{
- "test_id": "daut_viper_19",
+ "test_id": "no_heroes_single_t6__19",
"time": "2025-07-13 - 12:36",
"attacker": {
"name": "daut",
@@ -754,7 +754,7 @@
}
},
{
- "test_id": "daut_viper_20",
+ "test_id": "no_heroes_single_t6__20",
"time": "2025-07-13 - 12:39",
"attacker": {
"name": "daut",
@@ -824,7 +824,7 @@
}
},
{
- "test_id": "daut_viper_21",
+ "test_id": "no_heroes_single_t6__21",
"time": "2025-07-13 - 12:42",
"attacker": {
"name": "daut",
@@ -894,7 +894,7 @@
}
},
{
- "test_id": "daut_viper_22",
+ "test_id": "no_heroes_single_t6__22",
"time": "2025-07-13 - 12:43",
"attacker": {
"name": "daut",
@@ -964,7 +964,7 @@
}
},
{
- "test_id": "daut_viper_23",
+ "test_id": "no_heroes_single_t6__23",
"time": "2025-07-13 - 12:45",
"attacker": {
"name": "daut",
@@ -1034,7 +1034,7 @@
}
},
{
- "test_id": "daut_viper_24",
+ "test_id": "no_heroes_single_t6__24",
"time": "2025-07-13 - 12:47",
"attacker": {
"name": "daut",
@@ -1104,7 +1104,7 @@
}
},
{
- "test_id": "daut_viper_25",
+ "test_id": "no_heroes_single_t6__25",
"time": "2025-07-13 - 12:50",
"attacker": {
"name": "daut",
@@ -1174,7 +1174,7 @@
}
},
{
- "test_id": "daut_viper_26",
+ "test_id": "no_heroes_single_t6__26",
"time": "2025-07-13 - 12:54",
"attacker": {
"name": "daut",
diff --git a/testcases/2-testcases_no-heroes_t6_mixed_nc.json b/testcases/2-testcases_no-heroes_t6_mixed_nc.json
--- a/testcases/2-testcases_no-heroes_t6_mixed_nc.json
+++ b/testcases/2-testcases_no-heroes_t6_mixed_nc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "no_heroes_t6_mixed_1",
"time": "2025-07-12 - 22:59",
"attacker": {
"name": "daut",
@@ -78,7 +78,7 @@
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "no_heroes_t6_mixed_2",
"time": "2025-07-18 - 02:17",
"attacker": {
"name": "daut",
@@ -156,7 +156,7 @@
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "no_heroes_t6_mixed_3",
"time": "2025-07-18 - 02:30",
"attacker": {
"name": "daut",
@@ -234,7 +234,7 @@
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "no_heroes_t6_mixed_4",
"time": "2025-07-18 - 03:17",
"attacker": {
"name": "daut",
@@ -312,7 +312,7 @@
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "no_heroes_t6_mixed_5",
"time": "2025-07-18 - 03:24",
"attacker": {
"name": "daut",
@@ -390,7 +390,7 @@
}
},
{
- "test_id": "daut_viper_6",
+ "test_id": "no_heroes_t6_mixed_6",
"time": "2025-07-18 - 03:33",
"attacker": {
"name": "daut",
diff --git a/testcases/3-testcases_mixed-heroes-not-verified.json b/testcases/3-testcases_mixed-heroes-not-verified.json
--- a/testcases/3-testcases_mixed-heroes-not-verified.json
+++ b/testcases/3-testcases_mixed-heroes-not-verified.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "no_heroes_t6_mixed_2x_1",
"time": "2025-07-12 - 23:09",
"attacker": {
"name": "daut2x",
@@ -85,7 +85,7 @@
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "no_heroes_t6_mixed_2x_2",
"time": "2025-07-12 - 23:17",
"attacker": {
"name": "daut2x",
@@ -167,7 +167,7 @@
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "no_heroes_t6_mixed_2x_3",
"time": "2025-07-13 - 13:45",
"attacker": {
"name": "daut2x",
@@ -281,7 +281,7 @@
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "no_heroes_t6_mixed_2x_4",
"time": "2025-07-13 - 16:51",
"attacker": {
"name": "daut2x",
@@ -393,7 +393,7 @@
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "no_heroes_t6_mixed_2x_5",
"time": "2025-07-13 - 16:57",
"attacker": {
"name": "daut2x",
@@ -505,7 +505,7 @@
}
},
{
- "test_id": "daut_viper_7",
+ "test_id": "no_heroes_t6_mixed_2x_6",
"time": "2025-07-13 - 17:03",
"attacker": {
"name": "daut2x",
diff --git a/testcases/heroes_unittests/Flint_tc.json b/testcases/heroes_unittests/Flint_tc.json
--- a/testcases/heroes_unittests/Flint_tc.json
+++ b/testcases/heroes_unittests/Flint_tc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "flint_tc_1",
"time": "2025-07-20 - 03:49",
"attacker": {
"name": "daut",
@@ -105,7 +105,7 @@
]
},
{
- "test_id": "daut_viper_2",
+ "test_id": "flint_tc_2",
"time": "2025-07-20 - 04:07",
"attacker": {
"name": "daut",
diff --git a/testcases/heroes_unittests/Hector_tc.json b/testcases/heroes_unittests/Hector_tc.json
--- a/testcases/heroes_unittests/Hector_tc.json
+++ b/testcases/heroes_unittests/Hector_tc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "hector_tc_1",
"time": "2025-07-21 - 01:06",
"attacker": {
"name": "daut",
diff --git a/testcases/heroes_unittests/Jessie_tc_nc.json b/testcases/heroes_unittests/Jessie_tc_nc.json
--- a/testcases/heroes_unittests/Jessie_tc_nc.json
+++ b/testcases/heroes_unittests/Jessie_tc_nc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "jessie_tc_1",
"time": "2025-07-18 - 02:17",
"attacker": {
"name": "daut",
@@ -78,7 +78,7 @@
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "jessie_tc_2",
"time": "2025-07-18 - 03:33",
"attacker": {
"name": "daut",
@@ -156,7 +156,7 @@
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "jessie_tc_3",
"time": "2025-07-18 - 02:34",
"attacker": {
"name": "daut",
@@ -224,7 +224,7 @@
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "jessie_tc_4",
"time": "2025-07-18 - 02:38",
"attacker": {
"name": "daut",
@@ -300,7 +300,7 @@
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "jessie_tc_5",
"time": "2025-07-18 - 02:40",
"attacker": {
"name": "daut",
@@ -376,7 +376,7 @@
}
},
{
- "test_id": "daut_viper_6",
+ "test_id": "jessie_tc_6",
"time": "2025-07-18 - 02:44",
"attacker": {
"name": "daut",
@@ -461,7 +461,7 @@
}
},
{
- "test_id": "daut_viper_7",
+ "test_id": "jessie_tc_7",
"time": "2025-07-18 - 02:47",
"attacker": {
"name": "daut",
@@ -546,7 +546,7 @@
}
},
{
- "test_id": "daut_viper_8",
+ "test_id": "jessie_tc_8",
"time": "2025-07-18 - 02:57",
"attacker": {
"name": "daut",
diff --git a/testcases/heroes_unittests/Mia_tc.json b/testcases/heroes_unittests/Mia_tc.json
--- a/testcases/heroes_unittests/Mia_tc.json
+++ b/testcases/heroes_unittests/Mia_tc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "mia_tc_1",
"time": "2025-07-18 - 23:40",
"attacker": {
"name": "daut",
@@ -85,7 +85,7 @@
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "mia_tc_2",
"time": "2025-07-18 - 23:41",
"attacker": {
"name": "daut",
@@ -196,7 +196,7 @@
]
},
{
- "test_id": "daut_viper_3",
+ "test_id": "mia_tc_3",
"time": "2025-07-19 - 01:02",
"attacker": {
"name": "daut",
@@ -301,7 +301,7 @@
]
},
{
- "test_id": "daut_viper_4",
+ "test_id": "mia_tc_4",
"time": "2025-07-20 - 02:42",
"attacker": {
"name": "daut",
@@ -384,7 +384,7 @@
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "mia_tc_5",
"time": "2025-07-20 - 02:48",
"attacker": {
"name": "daut",
diff --git a/testcases/heroes_unittests/Molly_tc.json b/testcases/heroes_unittests/Molly_tc.json
--- a/testcases/heroes_unittests/Molly_tc.json
+++ b/testcases/heroes_unittests/Molly_tc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "molly_tc_1",
"time": "2025-07-18 - 02:17",
"attacker": {
"name": "daut",
@@ -78,7 +78,7 @@
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "molly_tc_2",
"time": "2025-07-18 - 03:33",
"attacker": {
"name": "daut",
@@ -156,7 +156,7 @@
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "molly_tc_3",
"time": "2025-07-18 - 02:34",
"attacker": {
"name": "daut",
@@ -224,7 +224,7 @@
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "molly_tc_4",
"time": "2025-07-18 - 04:43",
"attacker": {
"name": "daut",
@@ -309,7 +309,7 @@
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "molly_tc_5",
"time": "2025-07-18 - 05:37",
"attacker": {
"name": "daut",
diff --git a/testcases/heroes_unittests/Reina_tc.json b/testcases/heroes_unittests/Reina_tc.json
--- a/testcases/heroes_unittests/Reina_tc.json
+++ b/testcases/heroes_unittests/Reina_tc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "reina_tc_1",
"time": "2025-07-18 - 18:45",
"attacker": {
"name": "daut2x",
@@ -83,7 +83,7 @@
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "reina_tc_2",
"time": "2025-07-18 - 18:55",
"attacker": {
"name": "daut2x",
@@ -170,7 +170,7 @@
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "reina_tc_4",
"time": "2025-07-18 - 20:11",
"attacker": {
"name": "daut",
@@ -277,7 +277,7 @@
]
},
{
- "test_id": "daut_viper_5",
+ "test_id": "reina_tc_5",
"time": "2025-07-18 - 20:16",
"attacker": {
"name": "daut",
diff --git a/testcases/heroes_unittests/Seo-yoon_tc_nc.json b/testcases/heroes_unittests/Seo-yoon_tc_nc.json
--- a/testcases/heroes_unittests/Seo-yoon_tc_nc.json
+++ b/testcases/heroes_unittests/Seo-yoon_tc_nc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "seoyoon_tc_1",
"time": "2025-07-18 - 02:17",
"attacker": {
"name": "daut",
@@ -78,7 +78,7 @@
}
},
{
- "test_id": "daut_viper_6",
+ "test_id": "seoyoon_tc_6",
"time": "2025-07-18 - 03:33",
"attacker": {
"name": "daut",
@@ -156,7 +156,7 @@
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "seoyoon_tc_3",
"time": "2025-07-18 - 02:34",
"attacker": {
"name": "daut",
@@ -224,7 +224,7 @@
}
},
{
- "test_id": "daut_viper_4",
+ "test_id": "seoyoon_tc_4",
"time": "2025-07-18 - 04:20",
"attacker": {
"name": "daut",
@@ -307,7 +307,7 @@
}
},
{
- "test_id": "daut_viper_5",
+ "test_id": "seoyoon_tc_5",
"time": "2025-07-18 - 04:24",
"attacker": {
"name": "daut",
@@ -390,7 +390,7 @@
}
},
{
- "test_id": "daut_viper_6",
+ "test_id": "seoyoon_tc_6",
"time": "2025-07-18 - 04:28",
"attacker": {
"name": "daut",
@@ -478,7 +478,7 @@
}
},
{
- "test_id": "daut_viper_7",
+ "test_id": "seoyoon_tc_7",
"time": "2025-07-18 - 04:36",
"attacker": {
"name": "daut",
diff --git a/testcases/heroes_unittests/Zinman_tc.json b/testcases/heroes_unittests/Zinman_tc.json
--- a/testcases/heroes_unittests/Zinman_tc.json
+++ b/testcases/heroes_unittests/Zinman_tc.json
@@ -1,6 +1,6 @@
[
{
- "test_id": "daut_viper_1",
+ "test_id": "zinman_tc_1",
"time": "2025-07-18 - 23:01",
"attacker": {
"name": "daut",
@@ -77,7 +77,7 @@
}
},
{
- "test_id": "daut_viper_2",
+ "test_id": "zinman_tc_2",
"time": "2025-07-18 - 23:16",
"attacker": {
"name": "daut",
@@ -163,7 +163,7 @@
}
},
{
- "test_id": "daut_viper_3",
+ "test_id": "zinman_tc_3",
"time": "2025-07-18 - 23:31",
"attacker": {
"name": "daut",