WOS Sim
← Back to Runs

Compare Runs

A (baseline)
9ee426b7-faace9afe1e29/08/2026, 18:22:13
B (current)
002e2b2e-57c7f6ec1ee08/09/2026, 08:53:57
Avg Error A0.19%
Avg Error B0.20%
Δ Avg Error+0.01%
Changed128
Improved85
Regressed43
Added13
Retired0

Testcase Delta

301 / 301
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/wu_ming_solo_current_nc.jsonwu_ming_solo_current_nc0—-1.42%0.00%-1.42%improved
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21—-1.04%-1.80%+0.76%regressed
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v20—1.58%2.21%+0.63%regressed
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_43—-0.56%0.00%-0.56%improved
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_32+0.025%-0.48%0.00%-0.48%improved
testcases/heroes_unittests/Mia_tc.jsonmia_tc_21—-0.68%-0.23%-0.45%improved
testcases/emulator_verified/alonso_solo.jsonalonso_solo0—2.52%2.96%+0.44%regressed
testcases/emulator_verified/gordon_wayne_s1_extra_overlap_nc.jsongordon_wayne_s1_extra_overlap_nc0-0.05%1.03%0.63%-0.40%improved
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0—0.43%-0.03%-0.40%improved
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_10—-0.38%0.00%-0.38%improved
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo0-0.05%-0.41%0.09%-0.32%improved
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc0—-3.13%-2.83%-0.30%improved
testcases/emulator_verified/renee_gwen_bucket_retry_nc.jsonrenee_gwen_bucket_retry_nc0—0.29%0.00%-0.29%improved
testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2200-t9-lancers-vs-146-t1-fc10-lancers0—2.20%2.49%+0.29%regressed
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_43+0.013%-0.28%0.00%-0.28%improved
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_43—0.35%0.08%-0.27%improved
testcases/emulator_verified/jessie_solo_nc.jsonjessie_solo0—-0.24%0.00%-0.24%improved
testcases/emulator_verified/norah_s2_inf_only_B_nc.jsonnorah_s2_inf_only_B0—-0.24%0.00%-0.24%improved
testcases/emulator_verified/sergey_reina_combo_nc.jsonsergey_reina_combo_nc0—-0.24%-0.03%-0.21%improved
testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2201-t9-lancers-vs-146-t1-fc10-lancers0—-0.58%-0.37%-0.21%improved
testcases/emulator_verified/norah_zinman_combo_nc.jsonnorah_zinman_combo_nc0—0.05%0.24%+0.19%regressed
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo0—0.19%0.38%+0.19%regressed
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_54—-0.19%0.00%-0.19%improved
testcases/emulator_verified/renee_wayne_mixed_510_vs_888.jsonrenee_wayne_mixed_510_vs_8880—-0.08%-0.26%+0.18%regressed
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo0—0.35%0.52%+0.17%regressed
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_54—-0.15%0.00%-0.15%improved
testcases/emulator_verified/damage_precision_325l_008i_325m.jsondamage_precision_325l_008i_325m0—0.15%0.00%-0.15%improved
testcases/emulator_verified/philly_solo.jsonphilly_solo0—-0.16%-0.01%-0.15%improved
testcases/emulator_verified/mia_solo.jsonmia_solo1—0.19%0.32%+0.13%regressed
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo0—-0.92%-0.79%-0.13%improved
testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.jsons9-3000-t9-lancers-vs-146-t1-fc10-lancers0—0.11%0.22%+0.11%regressed
testcases/emulator_verified/norah_s2_splash_D.jsonnorah_s2_splash_D0—0.03%0.13%+0.10%regressed
testcases/emulator_verified/seo_yoon_solo_nc.jsonseo_yoon_solo0—0.10%0.00%-0.10%improved
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.12%+0.10%regressed
testcases/emulator_verified/renee_ahmose_damage_taken_overlap_attacker_nc.jsonrenee_ahmose_damage_taken_overlap_attacker_nc0-0.05%0.09%0.00%-0.09%improved
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_32—-0.09%0.00%-0.09%improved
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_32—-0.09%0.00%-0.09%improved
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_32—-0.09%0.00%-0.09%improved
testcases/emulator_verified/gordon_wayne_s1_extra_overlap_lvl2.jsongordon_wayne_s1_extra_overlap_nc0-0.05%0.24%0.16%-0.08%improved
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_54—-0.08%0.00%-0.08%improved
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_65—-0.07%0.00%-0.07%improved
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_54—0.50%0.43%-0.07%improved
testcases/emulator_verified/mia_solo.jsonmia_solo2—-0.16%-0.09%-0.07%improved
testcases/emulator_verified/wu_ming_rounding_followup_nc.jsonwu_ming_rounding_followup_nc0—0.07%0.00%-0.07%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.75%0.68%-0.07%improved
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_21—-0.07%0.00%-0.07%improved
testcases/heroes_unittests/Mia_tc.jsonmia_tc_32—0.07%0.00%-0.07%improved
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_21—-0.07%0.00%-0.07%improved
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_61—-0.07%0.00%-0.07%improved
testcases/2-testcases_no-heroes_t6_mixed_nc.jsonno_heroes_t6_mixed_21—-0.06%0.00%-0.06%improved
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_10-0.025%0.06%0.00%-0.06%improved
testcases/emulator_verified/norah_s2_splash_C_nc.jsonnorah_s2_splash_C0—-0.06%0.00%-0.06%improved
testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-11791-t9-marksmen-vs-one-t1-fc10-infantry0—0.54%0.48%-0.06%improved
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_10—-0.06%0.00%-0.06%improved
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_65—-0.06%0.00%-0.06%improved
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_10—-0.06%0.00%-0.06%improved
testcases/heroes_unittests/Reina_tc.jsonreina_tc_42—-0.64%-0.58%-0.06%improved
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_10—-0.06%0.00%-0.06%improved
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0—-0.03%-0.08%+0.05%regressed
testcases/emulator_verified/greg_solo.jsongreg_solo0—0.11%0.16%+0.05%regressed
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0-0.05%0.04%0.09%+0.05%regressed
testcases/emulator_verified/norah_solo.jsonnorah_solo0—-0.24%-0.19%-0.05%improved
testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-12000-t9-marksmen-vs-one-t1-fc10-infantry0—0.36%0.31%-0.05%improved
testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2100-t9-lancers-vs-146-t1-fc10-lancers0—0.47%0.52%+0.05%regressed
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_76—-0.05%0.00%-0.05%improved
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__134+0.009%-0.04%0.00%-0.04%improved
testcases/emulator_verified/mia_solo.jsonmia_solo4—0.30%0.34%+0.04%regressed
testcases/emulator_verified/patrick_solo_nc.jsonpatrick_solo0—-0.04%0.00%-0.04%improved
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_bucket.jsonwos451_gordon_s21_lancer_damage_up_bucket0-0.025%0.04%0.00%-0.04%improved
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.04%0.00%-0.04%improved
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_43—-0.04%0.00%-0.04%improved
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender0—0.35%0.32%-0.03%improved
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0—0.06%0.03%-0.03%improved
testcases/emulator_verified/hector_solo.jsonhector_solo0—0.13%0.16%+0.03%regressed
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator0—0.15%0.18%+0.03%regressed
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1—-2.02%-2.05%+0.03%improved
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo0—0.15%0.18%+0.03%regressed
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_bucket.jsonwos451_gordon_s11_lancer_damage_up_bucket0-0.025%0.03%0.00%-0.03%improved
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.22%+0.03%regressed
testcases/emulator_verified/flint_solo_nc.jsonflint_solo0+0.025%-0.02%0.00%-0.02%improved
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2—-0.02%0.00%-0.02%improved
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc0—0.53%0.55%+0.02%regressed
testcases/emulator_verified/hector_renee_wayne.jsonhector_renee_wayne0+0.05%-0.45%-0.43%-0.02%improved
testcases/emulator_verified/jasser_solo_nc.jsonjasser_solo0—-0.02%0.00%-0.02%improved
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo1+0.05%-0.02%0.00%-0.02%improved
testcases/emulator_verified/logan_bahiti_combo_nc.jsonlogan_bahiti_combo_nc0—0.43%0.45%+0.02%regressed
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo1+0.025%-0.02%0.00%-0.02%improved
testcases/emulator_verified/lynn_solo.jsonlynn_solo0—-0.06%-0.08%+0.02%regressed
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0—-0.02%0.00%-0.02%improved
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1—-0.12%-0.10%-0.02%improved
testcases/emulator_verified/mia_solo.jsonmia_solo0—-0.23%-0.21%-0.02%improved
testcases/emulator_verified/mia_solo.jsonmia_solo3—0.02%0.04%+0.02%regressed
testcases/emulator_verified/norah_s2_splash_A.jsonnorah_s2_splash_A0—0.19%0.21%+0.02%regressed
testcases/emulator_verified/sergey_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc0—-1.03%-1.01%-0.02%improved
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0—-0.38%-0.36%-0.02%improved
testcases/gatot_verified/s1-5050-vs-1000.jsons1-5050-vs-10000+0.007%0.02%0.00%-0.02%improved
testcases/gatot_verified/s2-health-1.1-5100-vs-1000.jsons2-health-1.1-5100-vs-10000+0.007%-0.02%0.00%-0.02%improved
testcases/heroes_unittests/Mia_tc.jsonmia_tc_43—-0.36%-0.34%-0.02%improved
testcases/heroes_unittests/Mia_tc.jsonmia_tc_54—-0.07%-0.05%-0.02%improved
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_32+0.05%-0.40%-0.39%-0.01%regressed
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo1—-0.26%-0.27%+0.01%regressed
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2—-0.17%-0.18%+0.01%regressed
testcases/emulator_verified/ambusher_logan_renee_vs_logan_patrick_nc.jsonambusher_logan_renee_vs_logan_patrick_nc0—-6.04%-6.05%+0.01%regressed
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc0—0.04%0.05%+0.01%regressed
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo0—-0.15%-0.14%-0.01%improved
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current0—0.01%0.02%+0.01%regressed
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current1—0.02%0.03%+0.01%regressed
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo0—0.08%0.09%+0.01%regressed
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo1—0.03%0.02%-0.01%improved
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo2—0.01%0.00%-0.01%improved
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0—-0.60%-0.59%-0.01%improved
testcases/emulator_verified/molly_solo.jsonmolly_solo0—-0.08%-0.09%+0.01%regressed
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0—-1.05%-1.04%-0.01%improved
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo1—0.20%0.21%+0.01%regressed
testcases/emulator_verified/philly_solo.jsonphilly_solo1—0.03%0.04%+0.01%regressed
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc0—1.06%1.07%+0.01%regressed
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo0—0.17%0.18%+0.01%regressed
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo0—-0.06%-0.05%-0.01%improved
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_8400—-0.92%-0.93%+0.01%regressed
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_hero_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_hero_nc0—-0.16%-0.17%+0.01%regressed
testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.jsons15.3-1000-inf-1000-lancer-0-marksman0-0.05%0.01%0.00%-0.01%improved
testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.jsons25-3000-inf-1000-marksman-vs-5000-inf0+0.01%0.01%0.00%-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.01%0.00%-0.01%improved
testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.jsons9-1500-t9-lancers-vs-146-t1-fc10-lancers0—-0.02%-0.01%-0.01%improved
testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.jsons9-1950-t9-lancers-vs-146-t1-fc10-lancers0—-0.10%-0.09%-0.01%improved
testcases/heroes_unittests/Flint_tc.jsonflint_tc_21-0.05%0.37%0.38%+0.01%regressed
testcases/heroes_unittests/Mia_tc.jsonmia_tc_10—0.21%0.20%-0.01%improved
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__101—0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__112—0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__123+0.01%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__145+0.004%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__156+0.013%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__167+0.005%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__178+0.003%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__189+0.017%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__1910—0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2011—0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2112—0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2213+0.004%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2314—0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2415+0.01%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2516—0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__2617+0.003%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsonno_heroes_single_t6__90+0.006%0.00%0.00%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_21—0.00%0.00%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_43—0.02%0.02%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsonno_heroes_t6_mixed_2x_65—0.17%0.17%0.00%unchanged
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_10—0.18%0.18%0.00%unchanged
testcases/emulator_verified/alonso_attacker_600_all.jsonalonso_attacker_600_all0—-0.15%-0.15%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer0—0.05%0.05%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_combo0—-0.01%-0.01%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0—-0.03%-0.03%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/damage_precision_325l_009i_325m.jsondamage_precision_325l_009i_325m0—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.017%0.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped0-0.017%0.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_zinman_combo_nc.jsonflint_zinman_combo_nc0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0—-0.01%0.01%0.00%changed
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current1—0.00%0.00%0.00%unchanged
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo0—-0.05%-0.05%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo0—0.00%0.00%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_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.00%0.00%0.00%unchanged
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0—-0.01%-0.01%0.00%unchanged
testcases/emulator_verified/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo0—0.06%0.06%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/philly_bahiti_combo.jsonphilly_bahiti_combo0—-0.13%-0.13%0.00%unchanged
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_solo_wip.jsonreina_solo_wip0—-0.01%-0.01%0.00%unchanged
testcases/emulator_verified/renee_charge_single_target_bucket_nc.jsonrenee_charge_single_target_bucket_nc0—0.00%0.00%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/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/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.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.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.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.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.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_bucket.jsonwos451_bradley_s22_infantry_damage_up_bucket0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.jsonwos451_bradley_s22_infantry_damage_up_control0—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_control.jsonwos451_gordon_s11_lancer_damage_up_control0—0.00%0.00%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_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.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo0—0.00%0.00%0.00%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0—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.006%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-5000-vs-1000.jsons1-5000-vs-10000+0.007%0.00%0.00%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.02%-0.02%-0.02%0.00%unchanged
testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.jsons15.3-1000-inf-0-lancer-1000-marksman0-0.038%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.jsons15.3-1000-inf-1000-lancer-1000-marksman0-0.032%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.025%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.029%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.008%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.006%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.007%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.007%0.00%0.00%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.017%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/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.00%0.00%0.00%unchanged
testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.jsons33-current-500-marksman-wayne3-vs-5000-gatot3-inf0—0.09%0.09%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.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/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/s4-fc9-gatot-40000.jsons4-fc9-gatot-400000—0.15%0.15%0.00%unchanged
testcases/gatot_verified/s4-fc9-gatot-90000.jsons4-fc9-gatot-900000—-0.09%-0.09%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/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-20000.jsons5-fc9-gatot-lumak-200000—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-25000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-25000-t9-marksmen-vs-one-t1-fc10-infantry0-0.05%0.14%0.14%0.00%regressed
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/heroes_unittests/Flint_tc.jsonflint_tc_10+0.05%-0.01%-0.01%0.00%unchanged
testcases/heroes_unittests/Hector_tc.jsonhector_tc_10—-0.01%-0.01%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsonjessie_tc_87—0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsonmolly_tc_54—-0.02%-0.02%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsonreina_tc_10—0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsonreina_tc_21—0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsonreina_tc_53—0.01%0.01%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_65—0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsonseoyoon_tc_76—0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsonzinman_tc_10—0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsonzinman_tc_21—0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsonzinman_tc_32—0.00%0.00%0.00%unchanged
testcases/emulator_verified/ahmose_no_infantry_240l_vs_125i.jsonahmose_no_infantry_240l_vs_125i0——0.00%—added
testcases/emulator_verified/ahmose_source_dies_001i_480l_vs_250i.jsonahmose_source_dies_001i_480l_vs_250i0——0.00%—added
testcases/emulator_verified/edith_ironclad_100i_vs_400m.jsonedith_ironclad_100i_vs_400m0——0.00%—added
testcases/emulator_verified/greg_deterrence_200i_vs_400m.jsongreg_deterrence_200i_vs_400m0——-0.12%—added
testcases/emulator_verified/gwen_blastmaster_cadence_800m_vs_1200i_100l_100m.jsongwen_blastmaster_cadence_800m_vs_1200i_100l_100m0-0.05%—1.27%—added
testcases/emulator_verified/gwen_blastmaster_fanout_800m_vs_600_each.jsongwen_blastmaster_fanout_800m_vs_600_each0-0.05%—0.35%—added
testcases/emulator_verified/gwen_blastmaster_small_timing_550m_vs_810i_45l_45m.jsongwen_blastmaster_small_timing_550m_vs_810i_45l_45m0-0.05%—6.83%—added
testcases/emulator_verified/hendrik_armor_lancer_scope.jsonhendrik_armor_lancer_scope0——0.00%—added
testcases/emulator_verified/hendrik_dragons_heir_250m_vs_400i_100l_100m.jsonhendrik_dragons_heir_250m_vs_400i_100l_100m0+0.05%—-0.59%—added
testcases/emulator_verified/hendrik_small_timing_250m_vs_150i_60l_60m.jsonhendrik_small_timing_250m_vs_150i_60l_60m0+0.05%—-1.92%—added
testcases/emulator_verified/reina_shadowblade_350l_vs_150i_50l_50m.jsonreina_shadowblade_350l_vs_150i_50l_50m0——0.25%—added
testcases/emulator_verified/renee_wu_ming_s1_damage_kind_nc.jsonrenee_wu_ming_s1_damage_kind_nc0——0.00%—added
testcases/emulator_verified/sonya_buffs_cadence_2200i_vs_800i_200l_800m.jsonsonya_buffs_cadence_2200i_vs_800i_200l_800m0——0.00%—added

Code / Config Changes

Commits (ce9afe1e → 7f6ec1ee)

7f6ec1eeArchive September 7 simulator parity resultspiddlyminx08/09/2026, 08:08:07

Code Changes (Run A → Run B)

Index: shared/fighters_data/fighters_stats.json
===================================================================
--- shared/fighters_data/fighters_stats.json prev run
+++ shared/fighters_data/fighters_stats.json this run
@@ -1,9 +1,9 @@
{
"max": {
- "inf": [1400.98, 1432.28, 1806.6, 1791.5],
- "lanc": [1400.98, 1432.28, 1756.8, 1722.5],
- "mark": [1400.98, 1431.28, 1806.4, 1785.4]
+ "inf": [1461.9, 1494.8, 1822.0, 2012.6],
+ "lanc": [1426.2, 1459.1, 2004.1, 1784.8],
+ "mark": [1424.5, 1457.4, 1991.5, 1819.8]
},
"custom": {
"inf": [1370.6, 1380.5, 1706.7, 1864.7],
"lanc": [1370.6, 1380.5, 1820.4, 1669.3],
Index: simulator/config/hero_definitions/Ahmose.json
===================================================================
--- simulator/config/hero_definitions/Ahmose.json prev run
+++ simulator/config/hero_definitions/Ahmose.json this run
@@ -5,10 +5,10 @@
"skills": {
"ViperFormation": {
"description": "His infantry pauses the attack once every 4 times reducing damage taken by Lancers and Marksmen by X% and Infantry by X% for 2 turns",
"trigger": {
- "type": "turn",
- "every": 4
+ "type": "turn",
+ "every": 4
},
"effects": {
"ViperFormation/1": {
"type": "no_attack",
@@ -20,50 +20,55 @@
"duration": {
"turns": {
"count": 1
}
- }
- },
- "ViperFormation/2": {
- "type": "active.hero.damageTaken.down",
- "value": [
- 10,
- 15,
- 20,
- 25,
- 30
- ],
- "units": {
- "applies_to": [
- "lancer",
- "marksman"
- ]
},
- "duration": {
- "turns": {
- "count": 2
+ "reason": "Protection is earned by using the Infantry pause. Delay its child effects so protection covers two complete turns, independent of attack order.",
+ "trigger_effects": {
+ "ViperFormation/2": {
+ "type": "active.hero.damageTaken.down",
+ "value": [
+ 10,
+ 15,
+ 20,
+ 25,
+ 30
+ ],
+ "units": {
+ "applies_to": [
+ "lancer",
+ "marksman"
+ ]
+ },
+ "duration": {
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
+ }
+ },
+ "ViperFormation/3": {
+ "type": "active.hero.damageTaken.down",
+ "value": [
+ 10,
+ 25,
+ 40,
+ 55,
+ 70
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ },
+ "duration": {
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
+ }
}
}
- },
- "ViperFormation/3": {
- "type": "active.hero.damageTaken.down",
- "value": [
- 10,
- 25,
- 40,
- 55,
- 70
- ],
- "units": {
- "applies_to": [
- "infantry"
- ]
- },
- "duration": {
- "turns": {
- "count": 2
- }
- }
}
}
},
"PrayerOfFlame": {
Index: simulator/config/hero_definitions/Gordon.json
===================================================================
--- simulator/config/hero_definitions/Gordon.json prev run
+++ simulator/config/hero_definitions/Gordon.json this run
@@ -12,8 +12,9 @@
},
"effects": {
"VenomInfusion/1": {
"type": "type.normal.damage.up",
+ "applies_to_damage_kinds": ["normal"],
"value": [
20,
40,
60,
Index: simulator/config/hero_definitions/Hendrik.json
===================================================================
--- simulator/config/hero_definitions/Hendrik.json prev run
+++ simulator/config/hero_definitions/Hendrik.json this run
@@ -43,10 +43,9 @@
"duration": {
"turns": {
"count": 2
}
- },
- "same_effect_stacking": "max"
+ }
}
}
},
"DragonsHeir": {
Index: simulator/config/hero_definitions/Norah.json
===================================================================
--- simulator/config/hero_definitions/Norah.json prev run
+++ simulator/config/hero_definitions/Norah.json this run
@@ -109,9 +109,10 @@
"applies_vs": "trigger.target"
},
"duration": {
"turns": {
- "count": 2
+ "count": 2,
+ "delay": 1
}
}
},
"Momentum/2": {
@@ -124,9 +125,10 @@
25
],
"duration": {
"turns": {
- "count": 2
+ "count": 2,
+ "delay": 1
}
}
}
}
Index: simulator/config/hero_definitions/README.md
===================================================================
--- simulator/config/hero_definitions/README.md prev run
+++ simulator/config/hero_definitions/README.md this run
@@ -194,8 +194,10 @@
For an `attack` trigger, `source` and `target` are matched directly against each intended normal attack. Reina demonstrates the reverse-side form: `source: "enemy.any"` and `target: "self.any"` listens for an enemy attack against Reina's side.
`turn` triggers reject `source` and `target`. They have no attack-shaped source or target; use concrete effect scopes. A broad turn-scoped effect can participate in every matching normal attack during its active window. For example, an `extra_skill_attack` applying to `self.any` with `turns.count: 1` can emit one extra job for each eligible troop attack that turn without matching the generated skill jobs.
+Ahmose's Viper Formation creates a one-turn Infantry `no_attack` effect on each scheduled turn, even without Infantry. Only using that effect to pause an attack creates its two `trigger_effects` for damage reduction. Those children start next turn and last two complete turns; an unused pause expires without granting protection. The configuration's `reason` records why activation and actual use are separate.
+
`pre_battle` and `battle_start` never have an attack-shaped intent. Its `source` and `target` fields are compiled but never used for matching; use effect scopes, not trigger selectors, to scope a battle-start effect.
## Effects
@@ -208,8 +210,9 @@
| Field | Accepted value | Meaning |
| --- | --- | --- |
| `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`. |
+| `applies_to_damage_kinds` | Non-empty array containing `normal` and/or `skill` | Optional runtime-modifier applicability gate. Omitted means both kinds. Restricts eligible damage jobs without changing the bucket selected by `type`. |
| `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-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. |
@@ -238,16 +241,18 @@
| `passive.<stat>.<direction>` | `stat`: `attack`, `lethality`, `health`, `defense`; `direction`: `up`, `down` | Permanent deterministic battle-start stat modifiers folded into the static damage profile. |
| `active.hero.<property>.<direction>` | `property`: `attack`, `lethality`, `health`, `defense`, `damage`, `damageTaken`; `direction`: `up`, `down` | Runtime modifier in a hero-labelled bucket. All combinations are supported even if the current hero files use only a subset. |
| `active.troop.<property>.<direction>` | Same values as `active.hero` | Runtime modifier in a separate troop-labelled bucket. Supported by the engine but not currently used in hero definitions. A hero effect placed here is still reported as hero-sourced; only its equation bucket changes. |
| `active.hero.shield`, `active.troop.shield` | Non-negative raw value | Taker-side protection subtracted after the percentage damage equation. |
-| `type.all.damage.<direction>` | `up`, `down` | Modifier applied to both normal and skill damage. |
-| `type.normal.damage.<direction>` | `up`, `down` | Dealer-side modifier for normal attacks only. |
-| `type.normal.damageTaken.<direction>` | `up`, `down` | Taker-side modifier for normal attacks only. |
-| `type.skill.damage.<direction>` | `up`, `down` | Dealer-side modifier for generated skill attacks only. |
-| `type.skill.damageTaken.<direction>` | `up`, `down` | Taker-side modifier for generated skill attacks only. |
+| `type.all.damage.<direction>` | `up`, `down` | Multiplicative all-damage arithmetic category. |
+| `type.normal.damage.<direction>` | `up`, `down` | Dealer-side normal-damage arithmetic category. The name does not impose job applicability. |
+| `type.normal.damageTaken.<direction>` | `up`, `down` | Taker-side normal-damage arithmetic category. The name does not impose job applicability. |
+| `type.skill.damage.<direction>` | `up`, `down` | Dealer-side skill-damage arithmetic category. The name does not impose job applicability. |
+| `type.skill.damageTaken.<direction>` | `up`, `down` | Taker-side skill-damage arithmetic category. The name does not impose job applicability. |
These effects do not mutate a fighter's stored Attack, Defense, Health, or Lethality. When an applicable damage job is calculated, they contribute factors to that job's equation. Expiry therefore affects future jobs only; it does not require undoing a stat mutation.
+A runtime modifier may add `"applies_to_damage_kinds": ["normal"]`, `["skill"]`, or `["normal", "skill"]` to restrict which damage jobs can use it while retaining the arithmetic bucket named by `type`. Omission means both normal and skill jobs. For example, `active.hero.damageTaken.up` with `applies_to_damage_kinds: ["normal"]` adds with unrestricted `active.hero.damageTaken.up` effects on normal jobs and is absent from skill jobs. Bucket definitions do not impose damage-kind eligibility. This field is not supported on carriers or special effects such as `extra_skill_attack`; generated-job classification belongs in `trigger_damage_jobs[].damage_kind` instead.
+
The current hero JSON files use only the subset listed by the inventory at the end of this guide. The additional combinations above are nevertheless registered native buckets. `active.hero.*` and `active.troop.*` are not source-validated: a hero definition can write to either family. The families are separate factors, so identical hero- and troop-labelled bonuses multiply rather than add together.
The four stat/property roles are important:
@@ -408,9 +413,9 @@
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. The multiplier is independent of `damage_kind`; that field alone decides whether normal-only, skill-only, or neither family of type modifiers applies.
+`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 classification is matched against each modifier's `applies_to_damage_kinds` eligibility.
Requirements:
- `trigger_damage_jobs` must be a non-empty array;
@@ -428,9 +433,9 @@
"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`.
+An optional `damage_kind` may be `normal` or `skill`, and defaults to `skill`. This is the only field that classifies the generated damage. Normal jobs are not attributed to `skillKills`.
Supported `source` and `target` selectors are:
| Selector | Meaning |
Index: simulator/config/hero_definitions/Reina.json
===================================================================
--- simulator/config/hero_definitions/Reina.json prev run
+++ simulator/config/hero_definitions/Reina.json this run
@@ -10,8 +10,9 @@
},
"effects": {
"AssassinsInstinct/1": {
"type": "type.normal.damage.up",
+ "applies_to_damage_kinds": ["normal"],
"value": [
10,
15,
20,
@@ -72,13 +73,13 @@
"effects": {
"ShadowBlade/1": {
"type": "extra_skill_attack",
"value": [
- 20,
- 40,
- 60,
- 80,
- 100
+ 120,
+ 140,
+ 160,
+ 180,
+ 200
],
"units": {
"applies_to": [
"lancer"
Index: simulator/config/hero_definitions/Renee.json
===================================================================
--- simulator/config/hero_definitions/Renee.json prev run
+++ simulator/config/hero_definitions/Renee.json this run
@@ -60,13 +60,14 @@
"attacks": { "count": 1 }
},
"trigger_effects": {
"Dreamcatcher/1": {
- "type": "type.marked_target.damage.up",
+ "type": "active.hero.damageTaken.up",
+ "applies_to_damage_kinds": ["normal"],
"value": [30, 60, 90, 120, 150],
"units": {
- "applies_to": "parent.use.source",
- "applies_vs": "parent.use.target"
+ "applies_to": "parent.use.target",
+ "applies_vs": "parent.use.source"
},
"duration": {
"turns": { "delay": 1, "count": 1 }
},
@@ -93,13 +94,14 @@
"attacks": { "count": 1 }
},
"trigger_effects": {
"Dreamslice/1": {
- "type": "type.marked_target.damage.up",
+ "type": "active.hero.damageTaken.up",
+ "applies_to_damage_kinds": ["normal"],
"value": [15, 30, 45, 60, 75],
"units": {
- "applies_to": "all",
- "applies_vs": "parent.use.target"
+ "applies_to": "parent.use.target",
+ "applies_vs": "any"
},
"duration": {
"turns": { "delay": 1, "count": 1 }
},
Index: simulator/config/hero_definitions/WuMing.json
===================================================================
--- simulator/config/hero_definitions/WuMing.json prev run
+++ simulator/config/hero_definitions/WuMing.json this run
@@ -10,8 +10,9 @@
},
"effects": {
"ShadowsEvasion/1": {
"type": "type.normal.damageTaken.down",
+ "applies_to_damage_kinds": ["normal"],
"value": [
5,
10,
15,
@@ -25,8 +26,9 @@
}
},
"ShadowsEvasion/2": {
"type": "type.skill.damageTaken.down",
+ "applies_to_damage_kinds": ["skill"],
"value": [
6,
12,
18,
@@ -66,8 +68,9 @@
},
"effects": {
"ElementalResonance/1": {
"type": "type.skill.damage.up",
+ "applies_to_damage_kinds": ["skill"],
"value": [
5,
10,
15,
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -37,16 +37,18 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /turn effect cannot use attack\/use-relative selector/i);
});
-test("loadSimulatorConfig rejects source and target on turn triggers", () => {
- const root = writeConfigWithTroopEffect({
- type: "active.hero.lethality.up",
- value: 10,
- units: { applies_to: "self.infantry" }
- }, { type: "turn", source: "infantry" });
- assert.throws(() => loadSimulatorConfigFromDir(root), /turn trigger cannot define source or target/i);
-});
+for (const field of ["source", "target"] as const) {
+ test(`loadSimulatorConfig rejects ${field} on turn triggers`, () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "self.infantry" }
+ }, { type: "turn", [field]: "infantry" });
+ assert.throws(() => loadSimulatorConfigFromDir(root), /turn trigger cannot define source or target/i);
+ });
+}
test("loadSimulatorConfig rejects legacy fields in simulator config", () => {
const root = join(tmpdir(), `wos-simulator-config-${Date.now()}`);
mkdirSync(join(root, "hero_definitions"), { recursive: true });
@@ -293,39 +295,101 @@
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({
+test("loadSimulatorConfig accepts explicit damage kinds and rejects removed delay metadata or the removed extra kind", () => {
+ const validNormal = 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" }]
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "normal" }]
});
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({
+ const removedExtraKind = 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" }]
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "extra" }]
});
- assert.doesNotThrow(() => loadSimulatorConfigFromDir(validExtra));
+ assert.doesNotThrow(() => loadSimulatorConfigFromDir(validNormal));
assert.throws(() => loadSimulatorConfigFromDir(removedDelay), /unknown trigger_damage_jobs key delivery_delay_turns/i);
- assert.throws(() => loadSimulatorConfigFromDir(invalidKind), /damage_kind.*normal.*skill.*extra/i);
+ assert.throws(() => loadSimulatorConfigFromDir(removedExtraKind), /damage_kind.*normal.*skill/i);
});
+test("loadSimulatorConfig accepts modifier damage-kind applicability and rejects invalid uses", () => {
+ const valid = writeConfigWithTroopEffect({
+ type: "active.hero.damageTaken.up",
+ applies_to_damage_kinds: ["normal"],
+ value: 25
+ });
+ const validBoth = writeConfigWithTroopEffect({
+ type: "type.normal.damageTaken.up",
+ applies_to_damage_kinds: ["normal", "skill"],
+ value: 25
+ });
+ const invalidKind = writeConfigWithTroopEffect({
+ type: "active.hero.damageTaken.up",
+ applies_to_damage_kinds: ["mystery"] as never,
+ value: 25
+ });
+ const empty = writeConfigWithTroopEffect({
+ type: "active.hero.damageTaken.up",
+ applies_to_damage_kinds: [],
+ value: 25
+ });
+ const duplicate = writeConfigWithTroopEffect({
+ type: "active.hero.damageTaken.up",
+ applies_to_damage_kinds: ["normal", "normal"],
+ value: 25
+ });
+ const ambiguousOldName = writeConfigWithTroopEffect({
+ type: "active.hero.damageTaken.up",
+ damage_kind: "normal",
+ value: 25
+ } as never);
+ const replacedSingularName = writeConfigWithTroopEffect({
+ type: "active.hero.damageTaken.up",
+ applies_to_damage_kind: "normal",
+ value: 25
+ } as never);
+ const nonModifier = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ applies_to_damage_kinds: ["normal"],
+ value: 100,
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ });
+
+ assert.doesNotThrow(() => loadSimulatorConfigFromDir(valid));
+ assert.doesNotThrow(() => loadSimulatorConfigFromDir(validBoth));
+ assert.throws(() => loadSimulatorConfigFromDir(invalidKind), /applies_to_damage_kinds.*normal.*skill/i);
+ assert.throws(() => loadSimulatorConfigFromDir(empty), /applies_to_damage_kinds.*non-empty array/i);
+ assert.throws(() => loadSimulatorConfigFromDir(duplicate), /applies_to_damage_kinds.*duplicates/i);
+ assert.throws(
+ () => loadSimulatorConfigFromDir(ambiguousOldName),
+ /damage_kind is ambiguous.*applies_to_damage_kinds.*trigger_damage_jobs\[\]\.damage_kind/i
+ );
+ assert.throws(
+ () => loadSimulatorConfigFromDir(replacedSingularName),
+ /applies_to_damage_kind was replaced by applies_to_damage_kinds/i
+ );
+ assert.throws(
+ () => loadSimulatorConfigFromDir(nonModifier),
+ /applies_to_damage_kinds is only supported for runtime damage modifiers/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" }]
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "normal" }]
});
const config = loadSimulatorConfigFromDir(root);
assert.equal(config.diagnostics.unsupportedEffects.some((effect) => effect.type === "extra_attack"), true);
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -111,8 +111,9 @@
if (!nested) validateDirectTurnEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);
+ validateAppliesToDamageKinds(effect as EffectIntentDefinition, file, skillId, effectId);
validateRequiredEffect(effect as EffectIntentDefinition, effectDefinitions, file, skillId, effectId);
validateEffectValueFormula(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
if (type === "attack_order") validateAttackOrderEffect(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);
@@ -134,8 +135,44 @@
}
}
}
+function validateAppliesToDamageKinds(
+ effect: EffectIntentDefinition,
+ file: string,
+ skillId: string,
+ effectId: string
+): void {
+ const legacy = effect as EffectIntentDefinition & { damage_kind?: unknown; applies_to_damage_kind?: unknown };
+ if (legacy.damage_kind !== undefined) {
+ throw new Error(
+ `effect damage_kind is ambiguous; use applies_to_damage_kinds for modifier eligibility or trigger_damage_jobs[].damage_kind for generated damage at ${file}:${skillId}.${effectId}`
+ );
+ }
+ if (legacy.applies_to_damage_kind !== undefined) {
+ throw new Error(
+ `applies_to_damage_kind was replaced by applies_to_damage_kinds at ${file}:${skillId}.${effectId}`
+ );
+ }
+ if (effect.applies_to_damage_kinds === undefined) return;
+ const path = `${file}:${skillId}.${effectId}.applies_to_damage_kinds`;
+ if (!Array.isArray(effect.applies_to_damage_kinds) || effect.applies_to_damage_kinds.length === 0) {
+ throw new Error(`applies_to_damage_kinds must be a non-empty array of "normal" and/or "skill" at ${path}`);
+ }
+ for (const kind of effect.applies_to_damage_kinds) {
+ if (kind !== "normal" && kind !== "skill") {
+ throw new Error(`applies_to_damage_kinds must contain only "normal" and/or "skill" at ${path}`);
+ }
+ }
+ if (new Set(effect.applies_to_damage_kinds).size !== effect.applies_to_damage_kinds.length) {
+ throw new Error(`applies_to_damage_kinds must not contain duplicates at ${path}`);
+ }
+ const definition = effect.type === undefined ? undefined : dynamicBucketDefinition(effect.type);
+ if (definition?.effectBucket !== true) {
+ throw new Error(`applies_to_damage_kinds is only supported for runtime damage modifiers at ${path}`);
+ }
+}
+
function* walkEffectDefinitions(
definitions: Record<string, Omit<EffectIntentDefinition, "id">>
): Iterable<[string, Omit<EffectIntentDefinition, "id">]> {
for (const [id, effect] of Object.entries(definitions)) {
@@ -211,8 +248,10 @@
}
if (trigger.first !== undefined && trigger.every === undefined) {
throw new Error(`trigger.first requires trigger.every at ${file}:${skillId}.trigger`);
}
+ // Keep the round clock independent of troop survival and attack order. Source/target
+ // turn intents were deliberately removed; troop dependencies belong in effect scopes and actual uses.
if (trigger.type === "turn" && (trigger.source !== undefined || trigger.target !== undefined)) {
throw new Error(`turn trigger cannot define source or target at ${file}:${skillId}.trigger; scope the effect instead`);
}
}
@@ -443,10 +482,10 @@
}
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}]`);
+ if (record.damage_kind !== undefined && record.damage_kind !== "normal" && record.damage_kind !== "skill") {
+ throw new Error(`trigger_damage_jobs damage_kind must be "normal" or "skill" at ${path}.trigger_damage_jobs[${jobIndex}]`);
}
}
function validateTriggerDamageJobSelector(
Index: simulator/src/damage.test.ts
===================================================================
--- simulator/src/damage.test.ts prev run
+++ simulator/src/damage.test.ts this run
@@ -153,11 +153,25 @@
{ trace: true, minInitialArmy: 200, capToTakerTroops: false }
);
assert.equal(Math.sqrt(200) * Math.sqrt(200), 200.00000000000003);
- assert.equal(outcome.trace?.armyTerm, 200);
+ assert.ok(Math.abs((outcome.trace?.armyTerm ?? 0) - 200) < 1e-12);
});
+test("damage calculator preserves the fractional square-root army term", () => {
+ const outcome = calculateIndexedDamageJob(
+ { ...job, roundStartTroops: {
+ attacker: { infantry: 1.5, lancer: 0, marksman: 0 },
+ defender: { infantry: 0, lancer: 1000, marksman: 0 }
+ } },
+ simpleFighters(),
+ [],
+ { trace: true, minInitialArmy: 3, capToTakerTroops: false }
+ );
+
+ assert.ok(Math.abs((outcome.trace?.armyTerm ?? 0) - Math.sqrt(6)) < 1e-12);
+});
+
test("damage calculator preserves full positive damage precision", () => {
const outcome = calculateIndexedDamageJob(
{ ...job, kind: "skill", sourceMultiplier: 1.23456 },
simpleFighters(),
@@ -170,9 +184,9 @@
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) {
+ for (const kind of ["normal", "skill"] as const) {
const outcome = calculateIndexedDamageJob(
{ ...job, kind, sourceMultiplier: 1.6 },
simpleFighters(),
[],
@@ -359,10 +373,10 @@
const unfactoredStaticDenominator =
unfactoredStaticProduct(dealerBuckets, "dealer", "denominator") *
unfactoredStaticProduct(takerBuckets, "taker", "denominator");
const unfactoredDamage =
- unfactoredDynamicProduct(scratch.factors, job.kind, "numerator") * unfactoredStaticNumerator /
- (100 * unfactoredDynamicProduct(scratch.factors, job.kind, "denominator") * unfactoredStaticDenominator);
+ unfactoredDynamicProduct(scratch.factors, "numerator") * unfactoredStaticNumerator /
+ (100 * unfactoredDynamicProduct(scratch.factors, "denominator") * unfactoredStaticDenominator);
assert.ok(Math.abs((profiled.trace?.damageBeforeOffsets ?? 0) - unfactoredDamage) < 1e-12);
assert.equal(profiled.trace?.atomicBuckets["player.attack"].totalPct, 50);
assert.equal(profiled.trace?.atomicBuckets["passive.attack.up"].totalPct, 20);
@@ -386,18 +400,12 @@
}
function unfactoredDynamicProduct(
factors: Float64Array,
- kind: DamageJob["kind"],
placement: BucketPlacement
): number {
return DYNAMIC_BUCKETS.reduce(
- (product, definition, index) => {
- const damageKind = "damageKind" in definition ? definition.damageKind : undefined;
- return definition.placement === placement && (!damageKind || damageKind === kind)
- ? product * factors[index]
- : product;
- },
+ (product, definition, index) => definition.placement === placement ? product * factors[index] : product,
1
);
}
@@ -464,9 +472,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 normal, skill, and extra damage jobs", () => {
+test("a durationless shield subtracts its full raw value from normal and skill damage jobs", () => {
const fighters = simpleFighters();
const shield = {
...effect("active.hero.shield", "defender", 25),
kind: "shield" as const,
@@ -478,16 +486,11 @@
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, [{
@@ -608,28 +611,39 @@
assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].placement, "numerator");
assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);
});
-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);
+test("bucket names do not restrict damage-job applicability", () => {
+ const namedNormal = effect("type.normal.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], {
+ const normalOutcome = calculateIndexedDamageJob(job, fighters, [namedNormal], { trace: true });
+ const skillOutcome = calculateIndexedDamageJob({ ...job, kind: "skill", sourceMultiplier: 1 }, fighters, [namedNormal], {
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);
+ assert.equal(skillOutcome.trace?.atomicBuckets["type.normal.damage.up"].totalPct, 100);
});
+test("damage-kind applicability restricts jobs without changing the modifier bucket", () => {
+ const unrestricted = effect("active.hero.damageTaken.up", "defender", 15);
+ const normalOnly = effect("active.hero.damageTaken.up", "defender", 30);
+ normalOnly.intent = { ...normalOnly.intent, id: "normal-only-damage-taken", applies_to_damage_kinds: ["normal"] };
+ normalOnly.source = { ...normalOnly.source, effectId: "normal-only-damage-taken" };
+ const effects = [unrestricted, normalOnly];
+ const fighters = simpleFighters();
+
+ const normalOutcome = calculateIndexedDamageJob(job, fighters, effects, { trace: true });
+ const skillOutcome = calculateIndexedDamageJob({ ...job, kind: "skill" }, fighters, effects, { trace: true });
+
+ assert.equal(normalOutcome.trace?.atomicBuckets["active.hero.damageTaken.up"].totalPct, 45);
+ assert.equal(normalOutcome.trace?.atomicBuckets["active.hero.damageTaken.up"].contributors.length, 2);
+ assert.equal(skillOutcome.trace?.atomicBuckets["active.hero.damageTaken.up"].totalPct, 15);
+ assert.equal(skillOutcome.trace?.atomicBuckets["active.hero.damageTaken.up"].contributors.length, 1);
+ assert.equal(normalOutcome.trace?.atomicBuckets["type.normal.damageTaken.up"].totalPct, 0);
+ assert.equal(skillOutcome.trace?.atomicBuckets["type.normal.damageTaken.up"].totalPct, 0);
+});
+
test("attack-duration bucket effects are charged by the applicable attack job", () => {
const oneAttackEffect = {
...effect("active.hero.attack.up", "attacker", 100),
intent: { id: "attack-up-active", type: "active.hero.attack.up", value: 100 },
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -33,9 +33,8 @@
interface DamageFactorTerm {
bucket: NumericBucketId;
jobSide: BucketJobSide;
placement: "numerator" | "denominator" | "post_subtract";
- damageKind?: DamageJob["kind"];
}
interface NumericDamageBuckets {
factors: Float64Array;
@@ -72,13 +71,10 @@
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" }),
- extra: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "extra" })
-};
+const DYNAMIC_EXPRESSION = compileDamageExpression(DYNAMIC_FACTOR_TERMS, {});
+const DYNAMIC_EXPRESSIONS = { normal: DYNAMIC_EXPRESSION, skill: DYNAMIC_EXPRESSION };
const STATIC_EXPRESSIONS = {
dealer: compileDamageExpression(STATIC_FACTOR_TERMS, { jobSide: "dealer" }),
taker: compileDamageExpression(STATIC_FACTOR_TERMS, { jobSide: "taker" })
};
@@ -134,9 +130,9 @@
Math.max(0, job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0)
);
const takerTroops = job.roundStartTroops[job.takerSide][job.takerUnit] ?? 0;
const initialArmy = options.minInitialArmy ?? minInitialArmy(fighters);
- const armyTerm = ceilIgnoringFloatResidue(Math.sqrt(dealerTroops) * Math.sqrt(initialArmy));
+ const armyTerm = Math.sqrt(dealerTroops) * Math.sqrt(initialArmy);
const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();
applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);
applyDynamicDamageBucketValue(buckets, "source.multiplier", job.sourceMultiplier ?? 1);
@@ -347,23 +343,19 @@
function compileDamageTerms(definitions: readonly BucketSpec[]): DamageFactorTerm[] {
return definitions.map((definition, bucket) => ({
bucket,
jobSide: definition.jobSide,
- placement: definition.placement,
- damageKind: definition.damageKind
+ placement: definition.placement
}));
}
function compileDamageExpression(
terms: DamageFactorTerm[],
selection: {
jobSide?: BucketJobSide;
- kind?: DamageJob["kind"];
}
): CompiledDamageExpression {
- const selected = terms
- .filter((term) => selection.jobSide === undefined || term.jobSide === selection.jobSide)
- .filter((term) => selection.kind === undefined || !term.damageKind || term.damageKind === selection.kind);
+ const selected = terms.filter((term) => selection.jobSide === undefined || term.jobSide === selection.jobSide);
return {
numeratorSlots: Int32Array.from(selected.filter((term) => term.placement === "numerator").map((term) => term.bucket)),
denominatorSlots: Int32Array.from(selected.filter((term) => term.placement === "denominator").map((term) => term.bucket)),
postSubtractSlots: Int32Array.from(selected.filter((term) => term.placement === "post_subtract").map((term) => term.bucket))
Index: simulator/src/damageBuckets.test.ts
===================================================================
--- simulator/src/damageBuckets.test.ts prev run
+++ simulator/src/damageBuckets.test.ts this run
@@ -15,9 +15,8 @@
"active.troop.damageTaken.up",
"active.troop.damageTaken.down"
] as const) {
assert.equal(DYNAMIC_BUCKETS.some((definition) => definition.name === bucket), true, bucket);
- assert.equal(dynamicBucketDefinition(bucket)?.damageKind, undefined, bucket);
}
assert.equal(dynamicBucketDefinition("active.hero.damage.up")?.placement, "numerator");
assert.equal(dynamicBucketDefinition("active.hero.damage.down")?.placement, "denominator");
assert.equal(dynamicBucketDefinition("active.hero.damageTaken.up")?.placement, "numerator");
@@ -26,12 +25,10 @@
test("type all damage buckets multiply percentage factors", () => {
assert.equal(dynamicBucketDefinition("type.all.damage.up")?.update, "multiply_pct_factor");
assert.equal(dynamicBucketDefinition("type.all.damage.up")?.placement, "numerator");
- assert.equal(dynamicBucketDefinition("type.all.damage.up")?.damageKind, undefined);
assert.equal(dynamicBucketDefinition("type.all.damage.down")?.update, "multiply_pct_factor");
assert.equal(dynamicBucketDefinition("type.all.damage.down")?.placement, "denominator");
- assert.equal(dynamicBucketDefinition("type.all.damage.down")?.damageKind, undefined);
});
test("damage scratch stores one numeric value per dynamic bucket with metadata-defined neutral values", () => {
const scratch = createDamageScratch() as unknown as {
@@ -55,7 +52,12 @@
const shield = dynamicBucketDefinition(bucket);
assert.equal(shield?.jobSide, "taker", bucket);
assert.equal(shield?.update, "add_raw", bucket);
assert.equal(shield?.placement, "post_subtract", bucket);
- assert.equal(shield?.damageKind, undefined, bucket);
}
});
+
+test("bucket definitions contain arithmetic metadata only", () => {
+ for (const definition of DYNAMIC_BUCKETS) {
+ assert.equal("damageKind" in definition, false, definition.name);
+ }
+});
Index: simulator/src/damageBuckets.ts
===================================================================
--- simulator/src/damageBuckets.ts prev run
+++ simulator/src/damageBuckets.ts this run
@@ -1,6 +1,4 @@
-import type { DamageKind } from "./types";
-
export type BucketJobSide = "dealer" | "taker";
export type BucketUpdate = "assign_factor" | "add_pct_factor" | "multiply_pct_factor" | "add_raw";
export type BucketPlacement = "numerator" | "denominator" | "post_subtract";
@@ -8,9 +6,8 @@
name: string;
jobSide: BucketJobSide;
update: BucketUpdate;
placement: BucketPlacement;
- damageKind?: DamageKind;
/** This bucket may be named by an EffectIntentDefinition. */
effectBucket?: true;
}
@@ -67,17 +64,16 @@
{ 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 },
- { name: "type.skill.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", damageKind: "skill", effectBucket: true },
- { name: "type.skill.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", damageKind: "skill", effectBucket: true },
- { name: "type.skill.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", damageKind: "skill", effectBucket: true },
- { name: "type.skill.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", damageKind: "skill", effectBucket: true }
+ { name: "type.normal.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "type.normal.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "type.normal.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "type.normal.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "type.skill.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "type.skill.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "type.skill.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "type.skill.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true }
] as const satisfies readonly BucketSpec[];
export type StaticBucketSpec = (typeof STATIC_BUCKETS)[number] & BucketSpec;
export type DynamicBucketSpec = (typeof DYNAMIC_BUCKETS)[number] & BucketSpec;
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -37,9 +37,9 @@
battleOrder: ActiveEffect[];
carriers: ActiveEffect[];
}
-const DAMAGE_KINDS: DamageKind[] = ["normal", "skill", "extra"];
+const DAMAGE_KINDS: DamageKind[] = ["normal", "skill"];
export const DAMAGE_JOB_SHAPE_SLOTS = DAMAGE_KINDS.length * 2 * 3 * 2 * 3;
export function createEffectIndex(
effectGroups: ActiveEffectGroup[] = [],
@@ -122,11 +122,12 @@
return EMPTY_JOB_SHAPE_SLOTS;
}
const bucket = (bucketOverride ?? runtimeDefinition?.name) as DynamicDamageBucket | undefined;
if (!bucket) return EMPTY_JOB_SHAPE_SLOTS;
+ const damageKindMask = kindMask(effect.intent.applies_to_damage_kinds ?? DAMAGE_KINDS);
const key =
- (((DYNAMIC_BUCKET_INDEX[bucket] * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 +
- (effect.appliesVs.units & 7);
+ ((((DYNAMIC_BUCKET_INDEX[bucket] * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 +
+ (effect.appliesVs.units & 7)) * 4 + damageKindMask;
const cached = JOB_SHAPE_CACHE.get(key);
if (cached) return cached;
const slots = buildShapeSlots(effect, bucket);
JOB_SHAPE_CACHE.set(key, slots);
@@ -137,9 +138,9 @@
function buildShapeSlots(effect: ActiveEffect, bucket: DynamicDamageBucket): Uint8Array {
const definition = dynamicBucketDefinition(bucket)!;
const slots: number[] = [];
- const jobKinds = definition.damageKind ? [definition.damageKind] : DAMAGE_KINDS;
+ const jobKinds = effect.intent.applies_to_damage_kinds ?? 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(
@@ -153,8 +154,11 @@
return Uint8Array.from(slots);
}
function kindIndex(kind: DamageKind): number { return DAMAGE_KINDS.indexOf(kind); }
+function kindMask(kinds: DamageKind[]): number {
+ return kinds.reduce((mask, kind) => mask | (1 << kindIndex(kind)), 0);
+}
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/recorder.ts
===================================================================
--- simulator/src/recorder.ts prev run
+++ simulator/src/recorder.ts this run
@@ -41,9 +41,8 @@
id: string;
bucket: NumericBucketId;
bucketName: DynamicDamageBucket;
placement: GroupPlacement;
- damageKind?: DamageJob["kind"];
}
export interface DamageRecordingResult {
job: DamageJob;
@@ -460,9 +459,9 @@
defender: { ...job.roundStartTroops.defender }
},
armyTerm: result.armyTerm,
atomicBuckets: toTraceBuckets(result.factors, this.contributors, staticEntries),
- aggregationGroups: buildAggregationGroups(job, result.factors, this.contributors, staticEntries),
+ aggregationGroups: buildAggregationGroups(result.factors, this.contributors, staticEntries),
appliedEffects,
rejectedEffects: this.rejectedEffects,
damageBeforeOffsets: result.damageBeforeOffsets,
offsetDamage: result.offsetDamage,
@@ -494,10 +493,9 @@
return {
id: bucket,
bucket: DYNAMIC_BUCKET_INDEX[bucket],
bucketName: bucket,
- placement: definition.placement,
- damageKind: "damageKind" in definition ? definition.damageKind : undefined
+ placement: definition.placement
};
}
function buildStaticProfileDescription(
@@ -614,17 +612,15 @@
return applied;
}
function buildAggregationGroups(
- job: DamageJob,
factors: Float64Array,
contributors: DamageBucketTrace["contributors"][],
staticEntries: StaticProfileEntry[]
): Record<string, DamageAggregationGroupTrace> {
const groups: Record<string, DamageAggregationGroupTrace> = {};
addStaticAggregationGroups(groups, staticEntries);
for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS, ...DEFAULT_OFFSET_TERMS]) {
- if (term.damageKind && term.damageKind !== job.kind) continue;
const definition = dynamicBucketDefinition(term.bucketName)!;
const factor = factors[term.bucket];
groups[term.id] = definition.update === "add_raw"
? {
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -362,24 +362,24 @@
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", () => {
+test("simulateBearBattle includes fixture-carried normal 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"
+ carriedNormalDamageConfig(),
+ "bear-renee-carried-normal"
);
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.attacks.some((attack) => attack.kind === "normal" && attack.sourceEffectId === "NightmareTrace/1"), 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", () => {
@@ -558,8 +558,121 @@
assert.equal(result.winner, "draw");
assert.equal(result.rounds, 1500);
});
+for (const scenario of [
+ { name: "expires without children when Infantry are absent", first: 1, infantry: 0, used: false },
+ { name: "still activates after Infantry die but creates no children", first: 2, infantry: 1, used: false },
+ { name: "preserves earned protection after Infantry die", first: 1, infantry: 1, used: true },
+ { name: "protects two complete following turns when used", first: 1, infantry: 1000, used: true }
+]) {
+ test(`turn-scheduled no_attack ${scenario.name}`, () => {
+ const result = runOnce({
+ maxRounds: 4,
+ attacker: {
+ troops: { infantry_t1: scenario.infantry, lancer_t1: 1000 },
+ heroes: { Protector: { skill_1: 1 } }
+ },
+ defender: { troops: { infantry_t1: 1000 }, heroes: {} }
+ }, minimalConfig({
+ Protector: {
+ name: "Protector",
+ troop_type: "infantry",
+ skills: {
+ Protection: {
+ trigger: { type: "turn", first: scenario.first, every: 4 },
+ effects: {
+ pause: {
+ type: "no_attack",
+ units: { applies_to: "self.infantry" },
+ duration: { turns: { count: 1 } },
+ trigger_effects: {
+ protection: {
+ type: "active.hero.damageTaken.down",
+ value: 25,
+ units: { applies_to: "self.any" },
+ duration: { turns: { count: 2, delay: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }), { mode: "trace" });
+
+ assert.equal(result.rounds, 4);
+ if (scenario.infantry <= 1) assert.equal(result.trace!.rounds[1].roundStartTroops.attacker.infantry, 0);
+ assert.ok(result.remaining.attacker.lancer > 0);
+ assert.ok(result.remaining.defender.infantry > 0);
+ assert.equal(result.skillReport.attacker.find(entry => entry.skillId === "Protection")!.skillActivations, 1);
+ assert.equal(result.attackControlCounts.no_attack, Number(scenario.used));
+ const protectedRounds = result.attacks.filter(attack =>
+ attack.dealerSide === "defender" && attack.appliedEffects?.some(effect => effect.effectId === "protection")
+ ).map(attack => attack.round);
+ assert.deepEqual(protectedRounds, scenario.used ? [2, 3] : []);
+ });
+}
+
+test("turn-scheduled no_attack creates no protection when its attack target is already exhausted", () => {
+ const result = runOnce({
+ maxRounds: 2,
+ attacker: {
+ troops: { infantry_t1: 1000000, lancer_t1: 1000000 },
+ heroes: { Protector: { skill_1: 1 } }
+ },
+ defender: { troops: { infantry_t1: 1, marksman_t1: 1000000 }, heroes: {} }
+ }, minimalConfig({
+ Protector: {
+ name: "Protector",
+ skills: {
+ Protection: {
+ trigger: { type: "turn", first: 1, every: 4 },
+ effects: {
+ pause: {
+ type: "no_attack",
+ units: { applies_to: "self.lancer" },
+ duration: { turns: { count: 1 } },
+ trigger_effects: {
+ protection: {
+ type: "active.hero.damageTaken.down",
+ value: 25,
+ units: { applies_to: "self.any" },
+ duration: { turns: { count: 2, delay: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }), { mode: "trace" });
+
+ assert.equal(result.rounds, 2);
+ assert.equal(result.trace!.rounds[1].roundStartTroops.defender.infantry, 0);
+ assert.ok(result.trace!.rounds[1].roundStartTroops.attacker.lancer > 0);
+ assert.equal(result.skillReport.attacker.find(entry => entry.skillId === "Protection")!.skillActivations, 1);
+ assert.equal(result.attackControlCounts.no_attack, 0);
+ assert.equal(result.effectActivationCounts.attacker, 1);
+ assert.ok(result.attacks.every(attack => !attack.appliedEffects?.some(effect => effect.effectId === "protection")));
+});
+
+for (const fixture of [
+ { id: "ahmose_no_infantry_240l_vs_125i", attacker: { infantry: 0, lancer: 0, marksman: 0 }, defender: { infantry: 11, lancer: 0, marksman: 0 } },
+ { id: "ahmose_source_dies_001i_480l_vs_250i", attacker: { infantry: 0, lancer: 3, marksman: 0 }, defender: { infantry: 0, lancer: 0, marksman: 0 } }
+]) {
+ test(`Ahmose Viper schedules unused pauses without protection: ${fixture.id}`, () => {
+ const path = new URL(`../testcases/emulator_verified/${fixture.id}.json`, import.meta.url);
+ const [input] = JSON.parse(readFileSync(path, "utf8")) as BattleInput[];
+ const result = runOnce(input, loadSimulatorConfig());
+
+ assert.equal(result.randomness.deterministic, true);
+ assert.deepEqual(result.remaining, { attacker: fixture.attacker, defender: fixture.defender });
+ assert.equal(result.skillReport.attacker.find(entry => entry.skillId === "ViperFormation")!.skillActivations, Math.floor(result.rounds / 4));
+ assert.equal(result.attackControlCounts.no_attack, 0);
+ });
+}
+
test("one turn-scoped extra attack effect serves every matching normal attack without recursion", () => {
const result = runOnce(
{
maxRounds: 1,
@@ -786,9 +899,9 @@
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" }],
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "normal" }],
duration: { turns: { delay: 1, count: 1 }, attacks: { count: 1 } }
}
}
}
@@ -805,22 +918,22 @@
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", () => {
+test("carried-damage fixture snapshots normal 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 } }
+ heroes: { Renee: { skill_1: 1 } }
},
defender: {
troops: { marksman_t1: 1000000 },
heroes: {}
}
},
- loadSimulatorConfig(),
+ carriedNormalDamageConfig(),
{ mode: "trace" }
);
const attackerJobs = result.attacks.filter((attack) => attack.dealerSide === "attacker");
@@ -828,83 +941,74 @@
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?.kind, "normal");
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);
+ assert.ok(roundThreeNormal);
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", () => {
+test("skill-damage amplification does not affect fixture-carried normal damage", () => {
const result = runOnce(
{
maxRounds: 3,
attacker: {
troops: { lancer_t1: 1000000 },
heroes: {
- WuMing: { skill_2: 1, skill_3: 1 },
+ WuMing: { skill_1: 1, skill_2: 1 },
Renee: { skill_1: 1 }
}
},
defender: {
troops: { marksman_t1: 1000000 },
heroes: {}
}
},
- loadSimulatorConfig(),
+ carriedNormalDamageConfig(true),
{ mode: "trace" }
);
const delayed = result.attacks.find((attack) => attack.sourceEffectId === "NightmareTrace/1");
- assert.equal(delayed?.kind, "extra");
+ assert.equal(delayed?.kind, "normal");
assert.equal(delayed?.trace?.atomicBuckets["active.hero.damage.up"].totalPct, 4);
- assert.equal(delayed?.trace?.aggregationGroups["type.skill.damage.up"], undefined);
+ assert.equal(delayed?.trace?.aggregationGroups["type.skill.damage.up"].totalPct, 0);
+ assert.equal(delayed?.trace?.aggregationGroups["type.skill.damage.up"].contributors.length, 0);
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
- });
+test("target-side damage-taken fixture locks the marked target with source-specific scopes", () => {
const input: BattleInput = {
maxRounds: 3,
attacker: {
- troops: { lancer_t1: 1000000 },
- heroes: { Renee: { skill_1: 1, skill_2: 1, skill_3: 1 } }
+ troops: { infantry_t1: 1000000, lancer_t1: 1000000 },
+ heroes: { ReneeFixture: { skill_1: 1, skill_2: 1, skill_3: 1 } }
},
defender: {
- troops: { marksman_t1: 1000000 },
+ troops: { infantry_t1: 1000000, 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")!;
+ const result = runOnce(input, reneeDamageTakenTargetFixture(), { mode: "trace" });
+ const markedTarget = result.attacks.find((attack) =>
+ attack.round === 2 && attack.kind === "normal" && attack.dealerSide === "attacker" && attack.dealerUnit === "lancer"
+ )!.takerUnit;
+ const otherTarget: UnitType = markedTarget === "infantry" ? "marksman" : "infantry";
+ const bucketTotal = (dealer: "infantry" | "lancer", target: UnitType): number | undefined =>
+ result.attacks.find((attack) =>
+ attack.round === 3 && attack.kind === "skill" && attack.dealerSide === "attacker" &&
+ attack.dealerUnit === dealer && attack.takerUnit === target
+ )?.trace?.atomicBuckets["active.hero.damageTaken.up"].totalPct;
- 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);
+ assert.equal(bucketTotal("infantry", otherTarget), 0);
+ assert.equal(bucketTotal("infantry", markedTarget), 15);
+ assert.equal(bucketTotal("lancer", otherTarget), 0);
+ assert.equal(bucketTotal("lancer", markedTarget), 45);
});
test("attack-duration effects with a round cap expire after one applicable attack", () => {
const result = runOnce(
@@ -3076,8 +3180,9 @@
trigger: { type: "battle_start" },
effects: {
boost: {
type: "type.skill.damage.up",
+ applies_to_damage_kinds: ["skill"],
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
duration: { attacks: { count: 2 } }
}
@@ -3605,4 +3710,113 @@
troopSkills: { name: "troop skills", skills: {} },
diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [] }
};
}
+
+function reneeDamageTakenTargetFixture(): SimulatorConfig {
+ return minimalConfig({
+ ReneeFixture: {
+ name: "Renee target fixture",
+ troop_type: "lancer",
+ skills: {
+ LancerMark: {
+ trigger: { type: "turn", every: 2 },
+ effects: {
+ "LancerMark/carrier": {
+ units: { applies_to: "self.lancer", applies_vs: "enemy.any" },
+ duration: { turns: { count: 1 }, attacks: { count: 1 } },
+ trigger_effects: {
+ "LancerMark/1": {
+ type: "active.hero.damageTaken.up",
+ value: 30,
+ units: { applies_to: "parent.use.target", applies_vs: "parent.use.source" },
+ duration: { turns: { delay: 1, count: 1 } }
+ }
+ }
+ }
+ }
+ },
+ AllTroopsMark: {
+ trigger: { type: "turn", every: 2 },
+ effects: {
+ "AllTroopsMark/carrier": {
+ units: { applies_to: "self.lancer", applies_vs: "enemy.any" },
+ duration: { turns: { count: 1 }, attacks: { count: 1 } },
+ trigger_effects: {
+ "AllTroopsMark/1": {
+ type: "active.hero.damageTaken.up",
+ value: 15,
+ units: { applies_to: "parent.use.target", applies_vs: "any" },
+ duration: { turns: { delay: 1, count: 1 } }
+ }
+ }
+ }
+ }
+ },
+ ProbeAllTargets: {
+ trigger: { type: "battle_start" },
+ effects: {
+ "ProbeAllTargets/1": {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: ["infantry", "lancer"], applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
+ duration: { turns: { count: 3 } }
+ }
+ }
+ }
+ }
+ }
+ });
+}
+
+function carriedNormalDamageConfig(includeSkillAmplifier = false): SimulatorConfig {
+ const heroDefinitions: Record<string, SkillFile> = {
+ Renee: {
+ name: "Renee fixture",
+ troop_type: "lancer",
+ skills: {
+ NightmareTrace: {
+ trigger: { type: "turn", every: 2 },
+ effects: {
+ "NightmareTrace/mark": {
+ units: { applies_to: "self.lancer", applies_vs: "enemy.any" },
+ duration: { turns: { count: 1 }, attacks: { count: 1 } },
+ trigger_effects: {
+ "NightmareTrace/1": {
+ 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: "normal" }],
+ duration: { turns: { delay: 1, count: 1 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ if (includeSkillAmplifier) {
+ heroDefinitions.WuMing = {
+ name: "Wu Ming fixture",
+ troop_type: "infantry",
+ skills: {
+ CommonDamage: {
+ trigger: { type: "battle_start" },
+ effects: {
+ common: { type: "active.hero.damage.up", value: 4 }
+ }
+ },
+ SkillDamage: {
+ trigger: { type: "battle_start" },
+ effects: {
+ skill: { type: "type.skill.damage.up", applies_to_damage_kinds: ["skill"], value: 5 }
+ }
+ }
+ }
+ };
+ }
+
+ return minimalConfig(heroDefinitions);
+}
Index: simulator/src/tooling/exportGatotTestcases.test.ts
===================================================================
--- simulator/src/tooling/exportGatotTestcases.test.ts prev run
+++ simulator/src/tooling/exportGatotTestcases.test.ts this run
@@ -3,9 +3,35 @@
import { resolve } from "node:path";
import { test } from "node:test";
import { gatotTestcaseArtifacts } from "./exportGatotTestcases";
+import { adaptTestcaseEntry, battleScoreDelta } from "./testcases";
+test("exported Gatot draw preserves both survivors, observed rounds and input cap", () => {
+ const testcase = gatotTestcaseArtifacts().find((artifact) =>
+ artifact.filename === "s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.json"
+ )?.testcase[0];
+ assert.ok(testcase);
+
+ assert.deepEqual(testcase.game_report_result, [
+ { attacker: 9720, defender: 1, winner: "draw", rounds: 1500 }
+ ]);
+ assert.equal(adaptTestcaseEntry(testcase).maxRounds, 1500);
+ assert.equal(battleScoreDelta(testcase.game_report_result), 9719);
+});
+
+test("exported Gatot victory does not replace the input cap with observed rounds", () => {
+ const testcase = gatotTestcaseArtifacts().find((artifact) =>
+ artifact.filename === "s4-fc9-gatot-10000.json"
+ )?.testcase[0];
+ assert.ok(testcase);
+
+ assert.deepEqual(testcase.game_report_result, [
+ { attacker: 9992, defender: 0, winner: "attacker", rounds: 923 }
+ ]);
+ assert.equal(adaptTestcaseEntry(testcase).maxRounds, 1500);
+});
+
test("generated Gatot testcase files stay in sync with the evidence inventory", () => {
const directory = resolve(import.meta.dirname, "../../../testcases/gatot_verified");
const artifacts = gatotTestcaseArtifacts();
const filenames = readdirSync(directory).sort();
Index: simulator/src/tooling/exportGatotTestcases.ts
===================================================================
--- simulator/src/tooling/exportGatotTestcases.ts prev run
+++ simulator/src/tooling/exportGatotTestcases.ts this run
@@ -21,12 +21,15 @@
description: `Gatot evidence section ${observation.section}: ${observation.caseLabel}`,
attacker: input.attacker,
defender: input.defender,
...(input.engagement_type !== undefined ? { engagement_type: input.engagement_type } : {}),
+ ...(input.maxRounds !== undefined ? { maxRounds: input.maxRounds } : {}),
game_report_result: [
{
attacker: observation.game.survivors.attacker,
- defender: observation.game.survivors.defender
+ defender: observation.game.survivors.defender,
+ winner: observation.game.winner,
+ ...(observation.game.rounds !== undefined ? { rounds: observation.game.rounds } : {})
}
]
};
}
Index: simulator/src/tooling/histogram.test.ts
===================================================================
--- simulator/src/tooling/histogram.test.ts prev run
+++ simulator/src/tooling/histogram.test.ts this run
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { histogramBins, histogramIntegerTicks } from "./histogram";
+
+test("integer outcomes never produce sub-unit hit-and-miss bins", () => {
+ const bins = histogramBins([100, 101, 102, 103, 104], [105]);
+ const populated = bins.filter(({ count }) => count > 0);
+
+ assert.ok(bins.every(({ start, end }) => end - start >= 1));
+ assert.ok(
+ bins.every(
+ ({ start, end }) =>
+ Math.abs(start % 1) === 0.5 && Math.abs(end % 1) === 0.5,
+ ),
+ );
+ assert.deepEqual(populated.map(({ start, count }) => [start, count]), [
+ [99.5, 1],
+ [100.5, 1],
+ [101.5, 1],
+ [102.5, 1],
+ [103.5, 1],
+ ]);
+ assert.deepEqual(histogramIntegerTicks(bins, 3), [100, 103, 105]);
+});
+
+test("game outcomes extend the domain without contributing histogram counts", () => {
+ const bins = histogramBins([10, 11, 12], [20]);
+
+ assert.equal(bins.reduce((sum, { count }) => sum + count, 0), 3);
+ assert.ok(bins.at(-1)!.end > 20);
+});
Index: simulator/src/tooling/histogram.ts
===================================================================
--- simulator/src/tooling/histogram.ts prev run
+++ simulator/src/tooling/histogram.ts this run
@@ -0,0 +1,68 @@
+export interface HistogramBin {
+ start: number;
+ end: number;
+ count: number;
+}
+
+export function histogramBins(
+ samples: readonly number[],
+ domainValues: readonly number[] = [],
+ targetBins = 30,
+): HistogramBin[] {
+ const values = [...samples, ...domainValues];
+ if (values.length === 0) return [];
+
+ const minimum = Math.floor(Math.min(...values));
+ const maximum = Math.ceil(Math.max(...values));
+ const width = niceBinWidth(
+ (maximum - minimum + 1) / Math.max(1, Math.floor(targetBins)),
+ );
+ let start = Math.floor(minimum / width) * width - 0.5;
+ let end = Math.ceil((maximum + 1) / width) * width - 0.5;
+ if (start === end) {
+ start -= width;
+ end += width;
+ }
+ const bins = Array.from(
+ { length: Math.max(1, Math.round((end - start) / width)) },
+ (_, index) => ({
+ start: start + index * width,
+ end: start + (index + 1) * width,
+ count: 0,
+ }),
+ );
+ for (const sample of samples) {
+ const index = Math.min(
+ bins.length - 1,
+ Math.max(0, Math.floor((sample - start) / width)),
+ );
+ bins[index]!.count += 1;
+ }
+ return bins;
+}
+
+export function histogramIntegerTicks(
+ bins: readonly HistogramBin[],
+ maximumTicks = 6,
+): number[] {
+ if (bins.length === 0) return [];
+ const minimum = Math.ceil(bins[0]!.start);
+ const maximum = Math.floor(bins.at(-1)!.end);
+ const integerCount = maximum - minimum + 1;
+ const tickCount = Math.min(
+ Math.max(2, Math.floor(maximumTicks)),
+ integerCount,
+ );
+ if (tickCount === 1) return [minimum];
+ return Array.from({ length: tickCount }, (_, index) =>
+ Math.round(minimum + ((maximum - minimum) * index) / (tickCount - 1)),
+ );
+}
+
+function niceBinWidth(rawWidth: number): number {
+ if (rawWidth <= 1) return 1;
+ const magnitude = 10 ** Math.floor(Math.log10(rawWidth));
+ const normalized = rawWidth / magnitude;
+ const multiplier = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
+ return multiplier * magnitude;
+}
Index: simulator/src/tooling/parityMetrics.ts
===================================================================
--- simulator/src/tooling/parityMetrics.ts prev run
+++ simulator/src/tooling/parityMetrics.ts this run
@@ -1,17 +1,21 @@
export interface OutcomeDistribution {
- n: number;
- mu: number;
- sigma: number;
+ samples: readonly number[];
}
+export interface OutcomeRange {
+ min: number;
+ max: number;
+}
+
export interface ParityThresholds {
- z_threshold?: number;
- min_bias_pct?: number;
+ p_threshold?: number;
max_diff_ratio?: number;
max_diff_ratio_deterministic?: number;
}
+export const DEFAULT_STOCHASTIC_P_THRESHOLD = 1 / 250;
+
export interface ParityComparisonMetrics {
n_candidate: number;
mu_candidate: number;
sigma_candidate: number;
@@ -20,188 +24,349 @@
sigma_reference: number;
bias_raw: number;
bias_pct: number;
sem: number;
- stat_type: "deterministic" | "zero_var" | "single_obs" | "t";
+ stat_type: "deterministic" | "cdf_support";
stat: number | null;
p: number | null;
- q: number | null;
passes: boolean;
+ cdf_rms?: number;
+ cdf_p?: number;
+ support_value?: number;
+ support_mass?: number;
+ support_p?: number;
+ flag_reason?: "cdf" | "support" | "cdf+support";
}
export function compareOutcomeDistribution(options: {
candidate: OutcomeDistribution;
reference: OutcomeDistribution;
initialTroops: number;
+ outcomeRange?: OutcomeRange;
deterministic: boolean;
thresholds?: ParityThresholds;
}): ParityComparisonMetrics {
- const thresholds = options.thresholds ?? {};
+ const candidate = summarize(options.candidate.samples, "candidate");
+ const reference = summarize(options.reference.samples, "reference");
const initialTroops = options.initialTroops || 1;
- const biasRaw = round(options.candidate.mu - options.reference.mu, 2);
+ const biasRaw = round(candidate.mu - reference.mu, 2);
const biasPct = round((biasRaw / initialTroops) * 100, 2);
- const zThreshold = thresholds.z_threshold ?? 2;
- const minBiasPct = thresholds.min_bias_pct ?? 0.5;
- const deterministicLimit = (thresholds.max_diff_ratio_deterministic ?? thresholds.max_diff_ratio ?? 0.01) * 100;
+ const deterministicLimit = (
+ options.thresholds?.max_diff_ratio_deterministic
+ ?? options.thresholds?.max_diff_ratio
+ ?? 0.01
+ ) * 100;
- let sem = 0;
- let stat: number | null = null;
- let p: number | null = null;
- let statType: ParityComparisonMetrics["stat_type"];
- let passes: boolean;
-
if (options.deterministic) {
- statType = "deterministic";
- passes = Math.abs(biasPct) <= deterministicLimit;
- } 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";
- sem = options.candidate.sigma;
- 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;
- } else {
- statType = "t";
- 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: candidate.n,
+ mu_candidate: round(candidate.mu, 2),
+ sigma_candidate: round(candidate.sigma, 2),
+ n_reference: reference.n,
+ mu_reference: round(reference.mu, 2),
+ sigma_reference: round(reference.sigma, 2),
+ bias_raw: biasRaw,
+ bias_pct: biasPct,
+ sem: round(candidate.sigma / Math.sqrt(candidate.n), 2),
+ stat_type: "deterministic",
+ stat: null,
+ p: null,
+ passes: Math.abs(biasPct) <= deterministicLimit
+ };
}
+ const range = normalizeOutcomeRange(options.outcomeRange ?? {
+ min: -Math.abs(initialTroops),
+ max: Math.abs(initialTroops)
+ });
+ const comparison = calibratedComparison(candidate.samples, reference.samples, range);
+ const threshold = options.thresholds?.p_threshold ?? DEFAULT_STOCHASTIC_P_THRESHOLD;
+
return {
- n_candidate: options.candidate.n,
- mu_candidate: round(options.candidate.mu, 2),
- sigma_candidate: round(options.candidate.sigma, 2),
- n_reference: options.reference.n,
- mu_reference: round(options.reference.mu, 2),
- sigma_reference: round(options.reference.sigma, 2),
+ n_candidate: candidate.n,
+ mu_candidate: round(candidate.mu, 2),
+ sigma_candidate: round(candidate.sigma, 2),
+ n_reference: reference.n,
+ mu_reference: round(reference.mu, 2),
+ sigma_reference: round(reference.sigma, 2),
bias_raw: biasRaw,
bias_pct: biasPct,
- sem: round(sem, 2),
- stat_type: statType,
- stat,
- p,
- q: null,
- passes
+ sem: round(candidate.sigma / Math.sqrt(candidate.n), 2),
+ stat_type: "cdf_support",
+ stat: round(-Math.log10(comparison.overallP), 4),
+ p: round(comparison.overallP, 6),
+ passes: comparison.overallP >= threshold,
+ cdf_rms: round(comparison.cdfRms, 6),
+ cdf_p: round(comparison.cdfP, 6),
+ support_value: comparison.supportValue,
+ support_mass: round(comparison.supportMass, 8),
+ support_p: round(comparison.supportP, 6),
+ flag_reason: comparison.flagReason
};
}
-export function applyBenjaminiHochberg(rows: Array<{ p: number | null; q: number | null }>): void {
- const ranked = rows
- .filter((row): row is { p: number; q: number | null } => row.p !== null)
- .sort((a, b) => a.p - b.p);
- const m = ranked.length;
- let runningMin = 1;
- for (let index = m - 1; index >= 0; index -= 1) {
- const rawQ = (ranked[index].p * m) / (index + 1);
- runningMin = Math.min(runningMin, rawQ);
- ranked[index].q = round(Math.min(1, runningMin), 6);
+function summarize(samples: readonly number[], label: string): {
+ samples: readonly number[];
+ n: number;
+ mu: number;
+ sigma: number;
+} {
+ if (samples.length === 0) throw new Error(`${label} distribution has no samples`);
+ if (samples.some((value) => !Number.isFinite(value))) throw new Error(`${label} distribution contains a non-finite sample`);
+ const mu = samples.reduce((sum, value) => sum + value, 0) / samples.length;
+ const variance = samples.length > 1
+ ? samples.reduce((sum, value) => sum + (value - mu) ** 2, 0) / (samples.length - 1)
+ : 0;
+ return { samples, n: samples.length, mu, sigma: Math.sqrt(variance) };
+}
+
+interface DistributionModel {
+ samples: readonly number[];
+ range: OutcomeRange;
+ cutoffCount: number;
+ candidatePairTailSum: number;
+ candidateCrossTailSums: readonly number[];
+ smoothedMass: ReadonlyMap<number, number>;
+}
+
+interface RawGroupMetrics {
+ cdfRms: number;
+ supportMass: number;
+}
+
+interface ObservedGroupMetrics extends RawGroupMetrics {
+ supportValue: number;
+}
+
+interface CalibratedComparison extends ObservedGroupMetrics {
+ cdfP: number;
+ supportP: number;
+ overallP: number;
+ flagReason: "cdf" | "support" | "cdf+support";
+}
+
+const COMPONENT_NULL_RESAMPLES = 20_000;
+const JOINT_NULL_RESAMPLES = 20_000;
+const SUPPORT_KERNEL = [1, 2, 3, 2, 1] as const;
+const SUPPORT_RADIUS = 2;
+const COMPARISON_EPSILON = 1e-12;
+
+function calibratedComparison(
+ candidateSamples: readonly number[],
+ referenceSamples: readonly number[],
+ range: OutcomeRange
+): CalibratedComparison {
+ const model = buildDistributionModel(candidateSamples, range);
+ const observed = observedGroupMetrics(model, referenceSamples);
+ const random = seededRandom(candidateSamples, referenceSamples.length);
+ const componentNull = generateNullMetrics(model, referenceSamples.length, COMPONENT_NULL_RESAMPLES, random);
+ const sortedCdf = componentNull.map((metrics) => metrics.cdfRms).sort((left, right) => left - right);
+ const sortedSupport = componentNull.map((metrics) => metrics.supportMass).sort((left, right) => left - right);
+ const cdfP = upperTailEmpiricalP(sortedCdf, observed.cdfRms);
+ const supportP = lowerTailEmpiricalP(sortedSupport, observed.supportMass);
+ const observedMinP = Math.min(cdfP, supportP);
+
+ let atLeastAsExtreme = 0;
+ for (const metrics of generateNullMetrics(model, referenceSamples.length, JOINT_NULL_RESAMPLES, random)) {
+ const nullMinP = Math.min(
+ upperTailEmpiricalP(sortedCdf, metrics.cdfRms),
+ lowerTailEmpiricalP(sortedSupport, metrics.supportMass)
+ );
+ if (nullMinP <= observedMinP + COMPARISON_EPSILON) atLeastAsExtreme += 1;
}
+ const overallP = (atLeastAsExtreme + 1) / (JOINT_NULL_RESAMPLES + 1);
+ const pDifference = Math.abs(cdfP - supportP);
+ const flagReason = pDifference <= 1 / (COMPONENT_NULL_RESAMPLES + 1)
+ ? "cdf+support"
+ : cdfP < supportP ? "cdf" : "support";
+
+ return {
+ ...observed,
+ supportValue: observed.supportValue,
+ cdfP,
+ supportP,
+ overallP,
+ flagReason
+ };
}
-function normalCdf(x: number): number {
- return 0.5 * (1 + erf(x / Math.SQRT2));
+function buildDistributionModel(samples: readonly number[], range: OutcomeRange): DistributionModel {
+ const cutoffCount = range.max - range.min + 1;
+ const candidateCrossTailSums = samples.map((sample) => (
+ samples.reduce((sum, other) => sum + tailCutoffCount(Math.max(sample, other), range), 0)
+ ));
+ const candidatePairTailSum = candidateCrossTailSums.reduce((sum, value) => sum + value, 0);
+ const kernelMass = new Map<number, number>();
+
+ for (const sample of samples) {
+ let retainedWeight = 0;
+ for (let offset = -SUPPORT_RADIUS; offset <= SUPPORT_RADIUS; offset += 1) {
+ const target = sample + offset;
+ if (target >= range.min && target <= range.max) retainedWeight += SUPPORT_KERNEL[offset + SUPPORT_RADIUS]!;
+ }
+ for (let offset = -SUPPORT_RADIUS; offset <= SUPPORT_RADIUS; offset += 1) {
+ const target = sample + offset;
+ if (target < range.min || target > range.max) continue;
+ const weight = SUPPORT_KERNEL[offset + SUPPORT_RADIUS]! / retainedWeight;
+ kernelMass.set(target, (kernelMass.get(target) ?? 0) + weight);
+ }
+ }
+
+ const smoothedMass = new Map([...kernelMass].map(([value, mass]) => [value, mass / samples.length]));
+ return {
+ samples,
+ range,
+ cutoffCount,
+ candidatePairTailSum,
+ candidateCrossTailSums,
+ smoothedMass
+ };
}
-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 observedGroupMetrics(model: DistributionModel, samples: readonly number[]): ObservedGroupMetrics {
+ const candidateCrossTailSums = samples.map((sample) => (
+ model.samples.reduce((sum, other) => sum + tailCutoffCount(Math.max(sample, other), model.range), 0)
+ ));
+ let supportValue = samples[0]!;
+ let supportMass = model.smoothedMass.get(supportValue) ?? 0;
+ for (const sample of samples.slice(1)) {
+ const mass = model.smoothedMass.get(sample) ?? 0;
+ if (mass < supportMass) {
+ supportMass = mass;
+ supportValue = sample;
+ }
+ }
+ return {
+ cdfRms: cdfRms(model, samples, candidateCrossTailSums),
+ supportMass,
+ supportValue
+ };
}
-function varianceDegreesTerm(variance: number, n: number): number {
- return n <= 1 ? 0 : (variance ** 2) / (n - 1);
+function generateNullMetrics(
+ model: DistributionModel,
+ sampleCount: number,
+ count: number,
+ random: () => number
+): RawGroupMetrics[] {
+ const metrics: RawGroupMetrics[] = [];
+ for (let sampleIndex = 0; sampleIndex < count; sampleIndex += 1) {
+ const samples: number[] = [];
+ const candidateCrossTailSums: number[] = [];
+ let supportMass = Number.POSITIVE_INFINITY;
+ for (let draw = 0; draw < sampleCount; draw += 1) {
+ const index = Math.floor(random() * model.samples.length);
+ samples.push(model.samples[index]!);
+ candidateCrossTailSums.push(model.candidateCrossTailSums[index]!);
+ supportMass = Math.min(supportMass, leaveOneOutKernelSupportMass(model, index, random));
+ }
+ metrics.push({ cdfRms: cdfRms(model, samples, candidateCrossTailSums), supportMass });
+ }
+ return metrics;
}
-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 leaveOneOutKernelSupportMass(model: DistributionModel, index: number, random: () => number): number {
+ const value = model.samples[index]!;
+ const range = model.range;
+ let retainedWeight = 0;
+ for (let offset = -SUPPORT_RADIUS; offset <= SUPPORT_RADIUS; offset += 1) {
+ const target = value + offset;
+ if (target >= range.min && target <= range.max) retainedWeight += SUPPORT_KERNEL[offset + SUPPORT_RADIUS]!;
+ }
+ let selectedWeight = random() * retainedWeight;
+ for (let offset = -SUPPORT_RADIUS; offset <= SUPPORT_RADIUS; offset += 1) {
+ const target = value + offset;
+ if (target < range.min || target > range.max) continue;
+ const kernelWeight = SUPPORT_KERNEL[offset + SUPPORT_RADIUS]!;
+ selectedWeight -= kernelWeight;
+ if (selectedWeight < 0) {
+ const fullMass = model.smoothedMass.get(target) ?? 0;
+ if (model.samples.length < 2) return fullMass;
+ const ownContribution = kernelWeight / retainedWeight;
+ return Math.max(0, (fullMass * model.samples.length - ownContribution) / (model.samples.length - 1));
+ }
+ }
+ return model.smoothedMass.get(value) ?? 0;
}
-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 cdfRms(
+ model: DistributionModel,
+ samples: readonly number[],
+ candidateCrossTailSums: readonly number[]
+): number {
+ let samplePairTailSum = 0;
+ for (const left of samples) {
+ for (const right of samples) {
+ samplePairTailSum += tailCutoffCount(Math.max(left, right), model.range);
+ }
+ }
+ const candidateCount = model.samples.length;
+ const sampleCount = samples.length;
+ const squared = (
+ model.candidatePairTailSum / candidateCount ** 2
+ + samplePairTailSum / sampleCount ** 2
+ - (2 * candidateCrossTailSums.reduce((sum, value) => sum + value, 0)) / (candidateCount * sampleCount)
+ ) / model.cutoffCount;
+ return Math.sqrt(Math.max(0, squared));
}
-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;
+function tailCutoffCount(value: number, range: OutcomeRange): number {
+ const firstCutoff = Math.max(range.min, Math.ceil(value));
+ return firstCutoff > range.max ? 0 : range.max - firstCutoff + 1;
+}
- 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;
+function upperTailEmpiricalP(sortedNull: readonly number[], observed: number): number {
+ return (sortedNull.length - lowerBound(sortedNull, observed - COMPARISON_EPSILON) + 1) / (sortedNull.length + 1);
+}
- 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;
+function lowerTailEmpiricalP(sortedNull: readonly number[], observed: number): number {
+ return (upperBound(sortedNull, observed + COMPARISON_EPSILON) + 1) / (sortedNull.length + 1);
+}
+
+function lowerBound(sorted: readonly number[], value: number): number {
+ let low = 0;
+ let high = sorted.length;
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2);
+ if (sorted[middle]! < value) low = middle + 1;
+ else high = middle;
}
+ return low;
+}
- return result;
+function upperBound(sorted: readonly number[], value: number): number {
+ let low = 0;
+ let high = sorted.length;
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2);
+ if (sorted[middle]! <= value) low = middle + 1;
+ else high = middle;
+ }
+ return low;
}
-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 normalizeOutcomeRange(range: OutcomeRange): OutcomeRange {
+ if (!Number.isFinite(range.min) || !Number.isFinite(range.max)) throw new Error("outcome range must be finite");
+ const min = Math.ceil(range.min);
+ const max = Math.floor(range.max);
+ if (min > max) throw new Error("outcome range is empty");
+ return { min, max };
}
-function erf(x: number): number {
- const sign = x < 0 ? -1 : 1;
- const ax = Math.abs(x);
- const t = 1 / (1 + 0.3275911 * ax);
- const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
- return sign * y;
+function seededRandom(samples: readonly number[], referenceN: number): () => number {
+ let state = (0x811c9dc5 ^ referenceN) >>> 0;
+ for (const sample of samples) {
+ const integer = Math.round(sample * 1000);
+ state = Math.imul(state ^ integer, 0x01000193) >>> 0;
+ state = Math.imul(state ^ Math.floor(integer / 0x1_0000_0000), 0x01000193) >>> 0;
+ }
+ if (state === 0) state = 0x9e3779b9;
+ return () => {
+ state ^= state << 13;
+ state ^= state >>> 17;
+ state ^= state << 5;
+ return (state >>> 0) / 0x1_0000_0000;
+ };
}
function round(value: number, digits: number): number {
return Number(value.toFixed(digits));
Index: simulator/src/tooling/testcases.test.ts
===================================================================
--- simulator/src/tooling/testcases.test.ts prev run
+++ simulator/src/tooling/testcases.test.ts this run
@@ -4,11 +4,13 @@
import { resolve } from "node:path";
import { test } from "node:test";
import { loadSimulatorConfig } from "../config-node";
+import { createTroopStatsRecord } from "../troopStats";
+import type { SimulatorConfig } from "../types";
import { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";
-import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
-import { adaptTestcaseEntry, applyComparisonQValues, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, deterministicRoundTolerancePct, discoverTestcaseFiles, runTestcases, testcaseArmiesFromEntry, type TestcaseSummaryEntry } from "./testcases";
+import { compareOutcomeDistribution, DEFAULT_STOCHASTIC_P_THRESHOLD, type ParityComparisonMetrics } from "./parityMetrics";
+import { adaptTestcaseEntry, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, deterministicRoundTolerancePct, discoverTestcaseFiles, runTestcases, testcaseArmiesFromEntry, type TestcaseSummaryEntry } from "./testcases";
test("discoverTestcaseFiles follows simulator/testcases symlink and skips disabled or stale files by default", () => {
const files = discoverTestcaseFiles();
@@ -82,18 +84,18 @@
marksman_t11_fc9: 9_876,
});
});
-test("runTestcases defaults stochastic cases to 100 samples", () => {
+test("runTestcases defaults stochastic cases to 500 samples", () => {
const config = loadSimulatorConfig();
const report = runTestcases({ matching: "simple_001" }, config);
const summary = Object.values(report.testcases)[0];
const detail = report.details[0];
- assert.equal(report.options.repeat, 100);
+ assert.equal(report.options.repeat, 500);
assert.equal(summary?.deterministic, false);
- assert.equal(summary?.sampleCount, 100);
- assert.equal(detail?.simulatorStats?.n, 100);
+ assert.equal(summary?.sampleCount, 500);
+ assert.equal(detail?.simulatorStats?.n, 500);
});
test("assignDetailArtifactPaths assigns deterministic compact detail paths", () => {
const config = loadSimulatorConfig();
@@ -191,24 +193,8 @@
assert.equal(report.counts.comparedToBaseline, 0);
assert.deepEqual(report.warnings, []);
});
-test("applyComparisonQValues corrects game comparisons only", () => {
- const firstGame = comparisonMetric(0.01);
- const secondGame = comparisonMetric(0.02);
- const ignoredBaseline = comparisonMetric(0.04);
- const testcases: Record<string, TestcaseSummaryEntry> = {
- "testcases/a.json#0": summaryEntry("a", 0, firstGame, ignoredBaseline),
- "testcases/b.json#0": summaryEntry("b", 0, secondGame, null)
- };
-
- applyComparisonQValues({ testcases });
-
- assert.equal(firstGame.q, 0.02);
- assert.equal(secondGame.q, 0.02);
- assert.equal(ignoredBaseline.q, null);
-});
-
test("calibration lookup supports simulator symlink and source testcase path variants", () => {
assert.deepEqual(testcaseFileLookupVariants("simulator/testcases/emulator_verified/simple_001_nc.json"), [
"simulator/testcases/emulator_verified/simple_001_nc.json",
"testcases/emulator_verified/simple_001_nc.json"
@@ -252,24 +238,28 @@
assert.equal(summary?.baseline, null);
assert.equal(entry?.visibility.attacker.heroes.length, 0);
assert.equal(entry?.visibility.defender.heroes.length, 0);
assert.equal(entry?.calibration, undefined);
+ assert.equal(entry?.comparisonSamples, undefined);
assert.equal(battleScoreDelta(entry?.gameResult), -186);
assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);
});
-test("runTestcases applies possible stat rounding correction to exact deterministic misses", () => {
+test("runTestcases retains the adjusted comparison samples when requested", () => {
const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "determinism_test_normal" }, config);
+ const report = runTestcases({ matching: "determinism_test_normal", includeSamples: true }, config);
const summary = Object.values(report.testcases)[0];
+ const detail = report.details[0];
assert.equal(summary?.testcase_id, "determinism_test_normal");
assert.equal(summary?.deterministic, true);
assert.equal(summary?.game?.passes, true);
assert.equal(summary?.game?.bias_raw, 0);
assert.equal(summary?.gameStatAdjustment?.mode, "deterministic_exact");
- assert.equal(summary?.gameStatAdjustment?.value, 0.05);
- assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);
+ const adjustment = summary?.gameStatAdjustment;
+ assert.ok(adjustment && adjustment.value > 0 && adjustment.value <= 0.05);
+ assert.notEqual(adjustment.unadjusted.mu_candidate, summary?.game?.mu_candidate);
+ assert.deepEqual(detail?.comparisonSamples, [summary?.game?.mu_candidate]);
});
test("deterministic testcase tolerance increases by 0.1 percent every ten rounds and caps at 0.7 percent", () => {
assert.equal(deterministicRoundTolerancePct(0), 0.2);
@@ -280,23 +270,42 @@
assert.equal(deterministicRoundTolerancePct(50), 0.7);
assert.equal(deterministicRoundTolerancePct(1500), 0.7);
});
-test("round-scaled deterministic tolerance accepts close Seo-yoon cases", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "Seo-yoon_tc_nc.json" }, config);
- const fourth = Object.values(report.testcases).find((summary) => summary.testcase_id === "daut_viper_4");
- const fifth = Object.values(report.testcases).find((summary) => summary.testcase_id === "daut_viper_5");
+test("round-scaled deterministic tolerance accepts a close fixture case", () => {
+ const testcaseRoot = tempDir("simulator-round-tolerance-testcases");
+ writeFileSync(
+ resolve(testcaseRoot, "round-scaled.json"),
+ JSON.stringify({
+ test_id: "round_scaled",
+ attacker: { troops: { infantry_t1: 1000 }, heroes: {} },
+ defender: { troops: { infantry_t1: 900 }, heroes: {} },
+ game_report_result: { attacker: 264, defender: 0 }
+ })
+ );
- assert.equal(fourth?.game?.passes, true);
- assert.equal(fifth?.game?.passes, true);
+ const report = runTestcases({ testcaseRoot }, testcaseFixtureConfig());
+ const summary = Object.values(report.testcases)[0];
+
+ assert.equal(report.details[0]?.result?.rounds, 130);
+ assert.equal(summary?.game?.bias_raw, 10);
+ assert.equal(summary?.game?.passes, true);
});
-test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {
- const config = loadSimulatorConfig();
- const report = runTestcases({ matching: "1-testcases_no-heroes_t6_single-type_nc.json", repeat: 1 }, config);
- const entry = report.details.find((item) => item.testcaseId === "daut_viper_9");
+test("runTestcases default round cap lets a long fixture battle reach its end", () => {
+ const testcaseRoot = tempDir("simulator-round-cap-testcases");
+ writeFileSync(
+ resolve(testcaseRoot, "long-battle.json"),
+ JSON.stringify({
+ test_id: "long_battle",
+ attacker: { troops: { infantry_t1: 1000 }, heroes: {} },
+ defender: { troops: { infantry_t1: 900 }, heroes: {} }
+ })
+ );
+ const report = runTestcases({ testcaseRoot, repeat: 1 }, testcaseFixtureConfig());
+ const entry = report.details[0];
+
assert.equal(entry?.result?.winner, "attacker");
assert.ok((entry?.result?.rounds ?? 0) > 100);
});
@@ -344,13 +353,13 @@
});
test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {
const metrics = compareOutcomeDistribution({
- candidate: { n: 1, mu: -186, sigma: 0 },
- reference: { n: 1, mu: -186, sigma: 0 },
+ candidate: { samples: [-186] },
+ reference: { samples: [-186] },
initialTroops: 1200,
deterministic: true,
- thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ thresholds: { max_diff_ratio_deterministic: 0.01 }
});
assert.deepEqual(metrics, {
n_candidate: 1,
@@ -364,75 +373,113 @@
sem: 0,
stat_type: "deterministic",
stat: null,
p: null,
- q: null,
passes: true
} satisfies ParityComparisonMetrics);
});
-test("compareOutcomeDistribution uses candidate spread for single-observation stochastic references", () => {
+test("compareOutcomeDistribution calibrates one observation against same-size simulator draws", () => {
const metrics = compareOutcomeDistribution({
- candidate: { n: 5, mu: 10, sigma: 2 },
- reference: { n: 1, mu: 8, sigma: 0 },
+ candidate: { samples: [0, 1, 2, 3] },
+ reference: { samples: [0] },
initialTroops: 100,
- deterministic: false,
- thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ outcomeRange: { min: 0, max: 3 },
+ deterministic: false
});
- assert.equal(metrics.stat_type, "single_obs");
+ assert.equal(metrics.stat_type, "cdf_support");
assert.equal(metrics.passes, true);
- assert.equal(metrics.bias_raw, 2);
- assert.equal(metrics.bias_pct, 2);
- assert.equal(metrics.sem, 2);
- assert.equal(metrics.stat, 1);
+ assert.equal(metrics.bias_raw, 1.5);
+ assert.ok(metrics.p! > 0.3);
+ assert.ok(metrics.cdf_p! > 0.4);
+ assert.ok(metrics.support_p! > 0.3);
});
-test("compareOutcomeDistribution fails single-observation references outside candidate spread", () => {
+test("stochastic parity requires odds below one in 250 to fail by default", () => {
+ assert.equal(DEFAULT_STOCHASTIC_P_THRESHOLD, 0.004);
+
+ const candidate = Array.from({ length: 10 }, (_, index) => Array(50).fill(index + 1)).flat();
const metrics = compareOutcomeDistribution({
- candidate: { n: 100, mu: 862.15, sigma: 3.79 },
- reference: { n: 1, mu: 879, sigma: 0 },
- initialTroops: 1440,
- deterministic: false,
- thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ candidate: { samples: candidate },
+ reference: { samples: [1, 1, 2, 2, 3, 3, 4, 4, 5, 5] },
+ initialTroops: 10,
+ outcomeRange: { min: 1, max: 10 },
+ deterministic: false
});
- assert.equal(metrics.stat_type, "single_obs");
- assert.equal(metrics.passes, false);
- assert.equal(metrics.bias_raw, -16.85);
- assert.equal(metrics.bias_pct, -1.17);
- assert.equal(metrics.sem, 3.79);
- assert.equal(metrics.stat, -4.4459);
+ assert.ok(metrics.p! > DEFAULT_STOCHASTIC_P_THRESHOLD);
+ assert.ok(metrics.p! < 0.05);
+ assert.equal(metrics.passes, true);
});
-test("compareOutcomeDistribution includes noisy game samples in a Welch t comparison", () => {
+test("compareOutcomeDistribution flags a gap wider than the two-point support tolerance", () => {
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 }
+ candidate: { samples: Array(50).fill(-5).concat(Array(50).fill(5)) },
+ reference: { samples: [0, 0, 0, 0] },
+ initialTroops: 100,
+ outcomeRange: { min: -5, max: 5 },
+ deterministic: false
});
- 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);
+ assert.equal(metrics.bias_raw, 0);
+ assert.equal(metrics.support_mass, 0);
+ assert.equal(metrics.flag_reason, "support");
+ assert.ok(metrics.p! < 0.001);
+ assert.equal(metrics.passes, false);
});
-test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {
- const rows = [
- { p: 0.01, q: null },
- { p: 0.03, q: null },
- { p: null, q: null }
- ];
+test("compareOutcomeDistribution accepts radius-two neighbours but retains a wide gap", () => {
+ const candidate = Array(50).fill(42).concat(Array(50).fill(49));
+ const nearMass = compareOutcomeDistribution({
+ candidate: { samples: candidate },
+ reference: { samples: [44] },
+ initialTroops: 20,
+ outcomeRange: { min: 40, max: 51 },
+ deterministic: false
+ });
+ const insideGap = compareOutcomeDistribution({
+ candidate: { samples: candidate },
+ reference: { samples: [45] },
+ initialTroops: 20,
+ outcomeRange: { min: 40, max: 51 },
+ deterministic: false
+ });
- applyBenjaminiHochberg(rows);
+ assert.equal(nearMass.support_mass, 0.05555556);
+ assert.equal(nearMass.passes, true);
+ assert.equal(insideGap.support_mass, 0);
+ assert.equal(insideGap.flag_reason, "support");
+ assert.equal(insideGap.passes, false);
+});
- assert.equal(rows[0].q, 0.02);
- assert.equal(rows[1].q, 0.03);
- assert.equal(rows[2].q, null);
+test("compareOutcomeDistribution reserves failure for exceedingly unlikely uniform groups", () => {
+ const candidate = Array.from({ length: 10 }, (_, index) => Array(50).fill(index + 1)).flat();
+ const compare = (reference: number[]) => compareOutcomeDistribution({
+ candidate: { samples: candidate },
+ reference: { samples: reference },
+ initialTroops: 10,
+ outcomeRange: { min: 1, max: 10 },
+ deterministic: false
+ });
+ const typical = compare([3, 3, 4, 5, 5, 5, 6, 7, 8, 9]);
+ const tooPerfect = compare([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
+ const concentrated = compare(Array(10).fill(6));
+ const wrongShape = compare([1, 1, 1, 1, 1, 10, 10, 10, 10, 10]);
+ const oneUnsupported = compare([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
+
+ assert.equal(typical.cdf_rms, 0.109545);
+ assert.equal(typical.passes, true);
+ assert.equal(tooPerfect.cdf_rms, 0);
+ assert.equal(tooPerfect.passes, true);
+ assert.equal(concentrated.p, 0.00505);
+ assert.equal(concentrated.passes, true);
+ assert.equal(wrongShape.p, 0.024049);
+ assert.equal(wrongShape.passes, true);
+ assert.ok(oneUnsupported.cdf_p! > 0.05);
+ assert.equal(oneUnsupported.support_mass, 0);
+ assert.equal(oneUnsupported.flag_reason, "support");
+ assert.equal(oneUnsupported.passes, false);
});
test("calibration lookup exposes full baseline snapshot metrics", () => {
const comparison = loadCalibrationComparison();
@@ -449,8 +496,25 @@
mkdirSync(dir, { recursive: true });
return dir;
}
+function testcaseFixtureConfig(): SimulatorConfig {
+ return {
+ troopStats: {
+ infantry_t1: createTroopStatsRecord({
+ id: "infantry_t1",
+ type: "infantry",
+ tier: 1,
+ stats: { attack: 100, defense: 100, lethality: 100, health: 100 }
+ })
+ },
+ heroGenerationStats: {},
+ heroDefinitions: {},
+ troopSkills: { name: "troop skills fixture", skills: {} },
+ diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [] }
+ };
+}
+
function comparisonMetric(p: number): ParityComparisonMetrics {
return {
n_candidate: 2,
mu_candidate: 10,
@@ -460,12 +524,11 @@
sigma_reference: 1,
bias_raw: 2,
bias_pct: 1,
sem: 1,
- stat_type: "t",
- stat: 2,
+ stat_type: "cdf_support",
+ stat: 0.2,
p,
- q: null,
passes: true
};
}
Index: simulator/src/tooling/testcases.ts
===================================================================
--- simulator/src/tooling/testcases.ts prev run
+++ simulator/src/tooling/testcases.ts this run
@@ -7,14 +7,14 @@
type CalibrationCaseComparison,
type SampleStats,
sampleStats
} from "./calibration";
-import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
+import { compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
import { prepareBattle, runPrepared } from "../simulator";
import { DamageAggregationError } from "../staticDamageProfile";
import type { BattleInput, BattleResult, FighterInput, SimulationMode, SimulatorConfig, StatBlock, UnitType } from "../types";
-const DEFAULT_STOCHASTIC_REPEAT = 100;
+const DEFAULT_STOCHASTIC_REPEAT = 500;
const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;
const STAT_ROUNDING_SCAN_STEPS = 50;
const STAT_ROUNDING_INTERPOLATION_LIMIT = STAT_ROUNDING_SCAN_STEPS;
const DETERMINISTIC_BASE_TOLERANCE_TENTHS_PCT = 2;
@@ -27,8 +27,9 @@
includeDisabled?: boolean;
repeat?: number;
seed?: string | number;
workers?: number;
+ includeSamples?: boolean;
}
export interface TestcaseArmyDefinition {
heroes: Record<string, Record<string, number>>;
@@ -53,8 +54,9 @@
calibration?: CalibrationCaseComparison;
result?: BattleResult;
simulatorScoreDelta?: number;
simulatorStats?: SampleStats;
+ comparisonSamples?: number[];
simulatorSampleOutcomes?: TestcaseSampleOutcome[];
simulatorSampleDeltas?: number[];
gameStatAdjustment?: TestcaseStatAdjustment;
deterministic?: boolean;
@@ -96,8 +98,9 @@
createdAt: string;
options: TestcaseRunOptions;
calibrationReportPath?: string;
artifactRoot?: string;
+ chartsArtifact?: string;
counts: {
filesFound: number;
testcasesFound: number;
executed: number;
@@ -156,8 +159,9 @@
result?: BattleResult;
deterministic?: boolean;
sampleCount?: number;
simulatorStats?: SampleStats;
+ simulatorSamples?: number[];
simulatorScoreDelta?: number;
simulatorSampleOutcomes?: TestcaseSampleOutcome[];
simulatorSampleDeltas?: number[];
diagnostics: string[];
@@ -358,9 +362,9 @@
report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: execution.error });
return;
}
const result = execution.result;
- if (!result || !execution.simulatorStats || execution.deterministic === undefined || execution.sampleCount === undefined) {
+ if (!result || !execution.simulatorStats || !execution.simulatorSamples || execution.deterministic === undefined || execution.sampleCount === undefined) {
const reason = "Worker returned incomplete testcase execution result";
detail.error = reason;
detail.errorDetails = { type: "IncompleteExecutionResult" };
detail.diagnostics.push(reason);
@@ -368,31 +372,34 @@
return;
}
const stats = execution.simulatorStats;
const gameResult = (entry as { game_report_result?: unknown }).game_report_result;
- const initialTroops = totalInputTroops(preparedCase.input!.attacker) + totalInputTroops(preparedCase.input!.defender);
- const simulatorDistribution = { n: stats.n, mu: stats.mu, sigma: stats.sigma };
- const gameDistribution = distributionFromGameResult(gameResult);
- let game = gameDistribution
+ const attackerTroops = totalInputTroops(preparedCase.input!.attacker);
+ const defenderTroops = totalInputTroops(preparedCase.input!.defender);
+ const initialTroops = attackerTroops + defenderTroops;
+ const simulatorSamples = execution.simulatorSamples;
+ const gameSamples = extractOutcomeScores(gameResult);
+ let game = gameSamples.length > 0
? compareOutcomeDistribution({
- candidate: simulatorDistribution,
- reference: gameDistribution,
+ candidate: { samples: simulatorSamples },
+ reference: { samples: gameSamples },
initialTroops,
+ outcomeRange: { min: -defenderTroops, max: attackerTroops },
deterministic: result.randomness.deterministic,
thresholds: comparison.thresholds
})
: null;
if (game) {
- const unroundedBiasRaw = simulatorDistribution.mu - gameDistribution!.mu;
+ const unroundedBiasRaw = stats.mu - mean(gameSamples);
game = adjustedForRoundingRules(game, result.randomness.deterministic, initialTroops, result.rounds, unroundedBiasRaw);
}
const gameStatAdjustment = game && preparedCase.input
? findGameStatAdjustment({
game,
input: preparedCase.input,
config,
job: { file: preparedCase.file, reportFile, testcaseId, index, input: preparedCase.input, repeat: execution.sampleCount, seed: undefined },
- reference: gameDistribution!,
+ reference: gameSamples,
initialTroops,
averageRounds: result.rounds,
deterministic: result.randomness.deterministic,
thresholds: comparison.thresholds
@@ -403,8 +410,11 @@
detail.result = result;
detail.deterministic = execution.deterministic;
detail.sampleCount = execution.sampleCount;
detail.simulatorStats = stats;
+ if (report.options.includeSamples) {
+ detail.comparisonSamples = [...(gameStatAdjustment?.samples ?? simulatorSamples)];
+ }
detail.simulatorScoreDelta = execution.simulatorScoreDelta;
detail.simulatorSampleOutcomes = execution.simulatorSampleOutcomes;
detail.simulatorSampleDeltas = execution.simulatorSampleDeltas;
detail.gameStatAdjustment = gameStatAdjustment ? statAdjustmentForReport(gameStatAdjustment) : undefined;
@@ -433,16 +443,17 @@
value: number;
mode: TestcaseStatAdjustment["mode"];
unadjusted: ParityComparisonMetrics;
adjusted: ParityComparisonMetrics;
+ samples: number[];
}
function findGameStatAdjustment(options: {
game: ParityComparisonMetrics;
input: BattleInput;
config: SimulatorConfig;
job: TestcaseExecutionJob;
- reference: { n: number; mu: number; sigma: number };
+ reference: number[];
initialTroops: number;
averageRounds: number;
deterministic: boolean;
thresholds?: Record<string, number>;
@@ -484,20 +495,24 @@
game: ParityComparisonMetrics;
input: BattleInput;
config: SimulatorConfig;
job: TestcaseExecutionJob;
- reference: { n: number; mu: number; sigma: number };
+ reference: number[];
initialTroops: number;
averageRounds: number;
deterministic: boolean;
thresholds?: Record<string, number>;
}, value: number): InternalStatAdjustment {
const adjustedInput = inputWithStatAdjustment(options.input, value);
- const candidateStats = simulateAdjustedDistribution(adjustedInput, options.job, options.config);
+ const candidateSamples = simulateAdjustedOutcomes(adjustedInput, options.job, options.config);
const adjusted = compareOutcomeDistribution({
- candidate: { n: candidateStats.n, mu: candidateStats.mu, sigma: candidateStats.sigma },
- reference: options.reference,
+ candidate: { samples: candidateSamples },
+ reference: { samples: options.reference },
initialTroops: options.initialTroops,
+ outcomeRange: {
+ min: -totalInputTroops(options.input.defender),
+ max: totalInputTroops(options.input.attacker)
+ },
deterministic: options.deterministic,
thresholds: options.thresholds
});
return {
@@ -508,10 +523,11 @@
adjusted,
options.deterministic,
options.initialTroops,
options.averageRounds,
- candidateStats.mu - options.reference.mu
- )
+ mean(candidateSamples) - mean(options.reference)
+ ),
+ samples: candidateSamples
};
}
function biasesBracketZero(first: number, second: number): boolean {
@@ -527,18 +543,18 @@
if (value < min || value > max) return undefined;
return roundStatAdjustment(value);
}
-function simulateAdjustedDistribution(input: BattleInput, job: TestcaseExecutionJob, config: SimulatorConfig): SampleStats {
+function simulateAdjustedOutcomes(input: BattleInput, job: TestcaseExecutionJob, config: SimulatorConfig): number[] {
const samples: number[] = [];
const compiled = prepareBattle(input, config);
for (let iteration = 0; iteration < job.repeat; iteration += 1) {
const seed = sampleSeed(job.seed ?? input.seed, job.file, job.testcaseId, job.index, iteration);
const result = runPrepared(compiled, seed, { mode: "fast" });
const score = battleScoreDelta(result);
if (score !== undefined) samples.push(score);
}
- return sampleStats(samples);
+ return samples;
}
function inputWithStatAdjustment(input: BattleInput, value: number): BattleInput {
const adjusted = structuredClone(input);
@@ -601,9 +617,8 @@
return Number(value.toFixed(3));
}
function finalizeReport(report: TestcaseRunReport): void {
- applyComparisonQValues(report);
report.counts.warnings = report.warnings.length;
report.counts.errors = report.errors.length;
report.counts.comparedToGame = Object.values(report.testcases).filter((entry) => entry.game).length;
report.counts.comparedToBaseline = Object.values(report.testcases).filter((entry) => entry.baseline).length;
@@ -651,8 +666,9 @@
result,
deterministic: result.randomness.deterministic,
sampleCount,
simulatorStats: sampleStats(samples, { includeSamples: job.includeSamples }),
+ simulatorSamples: samples,
simulatorScoreDelta: battleScoreDelta(result),
simulatorSampleOutcomes: sampleOutcomes,
simulatorSampleDeltas: sampleDeltas,
diagnostics: [...result.resolved.attacker.diagnostics, ...result.resolved.defender.diagnostics]
@@ -667,12 +683,8 @@
};
}
}
-export function applyComparisonQValues(report: Pick<TestcaseRunReport, "testcases">): void {
- applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.game).filter((value): value is ParityComparisonMetrics => !!value));
-}
-
export function assignDetailArtifactPaths(report: TestcaseRunReport, artifactRoot: string): void {
report.artifactRoot = artifactRoot;
report.details.forEach((detail, index) => {
const detailArtifact = `${artifactRoot}/cases/${String(index + 1).padStart(6, "0")}.json`;
@@ -911,21 +923,18 @@
const testcaseIndex = normalized.indexOf("testcases/");
return testcaseIndex >= 0 ? normalized.slice(testcaseIndex) : normalized;
}
-function distributionFromGameResult(value: unknown): { n: number; mu: number; sigma: number } | undefined {
- const outcomes = extractOutcomeScores(value);
- if (outcomes.length === 0) return undefined;
- const stats = sampleStats(outcomes);
- return { n: stats.n, mu: stats.mu, sigma: stats.sigma };
-}
-
function extractOutcomeScores(value: unknown): number[] {
const rows = Array.isArray(value) ? value : value ? [value] : [];
return rows
.map((row) => battleScoreDelta(row))
.filter((score): score is number => score !== undefined);
}
+function mean(values: readonly number[]): number {
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
+}
+
function totalInputTroops(fighter: FighterInput): number {
return Object.values(fighter.troops ?? {}).reduce((sum, count) => sum + Number(count || 0), 0);
}
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" | "extra";
+export type DamageKind = "normal" | "skill";
export type UnitMask = number;
export type ActiveEffectKind = "modifier" | "shield" | "extra_attack" | "control" | "battle_order" | "carrier";
export type SameEffectStacking = "add" | "max";
@@ -90,8 +90,10 @@
id: string;
/** Optional only for a child-bearing normal-attack carrier. */
type?: string;
value?: unknown;
+ /** Restrict this modifier to these damage job kinds without changing its arithmetic bucket. */
+ applies_to_damage_kinds?: DamageKind[];
/** Apply this modifier only while the named effect is applicable to the same damage job. */
requires_effect?: string;
value_formula?: PercentOfValueFormula;
value_evolution?: { type?: string; step?: string; value?: number };
Index: testcases/emulator_verified/ahmose_no_infantry_240l_vs_125i.json
===================================================================
--- testcases/emulator_verified/ahmose_no_infantry_240l_vs_125i.json prev run
+++ testcases/emulator_verified/ahmose_no_infantry_240l_vs_125i.json this run
@@ -0,0 +1,101 @@
+[
+ {
+ "test_id": "ahmose_no_infantry_240l_vs_125i",
+ "description": "Prospective deterministic source-eligibility probe. WIP full Ahmose110 and240 T6 Lancers versus minxxx nohero125 T6 Infantry. No own Infantry; S2 is inapplicable and S3 actually locked. Current fixed-turn S1 protection predicts attacker15 versus defender11 if S1 needs Infantry normal attacks or an Infantry source. Preserve full fresh24 stats, report identity, troop formation, survivor counts and Battle Details. Frozen protocol and predictions in docs/mechanics-audit/probes/ahmose-no-infantry/.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Ahmose": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "lancer_t6": 240
+ },
+ "stats": {
+ "inf": {
+ "attack": 336.6,
+ "defense": 337.5,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 125
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 11
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stats_snapshot": "all24 fresh fields visually verified",
+ "observed_battle_outcomes": [
+ {
+ "displayed_time": "2026-09-07 10:20:24",
+ "coordinates": {
+ "x": 787,
+ "y": 577
+ },
+ "winner": "defender",
+ "survivors": {
+ "attacker": 0,
+ "defender": 11
+ },
+ "skill_activations": {
+ "Ahmose": {
+ "ViperFormation": null,
+ "PrayerOfFlame": 1
+ }
+ },
+ "evidence_directory": "docs/mechanics-audit/probes/ahmose-no-infantry/capture-01"
+ }
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/ahmose_source_dies_001i_480l_vs_250i.json
===================================================================
--- testcases/emulator_verified/ahmose_source_dies_001i_480l_vs_250i.json prev run
+++ testcases/emulator_verified/ahmose_source_dies_001i_480l_vs_250i.json this run
@@ -0,0 +1,121 @@
+[
+ {
+ "test_id": "ahmose_source_dies_001i_480l_vs_250i",
+ "description": "Prospective deterministic Viper source-lifecycle discriminator. WIP fullAhmose110 with1 T6 Infantry and480 T6 Lancers attacks minxxx nohero250 T6 Infantry. The single ordinary Infantry must die before the first scheduled Viper activation and before3 normal attacks, checked in both frozen traces. Distinguishes initial Infantry presence from requiring surviving/attacking Infantry during battle; does not choose exact cadence. Preserve all24 fresh stats, full kits, report identity and images. Frozen package docs/mechanics-audit/probes/ahmose-no-infantry/source-death/.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Ahmose": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "infantry_t6": 1,
+ "lancer_t6": 480
+ },
+ "stats": {
+ "inf": {
+ "attack": 336.6,
+ "defense": 337.5,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 250
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 3,
+ "defender": 0
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stats_snapshot": "all24 fresh report fields visually verified; exactly equal frozen v2 input",
+ "data_quality_notes": [
+ "Parser rejected total481 versus troop-slot OCR480 after missing the visibly recorded1 Infantry. Original images/error retained; fixture manually transcribed after checking all fields.",
+ "Mail ID and per-line attacker survivors were read from the same reopened10:33:04 report, not a second battle.",
+ "No actual Infantry death turn is reported; inferred timing stays in simulator predictions.",
+ "Displayed Viper activation dash is preserved as null."
+ ],
+ "observed_battle_outcomes": [
+ {
+ "displayed_time": "2026-09-07 10:33:04",
+ "mail_id": "2734692464128625",
+ "coordinates": {
+ "x": 787,
+ "y": 577
+ },
+ "winner": "attacker",
+ "survivors": {
+ "attacker": 3,
+ "defender": 0
+ },
+ "remaining_troops": {
+ "attacker": {
+ "infantry": 0,
+ "lancer": 3,
+ "marksman": 0
+ },
+ "defender": {
+ "infantry": 0,
+ "lancer": 0,
+ "marksman": 0
+ }
+ },
+ "skill_activations": {
+ "Ahmose": {
+ "ViperFormation": null,
+ "PrayerOfFlame": 1
+ }
+ },
+ "evidence_directory": "docs/mechanics-audit/probes/ahmose-no-infantry/source-death/capture-01"
+ }
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/edith_ironclad_100i_vs_400m.json
===================================================================
--- testcases/emulator_verified/edith_ironclad_100i_vs_400m.json prev run
+++ testcases/emulator_verified/edith_ironclad_100i_vs_400m.json this run
@@ -0,0 +1,74 @@
+[
+ {
+ "test_id": "edith_ironclad_100i_vs_400m",
+ "description": "One accepted in-game battle with full minxxx Edith3/3/3 and100 T6 Infantry against WIP nohero400 T6 Marksmen. Game/current fresh-stat replay both leave102 defenders. Exact fullkit/T6counts, all24 report stats, distinct report identity, defender troop table and all three skill tooltip curves are visually verified and retained in docs/mechanics-audit/probes/edith-ironclad/. No fresh input correction was needed; hypothetical and historical design stats remain explicitly conditional in the immutable forecasts. No generation added to report stats.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Edith": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "infantry_t6": 100
+ },
+ "stats": {
+ "inf": {
+ "attack": 529.9,
+ "defense": 522.1,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 400
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 102
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/greg_deterrence_200i_vs_400m.json
===================================================================
--- testcases/emulator_verified/greg_deterrence_200i_vs_400m.json prev run
+++ testcases/emulator_verified/greg_deterrence_200i_vs_400m.json this run
@@ -0,0 +1,192 @@
+[
+ {
+ "test_id": "greg_deterrence_200i_vs_400m",
+ "description": "Prospective five-outcome S2 positive-contribution probe. minxxx fullGreg1/1/0 and200 T6 Infantry attacks WIP nohero400 T6 Marksmen. One persistent enemy class avoids prior target dilution; Infantry source distinguishes all-troop S2 eligibility from Marksman-only scope. S1 chance retained, S3 actually locked. Preserve exact fresh full reportstats, reportidentity, all individual outcomes, Battle Details and per-line survivors. Frozen design and models in docs/mechanics-audit/probes/greg-deterrence/.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Greg": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "infantry_t6": 200
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 375.8,
+ "defense": 376.3,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 400
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 113
+ },
+ {
+ "attacker": 0,
+ "defender": 108
+ },
+ {
+ "attacker": 0,
+ "defender": 109
+ },
+ {
+ "attacker": 0,
+ "defender": 113
+ },
+ {
+ "attacker": 0,
+ "defender": 110
+ }
+ ],
+ "metadata": {
+ "data_quality_notes": [
+ "Initial attacker-report notification obscured Infantry Attack on both sides. Corrected only those two OCR fields using visually checked clean counterpart stats; original fixture and images preserved in capture-01/input-correction.json."
+ ],
+ "observed_battle_outcomes": [
+ {
+ "displayed_time": "2026-09-07 08:32:39",
+ "coordinates": {
+ "x": 791,
+ "y": 577
+ },
+ "winner": "defender",
+ "survivors": {
+ "attacker": 0,
+ "defender": 113
+ },
+ "skill_activations": {
+ "Greg": {
+ "SwordOfJustice": 15,
+ "DeterrenceOfLaw": 22
+ }
+ },
+ "evidence_directory": "docs/mechanics-audit/probes/greg-deterrence/capture-01"
+ },
+ {
+ "displayed_time": "2026-09-07 09:45:59",
+ "coordinates": {
+ "x": 791,
+ "y": 577
+ },
+ "winner": "defender",
+ "survivors": {
+ "attacker": 0,
+ "defender": 108
+ },
+ "skill_activations": {
+ "Greg": {
+ "SwordOfJustice": 23,
+ "DeterrenceOfLaw": 22
+ }
+ },
+ "evidence_directory": "docs/mechanics-audit/probes/greg-deterrence/capture-02"
+ },
+ {
+ "displayed_time": "2026-09-07 09:52:08",
+ "coordinates": {
+ "x": 791,
+ "y": 577
+ },
+ "winner": "defender",
+ "survivors": {
+ "attacker": 0,
+ "defender": 109
+ },
+ "skill_activations": {
+ "Greg": {
+ "SwordOfJustice": 16,
+ "DeterrenceOfLaw": 28
+ }
+ },
+ "evidence_directory": "docs/mechanics-audit/probes/greg-deterrence/capture-03"
+ },
+ {
+ "displayed_time": "2026-09-07 10:00:17",
+ "coordinates": {
+ "x": 791,
+ "y": 577
+ },
+ "winner": "defender",
+ "survivors": {
+ "attacker": 0,
+ "defender": 113
+ },
+ "skill_activations": {
+ "Greg": {
+ "SwordOfJustice": 16,
+ "DeterrenceOfLaw": 17
+ }
+ },
+ "evidence_directory": "docs/mechanics-audit/probes/greg-deterrence/capture-04"
+ },
+ {
+ "displayed_time": "2026-09-07 10:05:22",
+ "coordinates": {
+ "x": 791,
+ "y": 577
+ },
+ "winner": "defender",
+ "survivors": {
+ "attacker": 0,
+ "defender": 110
+ },
+ "skill_activations": {
+ "Greg": {
+ "SwordOfJustice": 20,
+ "DeterrenceOfLaw": 21
+ }
+ },
+ "evidence_directory": "docs/mechanics-audit/probes/greg-deterrence/capture-05"
+ }
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/gwen_blastmaster_cadence_800m_vs_1200i_100l_100m.json
===================================================================
--- testcases/emulator_verified/gwen_blastmaster_cadence_800m_vs_1200i_100l_100m.json prev run
+++ testcases/emulator_verified/gwen_blastmaster_cadence_800m_vs_1200i_100l_100m.json this run
@@ -0,0 +1,157 @@
+[
+ {
+ "test_id": "gwen_blastmaster_cadence_800m_vs_1200i_100l_100m",
+ "description": "Prospective deterministic full-kit Gwen3/1/1 follow-up. 800T6Marksmen against1200T6Infantry+100T6Lancers+100T6Marksmen. Frozen predictions from the preceding report stats: first5/every4/allliving D535; first5/every5/allliving D580; first6/every4/allliving D555; first5/every5/current-target-only D723. All target lines survive in all hypotheses. Compare full endpoint and per-line losses; BattleDetails counts diagnostic only. Exact frozen inputs, parameter-only candidates and290stat probes preserved under docs/mechanics-audit/probes/gwen-blastmaster/followup. No skills disabled in live kit. Capture fresh report stats and rerun frozen rules before interpretation.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Gwen": {
+ "skill_1": 3,
+ "skill_2": 1,
+ "skill_3": 1
+ }
+ },
+ "troops": {
+ "marksman_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 443.6,
+ "defense": 444.1,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 1200,
+ "lancer_t6": 100,
+ "marksman_t6": 100
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 563
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stochastic_reason": [],
+ "stats_snapshot": "Fresh displayed report stats,2026-09-07 05:33:16; identical to preceding Gwen report. No generation bonus added.",
+ "observed_battle_outcomes": [
+ {
+ "report_timestamp_displayed": "2026-09-07 05:33:16",
+ "world_coord": {
+ "x": 791,
+ "y": 577
+ },
+ "attacker": {
+ "troops": 800,
+ "survivors": 0,
+ "losses": 0,
+ "injured": 280,
+ "lightly_injured": 520
+ },
+ "defender": {
+ "troops": 1400,
+ "survivors": 563,
+ "losses": 0,
+ "injured": 294,
+ "lightly_injured": 543
+ },
+ "skill_details": {
+ "attacker": {
+ "Gwen": {
+ "skill_1": {
+ "triggered": 1,
+ "displayed_kills": null
+ },
+ "skill_2": {
+ "triggered": 7,
+ "displayed_kills": 7
+ },
+ "skill_3": {
+ "triggered": 9,
+ "displayed_kills": 39
+ }
+ }
+ }
+ },
+ "rounds": null,
+ "transcription": "Overview, exact troop tiers/counts, every displayed stat, skill details and all defender troop outcome rows visually checked. Stats identical to first probe. Counterpart inbox capture is the same battle, not a new observation. No explicit game round total shown.",
+ "per_troop_survivors": {
+ "attacker": {
+ "infantry": 0,
+ "lancer": 0,
+ "marksman": 0
+ },
+ "defender": {
+ "infantry": 461,
+ "lancer": 58,
+ "marksman": 44
+ }
+ },
+ "mail_ids": {
+ "defender_view": "2734692464122044"
+ },
+ "defender_troop_details_source": "docs/mechanics-audit/probes/gwen-blastmaster/followup/defender-troops.json"
+ }
+ ],
+ "evidence": {
+ "protocol": "docs/mechanics-audit/probes/gwen-blastmaster/followup/README.md",
+ "prospective_prediction": "docs/mechanics-audit/probes/gwen-blastmaster/followup/prediction.json",
+ "report": "docs/mechanics-audit/probes/gwen-blastmaster/followup/report.json",
+ "overview_image": "docs/mechanics-audit/probes/gwen-blastmaster/followup/report_top.png",
+ "stats_image": "docs/mechanics-audit/probes/gwen-blastmaster/followup/report_stats.png",
+ "details_image": "docs/mechanics-audit/probes/gwen-blastmaster/followup/bd_top.png",
+ "deployment_image": "docs/mechanics-audit/probes/gwen-blastmaster/followup/attacker-deployment.png",
+ "original_capture_directory": "tmp/20260907T043341Z_wos_report_war_1",
+ "defender_troops_image": "docs/mechanics-audit/probes/gwen-blastmaster/followup/defender-troops.png"
+ },
+ "evidence_status": "unresolved_deterministic_disagreement",
+ "data_quality_notes": [
+ "Both failed automatic attempts stopped before attacking; this single battle was completed through visible wosctl shell controls. Displayed skill kills remain report values, not raw simulator kill totals."
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/gwen_blastmaster_fanout_800m_vs_600_each.json
===================================================================
--- testcases/emulator_verified/gwen_blastmaster_fanout_800m_vs_600_each.json prev run
+++ testcases/emulator_verified/gwen_blastmaster_fanout_800m_vs_600_each.json this run
@@ -0,0 +1,158 @@
+[
+ {
+ "test_id": "gwen_blastmaster_fanout_800m_vs_600_each",
+ "description": "Prospective deterministic Gwen S3 Blastmaster target-fanout discriminator. minxxx fields its complete freshly confirmed Gwen 3/1/1 kit with 800 T6 Marksmen against WIP no heroes and 600 each T6 Infantry, Lancers and Marksmen. All hero skills remain enabled. Primary hypotheses differ only in Blastmaster target fanout: one 10% skill job per living enemy troop line versus one 10% skill job to the normal attack target. Conditional historical-stat forecasts are defender1390 (239 Infantry,579 Lancers,572 Marksmen) versus defender1441, both22 rounds. Exact captured report stats must replace estimated inputs under the frozen candidate definitions before interpretation. Prediction and source provenance are in tmp/mechanics-audit-2026-09-07/gwen/prediction.json. Preserve full Battle Details and all per-line survivors; this first fixture addresses fanout, not all Gwen timing or stacking semantics.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Gwen": {
+ "skill_1": 3,
+ "skill_2": 1,
+ "skill_3": 1
+ }
+ },
+ "troops": {
+ "marksman_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 443.6,
+ "defense": 444.1,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 600,
+ "lancer_t6": 600,
+ "marksman_t6": 600
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 1397
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stochastic_reason": [],
+ "stats_snapshot": "Fresh displayed report stats, 2026-09-07 05:05:04; no generation bonus added.",
+ "observed_battle_outcomes": [
+ {
+ "report_timestamp_displayed": "2026-09-07 05:05:04",
+ "world_coord": {
+ "x": 791,
+ "y": 577
+ },
+ "attacker": {
+ "troops": 800,
+ "survivors": 0,
+ "losses": 0,
+ "injured": 280,
+ "lightly_injured": 520
+ },
+ "defender": {
+ "troops": 1800,
+ "survivors": 1397,
+ "losses": 0,
+ "injured": 142,
+ "lightly_injured": 261
+ },
+ "skill_details": {
+ "attacker": {
+ "Gwen": {
+ "skill_1": {
+ "triggered": 1,
+ "displayed_kills": null
+ },
+ "skill_2": {
+ "triggered": 3,
+ "displayed_kills": 3
+ },
+ "skill_3": {
+ "triggered": 4,
+ "displayed_kills": 17
+ }
+ }
+ }
+ },
+ "rounds": null,
+ "per_troop_survivors": {
+ "attacker": {
+ "infantry": 0,
+ "lancer": 0,
+ "marksman": 0
+ },
+ "defender": {
+ "infantry": 241,
+ "lancer": 581,
+ "marksman": 575
+ }
+ },
+ "transcription": "Visually checked overview, complete stats, skill details and defender troop details. Dash preserved as null. No explicit round total shown. Counterpart inbox capture is the same battle, not an additional observation.",
+ "mail_ids": {
+ "attacker_view": "2734692464121039",
+ "defender_view": "2734692464121040"
+ },
+ "defender_troop_details_source": "docs/mechanics-audit/probes/gwen-blastmaster/defender-troops.json"
+ }
+ ],
+ "evidence": {
+ "protocol": "docs/mechanics-audit/probes/gwen-blastmaster/README.md",
+ "prospective_prediction": "docs/mechanics-audit/probes/gwen-blastmaster/prediction.json",
+ "report": "docs/mechanics-audit/probes/gwen-blastmaster/report.json",
+ "overview_image": "docs/mechanics-audit/probes/gwen-blastmaster/report_top.png",
+ "stats_image": "docs/mechanics-audit/probes/gwen-blastmaster/report_stats.png",
+ "details_image": "docs/mechanics-audit/probes/gwen-blastmaster/bd_top.png",
+ "hero_levels_image": "docs/mechanics-audit/probes/gwen-blastmaster/hero-skills.png",
+ "original_capture_directory": "tmp/20260907T040507Z_wos_report_war_1",
+ "defender_troops_image": "docs/mechanics-audit/probes/gwen-blastmaster/defender-troops.png"
+ },
+ "evidence_status": "unresolved_deterministic_disagreement",
+ "data_quality_notes": [
+ "Captured overview, exact troops, every displayed stat and hero kit visually checked. Displayed skill kills are preserved as report values, not equated to raw simulator kill totals."
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/gwen_blastmaster_small_timing_550m_vs_810i_45l_45m.json
===================================================================
--- testcases/emulator_verified/gwen_blastmaster_small_timing_550m_vs_810i_45l_45m.json prev run
+++ testcases/emulator_verified/gwen_blastmaster_small_timing_550m_vs_810i_45l_45m.json this run
@@ -0,0 +1,156 @@
+[
+ {
+ "test_id": "gwen_blastmaster_small_timing_550m_vs_810i_45l_45m",
+ "description": "Prospective small-army timing discriminator. Full minxxx Gwen3/1/1 with550T6 Marksmen attacks nohero WIP810T6 Infantry+45T6 Lancers+45T6 Marksmen. Frozen currentS3first5/every4 predicts attacker76; first6/every4 attacker52; first4/every5 defender98; first5/every5 defender125. Robust separated sampled report-stat ranges; full kit and mixed-target scheduling retained. Package docs/mechanics-audit/probes/gwen-small-timing-550. Fresh report inputs must be verified.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Gwen": {
+ "skill_1": 3,
+ "skill_2": 1,
+ "skill_3": 1
+ }
+ },
+ "troops": {
+ "marksman_t6": 550
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 443.6,
+ "defense": 444.1,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 810,
+ "lancer_t6": 45,
+ "marksman_t6": 45
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 24
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stochastic_reason": [],
+ "observed_battle_outcomes": [
+ {
+ "report_timestamp_displayed": "2026-09-07 06:29:43",
+ "world_coord": {
+ "x": 791,
+ "y": 577
+ },
+ "attacker": {
+ "troops": 550,
+ "survivors": 0,
+ "losses": 0,
+ "injured": 193,
+ "lightly_injured": 357
+ },
+ "defender": {
+ "troops": 900,
+ "survivors": 24,
+ "losses": 0,
+ "injured": 308,
+ "lightly_injured": 568
+ },
+ "per_troop_survivors": {
+ "attacker": {
+ "infantry": 0,
+ "lancer": 0,
+ "marksman": 0
+ },
+ "defender": {
+ "infantry": 24,
+ "lancer": 0,
+ "marksman": 0
+ }
+ },
+ "skill_details": {
+ "attacker": {
+ "Gwen": {
+ "skill_1": {
+ "triggered": 1,
+ "displayed_kills": null
+ },
+ "skill_2": {
+ "triggered": 15,
+ "displayed_kills": 7
+ },
+ "skill_3": {
+ "triggered": 18,
+ "displayed_kills": 36
+ }
+ }
+ }
+ },
+ "rounds": null,
+ "mail_ids": {
+ "defender_view": "2734692464123174"
+ },
+ "defender_troop_details_source": "docs/mechanics-audit/probes/gwen-small-timing-550/defender-troops.json",
+ "transcription": "Visually checked both overview and counterpart report, every displayed stat, exact T6 troop counts, Gwen identity/full3/1/1 kit and Battle Details. Fresh fixture input exactly equals frozen input. Both backlines eliminated with Infantry remaining. No explicit game round count. Counterpart report is the same battle, not another observation; dash retained as null."
+ }
+ ],
+ "evidence": {
+ "protocol": "docs/mechanics-audit/probes/gwen-small-timing-550/README.md",
+ "prospective_prediction": "docs/mechanics-audit/probes/gwen-small-timing-550/prediction.json",
+ "captured_prediction": "docs/mechanics-audit/probes/gwen-small-timing-550/captured-prediction.json",
+ "report": "docs/mechanics-audit/probes/gwen-small-timing-550/report.json",
+ "overview_image": "docs/mechanics-audit/probes/gwen-small-timing-550/report_top.png",
+ "stats_image": "docs/mechanics-audit/probes/gwen-small-timing-550/report_stats.png",
+ "details_image": "docs/mechanics-audit/probes/gwen-small-timing-550/bd_top.png",
+ "defender_troops_image": "docs/mechanics-audit/probes/gwen-small-timing-550/defender-troops.png",
+ "original_capture_directory": "tmp/20260907T052949Z_wos_report_war_1"
+ },
+ "evidence_status": "unresolved_material_deterministic_disagreement",
+ "data_quality_notes": [
+ "Fresh report input exactly equals frozen input; stats already include hero bonuses. Game defender24 versus current attacker76, signed endpoint difference100. None of the frozen schedule or preparation-count alternatives explains the new result. Full kit retained; do not attribute mismatch uniquely to S3 timing."
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/hendrik_armor_lancer_scope.json
===================================================================
--- testcases/emulator_verified/hendrik_armor_lancer_scope.json prev run
+++ testcases/emulator_verified/hendrik_armor_lancer_scope.json this run
@@ -0,0 +1,148 @@
+[
+ {
+ "test_id": "hendrik_armor_lancer_scope",
+ "description": "Prospective deterministic Hendrik S2 non-Marksman recipient-scope probe. minxxx attacks with 100 no-hero T6 Lancers; WIP defends with 150 T6 Lancers and its full freshly captured 3/3/3 Hendrik kit. No Marksmen makes S3 inert under current scope; S1 remains active. S3 using Lancers and alternative S1 scaling are retained as competing diagnostic models. Frozen candidate definitions and conditional predictions in tmp/mechanics-audit-2026-09-07/next-probe/prediction.json. Recompute unchanged candidates with exact captured report stats before interpreting. Endpoint and Battle Details required; this cannot verify all S2 timing/stacking or S3 behavior.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "lancer_t6": 100
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Hendrik": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "lancer_t6": 150
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 465.5,
+ "defense": 458.7,
+ "lethality": 244.0,
+ "health": 218.8
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 17
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stochastic_reason": [],
+ "observed_battle_outcomes": [
+ {
+ "report_timestamp_displayed": "2026-09-07 05:42:10",
+ "world_coord": {
+ "x": 791,
+ "y": 577
+ },
+ "attacker": {
+ "troops": 100,
+ "survivors": 0,
+ "losses": 0,
+ "injured": 35,
+ "lightly_injured": 65
+ },
+ "defender": {
+ "troops": 150,
+ "survivors": 17,
+ "losses": 0,
+ "injured": 47,
+ "lightly_injured": 86
+ },
+ "per_troop_survivors": {
+ "attacker": {
+ "infantry": 0,
+ "lancer": 0,
+ "marksman": 0
+ },
+ "defender": {
+ "infantry": 0,
+ "lancer": 17,
+ "marksman": 0
+ }
+ },
+ "skill_details": {
+ "defender": {
+ "Hendrik": {
+ "skill_1": {
+ "triggered": 1,
+ "displayed_kills": null
+ },
+ "skill_2": {
+ "triggered": 13,
+ "displayed_kills": null
+ },
+ "skill_3": {
+ "triggered": null,
+ "displayed_kills": null
+ }
+ }
+ }
+ },
+ "rounds": null,
+ "transcription": "Visually checked overview, full report stats, exact T6 Lancer counts, hero identity and Battle Details. Dash retained as null; S3 shows no activation or kills. Per-type survivors follow directly from each single-type army. No explicit round total shown."
+ }
+ ],
+ "evidence": {
+ "protocol": "docs/mechanics-audit/probes/hendrik-armor/README.md",
+ "prospective_prediction": "docs/mechanics-audit/probes/hendrik-armor/prediction.json",
+ "report": "docs/mechanics-audit/probes/hendrik-armor/report.json",
+ "overview_image": "docs/mechanics-audit/probes/hendrik-armor/report_top.png",
+ "stats_image": "docs/mechanics-audit/probes/hendrik-armor/report_stats.png",
+ "details_image": "docs/mechanics-audit/probes/hendrik-armor/bd_top.png",
+ "original_capture_directory": "tmp/20260907T044217Z_wos_report_war_1"
+ },
+ "evidence_status": "supports_lancer_protection_in_context",
+ "data_quality_notes": [
+ "All displayed stats visually checked. Report values already include hero stats; no generation bonus added. Skill trigger counts are observed report values; zero eligible S3 damage sources must not be confused with simulator scheduler activation counts."
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/hendrik_dragons_heir_250m_vs_400i_100l_100m.json
===================================================================
--- testcases/emulator_verified/hendrik_dragons_heir_250m_vs_400i_100l_100m.json prev run
+++ testcases/emulator_verified/hendrik_dragons_heir_250m_vs_400i_100l_100m.json this run
@@ -0,0 +1,156 @@
+[
+ {
+ "test_id": "hendrik_dragons_heir_250m_vs_400i_100l_100m",
+ "description": "Prospective deterministic positive S3 fanout probe: WIP full Hendrik3/3/3 with250 T6 Marksmen attacks minxxx no heroes with400 T6 Infantry+100 T6 Lancers+100 T6 Marksmen. Current all-living S3 predicts minxxx476 survivors(314/84/78); target-only predicts516(316/100/100). Both preserve every defender line. Source report stats are conditional because roles reverse; capture fresh exact report stats before interpretation. Frozen alternative outer-army-ceil arithmetic retains same nominal outcomes. Full endpoint primary; per-line survivors diagnose positive backline damage. Package docs/mechanics-audit/probes/hendrik-dragons-heir.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Hendrik": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "marksman_t6": 250
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 465.5,
+ "defense": 458.7,
+ "lethality": 244.0,
+ "health": 218.8
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 400,
+ "lancer_t6": 100,
+ "marksman_t6": 100
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 471
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stochastic_reason": [],
+ "observed_battle_outcomes": [
+ {
+ "report_timestamp_displayed": "2026-09-07 06:02:19",
+ "world_coord": {
+ "x": 787,
+ "y": 577
+ },
+ "attacker": {
+ "troops": 250,
+ "survivors": 0,
+ "losses": 0,
+ "injured": 88,
+ "lightly_injured": 162
+ },
+ "defender": {
+ "troops": 600,
+ "survivors": 471,
+ "losses": 0,
+ "injured": 47,
+ "lightly_injured": 82
+ },
+ "per_troop_survivors": {
+ "attacker": {
+ "infantry": 0,
+ "lancer": 0,
+ "marksman": 0
+ },
+ "defender": {
+ "infantry": 313,
+ "lancer": 82,
+ "marksman": 76
+ }
+ },
+ "skill_details": {
+ "attacker": {
+ "Hendrik": {
+ "skill_1": {
+ "triggered": 1,
+ "displayed_kills": null
+ },
+ "skill_2": {
+ "triggered": 5,
+ "displayed_kills": null
+ },
+ "skill_3": {
+ "triggered": 6,
+ "displayed_kills": 17
+ }
+ }
+ }
+ },
+ "rounds": null,
+ "mail_ids": {
+ "defender_view": "2734692464122617"
+ },
+ "defender_troop_details_source": "docs/mechanics-audit/probes/hendrik-dragons-heir/defender-troops.json",
+ "transcription": "Visually checked report overview, all displayed stats, exact T6 troop counts, full Hendrik3/3/3 kit, Battle Details and defender troop outcomes. No explicit round total. Dash retained as null. Counterpart report is the same battle, not an additional observation."
+ }
+ ],
+ "evidence": {
+ "protocol": "docs/mechanics-audit/probes/hendrik-dragons-heir/README.md",
+ "prospective_prediction": "docs/mechanics-audit/probes/hendrik-dragons-heir/prediction.json",
+ "captured_prediction": "docs/mechanics-audit/probes/hendrik-dragons-heir/captured-prediction.json",
+ "report": "docs/mechanics-audit/probes/hendrik-dragons-heir/report.json",
+ "overview_image": "docs/mechanics-audit/probes/hendrik-dragons-heir/report_top.png",
+ "stats_image": "docs/mechanics-audit/probes/hendrik-dragons-heir/report_stats.png",
+ "details_image": "docs/mechanics-audit/probes/hendrik-dragons-heir/bd_top.png",
+ "defender_troops_image": "docs/mechanics-audit/probes/hendrik-dragons-heir/defender-troops.png",
+ "original_capture_directory": "tmp/20260907T050223Z_wos_report_war_1"
+ },
+ "evidence_status": "supports_positive_s3_fanout_with_unresolved_endpoint_residual",
+ "data_quality_notes": [
+ "Fresh displayed stats match the frozen role-reversed inputs. Report stats already include hero stats; no generation bonus added. Current forecast476[314,84,78] versus game471[313,82,76], a five-unit total residual. Target-only forecast516 with no backline loss is rejected."
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/hendrik_small_timing_250m_vs_150i_60l_60m.json
===================================================================
--- testcases/emulator_verified/hendrik_small_timing_250m_vs_150i_60l_60m.json prev run
+++ testcases/emulator_verified/hendrik_small_timing_250m_vs_150i_60l_60m.json this run
@@ -0,0 +1,157 @@
+[
+ {
+ "test_id": "hendrik_small_timing_250m_vs_150i_60l_60m",
+ "description": "Prospective full Hendrik3/3/3 timing discriminator. WIP250T6Marksmen versus minxxx nohero150T6Infantry/60T6Lancers/60T6Marksmen. Earlier S3 round2 versus configured round3; competing S2 timing retained. Full frozen package docs/mechanics-audit/probes/hendrik-small-timing. Fresh report stats required before interpretation.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Hendrik": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "marksman_t6": 250
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 465.5,
+ "defense": 458.7,
+ "lethality": 244.0,
+ "health": 218.8
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 150,
+ "lancer_t6": 60,
+ "marksman_t6": 60
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 68
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stochastic_reason": [],
+ "observed_battle_outcomes": [
+ {
+ "report_timestamp_displayed": "2026-09-07 07:31:27",
+ "world_coord": {
+ "x": 787,
+ "y": 577
+ },
+ "attacker": {
+ "troops": 250,
+ "survivors": 0,
+ "losses": 0,
+ "injured": 88,
+ "lightly_injured": 162
+ },
+ "defender": {
+ "troops": 270,
+ "survivors": 68,
+ "losses": 0,
+ "injured": 72,
+ "lightly_injured": 130
+ },
+ "per_troop_survivors": {
+ "attacker": {
+ "infantry": 0,
+ "lancer": 0,
+ "marksman": 0
+ },
+ "defender": {
+ "infantry": 16,
+ "lancer": 31,
+ "marksman": 21
+ }
+ },
+ "skill_details": {
+ "attacker": {
+ "Hendrik": {
+ "skill_1": {
+ "triggered": 1,
+ "displayed_kills": null
+ },
+ "skill_2": {
+ "triggered": 8,
+ "displayed_kills": null
+ },
+ "skill_3": {
+ "triggered": 11,
+ "displayed_kills": 27
+ }
+ }
+ }
+ },
+ "rounds": null,
+ "mail_ids": {
+ "attacker_view": "2734692464124634",
+ "defender_view": "2734692464124635"
+ },
+ "defender_troop_details_source": "docs/mechanics-audit/probes/hendrik-small-timing/defender-troops.json",
+ "transcription": "Root visually checked both report identities, exact T6 troops, all24 displayed stats, unchanged full Hendrik3/3/3 kit, both Battle Details views and defender troop rows. No observed round count. Dash is null. Counterpart report is the same battle, not another observation."
+ }
+ ],
+ "evidence": {
+ "protocol": "docs/mechanics-audit/probes/hendrik-small-timing/README.md",
+ "prospective_prediction": "docs/mechanics-audit/probes/hendrik-small-timing/prediction.json",
+ "captured_prediction": "docs/mechanics-audit/probes/hendrik-small-timing/captured-prediction.json",
+ "report": "docs/mechanics-audit/probes/hendrik-small-timing/report.json",
+ "overview_image": "docs/mechanics-audit/probes/hendrik-small-timing/report_top.png",
+ "stats_image": "docs/mechanics-audit/probes/hendrik-small-timing/report_stats.png",
+ "details_image": "docs/mechanics-audit/probes/hendrik-small-timing/bd_top.png",
+ "defender_troops_image": "docs/mechanics-audit/probes/hendrik-small-timing/defender-troops.png",
+ "original_capture_directory": "tmp/20260907T063132Z_wos_report_war_1"
+ },
+ "evidence_status": "supports_earlier_s3_timing_with_residual_uncertainty",
+ "data_quality_notes": [
+ "Exact fresh stats, troop counts and full kit match the frozen input. Game68[16,31,21] versus current78[19,34,25] and S3-first2 nominal71[16,32,23]. No fitted stats or generation additions. Every casualty sum checked."
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/reina_shadowblade_350l_vs_150i_50l_50m.json
===================================================================
--- testcases/emulator_verified/reina_shadowblade_350l_vs_150i_50l_50m.json prev run
+++ testcases/emulator_verified/reina_shadowblade_350l_vs_150i_50l_50m.json this run
@@ -0,0 +1,92 @@
+[
+ {
+ "test_id": "reina_shadowblade_350l_vs_150i_50l_50m",
+ "description": "Five independent in-game battles: WIP full Reina1/1/1 with350 T6 Lancers attacks minxxx nohero150 T6 Infantry+50 T6 Lancers+50 T6 Marksmen. Exact report stats, distinct timestamps, all outcomes, Battle Details and per-line evidence are preserved in docs/mechanics-audit/probes/reina-shadowblade/. The third report Infantry Attack OCR fields were corrected from its clean counterpart capture; all five actual inputs match. No generation bonus is added to report stats.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Reina": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 1
+ }
+ },
+ "troops": {
+ "lancer_t6": 350
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 326.2,
+ "defense": 325.0,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 150,
+ "lancer_t6": 50,
+ "marksman_t6": 50
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 166
+ },
+ {
+ "attacker": 0,
+ "defender": 163
+ },
+ {
+ "attacker": 0,
+ "defender": 163
+ },
+ {
+ "attacker": 0,
+ "defender": 157
+ },
+ {
+ "attacker": 0,
+ "defender": 174
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/renee_wu_ming_s1_damage_kind_nc.json
===================================================================
--- testcases/emulator_verified/renee_wu_ming_s1_damage_kind_nc.json prev run
+++ testcases/emulator_verified/renee_wu_ming_s1_damage_kind_nc.json this run
@@ -0,0 +1,110 @@
+[
+ {
+ "test_id": "renee_wu_ming_s1_damage_kind_nc",
+ "description": "Deterministic Renee S1 damage-kind discriminator. minxxx attacks with Renee 4/4/4 and 1500 T6 Lancers into WIP's Wu Ming 3/3/3 and 4000 T6 Infantry. Wu Ming S1 reduces normal damage to Infantry by 15% and skill damage by 18%. Before capture, the central predictions were: unaffected extra damage +430 with 47 Nightmare Trace activations, normal damage +360 with 51 activations, and skill damage +347 with 52 activations. The game returned +359 with 51 activations. A 2000-sample +/-0.05 displayed-stat probe produced +429-431/47 for unaffected extra, +359-361/51 for normal, and +346-349/51-52 for skill; the exact observation occurred 487 times under normal and never under either alternative.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Renee": {
+ "skill_1": 4,
+ "skill_2": 4,
+ "skill_3": 4
+ }
+ },
+ "troops": {
+ "lancer_t6": 1500
+ },
+ "stats": {
+ "inf": {
+ "attack": 293.4,
+ "defense": 277.2,
+ "lethality": 238.0,
+ "health": 232.4
+ },
+ "lanc": {
+ "attack": 611.8,
+ "defense": 602.1,
+ "lethality": 323.2,
+ "health": 285.5
+ },
+ "mark": {
+ "attack": 291.7,
+ "defense": 283.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Wu Ming": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "infantry_t6": 4000
+ },
+ "stats": {
+ "inf": {
+ "attack": 418.1,
+ "defense": 420.1,
+ "lethality": 210.2,
+ "health": 269.2
+ },
+ "lanc": {
+ "attack": 207.2,
+ "defense": 207.2,
+ "lethality": 186.7,
+ "health": 181.6
+ },
+ "mark": {
+ "attack": 216.2,
+ "defense": 210.4,
+ "lethality": 181.1,
+ "health": 176.9
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 359,
+ "defender": 0
+ }
+ ],
+ "metadata": {
+ "observed_battle_outcomes": [
+ {
+ "trial": 1,
+ "skill_counts": {
+ "renee_s1_nightmare_trace": 51
+ },
+ "attacker": {
+ "survivors": 359
+ },
+ "defender": {
+ "survivors": 0
+ }
+ }
+ ],
+ "candidate_predictions": {
+ "unaffected_extra": {
+ "attacker_survivors": 430,
+ "nightmare_trace_activations": 47
+ },
+ "normal": {
+ "attacker_survivors": 360,
+ "nightmare_trace_activations": 51
+ },
+ "skill": {
+ "attacker_survivors": 347,
+ "nightmare_trace_activations": 52
+ }
+ }
+ }
+ }
+]
Index: testcases/emulator_verified/sonya_buffs_cadence_2200i_vs_800i_200l_800m.json
===================================================================
--- testcases/emulator_verified/sonya_buffs_cadence_2200i_vs_800i_200l_800m.json prev run
+++ testcases/emulator_verified/sonya_buffs_cadence_2200i_vs_800i_200l_800m.json this run
@@ -0,0 +1,152 @@
+[
+ {
+ "test_id": "sonya_buffs_cadence_2200i_vs_800i_200l_800m",
+ "description": "Prospective deterministic full-kit WIP Sonya 1/1/0, 800 T6 infantry + 200 T6 lancers + 800 T6 marksmen, defending against minxxx no heroes with 2200 T6 infantry. Conditional first Gwen report stats predict WIP1095 versus S2 Lancer-only buff1081, S1 Lancer-only buff1073, faster S2 cadence1124. Timing and damage bucket alternatives nearly coincide and remain unresolved by this probe. Capture fresh report-resolved stats and rerun frozen candidates in docs/mechanics-audit/probes/sonya-buffs-cadence. No skills disabled in the live kit. Full endpoint primary; per-line survivors and Battle Details diagnostic.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 2200
+ },
+ "stats": {
+ "inf": {
+ "attack": 308.2,
+ "defense": 300.5,
+ "lethality": 244.0,
+ "health": 238.4
+ },
+ "lanc": {
+ "attack": 294.9,
+ "defense": 293.7,
+ "lethality": 224.3,
+ "health": 230.0
+ },
+ "mark": {
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Sonya": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "infantry_t6": 800,
+ "lancer_t6": 200,
+ "marksman_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 248.2,
+ "defense": 249.0,
+ "lethality": 186.3,
+ "health": 191.8
+ },
+ "lanc": {
+ "attack": 369.3,
+ "defense": 368.2,
+ "lethality": 186.8,
+ "health": 181.9
+ },
+ "mark": {
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 1175
+ }
+ ],
+ "metadata": {
+ "fixture_type": "single_hero_full_kit",
+ "skill_isolation": false,
+ "expected_stochastic": false,
+ "stochastic_reason": [],
+ "observed_battle_outcomes": [
+ {
+ "report_timestamp_displayed": "2026-09-07 05:52:29",
+ "world_coord": {
+ "x": 791,
+ "y": 577
+ },
+ "attacker": {
+ "troops": 2200,
+ "survivors": 0,
+ "losses": 0,
+ "injured": 770,
+ "lightly_injured": 1430
+ },
+ "defender": {
+ "troops": 1800,
+ "survivors": 1175,
+ "losses": 0,
+ "injured": 219,
+ "lightly_injured": 406
+ },
+ "per_troop_survivors": {
+ "attacker": {
+ "infantry": 0,
+ "lancer": 0,
+ "marksman": 0
+ },
+ "defender": {
+ "infantry": 175,
+ "lancer": 200,
+ "marksman": 800
+ }
+ },
+ "skill_details": {
+ "defender": {
+ "Sonya": {
+ "skill_1": {
+ "triggered": 1,
+ "displayed_kills": null
+ },
+ "skill_2": {
+ "triggered": 51,
+ "displayed_kills": 16
+ }
+ }
+ }
+ },
+ "rounds": null,
+ "mail_ids": {
+ "defender_view": "2734692464122411"
+ },
+ "defender_troop_details_source": "docs/mechanics-audit/probes/sonya-buffs-cadence/defender-troops.json",
+ "transcription": "Visually checked overview, every displayed stat, exact T6 troop counts, Sonya identity and full1/1/0 kit, Battle Details and defender troop outcomes. Explicit round total unavailable. Counterpart report is the same battle, not an additional observation. Dash retained as null."
+ }
+ ],
+ "evidence": {
+ "protocol": "docs/mechanics-audit/probes/sonya-buffs-cadence/README.md",
+ "prospective_prediction": "docs/mechanics-audit/probes/sonya-buffs-cadence/prediction.json",
+ "report": "docs/mechanics-audit/probes/sonya-buffs-cadence/report.json",
+ "overview_image": "docs/mechanics-audit/probes/sonya-buffs-cadence/report_top.png",
+ "stats_image": "docs/mechanics-audit/probes/sonya-buffs-cadence/report_stats.png",
+ "details_image": "docs/mechanics-audit/probes/sonya-buffs-cadence/bd_top.png",
+ "hero_levels_image": "docs/mechanics-audit/probes/sonya-buffs-cadence/hero-skills.png",
+ "defender_troops_image": "docs/mechanics-audit/probes/sonya-buffs-cadence/defender-troops.png",
+ "original_capture_directory": "tmp/20260907T045233Z_wos_report_war_1"
+ },
+ "evidence_status": "supports_combined_s1_s2_in_context",
+ "data_quality_notes": [
+ "Fresh Sonya Lancer Attack/Defense369.3/368.2 replace provisional no-hero208.5/207.4. All other active troop stat fields unchanged. Report already includes hero stats; no generation bonus added. The initial failed attempt stopped before defender deployment and produced no battle."
+ ]
+ }
+ }
+]
Index: testcases/emulator_verified/wu_ming_solo_current_nc.json
===================================================================
--- testcases/emulator_verified/wu_ming_solo_current_nc.json prev run
+++ testcases/emulator_verified/wu_ming_solo_current_nc.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wu_ming_solo_current_nc",
- "description": "Deterministic current-account Wu Ming solo coverage. WIP attacks with Wu Ming 3/3/3 and 170 each T6 Infantry, Lancers, and Marksmen into minxxx's 380 each T6 Infantry and Lancers. Using current no-hero stats, Wu Ming's S6 contribution estimated from the recent WIP Renee report, and the published skill values: the configured interpretation predicts a signed remaining score near -7; omitting S1's normal-damage reduction predicts about -297; omitting S2 predicts about -306; and incorrectly allowing S3 to amplify normal attacks predicts about +344. The exact captured stats will be substituted into these frozen interpretations after capture. This fixture positively exercises S1's normal-damage branch and S2; it checks that S3 remains inert without skill-damage jobs, but cannot positively validate S1's skill-damage branch or S3's amplification.",
+ "description": "Deterministic current-account Wu Ming solo coverage. WIP attacks with Wu Ming 3/3/3 and 170 each T6 Infantry, Lancers, and Marksmen into minxxx's 380 each T6 Infantry and Lancers. The displayed report stats are passed through unchanged and the recorded signed result is -223. This fixture positively exercises S1's normal-damage branch and S2; it checks that S3 remains inert without skill-damage jobs, but cannot positively validate S1's skill-damage branch or S3's amplification.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {
"Wu Ming": {
@@ -17,24 +17,24 @@
"marksman_t6": 170
},
"stats": {
"inf": {
- "attack": 414.7,
- "defense": 414.5,
- "lethality": 199.3,
- "health": 260.1
+ "attack": 444.7,
+ "defense": 445.6,
+ "lethality": 214.4,
+ "health": 273.3
},
"lanc": {
- "attack": 203.8,
- "defense": 201.6,
- "lethality": 181.3,
- "health": 181.1
+ "attack": 208.5,
+ "defense": 207.4,
+ "lethality": 186.8,
+ "health": 181.9
},
"mark": {
- "attack": 208.0,
- "defense": 204.8,
- "lethality": 175.7,
- "health": 176.4
+ "attack": 217.5,
+ "defense": 210.6,
+ "lethality": 181.4,
+ "health": 177.0
}
},
"joiner_heroes": {}
},
@@ -46,32 +46,32 @@
"lancer_t6": 380
},
"stats": {
"inf": {
- "attack": 282.7,
- "defense": 279.5,
- "lethality": 226.3,
- "health": 221.0
+ "attack": 305.3,
+ "defense": 284.6,
+ "lethality": 244.0,
+ "health": 238.4
},
"lanc": {
- "attack": 275.2,
- "defense": 278.5,
- "lethality": 216.2,
- "health": 217.5
+ "attack": 294.9,
+ "defense": 280.7,
+ "lethality": 224.3,
+ "health": 230.0
},
"mark": {
- "attack": 281.0,
- "defense": 286.0,
- "lethality": 226.3,
- "health": 221.4
+ "attack": 292.2,
+ "defense": 279.7,
+ "lethality": 235.8,
+ "health": 230.3
}
},
"joiner_heroes": {}
},
"game_report_result": [
{
"attacker": 0,
- "defender": 200
+ "defender": 223
}
]
}
]
Index: testcases/gatot_verified/s1-4900-vs-1000.json
===================================================================
--- testcases/gatot_verified/s1-4900-vs-1000.json prev run
+++ testcases/gatot_verified/s1-4900-vs-1000.json this run
@@ -41,12 +41,15 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 4897,
- "defender": 996
+ "defender": 996,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s1-4950-vs-1000.json
===================================================================
--- testcases/gatot_verified/s1-4950-vs-1000.json prev run
+++ testcases/gatot_verified/s1-4950-vs-1000.json this run
@@ -41,12 +41,15 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 4947,
- "defender": 855
+ "defender": 855,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s1-5000-vs-1000.json
===================================================================
--- testcases/gatot_verified/s1-5000-vs-1000.json prev run
+++ testcases/gatot_verified/s1-5000-vs-1000.json this run
@@ -41,12 +41,15 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 4997,
- "defender": 513
+ "defender": 513,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s1-5050-vs-1000.json
===================================================================
--- testcases/gatot_verified/s1-5050-vs-1000.json prev run
+++ testcases/gatot_verified/s1-5050-vs-1000.json this run
@@ -41,12 +41,15 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 5047,
- "defender": 74
+ "defender": 74,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s10-bradley-1000-inf-125-marksman-vs-5000-inf.json
===================================================================
--- testcases/gatot_verified/s10-bradley-1000-inf-125-marksman-vs-5000-inf.json prev run
+++ testcases/gatot_verified/s10-bradley-1000-inf-125-marksman-vs-5000-inf.json this run
@@ -53,12 +53,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 2296
+ "defender": 2296,
+ "winner": "defender",
+ "rounds": 875
}
]
}
]
Index: testcases/gatot_verified/s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman.json
===================================================================
--- testcases/gatot_verified/s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman.json prev run
+++ testcases/gatot_verified/s15.2-secondary-heroes-1000-inf-125-lancer-125-marksman.json this run
@@ -74,12 +74,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 851,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 366
}
]
}
]
Index: testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.json
===================================================================
--- testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.json prev run
+++ testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.json this run
@@ -54,12 +54,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 4491
+ "defender": 4491,
+ "winner": "defender",
+ "rounds": 278
}
]
}
]
Index: testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.json
===================================================================
--- testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.json prev run
+++ testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.json this run
@@ -54,12 +54,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 4973
+ "defender": 4973,
+ "winner": "defender",
+ "rounds": 266
}
]
}
]
Index: testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.json
===================================================================
--- testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.json prev run
+++ testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.json this run
@@ -55,12 +55,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 3064
+ "defender": 3064,
+ "winner": "defender",
+ "rounds": 238
}
]
}
]
Index: testcases/gatot_verified/s15.3-2000-inf-0-lancer-1000-marksman.json
===================================================================
--- testcases/gatot_verified/s15.3-2000-inf-0-lancer-1000-marksman.json prev run
+++ testcases/gatot_verified/s15.3-2000-inf-0-lancer-1000-marksman.json this run
@@ -54,12 +54,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 1281,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 907
}
]
}
]
Index: testcases/gatot_verified/s15.3-2000-inf-1000-lancer-0-marksman.json
===================================================================
--- testcases/gatot_verified/s15.3-2000-inf-1000-lancer-0-marksman.json prev run
+++ testcases/gatot_verified/s15.3-2000-inf-1000-lancer-0-marksman.json this run
@@ -54,12 +54,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 4338
+ "defender": 4338,
+ "winner": "defender",
+ "rounds": 545
}
]
}
]
Index: testcases/gatot_verified/s16-1500-inf-1500-marksman-vs-1000-inf.json
===================================================================
--- testcases/gatot_verified/s16-1500-inf-1500-marksman-vs-1000-inf.json prev run
+++ testcases/gatot_verified/s16-1500-inf-1500-marksman-vs-1000-inf.json this run
@@ -42,12 +42,14 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 2777,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker"
}
]
}
]
Index: testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-1000-inf.json
===================================================================
--- testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-1000-inf.json prev run
+++ testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-1000-inf.json this run
@@ -42,12 +42,14 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 3809,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker"
}
]
}
]
Index: testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-5000-inf.json
===================================================================
--- testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-5000-inf.json prev run
+++ testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-5000-inf.json this run
@@ -42,12 +42,14 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 396,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker"
}
]
}
]
Index: testcases/gatot_verified/s16-7500-inf-vs-5000-inf.json
===================================================================
--- testcases/gatot_verified/s16-7500-inf-vs-5000-inf.json prev run
+++ testcases/gatot_verified/s16-7500-inf-vs-5000-inf.json this run
@@ -35,12 +35,14 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 2513
+ "defender": 2513,
+ "winner": "defender"
}
]
}
]
Index: testcases/gatot_verified/s2-health-1.1-4950-vs-1000.json
===================================================================
--- testcases/gatot_verified/s2-health-1.1-4950-vs-1000.json prev run
+++ testcases/gatot_verified/s2-health-1.1-4950-vs-1000.json this run
@@ -41,12 +41,15 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 4947,
- "defender": 904
+ "defender": 904,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s2-health-1.1-5000-vs-1000.json
===================================================================
--- testcases/gatot_verified/s2-health-1.1-5000-vs-1000.json prev run
+++ testcases/gatot_verified/s2-health-1.1-5000-vs-1000.json this run
@@ -41,12 +41,15 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 4997,
- "defender": 670
+ "defender": 670,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s2-health-1.1-5050-vs-1000.json
===================================================================
--- testcases/gatot_verified/s2-health-1.1-5050-vs-1000.json prev run
+++ testcases/gatot_verified/s2-health-1.1-5050-vs-1000.json this run
@@ -41,12 +41,15 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 5047,
- "defender": 406
+ "defender": 406,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s2-health-1.1-5100-vs-1000.json
===================================================================
--- testcases/gatot_verified/s2-health-1.1-5100-vs-1000.json prev run
+++ testcases/gatot_verified/s2-health-1.1-5100-vs-1000.json this run
@@ -41,12 +41,15 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 5097,
- "defender": 80
+ "defender": 80,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s2-health-1.1-5150-vs-1000.json
===================================================================
--- testcases/gatot_verified/s2-health-1.1-5150-vs-1000.json prev run
+++ testcases/gatot_verified/s2-health-1.1-5150-vs-1000.json this run
@@ -41,12 +41,14 @@
"skill_3": 2
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 5147,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker"
}
]
}
]
Index: testcases/gatot_verified/s25-1000-inf-3000-marksman-vs-5000-inf.json
===================================================================
--- testcases/gatot_verified/s25-1000-inf-3000-marksman-vs-5000-inf.json prev run
+++ testcases/gatot_verified/s25-1000-inf-3000-marksman-vs-5000-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 2110
+ "defender": 2110,
+ "winner": "defender",
+ "rounds": 109
}
]
}
]
Index: testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-2000-inf.json
===================================================================
--- testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-2000-inf.json prev run
+++ testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-2000-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 3441,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 92
}
]
}
]
Index: testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-3000-inf.json
===================================================================
--- testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-3000-inf.json prev run
+++ testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-3000-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 2959,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 115
}
]
}
]
Index: testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-4000-inf.json
===================================================================
--- testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-4000-inf.json prev run
+++ testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-4000-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 2365,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 136
}
]
}
]
Index: testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.json
===================================================================
--- testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.json prev run
+++ testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 554,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 241
}
]
}
]
Index: testcases/gatot_verified/s3-5000-inf-vs-1000-inf-10-lancer.json
===================================================================
--- testcases/gatot_verified/s3-5000-inf-vs-1000-inf-10-lancer.json prev run
+++ testcases/gatot_verified/s3-5000-inf-vs-1000-inf-10-lancer.json this run
@@ -53,12 +53,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 4573,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 1151
}
]
}
]
Index: testcases/gatot_verified/s31-current-2000-inf-2000-marksman-vs-5000-inf.json
===================================================================
--- testcases/gatot_verified/s31-current-2000-inf-2000-marksman-vs-5000-inf.json prev run
+++ testcases/gatot_verified/s31-current-2000-inf-2000-marksman-vs-5000-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 2291,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 148
}
]
}
]
Index: testcases/gatot_verified/s32-current-750-marksman-wayne-vs-3000-inf.json
===================================================================
--- testcases/gatot_verified/s32-current-750-marksman-wayne-vs-3000-inf.json prev run
+++ testcases/gatot_verified/s32-current-750-marksman-wayne-vs-3000-inf.json this run
@@ -39,12 +39,14 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 2738
+ "defender": 2738,
+ "winner": "defender"
}
]
}
]
Index: testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.json
===================================================================
--- testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.json prev run
+++ testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.json this run
@@ -41,12 +41,14 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 4977
+ "defender": 4977,
+ "winner": "defender"
}
]
}
]
Index: testcases/gatot_verified/s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf.json
===================================================================
--- testcases/gatot_verified/s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf.json prev run
+++ testcases/gatot_verified/s34-current-2100-lancer-2100-marksman-vs-1100-gatot3-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 236
+ "defender": 236,
+ "winner": "defender",
+ "rounds": 179
}
]
}
]
Index: testcases/gatot_verified/s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf.json
===================================================================
--- testcases/gatot_verified/s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf.json prev run
+++ testcases/gatot_verified/s35-current-3000-lancer-1000-marksman-vs-1100-gatot3-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 314
+ "defender": 314,
+ "winner": "defender",
+ "rounds": 167
}
]
}
]
Index: testcases/gatot_verified/s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf.json
===================================================================
--- testcases/gatot_verified/s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf.json prev run
+++ testcases/gatot_verified/s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 1058
+ "defender": 1058,
+ "winner": "defender",
+ "rounds": 36
}
]
}
]
Index: testcases/gatot_verified/s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf.json
===================================================================
--- testcases/gatot_verified/s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf.json prev run
+++ testcases/gatot_verified/s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf.json this run
@@ -48,12 +48,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 3782,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 125
}
]
}
]
Index: testcases/gatot_verified/s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf.json
===================================================================
--- testcases/gatot_verified/s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf.json prev run
+++ testcases/gatot_verified/s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf.json this run
@@ -48,12 +48,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 224
+ "defender": 224,
+ "winner": "defender",
+ "rounds": 343
}
]
}
]
Index: testcases/gatot_verified/s39-1000-lancer-400-marksman-vs-500-gatot3-inf.json
===================================================================
--- testcases/gatot_verified/s39-1000-lancer-400-marksman-vs-500-gatot3-inf.json prev run
+++ testcases/gatot_verified/s39-1000-lancer-400-marksman-vs-500-gatot3-inf.json this run
@@ -42,12 +42,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 397
+ "defender": 397,
+ "winner": "defender",
+ "rounds": 101
}
]
}
]
Index: testcases/gatot_verified/s4-fc9-gatot-10000.json
===================================================================
--- testcases/gatot_verified/s4-fc9-gatot-10000.json prev run
+++ testcases/gatot_verified/s4-fc9-gatot-10000.json this run
@@ -41,12 +41,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 9992,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 923
}
]
}
]
Index: testcases/gatot_verified/s4-fc9-gatot-20000.json
===================================================================
--- testcases/gatot_verified/s4-fc9-gatot-20000.json prev run
+++ testcases/gatot_verified/s4-fc9-gatot-20000.json this run
@@ -41,12 +41,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 19983,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 702
}
]
}
]
Index: testcases/gatot_verified/s4-fc9-gatot-40000.json
===================================================================
--- testcases/gatot_verified/s4-fc9-gatot-40000.json prev run
+++ testcases/gatot_verified/s4-fc9-gatot-40000.json this run
@@ -41,12 +41,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 39135,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 610
}
]
}
]
Index: testcases/gatot_verified/s4-fc9-gatot-90000.json
===================================================================
--- testcases/gatot_verified/s4-fc9-gatot-90000.json prev run
+++ testcases/gatot_verified/s4-fc9-gatot-90000.json this run
@@ -41,12 +41,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 83565,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 560
}
]
}
]
Index: testcases/gatot_verified/s40-2000-inf-1000-marksman-vs-5000-inf.json
===================================================================
--- testcases/gatot_verified/s40-2000-inf-1000-marksman-vs-5000-inf.json prev run
+++ testcases/gatot_verified/s40-2000-inf-1000-marksman-vs-5000-inf.json this run
@@ -48,12 +48,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 2802,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 189
}
]
}
]
Index: testcases/gatot_verified/s41-300-inf-200-lancer-600-marksman-vs-500-inf.json
===================================================================
--- testcases/gatot_verified/s41-300-inf-200-lancer-600-marksman-vs-500-inf.json prev run
+++ testcases/gatot_verified/s41-300-inf-200-lancer-600-marksman-vs-500-inf.json this run
@@ -55,12 +55,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 939,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 196
}
]
}
]
Index: testcases/gatot_verified/s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf.json
===================================================================
--- testcases/gatot_verified/s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf.json prev run
+++ testcases/gatot_verified/s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf.json this run
@@ -46,12 +46,15 @@
"skill_3": 3
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 200,
- "defender": 168
+ "defender": 168,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s43-current-reciprocal-shield-cap-450-inf-vs-200-inf.json
===================================================================
--- testcases/gatot_verified/s43-current-reciprocal-shield-cap-450-inf-vs-200-inf.json prev run
+++ testcases/gatot_verified/s43-current-reciprocal-shield-cap-450-inf-vs-200-inf.json this run
@@ -41,12 +41,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 173,
- "defender": 200
+ "defender": 200,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf.json
===================================================================
--- testcases/gatot_verified/s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf.json prev run
+++ testcases/gatot_verified/s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf.json this run
@@ -41,12 +41,15 @@
"skill_3": 1
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 975,
- "defender": 498
+ "defender": 498,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s5-fc9-gatot-lumak-20000.json
===================================================================
--- testcases/gatot_verified/s5-fc9-gatot-lumak-20000.json prev run
+++ testcases/gatot_verified/s5-fc9-gatot-lumak-20000.json this run
@@ -44,12 +44,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 19986,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 705
}
]
}
]
Index: testcases/gatot_verified/s5-fc9-gatot-lumak-40000.json
===================================================================
--- testcases/gatot_verified/s5-fc9-gatot-lumak-40000.json prev run
+++ testcases/gatot_verified/s5-fc9-gatot-lumak-40000.json this run
@@ -44,12 +44,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 39914,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 598
}
]
}
]
Index: testcases/gatot_verified/s5-fc9-gatot-lumak-90000.json
===================================================================
--- testcases/gatot_verified/s5-fc9-gatot-lumak-90000.json prev run
+++ testcases/gatot_verified/s5-fc9-gatot-lumak-90000.json this run
@@ -44,12 +44,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 86767,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 554
}
]
}
]
Index: testcases/gatot_verified/s7-10000-t6-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s7-10000-t6-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s7-10000-t6-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 9540,
- "defender": 1
+ "defender": 1,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 9720,
- "defender": 1
+ "defender": 1,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s8-11548-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-11548-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-11548-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 11268,
- "defender": 1
+ "defender": 1,
+ "winner": "draw",
+ "rounds": 1500
}
]
}
]
Index: testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 11548,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 1300
}
]
}
]
Index: testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 11791,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 1121
}
]
}
]
Index: testcases/gatot_verified/s8-20000-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-20000-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-20000-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 19943,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 308
}
]
}
]
Index: testcases/gatot_verified/s8-25000-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-25000-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-25000-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 24929,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 329
}
]
}
]
Index: testcases/gatot_verified/s8-30116-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-30116-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-30116-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 30081,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 189
}
]
}
]
Index: testcases/gatot_verified/s8-30139-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-30139-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-30139-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 30116,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 123
}
]
}
]
Index: testcases/gatot_verified/s8-30179-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-30179-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-30179-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 30139,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 216
}
]
}
]
Index: testcases/gatot_verified/s8-30205-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-30205-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-30205-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 30179,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 139
}
]
}
]
Index: testcases/gatot_verified/s8-30228-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-30228-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-30228-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 30205,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 123
}
]
}
]
Index: testcases/gatot_verified/s8-33427-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-33427-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-33427-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 33401,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 144
}
]
}
]
Index: testcases/gatot_verified/s8-33452-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-33452-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-33452-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 33427,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 135
}
]
}
]
Index: testcases/gatot_verified/s8-33481-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-33481-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-33481-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 33452,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 160
}
]
}
]
Index: testcases/gatot_verified/s8-33510-t9-marksmen-vs-one-t1-fc10-infantry.json
===================================================================
--- testcases/gatot_verified/s8-33510-t9-marksmen-vs-one-t1-fc10-infantry.json prev run
+++ testcases/gatot_verified/s8-33510-t9-marksmen-vs-one-t1-fc10-infantry.json this run
@@ -35,12 +35,15 @@
"skill_3": 5
}
}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 33481,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker",
+ "rounds": 157
}
]
}
]
Index: testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.json
===================================================================
--- testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.json prev run
+++ testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.json this run
@@ -29,12 +29,14 @@
}
},
"heroes": {}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 85
+ "defender": 85,
+ "winner": "defender"
}
]
}
]
Index: testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.json
===================================================================
--- testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.json prev run
+++ testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.json this run
@@ -29,12 +29,14 @@
}
},
"heroes": {}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 45
+ "defender": 45,
+ "winner": "defender"
}
]
}
]
Index: testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.json
===================================================================
--- testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.json prev run
+++ testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.json this run
@@ -29,12 +29,14 @@
}
},
"heroes": {}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 37
+ "defender": 37,
+ "winner": "defender"
}
]
}
]
Index: testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.json
===================================================================
--- testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.json prev run
+++ testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.json this run
@@ -29,12 +29,14 @@
}
},
"heroes": {}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 0,
- "defender": 20
+ "defender": 20,
+ "winner": "defender"
}
]
}
]
Index: testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.json
===================================================================
--- testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.json prev run
+++ testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.json this run
@@ -29,12 +29,14 @@
}
},
"heroes": {}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 1233,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker"
}
]
}
]
Index: testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.json
===================================================================
--- testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.json prev run
+++ testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.json this run
@@ -29,12 +29,14 @@
}
},
"heroes": {}
},
+ "maxRounds": 1500,
"game_report_result": [
{
"attacker": 2201,
- "defender": 0
+ "defender": 0,
+ "winner": "attacker"
}
]
}
]
Index: testcases/heroes_unittests/Molly_tc.json
===================================================================
--- testcases/heroes_unittests/Molly_tc.json prev run
+++ testcases/heroes_unittests/Molly_tc.json this run
@@ -228,66 +228,69 @@
"time": "2025-07-18 - 04:43",
"attacker": {
"name": "daut",
"heroes": {
- "Molly": {
- "skill_1": 2,
- "skill_2": 2
- }
+
},
"troops": {
"infantry_t6": 1000,
"lancer_t6": 100,
"marksman_t6": 100
},
"stats": {
"inf": {
- "attack": 9.94,
- "defense": 9.44,
- "lethality": 1.75,
- "health": 1.75
+ "attack": 246.9,
+ "defense": 248.8,
+ "lethality": 186.1,
+ "health": 191.7
},
"lanc": {
- "attack": 57.75,
- "defense": 57.25,
- "lethality": 1.75,
- "health": 1.75
+ "attack": 207.2,
+ "defense": 207.2,
+ "lethality": 186.7,
+ "health": 181.6
},
"mark": {
- "attack": 10.43,
- "defense": 9.44,
- "lethality": 1.75,
- "health": 1.75
+ "attack": 216.2,
+ "defense": 210.4,
+ "lethality": 181.1,
+ "health": 176.9
}
},
"joiner_heroes": {}
},
"defender": {
"name": "viper",
- "heroes": {},
+ "heroes": {
+ "Molly": {
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 1
+ }
+ },
"troops": {
"infantry_t6": 100,
"lancer_t6": 30,
"marksman_t6": 30
},
"stats": {
"inf": {
- "attack": 184.5,
- "defense": 188.0,
- "lethality": 107.24,
- "health": 114.18
+ "attack": 305.3,
+ "defense": 297.6,
+ "lethality": 244,
+ "health": 238.4
},
"lanc": {
- "attack": 167.0,
- "defense": 163.0,
- "lethality": 110.31,
- "health": 100.21
+ "attack": 363.1,
+ "defense": 361.9,
+ "lethality": 229.3,
+ "health": 235.0
},
"mark": {
- "attack": 174.75,
- "defense": 168.25,
- "lethality": 117.75,
- "health": 104.74
+ "attack": 292.2,
+ "defense": 292.7,
+ "lethality": 235.8,
+ "health": 230.3
}
},
"joiner_heroes": {}
},
@@ -303,10 +306,10 @@
"MARKSMEN- Ranged Strike/1": "1 (98) "
}
},
"game_report_result": {
- "attacker": 0,
- "defender": 68
+ "attacker": 1070,
+ "defender": 0
}
},
{
"test_id": "molly_tc_5",
Show raw per-run patches
Run A 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",