WOS Sim
← Back to Runs

Compare Runs

A (baseline)
7936ff16-05a92a8e53d06/08/2026, 16:47:41
B (current)
bf9bc036-d8111747ac315/08/2026, 05:15:04
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error-0.00%
Changed78
Improved40
Regressed37
Added22
Retired0

Testcase Delta

289 / 289
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo00.02%-0.65%+0.63%regressed
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo0-0.05%-1.56%0.98%-0.58%regressed
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo0+0.05%0.00%-0.41%+0.41%regressed
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1+0.05%-1.87%-2.21%+0.34%regressed
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43+0.05%0.00%-0.28%+0.28%regressed
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo00.36%0.14%-0.22%improved
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21-1.29%-1.49%+0.20%regressed
testcases/emulator_verified/jessie_solo_nc.jsonjessie_solo0+0.027%-0.43%-0.24%-0.19%improved
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_54+0.05%0.00%-0.19%+0.19%regressed
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc00.19%-0.02%-0.17%improved
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc0-3.40%-3.54%+0.14%regressed
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0-0.32%-0.19%-0.13%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.06%-0.19%+0.13%regressed
testcases/heroes_unittests/Reina_tc.jsondaut_viper_42-0.62%-0.74%+0.12%regressed
testcases/gatot_verified/s42-current-wayne-skill-shield-scope-200-inf-vs-450-inf.jsons42-current-wayne-skill-shield-scope-200-inf-vs-450-inf00.13%-0.02%-0.11%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.87%0.76%-0.11%improved
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo00.19%0.10%-0.09%improved
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32+0.05%0.00%-0.09%+0.09%regressed
testcases/heroes_unittests/Molly_tc.jsondaut_viper_32+0.05%0.00%-0.09%+0.09%regressed
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32+0.05%0.00%-0.09%+0.09%regressed
testcases/gatot_verified/s10-bradley-1000-inf-125-marksman-vs-5000-inf.jsons10-bradley-1000-inf-125-marksman-vs-5000-inf0-0.016%-0.10%-0.02%-0.08%improved
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_54+0.05%-0.08%-0.15%+0.07%regressed
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_540.64%0.57%-0.07%improved
testcases/emulator_verified/sergey_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc0-1.62%-1.55%-0.07%improved
testcases/gatot_verified/s8-11791-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-11791-t9-marksmen-vs-one-t1-fc10-infantry0-0.05%0.61%0.54%-0.07%improved
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_10+0.041%0.00%0.06%+0.06%regressed
testcases/gatot_verified/s8-12000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-12000-t9-marksmen-vs-one-t1-fc10-infantry00.42%0.36%-0.06%improved
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_32+0.05%-0.43%-0.48%+0.05%regressed
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0+0.05%-0.04%-0.09%+0.05%regressed
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0-0.60%-0.65%+0.05%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo40.27%0.23%-0.04%improved
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc01.05%1.01%-0.04%improved
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201.62%1.65%+0.03%regressed
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.04%-0.01%-0.03%improved
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0-0.04%-0.01%-0.03%improved
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_hero_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_hero_nc00.09%0.12%+0.03%regressed
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_bucket.jsonwos451_gordon_s11_lancer_damage_up_bucket0-0.05%0.06%0.03%-0.03%improved
testcases/gatot_verified/s44-current-reciprocal-shield-cap-2500-inf-vs-500-inf.jsons44-current-reciprocal-shield-cap-2500-inf-vs-500-inf00.03%0.00%-0.03%improved
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43-0.35%-0.38%+0.03%regressed
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_32-0.42%-0.40%-0.02%improved
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current1+0.05%-0.02%0.00%-0.02%improved
testcases/emulator_verified/norah_zinman_combo_nc.jsonnorah_zinman_combo_nc00.09%0.07%-0.02%improved
testcases/emulator_verified/sergey_reina_combo_nc.jsonsergey_reina_combo_nc0-0.37%-0.35%-0.02%improved
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo00.19%0.17%-0.02%improved
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_8400-0.87%-0.85%-0.02%improved
testcases/gatot_verified/s2-health-1.1-4950-vs-1000.jsons2-health-1.1-4950-vs-10000-0.015%0.02%0.00%-0.02%improved
testcases/gatot_verified/s2-health-1.1-5000-vs-1000.jsons2-health-1.1-5000-vs-10000-0.009%0.02%0.00%-0.02%improved
testcases/gatot_verified/s2-health-1.1-5050-vs-1000.jsons2-health-1.1-5050-vs-10000-0.005%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.002%0.00%-0.02%+0.02%regressed
testcases/gatot_verified/s7-10000-t6-marksmen-vs-one-t1-fc10-infantry.jsons7-10000-t6-marksmen-vs-one-t1-fc10-infantry0-0.02%0.00%-0.02%improved
testcases/gatot_verified/s8-10000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-10000-t9-marksmen-vs-one-t1-fc10-infantry0-0.02%0.00%-0.02%improved
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54-0.10%-0.08%-0.02%improved
testcases/heroes_unittests/Molly_tc.jsondaut_viper_430.35%0.33%-0.02%improved
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_750.16%0.17%+0.01%regressed
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo0-0.03%-0.04%+0.01%regressed
testcases/emulator_verified/alonso_solo.jsonalonso_solo02.82%2.83%+0.01%regressed
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.01%-0.02%+0.01%regressed
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker00.04%0.05%+0.01%regressed
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo0-0.14%-0.15%+0.01%regressed
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0-0.01%0.00%-0.01%improved
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current10.00%0.01%+0.01%regressed
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo10.02%0.03%+0.01%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.19%-0.20%+0.01%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo10.23%0.24%+0.01%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo30.03%0.04%+0.01%regressed
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo00.01%0.02%+0.01%regressed
testcases/emulator_verified/molly_solo.jsonmolly_solo0-0.07%-0.06%-0.01%improved
testcases/emulator_verified/norah_s2_splash_D.jsonnorah_s2_splash_D00.02%0.01%-0.01%improved
testcases/emulator_verified/norah_solo.jsonnorah_solo0-0.30%-0.29%-0.01%improved
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0-0.38%-0.39%+0.01%regressed
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo0-0.06%-0.05%-0.01%improved
testcases/gatot_verified/s9-1500-t9-lancers-vs-146-t1-fc10-lancers.jsons9-1500-t9-lancers-vs-146-t1-fc10-lancers00.01%0.00%-0.01%improved
testcases/gatot_verified/s9-2200-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2200-t9-lancers-vs-146-t1-fc10-lancers01.99%1.98%-0.01%improved
testcases/gatot_verified/s9-2201-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2201-t9-lancers-vs-146-t1-fc10-lancers0-0.72%-0.73%+0.01%regressed
testcases/heroes_unittests/Hector_tc.jsondaut_viper_100.00%-0.01%+0.01%regressed
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.22%0.21%-0.01%improved
testcases/heroes_unittests/Mia_tc.jsondaut_viper_320.08%0.07%-0.01%improved
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1010.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1120.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_123+0.033%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_134+0.05%-0.04%-0.04%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_145+0.01%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_156+0.013%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_167+0.029%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_178+0.014%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_189+0.025%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_19100.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2011+0.043%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2112+0.017%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2213+0.015%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_23140.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2415+0.05%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2516+0.008%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2617+0.015%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_90+0.025%0.00%0.00%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_10+0.05%-0.38%-0.38%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_21+0.05%-0.06%-0.06%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_43+0.05%-0.56%-0.56%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_65+0.05%-0.07%-0.07%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_430.02%0.02%0.00%unchanged
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_100.18%0.18%0.00%unchanged
testcases/emulator_verified/alonso_attacker_600_all.jsonalonso_attacker_600_all0-0.12%-0.12%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer00.04%0.04%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer10.02%0.02%0.00%unchanged
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender00.30%0.30%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo1-0.26%-0.26%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2-0.19%-0.19%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_minxxx_attacks_nc.jsonattdef_jasser_patrick_minxxx_attacks00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_nc.jsonattdef_jasser_patrick00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_swap_nc.jsonattdef_jasser_patrick_swap00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks10.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_test_minxxx_attacks_nc.jsonattdef_test_minxxx_attacks_nc0-0.05%0.08%0.08%0.00%unchanged
testcases/emulator_verified/attdef_test_wip_attacks_nc.jsonattdef_test_wip_attacks_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo00.26%0.26%0.00%unchanged
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc00.04%0.04%0.00%unchanged
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_solo_nc.jsonflint_solo0+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/flint_zinman_combo_nc.jsonflint_zinman_combo_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/greg_solo.jsongreg_solo00.15%0.15%0.00%unchanged
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo0-0.05%-0.05%0.00%unchanged
testcases/emulator_verified/hector_renee_wayne.jsonhector_renee_wayne0-0.46%-0.46%0.00%unchanged
testcases/emulator_verified/hector_solo.jsonhector_solo00.11%0.11%0.00%unchanged
testcases/emulator_verified/jasser_solo_nc.jsonjasser_solo0+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo1+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/logan_bahiti_combo_nc.jsonlogan_bahiti_combo_nc00.54%0.54%0.00%unchanged
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo00.08%0.08%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo20.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_solo_minxxx_nc.jsonlogan_solo_minxxx00.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_solo_v2_nc.jsonlogan_solo_v200.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo1+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/lynn_solo.jsonlynn_solo0-0.06%-0.06%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current00.01%0.01%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1-0.13%-0.13%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.15%-0.15%0.00%unchanged
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0+0.05%-1.08%-1.08%0.00%unchanged
testcases/emulator_verified/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo00.08%0.08%0.00%unchanged
testcases/emulator_verified/norah_s2_inf_only_B_nc.jsonnorah_s2_inf_only_B0+0.05%-0.24%-0.24%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_A.jsonnorah_s2_splash_A00.21%0.21%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_B_nc.jsonnorah_s2_splash_B00.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_C_nc.jsonnorah_s2_splash_C0+0.05%-0.06%-0.06%0.00%unchanged
testcases/emulator_verified/patrick_solo_nc.jsonpatrick_solo0+0.004%-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo0-0.14%-0.14%0.00%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo10.20%0.20%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo00.16%0.16%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo10.02%0.02%0.00%unchanged
testcases/emulator_verified/reina_attacker_wip.jsonreina_attacker_wip00.01%0.01%0.00%unchanged
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator00.15%0.15%0.00%unchanged
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/seo_yoon_solo_nc.jsonseo_yoon_solo0-0.05%0.10%0.10%0.00%unchanged
testcases/emulator_verified/sergey_solo_nc.jsonsergey_solo00.00%0.00%0.00%unchanged
testcases/emulator_verified/simple_001_nc.jsonsimple_00100.00%0.00%0.00%unchanged
testcases/emulator_verified/troop_check_nc.jsontroop_check00.00%0.00%0.00%unchanged
testcases/emulator_verified/type_triangle_A_nc.jsontype_triangle_A_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/type_triangle_B_nc.jsontype_triangle_B_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_nohero_control_nc.jsonwos425_wip_nohero_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_patrick_health_nc.jsonwos425_wip_patrick_health_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_philly_defense.jsonwos425_wip_philly_defense0-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.jsonwos425_wip_sergey_damage_taken_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.jsonwos425_wip_sergey_patrick_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc1+0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_hero_nc.jsonwos444_bradley_s3_all_troops_damage_hero_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_hero_nc.jsonwos444_edith_s11_mark_damage_taken_hero_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.jsonwos444_edith_s12_lancer_damage_dealt_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_hero_nc.jsonwos444_edith_s12_lancer_damage_dealt_hero_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc10.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_bucket.jsonwos451_bradley_s21_lancer_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.jsonwos451_bradley_s21_lancer_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_bucket.jsonwos451_bradley_s22_infantry_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.jsonwos451_bradley_s22_infantry_damage_up_control0+0.025%0.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_bucket.jsonwos451_bradley_s3_all_damage_up_bucket00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.jsonwos451_bradley_s3_all_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.jsonwos451_gordon_s11_lancer_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_bucket.jsonwos451_gordon_s21_lancer_damage_up_bucket0-0.05%0.04%0.04%0.00%unchanged
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.jsonwos451_gordon_s21_lancer_damage_up_control00.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-4900-vs-1000.jsons1-4900-vs-100000.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-4950-vs-1000.jsons1-4950-vs-10000-0.015%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-5000-vs-1000.jsons1-5000-vs-10000-0.009%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s1-5050-vs-1000.jsons1-5050-vs-10000-0.004%-0.02%0.02%0.00%changed
testcases/gatot_verified/s15.3-1000-inf-0-lancer-1000-marksman.jsons15.3-1000-inf-0-lancer-1000-marksman0-0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s15.3-1000-inf-1000-lancer-0-marksman.jsons15.3-1000-inf-1000-lancer-0-marksman0-0.05%0.01%0.01%0.00%unchanged
testcases/gatot_verified/s15.3-1000-inf-1000-lancer-1000-marksman.jsons15.3-1000-inf-1000-lancer-1000-marksman0-0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s15.3-2000-inf-0-lancer-1000-marksman.jsons15.3-2000-inf-0-lancer-1000-marksman0-0.032%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s15.3-2000-inf-1000-lancer-0-marksman.jsons15.3-2000-inf-1000-lancer-0-marksman0-0.043%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s16-1500-inf-1500-marksman-vs-1000-inf.jsons16-1500-inf-1500-marksman-vs-1000-inf00.00%0.00%0.00%unchanged
testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-1000-inf.jsons16-2000-inf-2000-marksman-vs-1000-inf00.00%0.00%0.00%unchanged
testcases/gatot_verified/s16-2000-inf-2000-marksman-vs-5000-inf.jsons16-2000-inf-2000-marksman-vs-5000-inf0-0.004%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s16-7500-inf-vs-5000-inf.jsons16-7500-inf-vs-5000-inf0+0.013%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s2-health-1.1-5150-vs-1000.jsons2-health-1.1-5150-vs-100000.00%0.00%0.00%unchanged
testcases/gatot_verified/s25-1000-inf-3000-marksman-vs-5000-inf.jsons25-1000-inf-3000-marksman-vs-5000-inf00.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-inf00.00%0.00%0.00%unchanged
testcases/gatot_verified/s25-2000-inf-2000-marksman-vs-4000-inf.jsons25-2000-inf-2000-marksman-vs-4000-inf00.00%0.00%0.00%unchanged
testcases/gatot_verified/s25-3000-inf-1000-marksman-vs-5000-inf.jsons25-3000-inf-1000-marksman-vs-5000-inf0+0.019%0.01%0.01%0.00%unchanged
testcases/gatot_verified/s3-5000-inf-vs-1000-inf-10-lancer.jsons3-5000-inf-vs-1000-inf-10-lancer0-0.05%7.04%7.04%0.00%unchanged
testcases/gatot_verified/s31-current-2000-inf-2000-marksman-vs-5000-inf.jsons31-current-2000-inf-2000-marksman-vs-5000-inf0+0.025%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s32-current-750-marksman-wayne-vs-3000-inf.jsons32-current-750-marksman-wayne-vs-3000-inf0-0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s33-current-500-marksman-wayne3-vs-5000-gatot3-inf.jsons33-current-500-marksman-wayne3-vs-5000-gatot3-inf00.10%0.10%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-inf00.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-inf00.00%0.00%0.00%unchanged
testcases/gatot_verified/s36-current-600-lancer-600-marksman-vs-1100-gatot3-inf.jsons36-current-600-lancer-600-marksman-vs-1100-gatot3-inf0-0.05%0.00%0.00%0.00%unchanged
testcases/gatot_verified/s37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf.jsons37-current-bilateral-gatot-2000-inf-2000-marksman-vs-5000-inf00.00%0.00%0.00%unchanged
testcases/gatot_verified/s38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf.jsons38-current-bilateral-gatot-800-inf-800-marksman-vs-1100-inf0-0.05%0.04%0.04%0.00%unchanged
testcases/gatot_verified/s39-1000-lancer-400-marksman-vs-500-gatot3-inf.jsons39-1000-lancer-400-marksman-vs-500-gatot3-inf00.00%0.00%0.00%unchanged
testcases/gatot_verified/s4-fc9-gatot-10000.jsons4-fc9-gatot-1000000.01%0.01%0.00%unchanged
testcases/gatot_verified/s4-fc9-gatot-20000.jsons4-fc9-gatot-2000000.01%0.01%0.00%unchanged
testcases/gatot_verified/s4-fc9-gatot-40000.jsons4-fc9-gatot-4000000.14%0.14%0.00%unchanged
testcases/gatot_verified/s4-fc9-gatot-90000.jsons4-fc9-gatot-900000-0.11%-0.11%0.00%unchanged
testcases/gatot_verified/s40-2000-inf-1000-marksman-vs-5000-inf.jsons40-2000-inf-1000-marksman-vs-5000-inf00.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-inf00.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-inf00.00%0.00%0.00%unchanged
testcases/gatot_verified/s5-fc9-gatot-lumak-20000.jsons5-fc9-gatot-lumak-2000000.01%0.01%0.00%unchanged
testcases/gatot_verified/s5-fc9-gatot-lumak-40000.jsons5-fc9-gatot-lumak-4000000.00%0.00%0.00%unchanged
testcases/gatot_verified/s5-fc9-gatot-lumak-90000.jsons5-fc9-gatot-lumak-9000000.01%0.01%0.00%unchanged
testcases/gatot_verified/s8-20000-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-20000-t9-marksmen-vs-one-t1-fc10-infantry00.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-infantry00.14%0.14%0.00%unchanged
testcases/gatot_verified/s8-30116-t9-marksmen-vs-one-t1-fc10-infantry.jsons8-30116-t9-marksmen-vs-one-t1-fc10-infantry00.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-infantry00.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-infantry00.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-infantry00.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-infantry00.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-infantry00.01%0.01%0.00%unchanged
testcases/gatot_verified/s9-1950-t9-lancers-vs-146-t1-fc10-lancers.jsons9-1950-t9-lancers-vs-146-t1-fc10-lancers0-0.07%-0.07%0.00%unchanged
testcases/gatot_verified/s9-2100-t9-lancers-vs-146-t1-fc10-lancers.jsons9-2100-t9-lancers-vs-146-t1-fc10-lancers00.55%0.55%0.00%unchanged
testcases/gatot_verified/s9-3000-t9-lancers-vs-146-t1-fc10-lancers.jsons9-3000-t9-lancers-vs-146-t1-fc10-lancers00.15%0.15%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10+0.05%-0.01%-0.01%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.05%0.37%0.37%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.05%-0.05%-0.05%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_870.00%0.00%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21+0.05%-0.68%-0.68%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_54-0.01%-0.01%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_100.00%0.00%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_530.01%0.01%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43+0.05%-0.04%-0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54+0.05%-0.08%-0.08%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65+0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76+0.033%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10-0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21+0.007%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_320.00%0.00%0.00%unchanged
testcases/emulator_verified/army_term_precision_002i_003l.jsonarmy_term_precision_002i_003l00.00%added
testcases/emulator_verified/damage_precision_325l_008i_325m.jsondamage_precision_325l_008i_325m0-0.05%0.15%added
testcases/emulator_verified/damage_precision_325l_009i_325m.jsondamage_precision_325l_009i_325m0-0.05%0.00%added
testcases/emulator_verified/damage_precision_875i_026i_875m.jsondamage_precision_875i_026i_875m00.00%added
testcases/emulator_verified/gordon_wayne_s1_extra_overlap_lvl2.jsongordon_wayne_s1_extra_overlap_nc0-0.05%0.24%added
testcases/emulator_verified/gordon_wayne_s1_extra_overlap_nc.jsongordon_wayne_s1_extra_overlap_nc0-0.05%1.03%added
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo11.11%retired
testcases/emulator_verified/plateau_boundary_700m_006i_700l.jsonplateau_boundary_700m_006i_700l00.00%added
testcases/emulator_verified/plateau_boundary_700m_007i_700l.jsonplateau_boundary_700m_007i_700l00.00%added
testcases/emulator_verified/plateau_boundary_700m_008i_700l.jsonplateau_boundary_700m_008i_700l00.00%added
testcases/emulator_verified/plateau_boundary_700m_009i_700l.jsonplateau_boundary_700m_009i_700l00.00%added
testcases/emulator_verified/renee_ahmose_damage_taken_overlap_attacker_nc.jsonrenee_ahmose_damage_taken_overlap_attacker_nc0+0.05%-0.12%added
testcases/emulator_verified/renee_charge_single_target_bucket_nc.jsonrenee_charge_single_target_bucket_nc0+0.05%-0.25%added
testcases/emulator_verified/renee_gwen_bucket_retry_nc.jsonrenee_gwen_bucket_retry_nc0-0.05%0.29%added
testcases/emulator_verified/renee_hendrik_defense_bucket_nc.jsonrenee_hendrik_defense_bucket_nc0+0.05%0.00%added
testcases/emulator_verified/renee_wayne_mixed_510_vs_888.jsonrenee_wayne_mixed_510_vs_8880-0.04%added
testcases/emulator_verified/retarget_lancer_screen_nc.jsonretarget_lancer_screen_nc00.00%added
testcases/emulator_verified/wu_ming_bradley_current_nc.jsonwu_ming_bradley_current_nc00.00%added
testcases/emulator_verified/wu_ming_marksman_vs_infantry_nc.jsonwu_ming_marksman_vs_infantry_nc00.00%added
testcases/emulator_verified/wu_ming_rounding_followup_nc.jsonwu_ming_rounding_followup_nc0-0.05%0.07%added
testcases/emulator_verified/wu_ming_s1_isolation_nc.jsonwu_ming_s1_isolation_nc00.00%added
testcases/emulator_verified/wu_ming_s2_isolation_nc.jsonwu_ming_s2_isolation_nc0+0.025%0.00%added
testcases/emulator_verified/wu_ming_solo_current_nc.jsonwu_ming_solo_current_nc0+0.05%-1.42%added

Code / Config Changes

Commits (92a8e53d11747ac3)

11747ac3Add three-army lineup optimizerpiddlyminx14/08/2026, 20:38:57

Code Changes (Run A → Run B)

Index: simulator/config/hero_definitions/Ahmose.json
===================================================================
--- simulator/config/hero_definitions/Ahmose.json prev run
+++ simulator/config/hero_definitions/Ahmose.json this run
@@ -5,12 +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": "attack",
- "first": 4,
- "every": 5,
- "source": "infantry"
+ "type": "turn",
+ "every": 4
},
"effects": {
"ViperFormation/1": {
"type": "no_attack",
@@ -135,8 +133,9 @@
"applies_to": "target"
},
"duration": {
"turns": {
+ "delay": 1,
"count": 1
}
},
"same_effect_stacking": "add"
Index: simulator/config/hero_definitions/Alonso.json
===================================================================
--- simulator/config/hero_definitions/Alonso.json prev run
+++ simulator/config/hero_definitions/Alonso.json this run
@@ -94,13 +94,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
Index: simulator/config/hero_definitions/Blanchette.json
===================================================================
--- simulator/config/hero_definitions/Blanchette.json prev run
+++ simulator/config/hero_definitions/Blanchette.json this run
@@ -1,7 +1,8 @@
{
"name": "Blanchette",
"hero_generation": "S10",
+ "experimental": true,
"troop_type": "marksman",
"skills": {
"ArmedToTheTeeth": {
"description": "Blanchette increases all Troops' Lethality by X%.",
@@ -16,20 +17,18 @@
"BloodHunter": {
"description": "Blanchette's Marksmen fire a crystal blade every 3 rounds, dealing X% extra damage to their target.",
"trigger": {
"type": "turn",
- "every": 3,
- "source": "marksman"
+ "every": 3
},
"effects": {
"BloodHunter/1": {
"type": "extra_skill_attack",
"value": [15, 30, 45, 60, 75],
"units": {
- "applies_to": "trigger.source",
- "applies_vs": "trigger.target"
+ "applies_to": "self.marksman",
+ "applies_vs": "enemy.any"
},
- "duration": { "attacks": { "count": 1 } },
"trigger_damage_jobs": [
{ "source": "use.source", "target": "use.target" }
]
}
@@ -49,9 +48,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "any"
},
- "duration": { "attacks": { "count": 1 } },
"trigger_damage_jobs": [
{ "source": "use.source", "target": ["lancer"] }
]
},
@@ -61,9 +59,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "any"
},
- "duration": { "attacks": { "count": 1 } },
"trigger_damage_jobs": [
{ "source": "use.source", "target": ["marksman"] }
]
}
Index: simulator/config/hero_definitions/Bradley.json
===================================================================
--- simulator/config/hero_definitions/Bradley.json prev run
+++ simulator/config/hero_definitions/Bradley.json this run
@@ -27,9 +27,9 @@
"type": "battle_start"
},
"effects": {
"PowerShot/1": {
- "type": "active.hero.damage.up",
+ "type": "type.single_target.damage.up",
"value": [
6,
12,
18,
@@ -42,9 +42,9 @@
]
}
},
"PowerShot/2": {
- "type": "active.hero.damage.up",
+ "type": "type.single_target.damage.up",
"value": [
5,
10,
15,
Index: simulator/config/hero_definitions/Fred.json
===================================================================
--- simulator/config/hero_definitions/Fred.json prev run
+++ simulator/config/hero_definitions/Fred.json this run
@@ -1,7 +1,8 @@
{
"name": "Fred",
"hero_generation": "S9",
+ "experimental": true,
"troop_type": "lancer",
"skills": {
"HydraulicSuppression": {
"description": "Fred's water volleys destroy opponent momentum, reducing all enemy troops' Lethality by X%.",
@@ -39,9 +40,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": { "attacks": { "count": 1 } },
"trigger_damage_jobs": [
{ "source": "use.source", "target": "use.target" }
]
},
Index: simulator/config/hero_definitions/Freya.json
===================================================================
--- simulator/config/hero_definitions/Freya.json prev run
+++ simulator/config/hero_definitions/Freya.json this run
@@ -1,7 +1,8 @@
{
"name": "Freya",
"hero_generation": "S10",
+ "experimental": true,
"troop_type": "lancer",
"skills": {
"FogOfWar": {
"description": "Freya reduces all enemy Troops' Attack by X%.",
@@ -28,9 +29,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": { "attacks": { "count": 1 } },
"trigger_damage_jobs": [
{ "source": "use.source", "target": "use.target" }
]
}
Index: simulator/config/hero_definitions/Gordon.json
===================================================================
--- simulator/config/hero_definitions/Gordon.json prev run
+++ simulator/config/hero_definitions/Gordon.json this run
@@ -5,9 +5,9 @@
"skills": {
"VenomInfusion": {
"description": "Every 2 attacks, Lancers deal X% extra damage and apply poison to the target for 1 turn. Poisoned enemies deal Y% less damage.",
"trigger": {
- "type": "turn",
+ "type": "attack",
"every": 2,
"source": "lancer"
},
"effects": {
@@ -20,11 +20,10 @@
80,
100
],
"units": {
- "applies_to": [
- "lancer"
- ]
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
},
"duration": {
"attacks": {
"count": 1
@@ -44,9 +43,10 @@
"applies_to": "trigger.target"
},
"duration": {
"turns": {
- "count": 1
+ "count": 1,
+ "delay": 1
}
}
}
}
Index: simulator/config/hero_definitions/Greg.json
===================================================================
--- simulator/config/hero_definitions/Greg.json prev run
+++ simulator/config/hero_definitions/Greg.json this run
@@ -66,9 +66,10 @@
"applies_vs": "self.any"
},
"duration": {
"turns": {
- "count": 2
+ "count": 2,
+ "delay": 1
}
}
}
}
Index: simulator/config/hero_definitions/Gregory.json
===================================================================
--- simulator/config/hero_definitions/Gregory.json prev run
+++ simulator/config/hero_definitions/Gregory.json this run
@@ -1,7 +1,8 @@
{
"name": "Gregory",
"hero_generation": "S10",
+ "experimental": true,
"troop_type": "infantry",
"skills": {
"LegionOfTheSun": {
"description": "Gregory increases Attack by X% and Defense by Y% for all troops.",
@@ -30,9 +31,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": { "attacks": { "count": 1 } },
"trigger_damage_jobs": [
{ "source": "use.source", "target": "use.target" }
]
}
Index: simulator/config/hero_definitions/Gwen.json
===================================================================
--- simulator/config/hero_definitions/Gwen.json prev run
+++ simulator/config/hero_definitions/Gwen.json this run
@@ -5,10 +5,9 @@
"skills": {
"EagleVision": {
"description": "Increasing target's damage taken by X%",
"trigger": {
- "type": "attack",
- "source": "self.any"
+ "type": "battle_start"
},
"effects": {
"EagleVision/1": {
"type": "active.hero.damageTaken.up",
@@ -19,26 +18,20 @@
20,
25
],
"units": {
- "applies_to": "trigger.target",
- "applies_vs": "trigger.source"
- },
- "duration": {
- "attacks": {
- "count": 1
- }
+ "applies_to": "enemy.any"
}
}
}
},
"AirDominance": {
"description": "Grants all troops' attack X% extra damage after every 5 attacks and causes the target to receive X% extra damage for its next attack received",
"trigger": {
- "type": "turn",
+ "type": "attack",
"first": 5,
"every": 6,
- "source": "self.all"
+ "source": "self.any"
},
"effects": {
"AirDominance/1": {
"type": "extra_skill_attack",
@@ -52,13 +45,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "use.target"
@@ -93,8 +81,9 @@
"Blastmaster": {
"description": "Gwen equips her Marksmen with grenades, dealing X% extra damage to all enemies on the next attack of every 4 attacks",
"trigger": {
"type": "attack",
+ "first": 5,
"every": 4,
"source": "marksman"
},
"effects": {
@@ -111,16 +100,8 @@
"applies_to": [
"marksman"
]
},
- "duration": {
- "turns": {
- "delay": 1
- },
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "enemy.living"
Index: simulator/config/hero_definitions/Hector.json
===================================================================
--- simulator/config/hero_definitions/Hector.json prev run
+++ simulator/config/hero_definitions/Hector.json this run
@@ -119,13 +119,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
Index: simulator/config/hero_definitions/Hendrik.json
===================================================================
--- simulator/config/hero_definitions/Hendrik.json prev run
+++ simulator/config/hero_definitions/Hendrik.json this run
@@ -68,13 +68,8 @@
"units": {
"applies_to": "marksman",
"applies_vs": "any"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "enemy.living"
Index: simulator/config/hero_definitions/Magnus.json
===================================================================
--- simulator/config/hero_definitions/Magnus.json prev run
+++ simulator/config/hero_definitions/Magnus.json this run
@@ -1,7 +1,8 @@
{
"name": "Magnus",
"hero_generation": "S9",
+ "experimental": true,
"troop_type": "infantry",
"skills": {
"Rapacious": {
"description": "Magnus rouses his troops with brutal intensity, boosting friendly Troop Attack by X%.",
@@ -24,9 +25,9 @@
"IronPhalanx/1": {
"type": "active.hero.defense.up",
"value": [10, 20, 30, 40, 50],
"units": { "applies_to": "trigger.source" },
- "duration": { "turns": { "count": 1 } },
+ "duration": { "turns": { "delay": 1, "count": 1 } },
"same_effect_stacking": "max"
}
}
},
Index: simulator/config/hero_definitions/Mia.json
===================================================================
--- simulator/config/hero_definitions/Mia.json prev run
+++ simulator/config/hero_definitions/Mia.json this run
@@ -63,13 +63,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "any"
},
- "duration": {
- "turns": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "use.target"
Index: simulator/config/hero_definitions/Molly.json
===================================================================
--- simulator/config/hero_definitions/Molly.json prev run
+++ simulator/config/hero_definitions/Molly.json this run
@@ -61,13 +61,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
Index: simulator/config/hero_definitions/Norah.json
===================================================================
--- simulator/config/hero_definitions/Norah.json prev run
+++ simulator/config/hero_definitions/Norah.json this run
@@ -72,13 +72,8 @@
"lancer"
],
"applies_vs": "any"
},
- "duration": {
- "turns": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "enemy.living"
@@ -109,9 +104,10 @@
"applies_vs": "trigger.target"
},
"duration": {
"turns": {
- "count": 2
+ "count": 2,
+ "delay": 1
}
}
},
"Momentum/2": {
Index: simulator/config/hero_definitions/Philly.json
===================================================================
--- simulator/config/hero_definitions/Philly.json prev run
+++ simulator/config/hero_definitions/Philly.json this run
@@ -56,13 +56,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
Index: simulator/config/hero_definitions/README.md
===================================================================
--- simulator/config/hero_definitions/README.md prev run
+++ simulator/config/hero_definitions/README.md this run
@@ -27,11 +27,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": { "count": 1 }
- },
"trigger_damage_jobs": [
{ "source": "use.source", "target": "use.target" }
]
}
@@ -40,21 +37,18 @@
}
}
```
-At skill level 5, this trigger is attempted when the owning side's Lancer declares a normal attack whose `previous counter -> previous counter + 1` step crosses an even threshold. The counter is per side and unit type, and is shared by normal and skill-damage jobs. If nothing else has advanced it, the first activation is on the Lancer's second normal attack. It creates a one-use extra-attack effect, which immediately creates a skill-damage job from that Lancer to the normal attack's target at a `25 / 100 = 0.25` damage multiplier.
+At skill level 5, this trigger is attempted when the owning side's Lancer performs its second, fourth, sixth, ... successful normal attack. The cadence counter is per side and unit type. Dodged normal attacks count; `no_attack`, exhausted-target skips, and generated skill-damage jobs do not. The effect immediately creates a skill-damage job from that Lancer to the locked normal target at a `25 / 100 = 0.25` damage multiplier.
-That generated skill job also increments the Lancer's counter when the round commits. Assuming the target remains valid and one extra job runs each time, this example therefore activates on the Lancer's 2nd, 3rd, 4th, ... normal attacks—not its 2nd, 4th, 6th, ... normal attacks.
-
-The generated job does not evaluate `attack` triggers itself. If one or more generated jobs move the counter across a threshold, that threshold produces no activation and is not replayed later. A later normal attack fires only if its own `previous -> previous + 1` check crosses a threshold. Consequently, changing the number of jobs produced by an effect can change the future trigger cadence.
-
## File and hero fields
| Field | Accepted value | Meaning |
| --- | --- | --- |
| `name` | String | Name stored on the resolved hero and accepted when resolving battle input. It does not have to equal the filename. |
| `aliases` | Array of strings | Additional names accepted in battle input. Matching lowercases the name and removes every non-ASCII-alphanumeric character. Aliases, filenames, and `name` values must not collide after that normalization. |
| `hero_generation` | Key from `../hero_generation_stats.json` | Associates the hero with a generation stat block, such as `SR`, `S1`, `S1_natalia`, ..., `S8`. Different consumers apply that association differently; see below. |
+| `experimental` | Boolean | Optional UI metadata. Set to `true` to label a hero configuration as experimental in dashboard hero and simulator views. It does not change combat behaviour. |
| `troop_type` | `infantry`, `lancer`, or `marksman` | Hero class used by the dashboard catalogue and ingestion tools. It does **not** restrict which troops the hero's effects can affect, and the simulator's combat resolver does not otherwise read it. |
| `skills` | Object keyed by skill ID | The hero's skills, in skill-number order. The object key is the skill ID used in reports and may also be used when supplying a level. |
### Filename, `name`, aliases, and registration
@@ -139,14 +133,14 @@
| Value | Behaviour |
| --- | --- |
| `pre_battle` | Activated once while preparing the battle, before any runtime exists. Chance-free by definition and restricted to static `passive.*` effects; these feed the static damage profile (in-game: widget passives baked into player bonus stats). |
| `battle_start` | Is attempted once during battle setup, before round 1. `first`, `every`, `source`, and `target` do not gate this trigger type. |
-| `turn` | Is attempted at the start of matching simulator rounds. The simulator calls these rounds internally; JSON and game text commonly call them turns. Omitting `source` makes it global; defining `source` selects the separate per-unit path described below. |
-| `attack` | Is attempted when a matching **normal** attack is declared. Generated skill jobs do not evaluate it. Every intended normal attack's skill triggers are processed before any controls or damage jobs for the round. |
+| `turn` | Is attempted once at the start of matching simulator rounds. Turn triggers must not define `source` or `target`; scope their effects instead. |
+| `attack` | Is attempted procedurally for a matching **normal** attack, after locked-target exhaustion and pre-existing `no_attack` checks. Generated skill jobs do not evaluate it. |
Only these three exact strings are scheduled by the runtime. An unknown string is not rejected by a closed trigger schema; it simply leaves the skill unscheduled.
-**Phase gotcha for `attack`.** The simulator first resolves every side/unit's intended normal attack for the round, then activates all matching `attack` skills for all of those intents, and only afterwards evaluates controls and damage in intent order. An effect triggered by a later intent can therefore affect an earlier intent's damage in the same round. Conversely, an `attack_order` activated by an attack trigger is too late to change targets already selected for that round.
+Each attack is one procedural cluster. Effects produced by an earlier attack can affect later attacks, but a later intent cannot retroactively change an earlier attack. Target order is applied while locking that attack's target, before its trigger stage.
### `trigger.probability`
Accepts either a percentage number or a per-level array of percentages:
@@ -156,35 +150,26 @@
```
Omitting it means 100%. A scalar is coerced with `Number(...)`; an array selects by skill level first. The resolved value is clamped to 0–100, and a value that cannot become a finite number resolves to 0. The probability gate is attached to the whole skill trigger, not to individual effects.
-An `attack` trigger rolls separately for each matching normal intent. A global `turn` trigger rolls once for the round. A per-unit `turn` trigger also rolls only once; on success, the runtime then tries to activate the effects once for each living matching source unit type. It does not roll independently for infantry, lancer, and marksman.
+An `attack` trigger rolls separately for each matching normal attack. A `turn` trigger rolls once for the round, and every effect activated by that trigger shares the result of that one roll.
-On the per-unit turn path, the recorder counts the probability success as a skill activation before source/target intent construction finishes. A skill can therefore report one successful activation but zero effect activations when no living source/acceptable target produces a valid synthetic intent.
-
Passive (`passive.*`) battle-start effects may not use probability: static passive factors must be deterministic.
### `trigger.every` and `trigger.first`
`every` defines arithmetic thresholds. What is checked against those thresholds depends on the trigger type.
- for a `turn` trigger, `"every": 4` fires on rounds 4, 8, 12, ...;
-- for an `attack` trigger, `"every": 4` fires when a matching normal attack's `previous counter -> previous counter + 1` step crosses thresholds 4, 8, 12, ...; these need not be the unit's 4th, 8th, and 12th **normal** attacks;
+- for an `attack` trigger, `"every": 4` fires on that side/unit type's 4th, 8th, 12th, ... actual normal attacks;
- `"first": 4, "every": 5` uses thresholds 4, 9, 14, ... on the applicable round number or attack counter;
- omitting `every` means every otherwise-matching turn or attack;
- `first` is only valid when `every` is also present and must be at least 1.
Use positive integer intervals. The loader validates `first` but currently does not fully validate `every`, so malformed `every` values should not be treated as supported merely because they pass loading.
-**Gotcha — counter updates and trigger evaluation are different events.** A normal declaration evaluates `attack` triggers using `previousAttackCount` and `projectedAttackCount = previousAttackCount + 1`. At the end of the round, the counter is advanced once for every normal damage job, cancelled normal attack, and generated skill-damage job that actually ran. Generated jobs update the counter but never evaluate `attack` triggers. Thresholds reached or crossed by those jobs are therefore skipped, not queued.
+The counter advances only for a normal attack that reaches its attack stage. Dodge advances it because the attack occurred. A pre-existing `no_attack` control and an exhausted locked target suppress both the trigger and the counter. Extra skill jobs never change attack or received-attack cadence counters.
-An intended normal attack is not guaranteed to advance the counter. All intents and their `attack` triggers are created from round-start troop counts, but before each intent is calculated the simulator checks whether its selected target has already been exhausted by earlier same-round jobs. If so, that intent is dropped: its trigger has already been attempted, but it produces no job or cancellation record and advances neither attack nor received-attack counters. This can leave the same frequency threshold available to the unit's next normal declaration.
-
-Concrete current examples:
-
-- Sonya's `BountyTemptation` emits one skill job when its every-2 trigger succeeds. With a valid target, its Lancer counter advances by two on each successful round, so the skill fires on normal attacks 2, 3, 4, ... after the first activation.
-- Wayne's `RoundaboutHit` can emit two jobs, one against Lancers and one against Marksmen. When both targets exist, the Marksman counter advances by three on a successful round. Its every-2 trigger then fires on normal attacks 2, 4, 6, ... because the generated jobs themselves pass alternate thresholds without evaluating the trigger.
-
### `trigger.source` and `trigger.target`
These fields filter the attack shape that can trigger the skill.
@@ -207,36 +192,31 @@
There is no closed selector validator here. Empty arrays resolve to an empty unit mask, and malformed unit strings generally fail later when triggers are compiled. The parser also treats `friendly` as “all units on this field's default relation”; for `target`, that default is the enemy, so `friendly` is particularly misleading and should not be used.
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.
-For a `turn` trigger:
+`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.
-- with no `source`, the skill is global and activates once on each matching round; because no attack-shaped intent is constructed, `trigger.target` does not filter this path either;
-- with `source`, it is per-unit: after the single probability roll succeeds, the simulator considers each living matching source troop type and builds a synthetic attack-shaped intent for it;
-- source and target should resolve to opposing sides. A same-side source/target pairing cannot match the synthetic attack intent.
-
-**Per-unit target-selection gotcha.** The runtime does not search for a troop satisfying `trigger.target`. It first chooses one opposing target using the current `attack_order` or the default order `infantry`, `lancer`, `marksman`, then checks that chosen target against the trigger selector. If the chosen target fails the selector, that source produces no effects; the runtime does not try the next living target. Thus `target: "enemy.lancer"` can produce no effect while enemy Infantry is alive, even when enemy Lancers are also alive.
-
`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
-For `battle_start`, global `turn`, and `attack` triggers, every entry in `effects` is activated together when the trigger's probability gate succeeds. For a per-unit `turn` trigger, every entry is activated once for each source unit type that produces a valid synthetic source/target intent; a successful probability roll alone does not guarantee an effect activation.
+For `battle_start`, `turn`, and `attack` triggers, every top-level entry in `effects` is activated when the trigger's probability gate succeeds.
The entry's object key becomes the effect ID used in traces, applied-effect summaries, and extra-skill job attribution. Keep effect IDs unique across the catalogue: aggregate fields such as `extraSkillAttackJobsByEffect` are keyed by this ID alone. The current `SkillId/number` convention does that while keeping the source readable. The ID labels the effect but does not decide stacking identity; `same_effect_stacking` groups by the originating definition object and resolved scope.
Effect entries retain object enumeration order. That order is mechanically significant in some special-effect paths: extra-attack effects and their job definitions run sequentially, the first ready `attack_order` wins, and the last matching control of a given type is reported as the winner. Use descriptive non-integer IDs and treat reordering effects or `trigger_damage_jobs` as a behavioural change, not merely formatting.
| Field | Accepted value | Meaning |
| --- | --- | --- |
-| `type` | One supported modifier or special-effect string | Chooses the mechanic and, for modifiers, the damage-equation bucket. |
+| `type` | One supported modifier/special-effect string, or omitted on a child-bearing carrier | Chooses the mechanic and, for modifiers, the damage-equation bucket. |
| `value` | Usually a number or per-level numeric array | Percentage magnitude for modifiers; damage multiplier percentage for `extra_skill_attack`; fixed unit order for `attack_order`. |
| `units` | `{ applies_to?, applies_vs? }` | Resolves which troop lines may receive/use the effect and which opposing troop lines it applies against. |
-| `duration` | `{ turns?, attacks? }` | Optional round window and/or use limit. Omitted means the effect is permanent. |
+| `duration` | `{ turns?, attacks? }` | Optional round window and/or use limit. Omitted means permanent, except that `extra_skill_attack` defaults to one turn and one attack. |
| `same_effect_stacking` | `add` or `max` | Controls overlap between live activations of the same modifier definition and scope. Omitted means `add`. |
| `requires_effect` | Effect ID string | Applies a runtime damage modifier only while the named effect is applicable to the same damage job. |
| `value_evolution` | Evolution object | Optionally changes the effect value as rounds or uses advance. |
| `trigger_damage_jobs` | Non-empty array of job definitions | Required for `extra_skill_attack`; describes the actual skill-damage jobs it emits. |
+| `trigger_effects` | Object keyed by child effect ID | Defines children materialized only from an actual parent use. |
### `value` and level selection
For percentage-bucket modifiers, every configured scalar/array entry must be a finite, non-negative JSON number. Missing `value` is permitted and resolves to 0. Use a positive magnitude with a `.down` effect type; negative values are rejected rather than treated as the opposite direction.
@@ -322,17 +302,16 @@
- `"self"`, `"self.any"`, `"self.all"`, `"self.<unit>"`;
- `"enemy"`, `"enemy.any"`, `"enemy.all"`, `"enemy.<unit>"`;
- `"trigger.source"` or its short alias `"trigger"`;
- `"trigger.target"` or its short alias `"target"`.
+- `"parent.use.source"` or `"parent.use.target"` inside a keyed child effect.
`applies_vs: "all"` is deliberately rejected; use `"any"`. `units.side` is also rejected. Side belongs in relation-qualified selectors such as `enemy.any`.
-`trigger.source`/`trigger` resolve to the intent's dealer side and unit. `trigger.target`/`target` resolve to its taker side and unit. They are meaningful for `attack` triggers and valid per-unit `turn` intents. They are rejected on `pre_battle` and `battle_start` effects because no such intent exists.
+`trigger.source`/`trigger` resolve to an attack trigger's dealer side and unit. `trigger.target`/`target` resolve to its locked taker side and unit. Direct turn effects and pre-battle/battle-start effects reject attack-relative selectors. A nested effect instead uses `parent.use.source` and `parent.use.target`; these resolve from the concrete job or control in which the typed parent actually participated.
-**Gotcha — global turn triggers have no concrete source or target.** On a `turn` trigger with no `trigger.source`, the activation has no attack intent. Trigger-relative effect selectors consequently retain their precompiled fallback: all units on that field's default side. The loader records an `ambiguousTurnTriggerSelectors` diagnostic but does not reject the definition.
+An effect can define both its normal `type` and keyed `trigger_effects`. Its children activate only when the parent is actually used. A type-less effect is a normal-attack carrier: when its unit scopes match an eligible normal attack, it materializes its keyed children and consumes a use. The carrier is useful for bounded one-use consequences without inventing a combat bucket. Children are snapshotted after the parent's use, so a child cannot recursively affect the same consuming damage job.
-The current `Sonya.json` `TorrentialImpact/2` definition is affected. Its global turn trigger gives `applies_to: "trigger.target"` no target to resolve, so it becomes all troops on Sonya's own side. Because the effect is `no_attack`, it cancels Sonya's side's normal attacks on the trigger round. A trace with only this skill shows the round-5 Lancer attack cancelled and `TorrentialImpact/1`'s one-use extra-skill effect surviving until it is used on round 6. This is the runtime behaviour of the current JSON, despite the description saying the enemy target is stunned.
-
The parser also accepts `"friendly"`, but only as “all units on this field's default side.” It is not an owner-relative synonym in every context. No current hero definition uses it; use `self.any`, `enemy.any`, or `any` instead.
## Duration
@@ -346,8 +325,10 @@
}
}
```
+An omitted duration normally means that an effect is permanent. An omitted duration on `extra_skill_attack` instead defaults to `turns.count: 1` and `attacks.count: 1`. Any explicit duration replaces that default completely, so a partial duration constrains only its declared axis and `duration: {}` explicitly requests a permanent extra-attack effect.
+
`count` must be an integer at least 1. `delay` must be an integer at least 0. If both axes are present, they are independent constraints on the same instance. Turn expiry or exhaustion of the attack-use budget removes the effect, whichever happens first.
### `turns`
@@ -369,16 +350,16 @@
`attacks.count` is a limit on the number of times that particular effect instance participates in the relevant mechanic. “Attack” here is not uniformly synonymous with “normal attack”:
- a modifier use is an applicable normal **or skill** damage job to which it contributes a non-zero value;
- an extra-skill use is one eligible normal attack that emits at least one job, regardless of how many target jobs it emits;
-- a control use is an attack it cancels;
+- a `no_attack` control use is an attack it cancels; a dodge use zeroes only normal damage and still permits triggers, children, and extra attacks;
- an attack-order use is an attack whose target order it chooses.
`attacks.delay` skips that many otherwise-eligible mechanic uses before the effect can apply. For a modifier this means applicable damage jobs; for extra attacks, controls, and attack order it means applicable normal attacks. The last skipped use is not also the first active use. Delay decrements only when the effect's side/unit/job-kind gates otherwise match.
-For an effect created by an `attack` trigger, the triggering normal intent is eligible to be its first use because attack skills activate before controls and damage. Thus `attacks.delay: 1` normally skips that current matching intent and begins with the next eligible one; it does not mean “wait one complete round.” The all-intents trigger phase can make the first eligible use a different, earlier intent in the same round when the effect has broad scope.
+For an effect created by an `attack` trigger, the triggering normal attack is eligible to be its first use. Thus `attacks.delay: 1` skips that current matching attack and begins with the next eligible one; it does not mean “wait one complete round.”
-Cancelled attacks normally still consume applicable attack-limited modifiers and controls because the attack was declared even though it did not land. Permanent modifiers without an `attacks` duration are not use-charged through this cancellation path merely because they have `value_evolution.step: "attack"`. Simulation options can disable cancellation charging separately for dodge or no-attack, but those options are outside the hero JSON.
+`no_attack` and dodge normally still consume applicable attack-limited durations. This duration-only charging does not count as a typed parent mechanic use and therefore does not materialize children. Permanent modifiers without an `attacks` duration are not use-charged through this path merely because they have `value_evolution.step: "attack"`. Simulation options can disable duration charging separately for dodge or no-attack, but those options are outside the hero JSON.
## `same_effect_stacking`
| Value | Behaviour |
@@ -425,17 +406,19 @@
### `extra_skill_attack`
This creates an active effect that can be used by eligible normal attacks. `units.applies_to`/`applies_vs` gate the parent normal attack; they do not by themselves define the generated jobs. After the parent normal job is calculated, each `trigger_damage_jobs` entry expands into zero or more immediate `kind: "skill"` jobs.
+When `duration` is omitted, the active effect defaults to one turn and one applicable attack. Declare `duration` only when the extra attack needs a different lifetime or delay.
+
`value` becomes the generated job's source multiplier: `100` means `1.0`, `25` means `0.25`, and `200` means `2.0`. The result is a **new full damage calculation**, not that percentage of the parent normal job's final kills or raw damage. It uses the generated source/target pair, round-start troop counts, the ordinary static factors, general active modifiers, and skill-only type modifiers. Normal-only type modifiers do not apply.
Requirements:
- `trigger_damage_jobs` must be a non-empty array;
- every job must have `source` and `target`;
- `same_effect_stacking` is not allowed;
- generated jobs do not trigger `attack` skills recursively;
-- every generated job that actually runs advances its dealer's attack counter and taker's received counter when the round commits;
+- generated jobs do not advance normal attack or received-attack cadence counters;
- each live applicable extra-attack effect runs independently; there is no max/add suppression between them.
Each job has this shape:
@@ -488,12 +471,10 @@
Attack-triggered controls are activated during the all-triggers phase and can cancel the same normal intent that triggered them. All matching attack skills have already attempted before the cancellation is applied.
If both a no-attack and dodge control apply to the same normal attack, no-attack wins. If several live controls of the same type match, the last one encountered is reported as the winning control; matching attack-limited controls can still be charged together. `same_effect_stacking` does not select among controls.
-**One-use control gotcha.** Control applicability is determined for every normal intent in the round before any of those intents is processed and charges a use. A broad control with `attacks.count: 1` can therefore already be attached to several same-round intents and cancel all of them, even though its first cancellation expires it from the live index. The current one-use Reina dodge is narrowed to its concrete triggering dealer/target pair, but the generic duration shape does not itself guarantee only one cancellation per round.
+Controls are checked from the live index for each attack in procedural order. A one-use broad control expires after its first actual use and cannot be pre-attached to later attacks. `no_attack` emits no normal or extra job and advances no cadence counter. Dodge emits a zero-kill normal outcome with `dodged: true`, advances normal cadence, and allows attack triggers and extra jobs to continue.
-A cancelled normal attack emits no normal damage job and no extra-skill jobs, but it still advances the dealer's normal attack counter and the target's received counter once when the round commits.
-
### `attack_order`
Overrides target preference for matching normal attacks. Although no current hero definition uses it, it is a supported native effect type.
@@ -508,9 +489,9 @@
`value` must be a non-empty all-string array of valid unit names. Because all-string arrays are preserved by level resolution, this is one fixed order for every skill level, not a per-level value array. The simulator chooses the first unit in the array with positive round-start troops. Unlisted unit types are not appended as fallbacks; if none of the listed types is alive, no normal intent is created for that dealer.
Only `units.applies_to` gates which dealer troop lines use the order. `applies_vs` is not consulted by attack-order target selection, even though the current troop-skill `Ambusher` definition supplies it.
-Target order is resolved before attack triggers for the round. Orders activated at battle start or round start can affect that round; an order activated by an `attack` trigger cannot affect the already-created intents until a later round. When multiple live orders match a dealer, the first ready effect in runtime insertion order wins and later orders are not consulted. A per-unit turn trigger may use an active order to select its synthetic target, but doing so does not consume an order use. Duration and attack delay/count otherwise work as described above.
+Target order is resolved for each attack immediately before its exhaustion/control/trigger stages. Orders activated at battle start, round start, or an earlier attack can affect a later attack in the same round. An order created by the attack it would order is necessarily too late for that attack. When multiple live orders match a dealer, the first ready effect in runtime insertion order wins and later orders are not consulted. Duration and attack delay/count otherwise work as described above.
## Values currently present in hero definitions
The engine accepts some values not currently used by a hero. This inventory distinguishes the data that exists in this directory from the wider native effect vocabulary documented above.
@@ -520,15 +501,16 @@
| `hero_generation` | `SR`, `S1`, `S1_natalia`, `S1_jeronimo`, `S2` through `S10` |
| `troop_type` | `infantry`, `lancer`, `marksman` |
| `requirements[].type/value` | `engagement_type` with `rally` or `garrison`, always beginning at level 1 |
| `trigger.type` | `pre_battle`, `battle_start`, `turn`, `attack` |
-| `trigger.source` | omitted, `infantry`, `lancer`, `marksman`, `self.any`, `self.all`, `enemy.any` |
-| `trigger.target` | omitted or `self.any` |
+| `trigger.source` | attack triggers only: omitted, `infantry`, `lancer`, `marksman`, `self.any`, `self.all`, `enemy.any` |
+| `trigger.target` | attack triggers only: omitted or `self.any` |
| `trigger.probability` | omitted or a five-level numeric percentage array |
| `trigger.first` | omitted, 4, or 5 |
| `trigger.every` | omitted, 2, 3, 4, 5, or 6 |
-| `units.applies_to` | omitted; unit strings/arrays; `all`; `enemy.any`, `enemy.infantry`, `enemy.marksman`; `target`; `trigger.source`; `trigger.target` |
-| `units.applies_vs` | omitted; `any`; unit strings/arrays; `self.any`; `trigger.source`; `trigger.target` |
+| `units.applies_to` | omitted; unit strings/arrays; `all`; relation-qualified selectors; attack-relative selectors; nested `parent.use.*` selectors |
+| `units.applies_vs` | omitted; `any`; unit strings/arrays; relation-qualified selectors; attack-relative selectors; nested `parent.use.*` selectors |
+| `trigger_effects` | omitted or an object keyed by stable child effect IDs |
| `duration.turns` | `count` 1–3; optional `delay: 1`; or `delay: 1` with no count |
| `duration.attacks` | `count: 1` or `count: 10`; no hero currently uses `attacks.delay` |
| `same_effect_stacking` | omitted, `add`, or `max` |
| `value_evolution` | only `{ "type": "pct_decay", "step": "attack", "value": 15 }` |
@@ -557,18 +539,18 @@
- unknown effect types are added to `config.diagnostics.unsupportedEffects`, but loading does not throw solely for that reason. The skill can still report effect activation even though the unknown effect is not indexed and changes no combat mechanic;
- some unknown or misspelled fields outside tightly validated objects can be silently ignored;
- `description`, `notes`, and `status` never override runtime behaviour.
-The current 33 hero files load with zero unsupported-effect diagnostics and one ambiguity diagnostic: Sonya `TorrentialImpact/2`, described in the units section.
+The current hero files load with zero unsupported-effect diagnostics. Invalid turn source/target fields and direct turn attack-relative selectors are rejected.
After editing definitions, at minimum load the config and inspect both diagnostics arrays, then run the simulator tests and a focused trace that exercises the relevant timing/scope interaction. A definition being valid JSON—or a description sounding plausible—is not evidence that the mechanic behaves as intended.
## Implementation reference
The behaviour described here is owned primarily by:
- `simulator/src/config.ts` — loading, aliases, diagnostics, and config validation;
-- `simulator/src/resolve.ts` — hero lookup, skill ordering/levels, requirements, and generation-stat baking;
+- `simulator/src/fighterResolution.ts` — hero lookup, skill ordering/levels, requirements, nested-effect hydration, and generation-stat baking;
- `simulator/src/effects.ts` — trigger matching, probability, selectors, durations, stacking defaults, and value evolution;
- `simulator/src/runtimeSkills.ts` — trigger scheduling and prepared modifier groups;
- `simulator/src/simulator.ts` — round/attack phases, controls, extra-skill jobs, counters, and use charging;
- `simulator/src/damageBuckets.ts` and `simulator/src/damage.ts` — effect-type buckets and damage arithmetic;
Index: simulator/config/hero_definitions/Reina.json
===================================================================
--- simulator/config/hero_definitions/Reina.json prev run
+++ simulator/config/hero_definitions/Reina.json this run
@@ -84,13 +84,8 @@
"lancer"
],
"applies_vs": "trigger.target"
},
- "duration": {
- "turns": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
Index: simulator/config/hero_definitions/Renee.json
===================================================================
--- simulator/config/hero_definitions/Renee.json prev run
+++ simulator/config/hero_definitions/Renee.json this run
@@ -6,97 +6,101 @@
"NightmareTrace": {
"description": "Troops place Dream Marks on targets every two turns, dealing extra Lancer damage once next turn",
"trigger": {
"type": "turn",
- "every": 2,
- "source": "lancer"
+ "every": 2
},
"effects": {
- "NightmareTrace/1": {
- "type": "active.hero.health.down",
- "value": [
- 40,
- 80,
- 120,
- 160,
- 200
- ],
+ "NightmareTrace/mark": {
"units": {
- "applies_to": "target",
- "applies_vs": "lancer"
+ "applies_to": "self.lancer",
+ "applies_vs": "enemy.any"
},
"duration": {
- "turns": {
- "count": 1,
- "delay": 1
- },
- "attacks": {
- "count": 1
- }
+ "turns": { "count": 1 },
+ "attacks": { "count": 1 }
},
- "same_effect_stacking": "max"
+ "trigger_effects": {
+ "NightmareTrace/1": {
+ "type": "active.hero.damageTaken.up",
+ "value": [40, 80, 120, 160, 200],
+ "units": {
+ "applies_to": "parent.use.target",
+ "applies_vs": "parent.use.source"
+ },
+ "duration": {
+ "turns": { "delay": 1, "count": 1 },
+ "attacks": { "count": 1 }
+ },
+ "same_effect_stacking": "max"
+ }
+ }
}
}
},
"Dreamcatcher": {
"description": "Dream Marks highlight enemy vulnerabilities, increasing damage dealt by Lancers to marked targets",
"trigger": {
"type": "turn",
- "every": 2,
- "source": "lancer"
+ "every": 2
},
"effects": {
- "Dreamcatcher/1": {
- "type": "active.hero.health.down",
- "value": [
- 30,
- 60,
- 90,
- 120,
- 150
- ],
+ "Dreamcatcher/mark": {
"units": {
- "applies_to": "target",
- "applies_vs": "lancer"
+ "applies_to": "self.lancer",
+ "applies_vs": "enemy.any"
},
"duration": {
- "turns": {
- "count": 1,
- "delay": 1
- }
+ "turns": { "count": 1 },
+ "attacks": { "count": 1 }
},
- "same_effect_stacking": "max"
+ "trigger_effects": {
+ "Dreamcatcher/1": {
+ "type": "active.hero.damageTaken.up",
+ "value": [30, 60, 90, 120, 150],
+ "units": {
+ "applies_to": "parent.use.target",
+ "applies_vs": "parent.use.source"
+ },
+ "duration": {
+ "turns": { "delay": 1, "count": 1 }
+ },
+ "same_effect_stacking": "max"
+ }
+ }
}
}
},
"Dreamslice": {
"description": "Dream Marks expose enemy weaknesses, increasing damage taken by marked targets for all troops",
"trigger": {
"type": "turn",
- "every": 2,
- "source": "lancer"
+ "every": 2
},
"effects": {
- "Dreamslice/1": {
- "type": "active.hero.health.down",
- "value": [
- 15,
- 30,
- 45,
- 60,
- 75
- ],
+ "Dreamslice/mark": {
"units": {
- "applies_to": "target",
- "applies_vs": "any"
+ "applies_to": "self.lancer",
+ "applies_vs": "enemy.any"
},
"duration": {
- "turns": {
- "count": 1,
- "delay": 1
- }
+ "turns": { "count": 1 },
+ "attacks": { "count": 1 }
},
- "same_effect_stacking": "max"
+ "trigger_effects": {
+ "Dreamslice/1": {
+ "type": "active.hero.damageTaken.up",
+ "value": [15, 30, 45, 60, 75],
+ "units": {
+ "applies_to": "parent.use.target",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "turns": { "delay": 1, "count": 1 }
+ },
+ "same_effect_stacking": "max"
+ }
+ }
}
}
},
"DreamfangRally": {
Index: simulator/config/hero_definitions/Sonya.json
===================================================================
--- simulator/config/hero_definitions/Sonya.json prev run
+++ simulator/config/hero_definitions/Sonya.json this run
@@ -41,13 +41,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "use.target"
@@ -64,9 +59,10 @@
25
],
"duration": {
"turns": {
- "count": 1
+ "count": 1,
+ "delay": 1
}
},
"same_effect_stacking": "max"
}
@@ -75,10 +71,9 @@
"TorrentialImpact": {
"description": "Sonya's Lancers will seize every chance to launch a surprise raid like underwater currents. Her Lancers deal X% damage every 5 turns and stun the target for 1 turn.",
"trigger": {
"type": "turn",
- "every": 5,
- "source": "lancer"
+ "every": 5
},
"effects": {
"TorrentialImpact/1": {
"type": "extra_skill_attack",
@@ -89,32 +84,30 @@
200,
250
],
"units": {
- "applies_to": "trigger.source",
- "applies_vs": "trigger.target"
+ "applies_to": "self.lancer",
+ "applies_vs": "enemy.any"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "use.target"
}
- ]
- },
- "TorrentialImpact/2": {
- "type": "no_attack",
- "units": {
- "applies_to": "trigger.target",
- "applies_vs": "trigger.source"
- },
- "duration": {
- "turns": {
- "count": 1
+ ],
+ "trigger_effects": {
+ "TorrentialImpact/2": {
+ "type": "no_attack",
+ "units": {
+ "applies_to": "parent.use.target",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "turns": {
+ "count": 1,
+ "delay": 1
+ }
+ }
}
}
}
}
Index: simulator/config/hero_definitions/Wayne.json
===================================================================
--- simulator/config/hero_definitions/Wayne.json prev run
+++ simulator/config/hero_definitions/Wayne.json this run
@@ -6,10 +6,9 @@
"ThunderStrike": {
"description": "Wayne's brilliant battle planning allows all troops to launch an extra attack every 4 turns, dealing X% damage.",
"trigger": {
"type": "turn",
- "every": 4,
- "source": "self.any"
+ "every": 4
},
"effects": {
"ThunderStrike/1": {
"type": "extra_skill_attack",
@@ -20,13 +19,13 @@
80,
100
],
"units": {
- "applies_to": "trigger.source",
- "applies_vs": "any"
+ "applies_to": "self.any",
+ "applies_vs": "enemy.any"
},
"duration": {
- "attacks": {
+ "turns": {
"count": 1
}
},
"trigger_damage_jobs": [
@@ -58,13 +57,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "any"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": [
@@ -85,13 +79,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "any"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": [
@@ -127,13 +116,8 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "use.target"
Index: simulator/config/hero_definitions/Xura.json
===================================================================
--- simulator/config/hero_definitions/Xura.json prev run
+++ simulator/config/hero_definitions/Xura.json this run
@@ -1,7 +1,8 @@
{
"name": "Xura",
"hero_generation": "S9",
+ "experimental": true,
"troop_type": "marksman",
"skills": {
"FungalFog": {
"description": "Xura releases an underground fungi that quickly multiplies to block enemy vision, reducing damage dealt to friendly troops by X%.",
@@ -28,18 +29,17 @@
"units": {
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
- "duration": { "attacks": { "count": 1 } },
"trigger_damage_jobs": [
{ "source": "use.source", "target": "use.target" }
]
},
"PiercingArrow/2": {
"type": "active.hero.damageTaken.up",
"value": [5, 10, 15, 20, 25],
- "units": { "applies_to": "trigger.target" },
- "duration": { "turns": { "count": 1 } },
+ "units": { "applies_to": "trigger.target", "applies_vs": "trigger.source" },
+ "duration": { "turns": { "count": 1, "delay": 1 } },
"same_effect_stacking": "max"
}
}
},
Index: simulator/config/troop_skills.json
===================================================================
--- simulator/config/troop_skills.json prev run
+++ simulator/config/troop_skills.json this run
@@ -15,9 +15,9 @@
"type": "battle_start"
},
"effects": {
"MasterBrawler/1": {
- "type": "active.troop.lethality.up",
+ "type": "type.single_target.damage.up",
"value": [
10
],
"units": {
@@ -166,9 +166,9 @@
"type": "battle_start"
},
"effects": {
"Charge/1": {
- "type": "active.troop.lethality.up",
+ "type": "type.single_target.damage.up",
"value": [
10
],
"units": {
@@ -257,13 +257,8 @@
"lancer"
],
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
@@ -329,9 +324,9 @@
"type": "battle_start"
},
"effects": {
"RangedStrike/1": {
- "type": "active.troop.lethality.up",
+ "type": "type.single_target.damage.up",
"value": [
10
],
"units": {
@@ -429,13 +424,8 @@
"marksman"
],
"applies_vs": "trigger.target"
},
- "duration": {
- "attacks": {
- "count": 1
- }
- },
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { join, relative } from "node:path";
+import { join } from "node:path";
import { test } from "node:test";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
@@ -10,51 +10,42 @@
loadSimulatorConfigFromDir,
} from "./config-node";
import type { SkillFile } from "./types";
-test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {
- const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];
+test("loadSimulatorConfig accepts keyed nested effects on a type-less attack carrier", () => {
+ const root = writeConfigWithTroopEffect({
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ duration: { attacks: { count: 1 } },
+ trigger_effects: {
+ child: {
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "parent.use.source", applies_vs: "parent.use.target" }
+ }
+ }
+ }, { type: "attack", every: 2, source: "infantry" });
- assert.deepEqual(effect.duration, {
- turns: { delay: 1 },
- attacks: { count: 1 }
- });
+ const config = loadSimulatorConfigFromDir(root);
+ assert.equal(config.diagnostics.unsupportedEffects.length, 0);
});
-test("Volley raises the triggering attack's damage instead of scheduling an extra skill attack", () => {
- const effect = loadSimulatorConfig().troopSkills.skills.Volley.effects["Volley/1"];
-
- assert.equal(effect.type, "active.troop.damage.up");
- assert.deepEqual(effect.value, [100]);
- assert.deepEqual(effect.duration, { attacks: { count: 1 } });
- assert.equal("trigger_damage_jobs" in effect, false);
-});
-
-test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {
+test("loadSimulatorConfig rejects attack-relative selectors on direct turn effects", () => {
const root = writeConfigWithTroopEffect({
type: "active.hero.lethality.up",
value: 10,
units: { applies_to: "trigger.source", applies_vs: "target" }
- });
+ }, { type: "turn" });
- const config = loadSimulatorConfigFromDir(root);
+ assert.throws(() => loadSimulatorConfigFromDir(root), /turn effect cannot use attack\/use-relative selector/i);
+});
- assert.deepEqual(config.diagnostics.ambiguousTurnTriggerSelectors, [
- {
- file: relative(process.cwd(), join(root, "troop_skills.json")),
- skillId: "ExampleSkill",
- effectId: "ExampleSkill/1",
- selector: "trigger.source",
- reason: "Turn trigger has no concrete attack intent; trigger-relative unit selectors fall back to all units"
- },
- {
- file: relative(process.cwd(), join(root, "troop_skills.json")),
- skillId: "ExampleSkill",
- effectId: "ExampleSkill/1",
- selector: "target",
- reason: "Turn trigger has no concrete attack intent; trigger-relative unit selectors fall back to all units"
- }
- ]);
+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);
});
test("loadSimulatorConfig rejects legacy fields in simulator config", () => {
const root = join(tmpdir(), `wos-simulator-config-${Date.now()}`);
@@ -418,9 +409,9 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /requires_effect references missing effect MissingShield\/1/i);
});
-function writeConfigWithTroopEffect(effect: Record<string, unknown>, trigger: Record<string, unknown> = { type: "turn" }): string {
+function writeConfigWithTroopEffect(effect: Record<string, unknown>, trigger: Record<string, unknown> = { type: "attack" }): string {
const root = join(tmpdir(), `wos-simulator-config-trigger-jobs-${Date.now()}-${Math.random().toString(16).slice(2)}`);
mkdirSync(join(root, "hero_definitions"), { recursive: true });
writeFileSync(join(root, "hero_generation_stats.json"), JSON.stringify({ S1: { attack: 1, defense: 1, lethality: 1, health: 1 } }));
writeFileSync(
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -29,10 +29,9 @@
export function buildSimulatorConfig(raw: RawSimulatorConfig): SimulatorConfig {
const diagnostics: ConfigDiagnostics = {
legacyFields: [],
effectTypes: {},
- unsupportedEffects: [],
- ambiguousTurnTriggerSelectors: []
+ unsupportedEffects: []
};
scanLegacyFields(raw.heroGenerationStats, raw.fileLabel?.("hero_generation_stats") ?? "config/hero_generation_stats.json", "$", diagnostics);
scanLegacyFields(raw.troopSkills, raw.fileLabel?.("troop_skills") ?? "config/troop_skills.json", "$", diagnostics);
@@ -94,9 +93,9 @@
function collectEffectDiagnostics(skillFile: SkillFile, file: string, diagnostics: ConfigDiagnostics): void {
const effectDefinitions = new Map<string, EffectIntentDefinition[]>();
for (const skill of Object.values(skillFile.skills ?? {})) {
- for (const [effectId, effect] of Object.entries(skill.effects ?? {})) {
+ for (const [effectId, effect] of walkEffectDefinitions(skill.effects ?? {})) {
const definitions = effectDefinitions.get(effectId);
const definition = { id: effectId, ...effect } as EffectIntentDefinition;
if (definitions) definitions.push(definition);
else effectDefinitions.set(effectId, [definition]);
@@ -104,12 +103,13 @@
}
for (const [skillId, skill] of Object.entries(skillFile.skills ?? {})) {
validateTriggerDefinition(skill.trigger, file, skillId);
if (skill.trigger.type === "pre_battle") validatePreBattleSkill(skill, file, skillId);
- for (const [effectId, effect] of Object.entries(skill.effects ?? {})) {
- const type = String((effect as { type?: unknown }).type ?? "");
- diagnostics.effectTypes[type] = (diagnostics.effectTypes[type] ?? 0) + 1;
- collectAmbiguousTurnTriggerSelectorDiagnostics(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId, diagnostics);
+ for (const [effectId, effect, nested] of walkEffectDefinitionsWithDepth(skill.effects ?? {})) {
+ const type = (effect as { type?: unknown }).type;
+ const typeName = typeof type === "string" ? type : "(carrier)";
+ diagnostics.effectTypes[typeName] = (diagnostics.effectTypes[typeName] ?? 0) + 1;
+ 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);
validateRequiredEffect(effect as EffectIntentDefinition, effectDefinitions, file, skillId, effectId);
@@ -117,21 +117,44 @@
if (type === "attack_order") validateAttackOrderEffect(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);
validateStaticBucketEffect(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);
- if (!KNOWN_EFFECT_TYPES.has(type)) {
+ if (type === undefined) {
+ if (!effect.trigger_effects || Object.keys(effect.trigger_effects).length === 0) {
+ throw new Error(`type-less effect must define non-empty trigger_effects at ${file}:${skillId}.${effectId}`);
+ }
+ } else if (typeof type !== "string" || !KNOWN_EFFECT_TYPES.has(type)) {
diagnostics.unsupportedEffects.push({
file,
skillId,
effectId,
- type,
+ type: String(type ?? ""),
reason: "Effect type is not supported by the native simulator"
});
}
}
}
}
+function* walkEffectDefinitions(
+ definitions: Record<string, Omit<EffectIntentDefinition, "id">>
+): Iterable<[string, Omit<EffectIntentDefinition, "id">]> {
+ for (const [id, effect] of Object.entries(definitions)) {
+ yield [id, effect];
+ if (effect.trigger_effects) yield* walkEffectDefinitions(effect.trigger_effects);
+ }
+}
+
+function* walkEffectDefinitionsWithDepth(
+ definitions: Record<string, Omit<EffectIntentDefinition, "id">>,
+ nested = false
+): Iterable<[string, Omit<EffectIntentDefinition, "id">, boolean]> {
+ for (const [id, effect] of Object.entries(definitions)) {
+ yield [id, effect, nested];
+ if (effect.trigger_effects) yield* walkEffectDefinitionsWithDepth(effect.trigger_effects, true);
+ }
+}
+
function validateRequiredEffect(
effect: EffectIntentDefinition,
effectDefinitions: Map<string, EffectIntentDefinition[]>,
file: string,
@@ -145,9 +168,9 @@
}
if (effect.requires_effect === effectId) {
throw new Error(`effect cannot require itself at ${path}`);
}
- if (dynamicBucketDefinition(effect.type)?.effectBucket !== true) {
+ if (effect.type === undefined || dynamicBucketDefinition(effect.type)?.effectBucket !== true) {
throw new Error(`requires_effect is only supported for runtime damage modifiers at ${path}`);
}
const required = effectDefinitions.get(effect.requires_effect) ?? [];
if (required.length === 0) {
@@ -158,31 +181,24 @@
}
if (required[0].requires_effect !== undefined) {
throw new Error(`requires_effect chains are not supported at ${path}`);
}
- if (dynamicBucketDefinition(required[0].type)?.effectBucket !== true) {
+ if (required[0].type === undefined || dynamicBucketDefinition(required[0].type)?.effectBucket !== true) {
throw new Error(`requires_effect must reference a runtime damage modifier at ${path}`);
}
}
-function collectAmbiguousTurnTriggerSelectorDiagnostics(
+function validateDirectTurnEffectSelectors(
trigger: SkillFile["skills"][string]["trigger"],
effect: EffectIntentDefinition,
file: string,
skillId: string,
- effectId: string,
- diagnostics: ConfigDiagnostics
+ effectId: string
): void {
- if (trigger.type !== "turn" || trigger.source !== undefined) return;
+ if (trigger.type !== "turn") return;
for (const selector of [effect.units?.applies_to, effect.units?.applies_vs]) {
- if (!isTriggerRelativeUnitSelector(selector)) continue;
- diagnostics.ambiguousTurnTriggerSelectors.push({
- file,
- skillId,
- effectId,
- selector,
- reason: "Turn trigger has no concrete attack intent; trigger-relative unit selectors fall back to all units"
- });
+ if (!isUseRelativeUnitSelector(selector)) continue;
+ throw new Error(`turn effect cannot use attack/use-relative selector ${JSON.stringify(selector)} at ${file}:${skillId}.${effectId}`);
}
}
function validateTriggerDefinition(trigger: SkillFile["skills"][string]["trigger"], file: string, skillId: string): void {
@@ -195,14 +211,21 @@
}
if (trigger.first !== undefined && trigger.every === undefined) {
throw new Error(`trigger.first requires trigger.every at ${file}:${skillId}.trigger`);
}
+ 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`);
+ }
}
function isTriggerRelativeUnitSelector(selector: unknown): selector is string {
return selector === "trigger" || selector === "trigger.source" || selector === "target" || selector === "trigger.target";
}
+function isUseRelativeUnitSelector(selector: unknown): selector is string {
+ return isTriggerRelativeUnitSelector(selector) || selector === "parent.use.source" || selector === "parent.use.target";
+}
+
function validateBattleStartEffectSelectors(
trigger: SkillFile["skills"][string]["trigger"],
effect: EffectIntentDefinition,
file: string,
@@ -257,13 +280,13 @@
file: string,
skillId: string,
effectId: string
): void {
- const definition = staticBucketDefinition(effect.type);
+ const definition = effect.type === undefined ? undefined : staticBucketDefinition(effect.type);
if (!definition) return;
const path = `${file}:${skillId}.${effectId}`;
- if (!isPassiveBucket(effect.type)) {
+ if (effect.type === undefined || !isPassiveBucket(effect.type)) {
throw new Error(`effect type ${effect.type} targets an input-derived static bucket and cannot be defined by a skill at ${path}`);
}
if (trigger.type !== "pre_battle") {
throw new Error(`effect targeting static bucket ${effect.type} must use a pre_battle trigger at ${path}`);
@@ -279,9 +302,9 @@
}
}
function validateNativeEffectValue(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
- const definition = dynamicBucketDefinition(effect.type) ?? staticBucketDefinition(effect.type);
+ const definition = effect.type === undefined ? undefined : dynamicBucketDefinition(effect.type) ?? staticBucketDefinition(effect.type);
if (definition?.effectBucket !== true) return;
const values = Array.isArray(effect.value) ? effect.value : [effect.value];
for (const value of values) {
if (value === undefined) continue;
@@ -433,9 +456,9 @@
throw new Error(`invalid trigger_damage_jobs ${role} selector ${JSON.stringify(selector)} at ${path}.trigger_damage_jobs[${jobIndex}]`);
}
function isAllowedTriggerDamageJobSelector(selector: TriggerDamageJobDefinition["source"]): boolean {
- const supported = new Set(["use.source", "use.target", "effect.applies_to", "effect.applies_vs", "enemy.living", "self.living"]);
+ const supported = new Set(["use.source", "use.target", "parent.use.source", "parent.use.target", "effect.applies_to", "effect.applies_vs", "enemy.living", "self.living"]);
if (typeof selector === "string") return supported.has(selector) || (UNIT_TYPES as string[]).includes(selector);
if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));
return false;
}
Index: simulator/src/damage.test.ts
===================================================================
--- simulator/src/damage.test.ts prev run
+++ simulator/src/damage.test.ts this run
@@ -3,9 +3,8 @@
import {
calculateDamageJob,
ceilIgnoringFloatResidue,
- ceilSqrtProduct,
createDamageScratch,
evaluateDamageExpression
} from "./damage";
import { DYNAMIC_BUCKETS, STATIC_BUCKETS, type BucketJobSide, type BucketPlacement } from "./damageBuckets";
@@ -130,21 +129,15 @@
});
test("float-safe ceilings ignore residue but preserve genuine fractional troops", () => {
assert.equal(ceilIgnoringFloatResidue(200.00000000000003), 200);
+ assert.equal(ceilIgnoringFloatResidue(Math.sqrt(2) * Math.sqrt(2)), 2);
+ assert.equal(ceilIgnoringFloatResidue(10 + 5e-13), 10);
+ assert.equal(ceilIgnoringFloatResidue(10 + 2e-12), 11);
assert.equal(ceilIgnoringFloatResidue(200.001), 201);
assert.equal(ceilIgnoringFloatResidue(0.01), 1);
});
-test("army term uses an exact safe-integer square-root ceiling", () => {
- assert.equal(ceilSqrtProduct(2, 2), 2);
- assert.equal(ceilSqrtProduct(200, 200), 200);
- assert.equal(ceilSqrtProduct(2_000_000, 2_000_000), 2_000_000);
- assert.equal(ceilSqrtProduct(2_000_000, 1_999_999), 2_000_000);
- assert.throws(() => ceilSqrtProduct(1.5, 2), /safe integers/);
- assert.throws(() => ceilSqrtProduct(100_000_000, 100_000_000), /MAX_SAFE_INTEGER/);
-});
-
test("damage calculator does not over-count a whole-number army term", () => {
const exactSquareJob: DamageJob = {
...job,
roundStartTroops: {
@@ -163,32 +156,32 @@
assert.equal(Math.sqrt(200) * Math.sqrt(200), 200.00000000000003);
assert.equal(outcome.trace?.armyTerm, 200);
});
-test("damage calculator rounds positive damage upward to three decimal places", () => {
+test("damage calculator preserves full positive damage precision", () => {
const outcome = calculateIndexedDamageJob(
{ ...job, kind: "skill", sourceMultiplier: 1.23456 },
simpleFighters(),
[],
{ trace: true, minInitialArmy: 1000, capToTakerTroops: false }
);
assert.ok(Math.abs((outcome.trace?.damageBeforeOffsets ?? 0) - 12.3456) < 1e-12);
- assert.equal(outcome.trace?.rawDamage, 12.346);
- assert.equal(outcome.kills, 12.346);
+ assert.ok(Math.abs((outcome.trace?.rawDamage ?? 0) - 12.3456) < 1e-12);
+ assert.ok(Math.abs(outcome.kills - 12.3456) < 1e-12);
});
-test("damage rounding does not advance a value already on the three-decimal grid", () => {
+test("damage calculation does not quantize values near the three-decimal grid", () => {
const outcome = calculateIndexedDamageJob(
{ ...job, kind: "skill", sourceMultiplier: 2.007 },
simpleFighters(),
[],
{ trace: true, minInitialArmy: 10, capToTakerTroops: false }
);
assert.ok((outcome.trace?.damageBeforeOffsets ?? 0) * 1000 > 2007);
- assert.equal(outcome.trace?.rawDamage, 2.007);
- assert.equal(outcome.kills, 2.007);
+ assert.equal(outcome.trace?.rawDamage, outcome.trace?.damageBeforeOffsets);
+ assert.equal(outcome.kills, outcome.trace?.damageBeforeOffsets);
});
test("damage calculator uses centralized bucket definitions for player stat routing", () => {
const attacker: ResolvedFighter = {
@@ -414,16 +407,16 @@
const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
const combined = calculateIndexedDamageJob(
job,
fighters,
- [effect("active.hero.lethality.up", "attacker", 20, "hero_skill"), effect("active.troop.lethality.up", "attacker", 10, "troop_skill")],
+ [effect("active.hero.lethality.up", "attacker", 20, "hero_skill"), effect("type.single_target.damage.up", "attacker", 10, "troop_skill")],
{ trace: true }
);
assert.equal(combined.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 20);
- assert.equal(combined.trace?.atomicBuckets["active.troop.lethality.up"].totalPct, 10);
+ assert.equal(combined.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 10);
assert.equal(combined.trace?.aggregationGroups["active.hero.lethality.up"].factor, 1.2);
- assert.equal(combined.trace?.aggregationGroups["active.troop.lethality.up"].factor, 1.1);
+ assert.equal(combined.trace?.aggregationGroups["type.single_target.damage.up"].factor, 1.1);
assert.ok(
Math.abs(
(combined.trace?.damageBeforeOffsets ?? 0) -
(baseline.trace?.damageBeforeOffsets ?? 0) * 1.2 * 1.1
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -93,9 +93,10 @@
const STATIC_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(STATIC_BUCKETS);
const DYNAMIC_NEUTRAL_VALUES = Float64Array.from(DYNAMIC_BUCKETS.map(({ update }) => bucketNeutralValue(update)));
const STATIC_NEUTRAL_VALUES = Float64Array.from(STATIC_BUCKETS.map(({ update }) => bucketNeutralValue(update)));
const DAMAGE_SCALE_BUCKET = createEvaluatedDamageBucket(1 / 100);
-const DAMAGE_DECIMAL_SCALE = 1000;
+// Smallest tested power-of-ten tolerance that removes the verified residue.
+const CEILING_EPSILON = 1e-12;
/** Smaller side's initial army; battle-invariant. */
export function minInitialArmy(fighters: Record<SideId, ResolvedFighter>): number {
return Math.max(
@@ -113,8 +114,10 @@
staticDamageProfile: StaticDamageProfile;
scratch?: DamageScratch;
capToTakerTroops?: boolean;
usedEffects?: ActiveEffect[];
+ /** Effects whose primary mechanic actually won/applied; excludes duration-only siblings. */
+ primaryUsedEffects?: ActiveEffect[];
minInitialArmy?: number;
}
export function calculateDamageJob(
@@ -133,23 +136,25 @@
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 = ceilSqrtProduct(dealerTroops, initialArmy);
+ const armyTerm = ceilIgnoringFloatResidue(Math.sqrt(dealerTroops) * Math.sqrt(initialArmy));
const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();
applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);
applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
const usedEffects = options.usedEffects ?? [];
+ const primaryUsedEffects = options.primaryUsedEffects ?? usedEffects;
const jobSlot = damageJobSlot(job);
applyBucketEffects(
options.effectIndex.damageGroupsByJobShape[jobSlot],
options.effectIndex.liveEffectsByGroup,
job.round,
jobSlot,
buckets,
recording,
- usedEffects
+ usedEffects,
+ primaryUsedEffects
);
const damageBeforeOffsets = evaluateDamageExpressionForJob(job, buckets, staticProfile);
let offsetDamage = sumSlots(buckets.factors, DYNAMIC_EXPRESSIONS[job.kind].postSubtractSlots);
@@ -157,12 +162,13 @@
buckets,
job.round,
Math.max(0, damageBeforeOffsets - offsetDamage),
recording,
- usedEffects
+ usedEffects,
+ primaryUsedEffects
);
const unroundedDamage = Math.max(0, damageBeforeOffsets - offsetDamage);
- const rawDamage = ceilIgnoringFloatResidue(unroundedDamage * DAMAGE_DECIMAL_SCALE) / DAMAGE_DECIMAL_SCALE;
+ const rawDamage = unroundedDamage;
const uncappedKills = rawDamage;
const kills = options.capToTakerTroops === false ? uncappedKills : Math.min(takerTroops, uncappedKills);
return recording.finish({ job, factors: buckets.factors, armyTerm, damageBeforeOffsets, offsetDamage, rawDamage, kills });
}
@@ -173,9 +179,10 @@
round: number,
jobSlot: number,
buckets: NumericDamageBuckets,
recording: DamageJobRecorder,
- usedEffects: ActiveEffect[]
+ usedEffects: ActiveEffect[],
+ primaryUsedEffects: ActiveEffect[]
): void {
for (const group of groups) {
const dependency = group.requiredGroupOrdinalsByJobShape;
if (dependency && !requiredEffectIsApplicable(dependency, liveEffectsByGroup, round, jobSlot)) continue;
@@ -183,16 +190,16 @@
if (effects.length === 0) continue;
if (group.sameEffectStacking !== "max") {
for (const effect of effects) {
if (!advanceEffectAttackDelay(effect)) continue;
- applyBucketEffect(effect, round, buckets, recording, usedEffects);
+ applyBucketEffect(effect, round, buckets, recording, usedEffects, primaryUsedEffects);
}
continue;
}
if (effects.length === 1) {
const effect = effects[0];
if (advanceEffectAttackDelay(effect)) {
- applyBucketEffect(effect, round, buckets, recording, usedEffects);
+ applyBucketEffect(effect, round, buckets, recording, usedEffects, primaryUsedEffects);
}
continue;
}
let selected: ActiveEffect | undefined;
@@ -207,9 +214,9 @@
selectedValue = appliedValue;
}
}
if (selected) {
- applyBucketEffectGroup(selected, eligible, selectedValue, buckets, recording, usedEffects);
+ applyBucketEffectGroup(selected, eligible, selectedValue, buckets, recording, usedEffects, primaryUsedEffects);
}
}
}
@@ -249,9 +256,10 @@
effect: ActiveEffect,
round: number,
buckets: NumericDamageBuckets,
recording: DamageJobRecorder,
- usedEffects: ActiveEffect[]
+ usedEffects: ActiveEffect[],
+ primaryUsedEffects: ActiveEffect[]
): void {
if (isTurnShield(effect)) {
buckets.turnShield = effect;
return;
@@ -263,8 +271,9 @@
recording
);
if (appliedValue !== 0) {
usedEffects.push(effect);
+ if (primaryUsedEffects !== usedEffects && effect.triggerEffects?.length) primaryUsedEffects.push(effect);
} else {
recording.recordRejected(effect, "same_effect_max_superseded");
}
}
@@ -274,17 +283,19 @@
effects: ActiveEffect[],
selectedValue: number,
buckets: NumericDamageBuckets,
recording: DamageJobRecorder,
- usedEffects: ActiveEffect[]
+ usedEffects: ActiveEffect[],
+ primaryUsedEffects: ActiveEffect[]
): void {
if (isTurnShield(selected)) {
buckets.turnShield = selected;
buckets.turnShieldGroup = effects;
return;
}
const appliedValue = applySelectedBucket(selected, selectedValue, buckets, recording);
if (appliedValue !== 0) {
+ if (primaryUsedEffects !== usedEffects && selected.triggerEffects?.length) primaryUsedEffects.push(selected);
// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
for (const effect of effects) {
usedEffects.push(effect);
if (effect !== selected) recording.recordRejected(effect, "same_effect_max_suppressed");
@@ -302,9 +313,10 @@
buckets: NumericDamageBuckets,
round: number,
damage: number,
recording: DamageJobRecorder,
- usedEffects: ActiveEffect[]
+ usedEffects: ActiveEffect[],
+ primaryUsedEffects: ActiveEffect[]
): number {
const selected = buckets.turnShield;
if (!selected || damage <= 0) return 0;
const appliedValue = Math.min(selected.getCurrentValue(round), damage);
@@ -314,41 +326,27 @@
const group = buckets.turnShieldGroup;
if (!group) {
selected.initialValue = Math.max(0, selected.initialValue - appliedValue);
usedEffects.push(selected);
+ if (primaryUsedEffects !== usedEffects && selected.triggerEffects?.length) primaryUsedEffects.push(selected);
return appliedValue;
}
for (const effect of group) {
effect.initialValue = Math.max(0, effect.initialValue - appliedValue);
usedEffects.push(effect);
if (effect !== selected) recording.recordRejected(effect, "same_effect_max_suppressed");
}
+ if (primaryUsedEffects !== usedEffects && selected.triggerEffects?.length) primaryUsedEffects.push(selected);
return appliedValue;
}
-/**
- * Preserve the integer ceiling while ignoring a one-ULP overshoot from operations
- * such as sqrt(200) * sqrt(200) = 200.00000000000003.
- */
+/** Preserve genuine fractions while ignoring sub-epsilon floating-point residue. */
export function ceilIgnoringFloatResidue(value: number): number {
if (value === 0) return 0;
- return Math.ceil(value - Number.EPSILON * Math.max(1, value));
+ return Math.ceil(value - CEILING_EPSILON);
}
-export function ceilSqrtProduct(left: number, right: number): number {
- if (!Number.isSafeInteger(left) || left < 0 || !Number.isSafeInteger(right) || right < 0) {
- throw new RangeError(`ceilSqrtProduct requires non-negative safe integers; received ${left} and ${right}`);
- }
- const product = left * right;
- if (!Number.isSafeInteger(product)) {
- throw new RangeError(`ceilSqrtProduct product exceeds Number.MAX_SAFE_INTEGER: ${left} * ${right}`);
- }
-
- const root = Math.floor(Math.sqrt(product));
- return root * root >= product ? root : root + 1;
-}
-
function compileDamageTerms(definitions: readonly BucketSpec[]): DamageFactorTerm[] {
return definitions.map((definition, bucket) => ({
bucket,
jobSide: definition.jobSide,
Index: simulator/src/damageBuckets.ts
===================================================================
--- simulator/src/damageBuckets.ts prev run
+++ simulator/src/damageBuckets.ts this run
@@ -60,15 +60,15 @@
{ name: "active.troop.defense.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
{ name: "active.troop.defense.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
{ name: "active.troop.health.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },
{ name: "active.troop.health.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },
- { name: "active.troop.lethality.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
- { name: "active.troop.lethality.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
{ name: "active.troop.shield", jobSide: "taker", update: "add_raw", placement: "post_subtract", effectBucket: true },
{ name: "source.extraSkill", jobSide: "dealer", update: "assign_factor", placement: "numerator", damageKind: "skill" },
{ name: "troops.count", jobSide: "dealer", update: "assign_factor", placement: "numerator" },
{ name: "type.all.damage.down", jobSide: "dealer", update: "multiply_pct_factor", placement: "denominator", effectBucket: true },
{ name: "type.all.damage.up", jobSide: "dealer", update: "multiply_pct_factor", placement: "numerator", effectBucket: true },
+ { name: "type.single_target.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },
+ { name: "type.single_target.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },
{ name: "type.normal.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", damageKind: "normal", effectBucket: true },
{ name: "type.normal.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", damageKind: "normal", effectBucket: true },
{ name: "type.normal.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", damageKind: "normal", effectBucket: true },
{ name: "type.normal.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", damageKind: "normal", effectBucket: true },
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -34,8 +34,9 @@
liveEffectsByGroup: ActiveEffect[][];
controls: ActiveEffect[];
extraAttacks: ActiveEffect[];
battleOrder: ActiveEffect[];
+ carriers: ActiveEffect[];
}
export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;
@@ -48,9 +49,10 @@
effectGroups,
liveEffectsByGroup: effectGroups.map(() => []),
controls: [],
extraAttacks: [],
- battleOrder: []
+ battleOrder: [],
+ carriers: []
};
}
@@ -66,8 +68,12 @@
if (effect.kind === "battle_order") {
index.battleOrder.push(effect);
return;
}
+ if (effect.kind === "carrier") {
+ index.carriers.push(effect);
+ return;
+ }
const group = effect.effectGroup;
if (!group) throw new Error(`Runtime modifier ${effect.intent.id} has no prepared effect group`);
const live = index.liveEffectsByGroup[group.ordinal];
@@ -76,17 +82,18 @@
effect.bucketIndex = group.bucketIndex;
}
export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {
- if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;
- return dynamicBucketDefinition(effect.intent.type)?.effectBucket === true;
+ if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order" || effect.kind === "carrier") return true;
+ return effect.intent.type !== undefined && dynamicBucketDefinition(effect.intent.type)?.effectBucket === true;
}
export function expireEffectIndex(index: EffectIndex, effect: ActiveEffect): void {
effect.expired = true;
if (effect.kind === "control") removeStable(index.controls, effect);
else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);
else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);
+ else if (effect.kind === "carrier") removeStable(index.carriers, effect);
removeEffectGroupEntry(index, effect);
}
export function damageJobSlot(job: DamageJob): number {
@@ -108,9 +115,9 @@
return DYNAMIC_BUCKET_INDEX[bucket];
}
export function damageShapeSlotsForEffect(effect: ActiveEffect, bucketOverride?: DynamicDamageBucket): Uint8Array {
- const runtimeDefinition = bucketOverride === undefined ? dynamicBucketDefinition(effect.intent.type) : undefined;
+ const runtimeDefinition = bucketOverride === undefined && effect.intent.type !== undefined ? dynamicBucketDefinition(effect.intent.type) : undefined;
if (!bucketOverride && runtimeDefinition?.effectBucket !== true) {
return EMPTY_JOB_SHAPE_SLOTS;
}
const bucket = (bucketOverride ?? runtimeDefinition?.name) as DynamicDamageBucket | undefined;
Index: simulator/src/effects.test.ts
===================================================================
--- simulator/src/effects.test.ts prev run
+++ simulator/src/effects.test.ts this run
@@ -0,0 +1,45 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { activateEffect } from "./effects";
+import type { EffectDuration, ResolvedEffectIntentDefinition, ResolvedSkill } from "./types";
+
+const skill: ResolvedSkill = {
+ id: "SyntheticExtra",
+ name: "Synthetic Extra",
+ sourceKind: "hero_skill",
+ side: "attacker",
+ heroName: "Synthetic",
+ level: 1,
+ trigger: { type: "battle_start" },
+ effects: []
+};
+
+function extraAttackIntent(duration?: EffectDuration): ResolvedEffectIntentDefinition {
+ const sourceDefinition = {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "self.marksman", applies_vs: "enemy.any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ ...(duration === undefined ? {} : { duration })
+ } satisfies ResolvedEffectIntentDefinition["sourceDefinition"];
+
+ return { id: "SyntheticExtra/1", ...sourceDefinition, sourceDefinition };
+}
+
+test("extra skill attacks default to one turn and one attack", () => {
+ const effect = activateEffect(skill, extraAttackIntent(), 3);
+
+ assert.deepEqual(effect.duration, {
+ turns: { count: 1 },
+ attacks: { count: 1 }
+ });
+});
+
+test("explicit extra skill attack duration replaces the default", () => {
+ const twoAttackEffect = activateEffect(skill, extraAttackIntent({ attacks: { count: 2 } }), 3);
+ const permanentEffect = activateEffect(skill, extraAttackIntent({}), 3);
+
+ assert.deepEqual(twoAttackEffect.duration, { attacks: { count: 2 } });
+ assert.deepEqual(permanentEffect.duration, {});
+});
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -35,9 +35,16 @@
intentScoped: boolean;
}
const ACTIVATION_CACHE = new WeakMap<EffectIntentDefinition, CompiledActivation>();
-const INTENT_SCOPED_VALUES = new Set(["trigger.source", "trigger", "trigger.target", "target"]);
+const INTENT_SCOPED_VALUES = new Set([
+ "trigger.source",
+ "trigger",
+ "trigger.target",
+ "target",
+ "parent.use.source",
+ "parent.use.target"
+]);
export function oppositeSide(side: SideId): SideId {
return side === "attacker" ? "defender" : "attacker";
}
@@ -118,10 +125,10 @@
const units = intent.units ?? {};
const ownerSide = skill.side;
const staticAppliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", undefined, ownerSide);
const staticAppliesVs = resolveUnitScope(units.applies_vs, oppositeSide(staticAppliesTo.side), "applies_vs", undefined, ownerSide);
- const duration = normalizeDuration(intent.duration);
const effectKind = kindForIntent(intent);
+ const duration = normalizeDuration(intent.duration, effectKind);
if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {
throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);
}
const evolution = intent.value_evolution;
@@ -195,8 +202,9 @@
duration: compiled.duration,
remainingAttackDelay: compiled.attackDelay,
uses: 0,
sameEffectStacking: compiled.sameEffectStacking,
+ triggerEffects: intent.triggerEffects,
effectGroup: intent.effectGroupsByScopeKey?.[resolvedEffectScopeKey(appliesTo, appliesVs)],
effectGroupPosition: undefined
};
if (!compiled.valueEvolution) return effect;
@@ -311,12 +319,12 @@
): ResolvedUnitScope {
if (role === "applies_vs" && value === "all") {
throw new Error('effect units.applies_vs cannot be "all"; use "any" for an unrestricted usage gate');
}
- if ((value === "trigger.source" || value === "trigger") && attackIntent) {
+ if ((value === "trigger.source" || value === "trigger" || value === "parent.use.source") && attackIntent) {
return { side: attackIntent.dealerSide, units: unitMask(attackIntent.dealerUnit) };
}
- if ((value === "trigger.target" || value === "target") && attackIntent) {
+ if ((value === "trigger.target" || value === "target" || value === "parent.use.target") && attackIntent) {
return { side: attackIntent.takerSide, units: unitMask(attackIntent.takerUnit) };
}
if (ownerSide && isRelationQualifiedSelector(value)) {
const selector = parseTriggerSelector(value, "self");
@@ -328,26 +336,31 @@
}
function normalizeUnitList(value: unknown): UnitType[] | undefined {
if (Array.isArray(value)) return value.map((entry) => normalizeUnitType(String(entry)));
- if (typeof value === "string" && !["any", "target", "all", "trigger", "trigger.source", "trigger.target", "friendly"].includes(value)) return [normalizeUnitType(value)];
+ if (typeof value === "string" && !["any", "target", "all", "trigger", "trigger.source", "trigger.target", "parent.use.source", "parent.use.target", "friendly"].includes(value)) return [normalizeUnitType(value)];
return undefined;
}
function isRelationQualifiedSelector(value: unknown): value is string {
return typeof value === "string" && (value === "self" || value === "enemy" || value.startsWith("self.") || value.startsWith("enemy."));
}
function kindForIntent(intent: EffectIntentDefinition): ActiveEffectKind {
+ if (intent.type === undefined) return "carrier";
if (intent.type === "active.hero.shield" || intent.type === "active.troop.shield") return "shield";
if (intent.type === "extra_skill_attack") return "extra_attack";
if (intent.type === "dodge" || intent.type === "no_attack") return "control";
if (intent.type === "attack_order") return "battle_order";
return "modifier";
}
-function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {
- if (!duration) return {};
+function normalizeDuration(duration: EffectIntentDefinition["duration"], effectKind: ActiveEffectKind): EffectDuration {
+ if (duration === undefined) {
+ return effectKind === "extra_attack"
+ ? { turns: { count: 1 }, attacks: { count: 1 } }
+ : {};
+ }
return {
...(duration.turns ? { turns: normalizeDurationAxis(duration.turns) } : {}),
...(duration.attacks ? { attacks: normalizeDurationAxis(duration.attacks) } : {})
};
Index: simulator/src/extraAttacks.ts
===================================================================
--- simulator/src/extraAttacks.ts prev run
+++ simulator/src/extraAttacks.ts this run
@@ -13,9 +13,11 @@
import { normalizeUnitType } from "./normalize";
import {
capJobToRemainingTarget,
chargeEffectUse,
- chargeUsedEffects,
+ chargeUsedEffectsForJob,
+ effectUseIntent,
+ materializeTriggeredEffects,
targetExhausted,
type DamageJobResult,
type RunLoopOptions,
type Runtime
@@ -47,8 +49,9 @@
);
for (const effect of effects) {
const sourceEffectId = effect.source.effectId ?? effect.intent.id;
let processedJobCount = 0;
+ let firstProcessedJob: DamageJob | undefined;
for (const definition of effect.triggerDamageJobs ?? []) {
const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);
const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);
const multiplier = effect.getCurrentValue(round) / 100;
@@ -73,16 +76,18 @@
recorder.recordScheduledDamageJob(job);
const result = calculateDamageJob(job, fighters, damageJobOptions);
if (loopOptions.capRoundKills) capJobToRemainingTarget(result, job, roundStartTroops, roundTargetDamage, recorder);
processedJobCount += 1;
+ firstProcessedJob ??= job;
runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;
results.push({ job, result, intent });
triggeredKills += result.kills;
- chargeUsedEffects(runtime);
+ chargeUsedEffectsForJob(runtime, job, recorder);
}
}
}
if (processedJobCount > 0) {
+ materializeTriggeredEffects(effect, round, effectUseIntent(firstProcessedJob!), runtime, recorder);
chargeEffectUse(runtime, effect);
recorder.recordExtraAttack(normalAttack, effect, processedJobCount);
}
}
@@ -109,10 +114,10 @@
effect: ActiveEffect,
normalAttack: DamageJob,
roundStartTroops: DamageJob["roundStartTroops"]
): TriggerJobUnit[] {
- if (selector === "use.source") return [{ side: normalAttack.dealerSide, unit: normalAttack.dealerUnit }];
- if (selector === "use.target") return [{ side: normalAttack.takerSide, unit: normalAttack.takerUnit }];
+ if (selector === "use.source" || selector === "parent.use.source") return [{ side: normalAttack.dealerSide, unit: normalAttack.dealerUnit }];
+ if (selector === "use.target" || selector === "parent.use.target") return [{ side: normalAttack.takerSide, unit: normalAttack.takerUnit }];
if (selector === "effect.applies_to") return unitsFromMask(effect.appliesTo.units).map((unit) => ({ side: effect.appliesTo.side, unit }));
if (selector === "effect.applies_vs") return unitsFromMask(effect.appliesVs.units).map((unit) => ({ side: effect.appliesVs.side, unit }));
if (selector === "enemy.living") return livingUnits(normalAttack.takerSide, roundStartTroops);
if (selector === "self.living") return livingUnits(normalAttack.dealerSide, roundStartTroops);
Index: simulator/src/fighterResolution.ts
===================================================================
--- simulator/src/fighterResolution.ts prev run
+++ simulator/src/fighterResolution.ts this run
@@ -104,8 +104,17 @@
// Build-time scaffolding: fold each MAIN hero's generation-stat block into the stat block for
// that hero's troop type, so the simulator can treat FighterInput.stats as authoritative.
export function applyHeroGenerationStats(input: FighterInput, config: SimulatorConfig): FighterInput {
+ return adjustHeroGenerationStats(input, config, 1);
+}
+
+/** Remove already-included main-hero generation stats to recover a hero-neutral input. */
+export function removeHeroGenerationStats(input: FighterInput, config: SimulatorConfig): FighterInput {
+ return adjustHeroGenerationStats(input, config, -1);
+}
+
+function adjustHeroGenerationStats(input: FighterInput, config: SimulatorConfig, direction: 1 | -1): FighterInput {
const stats = resolveInputStatBonuses(input.stats);
for (const instance of heroInputInstances(input)) {
if (instance.role !== "main") continue;
const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
@@ -118,9 +127,15 @@
if (!definition.troop_type) {
throw new Error(`Hero ${definition.name ?? instance.name} has hero_generation ${generation} but no troop_type`);
}
const unit = normalizeUnitType(String(definition.troop_type));
- stats[unit] = addStats(stats[unit], normalizeStatBlock(generationStats as Record<string, unknown>));
+ const adjustment = normalizeStatBlock(generationStats as Record<string, unknown>);
+ stats[unit] = addStats(stats[unit], {
+ attack: direction * adjustment.attack,
+ defense: direction * adjustment.defense,
+ lethality: direction * adjustment.lethality,
+ health: direction * adjustment.health
+ });
}
return { ...input, stats };
}
@@ -269,17 +284,9 @@
troopType?: UnitType,
heroInstanceId?: string,
heroRole?: "main" | "joiner"
): ResolvedSkill {
- const effects: ResolvedEffectIntentDefinition[] = [];
- for (const [effectId, effect] of Object.entries(rawSkill.effects ?? {})) {
- effects.push({
- id: effectId,
- ...effect,
- value: levelSelectPreservingOrder(effect.value, level),
- sourceDefinition: effect
- });
- }
+ const effects = hydrateEffects(rawSkill.effects ?? {}, level);
return {
id: skillId,
name: skillId,
sourceKind,
@@ -293,8 +300,26 @@
effects
};
}
+function hydrateEffects(
+ definitions: Record<string, Omit<EffectIntentDefinition, "id">>,
+ level: number
+): ResolvedEffectIntentDefinition[] {
+ const hydrated: ResolvedEffectIntentDefinition[] = [];
+ for (const [effectId, effect] of Object.entries(definitions)) {
+ const resolved: ResolvedEffectIntentDefinition = {
+ id: effectId,
+ ...effect,
+ value: levelSelectPreservingOrder(effect.value, level),
+ sourceDefinition: effect
+ };
+ if (effect.trigger_effects) resolved.triggerEffects = hydrateEffects(effect.trigger_effects, level);
+ hydrated.push(resolved);
+ }
+ return hydrated;
+}
+
function levelSelectPreservingOrder(value: unknown, level: number): unknown {
if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value;
return valueAtLevel(value, level);
}
Index: simulator/src/recorder.ts
===================================================================
--- simulator/src/recorder.ts prev run
+++ simulator/src/recorder.ts this run
@@ -107,9 +107,10 @@
recordBattleOrder(intent: AttackIntent, effect: ActiveEffect, chosenTarget: UnitType): void;
recordScheduledDamageJob(job: DamageJob): void;
/** One extra_skill_attack firing on a normal attack; spawnedJobCount is the number of its jobs that ran (always > 0). */
recordExtraAttack(normalJob: DamageJob, effect: ActiveEffect, spawnedJobCount: number): void;
- recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "dodge" | "no_attack"): void;
+ recordDodged(intent: AttackIntent, normalJob: DamageJob, effect: ActiveEffect): void;
+ recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "no_attack"): void;
recordDamageJob(job: DamageJob, result: DamageResult, intent?: AttackIntent): void;
recordFinalKills(result: DamageResult): void;
recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[]): void;
readonly attacks: AttackOutcome[];
@@ -145,8 +146,9 @@
recordSkillEffectActivated() {}
recordSkillDamageJob() {}
recordScheduledDamageJob() {}
recordExtraAttack() {}
+ recordDodged() {}
recordCancelled() {}
recordDamageJob() {}
recordFinalKills() {}
recordRound() {}
@@ -250,9 +252,9 @@
if (events) events.push(event);
else this.extraAttackEvents.set(normalJob, [event]);
}
- recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "dodge" | "no_attack"): void {
+ recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "no_attack"): void {
const order = this.orderEvents.get(intent);
this.orderEvents.delete(intent);
const control = this.effectEvent(effect, intent.round, { kind: "control", reason });
const appliedEffects = order ? [order, control] : [control];
@@ -269,8 +271,33 @@
cancelReason: reason
});
}
+ recordDodged(intent: AttackIntent, normalJob: DamageJob, effect: ActiveEffect): void {
+ const order = this.orderEvents.get(intent);
+ this.orderEvents.delete(intent);
+ const control = this.effectEvent(effect, intent.round, { kind: "control", reason: "dodge" });
+ const extras = this.extraAttackEvents.get(normalJob);
+ this.extraAttackEvents.delete(normalJob);
+ const appliedEffects = [
+ ...(order ? [order] : []),
+ control,
+ ...(extras ?? [])
+ ];
+ this.attacks.push({
+ round: intent.round,
+ kind: "normal",
+ dealerSide: intent.dealerSide,
+ dealerUnit: intent.dealerUnit,
+ takerSide: intent.takerSide,
+ takerUnit: intent.takerUnit,
+ kills: 0,
+ ...(this.detailedAttackEffects ? { counterDeltas: counterDeltas(intent, "normal_attack") } : {}),
+ appliedEffects,
+ dodged: true
+ });
+ }
+
recordDamageJob(job: DamageJob, result: DamageResult, intent?: AttackIntent): void {
const sourceSkillReport = this.skillDamageReports.get(job);
this.skillDamageReports.delete(job);
if (job.kind === "skill" && sourceSkillReport && result.kills > 0) {
@@ -280,9 +307,8 @@
if (intent && job.kind === "normal") this.orderEvents.delete(intent);
const extras = this.extraAttackEvents.get(job);
this.extraAttackEvents.delete(job);
const appliedEffects = mergeAppliedEffects(result.appliedEffects, order, extras);
- const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";
this.attacks.push({
round: job.round,
kind: job.kind,
sourceEffectId: job.sourceEffectId,
@@ -290,9 +316,9 @@
dealerUnit: job.dealerUnit,
takerSide: job.takerSide,
takerUnit: job.takerUnit,
kills: result.kills,
- ...(this.detailedAttackEffects ? { counterDeltas: counterDeltas(job, cause) } : {}),
+ ...(this.detailedAttackEffects && job.kind === "normal" ? { counterDeltas: counterDeltas(job, "normal_attack") } : {}),
...(appliedEffects.length ? { appliedEffects } : {}),
trace: result.trace
});
}
@@ -401,9 +427,9 @@
const identity = {
effectId: effectId(effect),
source: sourceLabel(effect),
sourceSide: effect.ownerSide,
- bucket: effect.intent.type,
+ bucket: effect.intent.type ?? "(carrier)",
sameEffectStacking: effect.sameEffectStacking
};
if (effect.kind === "shield") {
this.contributors[effect.bucketIndex]?.push({ ...identity, value });
@@ -728,9 +754,9 @@
function appliedEffectSummary(effect: ActiveEffect, value: number): AppliedEffect {
const identity = {
effectId: effectId(effect),
sourceSide: effect.ownerSide,
- bucket: effect.intent.type
+ bucket: effect.intent.type ?? "(carrier)"
};
return effect.kind === "shield" ? { ...identity, value } : { ...identity, valuePct: value };
}
Index: simulator/src/runtime.ts
===================================================================
--- simulator/src/runtime.ts prev run
+++ simulator/src/runtime.ts this run
@@ -30,8 +30,9 @@
expireEffectsByRound: Array<ActiveEffect[] | undefined>;
// Per-job scratch: effects that affected the job being calculated; drained by
// chargeUsedEffects (uses += 1 each) after every job in every mode.
usedEffects: ActiveEffect[];
+ primaryUsedEffects: ActiveEffect[];
staticDamageProfile: StaticDamageProfile;
damageScratch: DamageScratch;
rng: Rng;
skills: RuntimeSkills;
@@ -72,8 +73,9 @@
),
activateEffectsByRound: [],
expireEffectsByRound: [],
usedEffects: [],
+ primaryUsedEffects: [],
staticDamageProfile,
damageScratch: createDamageScratch(),
rng,
skills,
@@ -161,29 +163,43 @@
runtime: Runtime,
recorder: BattleRecorder,
intent: AttackIntent
): DeferredEffectPlan[] | undefined {
- let deferred: DeferredEffectPlan[] | undefined;
+ let deferredEffects: DeferredEffectPlan[] | undefined;
for (const prepared of preparedSkills) {
const { skill } = prepared;
- if (!skillMatchesTrigger(skill, "attack_declared", round, intent)) continue;
+ if (!preparedAttackFrequencyMatches(prepared, intent)) continue;
recorder.recordSkillTriggerAttempt(skill);
- if (!chancePasses(skill, runtime.rng)) continue;
+ if (!preparedChancePasses(prepared.probabilityPct, runtime.rng)) continue;
recorder.recordSkillTriggered(skill);
for (const effectIntent of prepared.immediateEffects) {
const effect = activateEffect(skill, effectIntent, round, intent);
addActiveEffect(runtime, effect);
runtime.effectActivationCounts[skill.side] += 1;
recorder.recordSkillEffectActivated(skill);
}
if (prepared.deferredEffects) {
- if (deferred) deferred.push(...prepared.deferredEffects);
- else deferred = prepared.deferredEffects.slice();
+ if (deferredEffects) deferredEffects.push(...prepared.deferredEffects);
+ else deferredEffects = prepared.deferredEffects.slice();
}
}
- return deferred;
+ return deferredEffects;
}
+function preparedAttackFrequencyMatches(prepared: PreparedAttackSkill, intent: AttackIntent): boolean {
+ const every = prepared.everyAttack;
+ if (every === undefined) return true;
+ const count = intent.projectedAttackCount;
+ const first = prepared.firstAttack ?? every;
+ return count >= first && (count - first) % every === 0;
+}
+
+export function preparedChancePasses(probabilityPct: number, rng: Rng): boolean {
+ if (probabilityPct <= 0) return false;
+ if (probabilityPct >= 100) return true;
+ return rng() < probabilityPct / 100;
+}
+
export function materializeDeferredEffects(
plans: DeferredEffectPlan[] | undefined,
round: number,
intent: AttackIntent,
@@ -240,20 +256,69 @@
roundTargetDamage[job.takerSide][job.takerUnit] += result.kills;
recorder.recordFinalKills(result);
}
-// Apply the round's effects to fighter state: remove killed troops and bump attack/received
-// counters. Every declared attack (each damage job and each cancelled attack) counts as one
-// attack for its attacker unit and one received for its defender unit.
-
// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in
// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.
export function chargeUsedEffects(runtime: Runtime): void {
if (runtime.usedEffects.length === 0) return;
for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);
runtime.usedEffects.length = 0;
+ runtime.primaryUsedEffects.length = 0;
}
+/** Activate a parent's keyed children from the concrete source/target of its actual use. */
+export function materializeTriggeredEffects(
+ parent: ActiveEffect,
+ round: number,
+ useIntent: AttackIntent,
+ runtime: Runtime,
+ recorder: BattleRecorder
+): void {
+ const skill = parent.sourceSkill;
+ if (!skill || !parent.triggerEffects?.length) return;
+ for (const childIntent of parent.triggerEffects) {
+ const child = activateEffect(skill, childIntent, round, useIntent);
+ addActiveEffect(runtime, child);
+ runtime.effectActivationCounts[skill.side] += 1;
+ recorder.recordSkillEffectActivated(skill);
+ }
+}
+
+/** Materialize selected parents' children after the job, then charge all participating effects. */
+export function chargeUsedEffectsForJob(
+ runtime: Runtime,
+ job: DamageJob,
+ recorder: BattleRecorder
+): void {
+ if (runtime.usedEffects.length === 0) return;
+ if (runtime.primaryUsedEffects.length > 0) {
+ const useIntent = effectUseIntent(job);
+ for (const effect of runtime.primaryUsedEffects) {
+ materializeTriggeredEffects(effect, job.round, useIntent, runtime, recorder);
+ }
+ }
+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);
+ runtime.usedEffects.length = 0;
+ runtime.primaryUsedEffects.length = 0;
+}
+
+export function effectUseIntent(job: DamageJob): AttackIntent {
+ return {
+ round: job.round,
+ source: "normal",
+ dealerSide: job.dealerSide,
+ dealerUnit: job.dealerUnit,
+ takerSide: job.takerSide,
+ takerUnit: job.takerUnit,
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 0,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 0
+ };
+}
+
export function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {
effect.uses += 1;
const limit = effectAttackUseLimit(effect);
if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);
@@ -263,6 +328,6 @@
if (effect.expired) return;
expireEffectIndex(runtime.effectIndex, effect);
}
-// A cancelled attack still charges the attacker's attack-constrained effects (the attack
-// happened, it just didn't land) plus the control that cancelled it, whatever its duration.
+// A control can still consume applicable attack-duration effects for duration bookkeeping,
+// but this duration-only charge neither advances normal cadence nor materializes children.
Index: simulator/src/runtimeSkills.ts
===================================================================
--- simulator/src/runtimeSkills.ts prev run
+++ simulator/src/runtimeSkills.ts this run
@@ -21,10 +21,9 @@
export interface RuntimeSkills {
// Chance-free static-passive skills activated once at prepare time, before any runtime exists.
preBattle: ResolvedSkill[];
battleStart: ResolvedSkill[];
- roundStartGlobal: ResolvedSkill[];
- roundStartPerUnit: ResolvedSkill[];
+ roundStart: PreparedRoundSkill[];
attackDeclaredByJobShape: Array<PreparedAttackSkill[] | undefined>;
effectGroups: ActiveEffectGroup[];
damageGroupsByJobShape: ActiveEffectGroup[][];
randomness: BattleRandomness;
@@ -38,16 +37,25 @@
export interface PreparedAttackSkill {
skill: ResolvedSkill;
immediateEffects: ResolvedEffectIntentDefinition[];
deferredEffects?: DeferredEffectPlan[];
+ probabilityPct: number;
+ everyAttack?: number;
+ firstAttack?: number;
}
+export interface PreparedRoundSkill {
+ skill: ResolvedSkill;
+ probabilityPct: number;
+ everyTurn?: number;
+ firstTurn?: number;
+}
+
export function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {
const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);
const preBattle: ResolvedSkill[] = [];
const battleStart: ResolvedSkill[] = [];
- const roundStartGlobal: ResolvedSkill[] = [];
- const roundStartPerUnit: ResolvedSkill[] = [];
+ const roundStart: PreparedRoundSkill[] = [];
const attackDeclaredByJobShape: Array<PreparedAttackSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });
const chanceSkillIds: Record<SideId, string[]> = { attacker: [], defender: [] };
const effectGroups: ActiveEffectGroup[] = [];
const damageGroupsByJobShape: ActiveEffectGroup[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => []);
@@ -57,8 +65,9 @@
const groupTablesByDefinition: Record<SideId, Map<object, Array<ActiveEffectGroup | undefined>>> = {
attacker: new Map(),
defender: new Map()
};
+ const useContextIntentsBySkill = new Map<ResolvedSkill, AttackIntent[]>();
for (const skill of all) {
if (skill.trigger.type === "pre_battle") {
preBattle.push(skill);
@@ -70,15 +79,35 @@
if (skill.trigger.type === "battle_start") {
battleStart.push(skill);
} else if (skill.trigger.type === "turn") {
- (hasPerUnitRoundTrigger(skill) ? roundStartPerUnit : roundStartGlobal).push(skill);
+ roundStart.push({
+ skill,
+ probabilityPct: trigger.probabilityPct,
+ ...(skill.trigger.every === undefined
+ ? {}
+ : {
+ everyTurn: skill.trigger.every,
+ firstTurn: skill.trigger.first ?? skill.trigger.every
+ })
+ });
} else if (skill.trigger.type === "attack") {
- const immediateEffects = skill.effects.filter((intent) => intent.value_formula === undefined);
+ const immediateEffects: ResolvedEffectIntentDefinition[] = [];
const deferredIntents = skill.effects.filter((intent) => intent.value_formula !== undefined);
+ for (const intent of skill.effects) {
+ if (intent.value_formula !== undefined) continue;
+ immediateEffects.push(intent);
+ }
const prepared: PreparedAttackSkill = {
skill,
immediateEffects,
+ probabilityPct: trigger.probabilityPct,
+ ...(skill.trigger.every === undefined
+ ? {}
+ : {
+ everyAttack: skill.trigger.every,
+ firstAttack: skill.trigger.first ?? skill.trigger.every
+ }),
...(deferredIntents.length > 0
? { deferredEffects: deferredIntents.map((intent) => ({ skill, intent })) }
: {})
};
@@ -91,10 +120,10 @@
}
}
}
- for (const intent of skill.effects) {
- const definition = dynamicBucketDefinition(intent.type);
+ for (const intent of walkEffects(skill.effects)) {
+ const definition = intent.type === undefined ? undefined : dynamicBucketDefinition(intent.type);
if (definition?.effectBucket !== true) continue;
const existingTable = groupTablesByDefinition[skill.side].get(intent.sourceDefinition);
if (existingTable) {
intent.effectGroupsByScopeKey = existingTable;
@@ -102,19 +131,19 @@
}
const groupsByScopeKey: Array<ActiveEffectGroup | undefined> = [];
const groupsForDefinition: ActiveEffectGroup[] = [];
const slotsForDefinition: Uint8Array[] = [];
- for (const attackIntent of potentialActivationIntents(skill)) {
- const candidate = activateEffect(skill, intent, 1, attackIntent);
- const scopeKey = resolvedEffectScopeKey(candidate.appliesTo, candidate.appliesVs);
+ for (const attackIntent of potentialActivationIntents(skill, intent, useContextIntentsBySkill)) {
+ const effect = activateEffect(skill, intent, 1, attackIntent);
+ const scopeKey = resolvedEffectScopeKey(effect.appliesTo, effect.appliesVs);
if (groupsByScopeKey[scopeKey]) continue;
- const slots = damageShapeSlotsForEffect(candidate, definition.name);
+ const slots = damageShapeSlotsForEffect(effect, definition.name);
if (slots.length === 0) continue;
- if (candidate.sameEffectStacking === "max") assertDisjointResolvedGroupSlots(intent.id, slots, slotsForDefinition);
+ if (effect.sameEffectStacking === "max") assertDisjointResolvedGroupSlots(intent.id, slots, slotsForDefinition);
const group: ActiveEffectGroup = {
ordinal: effectGroups.length,
bucketIndex: damageBucketIndex(definition.name),
- sameEffectStacking: candidate.sameEffectStacking
+ sameEffectStacking: effect.sameEffectStacking
};
groupMetadata.set(group, {
ownerSide: skill.side,
effectId: intent.id,
@@ -134,10 +163,9 @@
linkRequiredEffectGroups(effectGroups, damageGroupsByJobShape, groupMetadata);
return {
preBattle,
battleStart,
- roundStartGlobal,
- roundStartPerUnit,
+ roundStart,
attackDeclaredByJobShape,
effectGroups,
damageGroupsByJobShape,
randomness: {
@@ -176,12 +204,8 @@
group.requiredGroupOrdinalsByJobShape = byJobShape;
}
}
-function hasPerUnitRoundTrigger(skill: ResolvedSkill): boolean {
- return skill.trigger.type === "turn" && skill.trigger.source !== undefined;
-}
-
function assertDisjointResolvedGroupSlots(effectId: string, slots: Uint8Array, existingGroups: Iterable<Uint8Array>): void {
for (const existing of existingGroups) {
for (const slot of slots) {
if (existing.includes(slot)) {
@@ -193,10 +217,27 @@
// Enumerate the attack-intent shapes this skill's trigger can ever activate with, so
// preparation can pre-create an effect group for every reachable resolved scope. The
// activations built from these intents are throwaway scope probes, never indexed.
-function potentialActivationIntents(skill: ResolvedSkill): Array<AttackIntent | undefined> {
- if (skill.trigger.type === "battle_start" || (skill.trigger.type === "turn" && skill.trigger.source === undefined)) return [undefined];
+function potentialActivationIntents(
+ skill: ResolvedSkill,
+ effect: ResolvedEffectIntentDefinition,
+ useContextIntentsBySkill: Map<ResolvedSkill, AttackIntent[]>
+): Array<AttackIntent | undefined> {
+ const units = effect.units ?? {};
+ const selectors = [units.applies_to, units.applies_vs];
+ const needsUseContext = selectors.some((selector) =>
+ selector === "trigger.source" || selector === "trigger" || selector === "trigger.target" || selector === "target" ||
+ selector === "parent.use.source" || selector === "parent.use.target"
+ );
+ if (!needsUseContext) return NO_USE_CONTEXT_INTENTS;
+ const cached = useContextIntentsBySkill.get(skill);
+ if (cached) return cached;
+ if (skill.trigger.type === "battle_start" || skill.trigger.type === "turn") {
+ const intents = allOwnerAttackIntents(skill.side);
+ useContextIntentsBySkill.set(skill, intents);
+ return intents;
+ }
const trigger = compiledTriggerForSkill(skill);
const dealerSide = trigger.source.side;
let takerSide = trigger.target.side;
if (skill.trigger.type === "turn" && takerSide === dealerSide) takerSide = oppositeSide(dealerSide);
@@ -220,6 +261,41 @@
projectedReceivedAttackCount: 1
});
}
}
+ useContextIntentsBySkill.set(skill, intents);
return intents;
}
+
+const NO_USE_CONTEXT_INTENTS: Array<undefined> = [undefined];
+
+function allOwnerAttackIntents(ownerSide: SideId): AttackIntent[] {
+ const other = oppositeSide(ownerSide);
+ const intents: AttackIntent[] = [];
+ for (const [dealerSide, takerSide] of [[ownerSide, other], [other, ownerSide]] as const) {
+ for (const dealerUnit of unitsFromMask(7)) {
+ for (const takerUnit of unitsFromMask(7)) {
+ intents.push({
+ round: 1,
+ source: "normal",
+ dealerSide,
+ dealerUnit,
+ takerSide,
+ takerUnit,
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 1,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 1
+ });
+ }
+ }
+ }
+ return intents;
+}
+
+function* walkEffects(effects: ResolvedEffectIntentDefinition[]): Iterable<ResolvedEffectIntentDefinition> {
+ for (const effect of effects) {
+ yield effect;
+ if (effect.triggerEffects) yield* walkEffects(effect.triggerEffects);
+ }
+}
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -3,14 +3,16 @@
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { BattleInputBuilder } from "./battleInputBuilder";
+import { buildSimulatorConfig } from "./config";
import { loadSimulatorConfig } from "./config-node";
-import { createSeededRng, chancePasses, compiledTriggerForSkill } from "./effects";
+import { createSeededRng, chancePasses } from "./effects";
import { applyHeroGenerationStats, resolveFighter } from "./fighterResolution";
import { prepareBattle, runPrepared, simulateBattles, simulateBearBattle, signedRemainingScore } from "./simulator";
import { BEAR_TROOP_ID, createTroopStatsRecord, generateTroopStatsCatalogue } from "./troopStats";
import type { AppliedEffect, BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, TroopStatsCatalogue, UnitType } from "./types";
+import { UNIT_TYPES } from "./types";
function hasEffectKind(effect: AppliedEffect, kind: string): boolean {
return "kind" in effect && effect.kind === kind;
}
@@ -372,8 +374,38 @@
assert.equal(result.trace?.rounds[0]?.roundStartTroops.defender.infantry, 1);
assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);
});
+test("normal attacks execute troop-major, alternating attacker and defender within each troop type", () => {
+ const result = runOnce(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1_000, lancer_t1: 1_000, marksman_t1: 1_000 },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t1: 1_000, lancer_t1: 1_000, marksman_t1: 1_000 },
+ heroes: {}
+ }
+ },
+ minimalConfig(),
+ { mode: "trace" }
+ );
+
+ assert.deepEqual(
+ result.trace?.rounds[0]?.intents.map((intent) => [intent.dealerSide, intent.dealerUnit]),
+ [
+ ["attacker", "infantry"],
+ ["defender", "infantry"],
+ ["attacker", "lancer"],
+ ["defender", "lancer"],
+ ["attacker", "marksman"],
+ ["defender", "marksman"]
+ ]
+ );
+});
+
test("runPrepared skips later attacks against same-round exhausted targets only", () => {
const result = runOnce(
{
maxRounds: 1,
@@ -463,9 +495,9 @@
const result = runOnce(input!, config, { mode: "trace" });
assert.equal(totalRemaining(result.remaining.attacker) - totalRemaining(result.remaining.defender), -186);
assert.equal(result.remaining.defender.lancer, 186);
- assert.equal(result.trace?.rounds[1]?.roundStartTroops.defender.lancer.toFixed(6), "198.013000");
+ assert.equal(result.trace?.rounds[1]?.roundStartTroops.defender.lancer.toFixed(6), "198.013242");
});
test("runPrepared defaults to a 1500 round cap when no explicit maxRounds is provided", () => {
const result = runOnce(
@@ -479,33 +511,34 @@
assert.equal(result.winner, "draw");
assert.equal(result.rounds, 1500);
});
-test("round-start trigger source self.any rolls once then creates one target-locked effect per live unit type", () => {
+test("one turn-scoped extra attack effect serves every matching normal attack without recursion", () => {
const result = runOnce(
{
maxRounds: 1,
attacker: {
- troops: { infantry_t1: 100, lancer_t1: 100 },
- heroes: { PerUnit: { skill_1: 1 } }
+ troops: { infantry_t1: 1000000, lancer_t1: 1000000, marksman_t1: 1000000 },
+ heroes: { AllTroops: { skill_1: 1 } }
},
defender: {
- troops: { lancer_t1: 100 },
+ troops: { infantry_t1: 1000000, lancer_t1: 1000000, marksman_t1: 1000000 },
heroes: {}
}
},
minimalConfig({
- PerUnit: {
- name: "PerUnit",
+ AllTroops: {
+ name: "AllTroops",
skills: {
- RoundFanout: {
- trigger: { type: "turn", source: "self.any" },
+ SharedTurnExtra: {
+ trigger: { type: "turn" },
effects: {
- buff: {
- type: "active.hero.lethality.up",
+ extra: {
+ type: "extra_skill_attack",
value: 100,
- units: { applies_to: "trigger", applies_vs: "target" },
- duration: { turns: { count: 1 } }
+ units: { applies_to: "self.any", applies_vs: "enemy.any" },
+ duration: { turns: { count: 1 } },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
}
}
}
}
@@ -513,12 +546,18 @@
}),
{ mode: "trace" }
);
- const report = result.skillReport.attacker.find((entry) => entry.skillId === "RoundFanout");
+ const report = result.skillReport.attacker.find((entry) => entry.skillId === "SharedTurnExtra");
assert.equal(report?.triggersSeen, 1);
assert.equal(report?.skillActivations, 1);
- assert.equal(report?.effectActivations, 2);
+ assert.equal(report?.effectActivations, 1);
+ assert.deepEqual(
+ result.attacks
+ .filter((attack) => attack.kind === "skill" && attack.dealerSide === "attacker" && attack.sourceEffectId === "extra")
+ .map((attack) => attack.dealerUnit),
+ UNIT_TYPES
+ );
});
test("failed probability gates are recorded as trigger attempts only in trace mode", () => {
const input: BattleInput = {
@@ -630,15 +669,21 @@
}
}
},
NightmareTrace: {
- trigger: { type: "turn", every: 2, source: "lancer" },
+ trigger: { type: "turn", every: 2 },
effects: {
mark: {
- type: "active.hero.defense.down",
- value: 100,
- units: { applies_to: "target", applies_vs: "lancer" },
- duration: { turns: { count: 1, delay: 1 }, attacks: { count: 1 } }
+ units: { applies_to: "self.lancer", applies_vs: "enemy.infantry" },
+ duration: { turns: { count: 1 }, attacks: { count: 1 } },
+ trigger_effects: {
+ child: {
+ type: "active.hero.defense.down",
+ value: 100,
+ units: { applies_to: "parent.use.target", applies_vs: "parent.use.source" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
}
}
}
}
@@ -872,57 +917,98 @@
});
test("runPrepared reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
- const config = loadSimulatorConfig();
+ const config = minimalConfig({
+ Reporter: {
+ name: "Reporter",
+ skills: {
+ Strike: {
+ trigger: { type: "battle_start" },
+ effects: {
+ extra: {
+ type: "extra_skill_attack",
+ value: 10,
+ units: { applies_to: "self.marksman", applies_vs: "enemy.infantry" },
+ duration: { attacks: { count: 1 } },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ }
+ }
+ }
+ }
+ },
+ Dodger: {
+ name: "Dodger",
+ skills: {
+ Dodge: {
+ trigger: { type: "battle_start" },
+ effects: {
+ dodge: { type: "dodge", units: { applies_to: "self.infantry", applies_vs: "enemy.marksman" }, duration: { attacks: { count: 1 } } }
+ }
+ }
+ }
+ }
+ });
+ config.troopSkills = {
+ name: "Synthetic troop skills",
+ skills: {
+ Formation: {
+ troop_type: "marksman",
+ requirements: [{ level: 1, type: "tier", value: 1 }],
+ trigger: { type: "battle_start" },
+ effects: { buff: { type: "active.troop.attack.up", value: [1] } }
+ }
+ }
+ };
const result = runOnce(
{
- maxRounds: 2,
+ maxRounds: 1,
attacker: {
name: "A",
- troops: { marksman_t8_fc5: 900, infantry_t8_fc5: 100 },
- stats: {
- mark: { attack: 100, defense: 100, lethality: 100, health: 100 },
- inf: { attack: 100, defense: 100, lethality: 100, health: 100 }
- },
- heroes: { Alonso: { skill_1: 5, skill_2: 5, skill_3: 5 } }
+ troops: { marksman_t1: 1000 },
+ heroes: { Reporter: { skill_1: 1 } }
},
defender: {
name: "D",
- troops: { infantry_t8_fc5: 1000 },
- stats: { inf: { attack: 100, defense: 100, lethality: 100, health: 100 } },
- heroes: { Flint: { skill_1: 5, skill_2: 5, skill_3: 5 } }
+ troops: { infantry_t1: 1000 },
+ heroes: { Dodger: { skill_1: 1 } }
}
},
config
);
- assert.ok(result.resolved.attacker.heroes.some((hero) => hero.name === "Alonso"));
- assert.ok(result.resolved.attacker.troopSkillIds.length > 0);
+ assert.ok(result.resolved.attacker.heroes.some((hero) => hero.name === "Reporter"));
+ assert.ok(result.resolved.attacker.troopSkillIds.includes("Formation"));
assert.ok(result.skillReport.attacker.some((entry) => entry.effectActivations > 0));
assert.ok(Object.keys(result.extraSkillAttackJobsByEffect).length > 0);
assert.ok(result.attackControlCounts.dodge >= 0);
assert.ok(result.attackControlCounts.no_attack >= 0);
});
test("display-name hero aliases resolve to simulator hero definitions", () => {
- const config = loadSimulatorConfig();
+ const config = buildSimulatorConfig({
+ heroGenerationStats: {},
+ troopSkills: { name: "Synthetic troop skills", skills: {} },
+ heroDefinitions: {
+ First: { name: "First Canonical", aliases: ["First Display"], skills: {} },
+ Second: { name: "Second Canonical", aliases: ["Second Display"], skills: {} }
+ }
+ });
const fighter = resolveFighter(
{
troops: { infantry_t1: 1 },
heroes: {
- "Ling Xue": { skill_1: 1 },
- "Lumak Bokan": { skill_1: 1 },
- "Wu Ming": { skill_1: 1 }
+ "First Display": { skill_1: 1 },
+ "Second Display": { skill_1: 1 }
}
},
"attacker",
config
);
assert.deepEqual(
fighter.heroes.map((hero) => hero.name),
- ["Ling", "Lumak", "Wu Ming"]
+ ["First Canonical", "Second Canonical"]
);
assert.equal(fighter.heroes.some((hero) => hero.missing), false);
assert.equal(fighter.diagnostics.some((line) => line.includes("Missing hero definition")), false);
});
@@ -1120,9 +1206,9 @@
assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });
});
-test("cancelled attacks report the winning control as an applied effect", () => {
+test("no_attack outcomes report the winning control as an applied effect", () => {
const result = runOnce(
{
maxRounds: 1,
attacker: {
@@ -1138,20 +1224,20 @@
Canceller: {
name: "Canceller",
skills: {
CancelAndDebuff: {
- trigger: { type: "attack", source: "infantry" },
+ trigger: { type: "battle_start" },
effects: {
cancel: {
type: "no_attack",
value: 100,
- units: { applies_to: "trigger", applies_vs: "target" },
+ units: { applies_to: "self.infantry", applies_vs: "enemy.any" },
duration: { attacks: { count: 1 } }
},
debuff: {
type: "active.hero.attack.up",
value: 100,
- units: { applies_to: "trigger", applies_vs: "target" },
+ units: { applies_to: "self.infantry", applies_vs: "enemy.any" },
duration: { attacks: { count: 1 } }
}
}
}
@@ -1198,13 +1284,13 @@
StopInfantry: {
name: "StopInfantry",
skills: {
Pause: {
- trigger: { type: "attack", source: "infantry" },
+ trigger: { type: "battle_start" },
effects: {
stop: {
type: "no_attack",
- units: { applies_to: "trigger", applies_vs: "target" },
+ units: { applies_to: "self.infantry", applies_vs: "enemy.any" },
duration: { turns: { count: 1 } }
}
}
}
@@ -1222,9 +1308,9 @@
false
);
});
-test("dodge applies_to cancels attacks targeting that unit, not that unit attacking", () => {
+test("dodge zeroes attacks targeting that unit without cancelling the attack", () => {
const result = runOnce(
{
maxRounds: 1,
attacker: {
@@ -1240,13 +1326,13 @@
Dodger: {
name: "Dodger",
skills: {
StepAside: {
- trigger: { type: "attack", source: "enemy.infantry", target: "self.infantry" },
+ trigger: { type: "battle_start" },
effects: {
evade: {
type: "dodge",
- units: { applies_to: "target", applies_vs: "trigger" },
+ units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },
duration: { attacks: { count: 1 } }
}
}
}
@@ -1255,18 +1341,18 @@
})
);
assert.deepEqual(
- result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => [attack.round, attack.dealerSide, attack.dealerUnit]),
+ result.attacks.filter((attack) => attack.dodged).map((attack) => [attack.round, attack.dealerSide, attack.dealerUnit]),
[[1, "attacker", "infantry"]]
);
assert.equal(
- result.attacks.some((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "infantry" && attack.cancelReason === "dodge"),
+ result.attacks.some((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "infantry" && attack.dodged),
false
);
});
-test("attack-declared controls from later intents affect earlier same-round attacks", () => {
+test("attack-triggered dodge applies to the normal attack that triggered it", () => {
const result = runOnce(
{
maxRounds: 1,
attacker: {
@@ -1282,14 +1368,14 @@
ReactiveDodge: {
name: "ReactiveDodge",
skills: {
StepAside: {
- trigger: { type: "attack", source: "infantry" },
+ trigger: { type: "attack", source: "enemy.any", target: "self.infantry" },
effects: {
evade: {
type: "dodge",
- units: { applies_to: "self.infantry", applies_vs: "any" },
- duration: { turns: { count: 1 } }
+ units: { applies_to: "trigger.target", applies_vs: "trigger.source" },
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1297,11 +1383,13 @@
})
);
assert.deepEqual(
- result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => [attack.round, attack.dealerSide, attack.dealerUnit]),
- [[1, "attacker", "infantry"]]
+ result.attacks.filter((attack) => attack.dodged).map((attack) => [attack.dealerSide, attack.dealerUnit, attack.takerSide, attack.takerUnit]),
+ [["attacker", "infantry", "defender", "infantry"]]
);
+ assert.equal(result.attackControlCounts.dodge, 1);
+ assert.equal(result.skillReport.defender.find((entry) => entry.skillId === "StepAside")?.skillActivations, 1);
});
test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {
// Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is
@@ -1355,9 +1443,9 @@
const uncharged = roundTwoKills({ useEffectsOnNoAttack: false });
assert.ok(uncharged > charged, `expected uncharged buff to boost round 2 (${uncharged} > ${charged})`);
});
-test("cancelled normal attacks advance attack counters for later frequency checks", () => {
+test("no_attack does not advance normal attack cadence counters", () => {
const result = runOnce(
{
maxRounds: 4,
attacker: {
@@ -1389,13 +1477,13 @@
);
assert.deepEqual(
result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.round),
- [2, 4]
+ [3]
);
});
-test("attack frequency triggers can start at a different first threshold", () => {
+test("attack frequency triggers count actual normal attacks and honor first/every", () => {
const result = runOnce(
{
maxRounds: 14,
attacker: {
@@ -1427,12 +1515,325 @@
);
assert.deepEqual(
result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.round),
- [4, 9, 14]
+ [5, 11]
);
});
+test("attack cadence is independent per troop type at attacks 5, 11, and 17", () => {
+ const result = runOnce(
+ {
+ maxRounds: 17,
+ attacker: {
+ troops: { infantry_t1: 10000000, lancer_t1: 10000000, marksman_t1: 10000000 },
+ heroes: { Cadence: { skill_1: 1 } }
+ },
+ defender: { troops: { infantry_t1: 10000000 }, heroes: {} }
+ },
+ minimalConfig({
+ Cadence: {
+ name: "Cadence",
+ skills: {
+ EverySixAfterFive: {
+ trigger: { type: "attack", first: 5, every: 6, source: "self.any" },
+ effects: {
+ extra: {
+ type: "extra_skill_attack",
+ value: 1,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ duration: { attacks: { count: 1 } },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ for (const unit of UNIT_TYPES) {
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.kind === "skill" && attack.dealerUnit === unit && attack.sourceEffectId === "extra").map((attack) => attack.round),
+ [5, 11, 17]
+ );
+ }
+});
+
+test("extra skill jobs do not advance normal-attack cadence", () => {
+ const result = runOnce(
+ {
+ maxRounds: 4,
+ attacker: { troops: { infantry_t1: 1000000 }, heroes: { Cadence: { skill_1: 1 } } },
+ defender: { troops: { infantry_t1: 1000000 }, heroes: {} }
+ },
+ minimalConfig({
+ Cadence: {
+ name: "Cadence",
+ skills: {
+ EveryOther: {
+ trigger: { type: "attack", every: 2, source: "infantry" },
+ effects: {
+ extra: {
+ type: "extra_skill_attack",
+ value: 10,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ duration: { attacks: { count: 1 } },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ assert.deepEqual(result.attacks.filter((attack) => attack.kind === "skill").map((attack) => attack.round), [2, 4]);
+ assert.equal(result.attacks.filter((attack) => attack.kind === "skill").every((attack) => attack.counterDeltas === undefined), true);
+});
+
+test("direct target-scoped attack effects apply to the triggering job at cadence", () => {
+ const result = runOnce(
+ {
+ maxRounds: 4,
+ attacker: { troops: { lancer_t1: 1000000 }, heroes: { CadenceDebuff: { skill_1: 1 } } },
+ defender: { troops: { infantry_t1: 1000000 }, heroes: {} }
+ },
+ minimalConfig({
+ CadenceDebuff: {
+ name: "CadenceDebuff",
+ skills: {
+ EveryOther: {
+ trigger: { type: "attack", every: 2, source: "lancer" },
+ effects: {
+ sourceLocked: {
+ type: "active.hero.health.down",
+ value: 37,
+ units: { applies_to: "trigger.target", applies_vs: "trigger.source" },
+ duration: { attacks: { count: 1 } },
+ same_effect_stacking: "max"
+ },
+ anySource: {
+ type: "active.hero.health.down",
+ value: 11,
+ units: { applies_to: "trigger.target", applies_vs: "any" },
+ duration: { attacks: { count: 1 } },
+ same_effect_stacking: "max"
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const healthDownByRound = result.attacks
+ .filter((attack) => attack.kind === "normal" && attack.dealerSide === "attacker")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.health.down"].totalPct ?? 0);
+ assert.deepEqual(healthDownByRound, [0, 48, 0, 48]);
+});
+
+test("same-round target exhaustion suppresses the later normal trigger", () => {
+ const result = runOnce(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000000, lancer_t1: 100 },
+ stats: { infantry: { attack: 1000000, lethality: 1000000 } },
+ heroes: { LateProc: { skill_1: 1 } }
+ },
+ defender: { troops: { infantry_t1: 1 }, heroes: {} }
+ },
+ minimalConfig({
+ LateProc: {
+ name: "LateProc",
+ skills: {
+ LancerOnly: {
+ trigger: { type: "attack", source: "lancer" },
+ effects: {
+ buff: { type: "active.hero.attack.up", value: 100, units: { applies_to: "trigger.source", applies_vs: "trigger.target" } }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const report = result.skillReport.attacker.find((entry) => entry.skillId === "LancerOnly");
+ assert.equal(report?.triggersSeen, 0);
+ assert.equal(report?.effectActivations, 0);
+});
+
+test("dodge records a zero-normal outcome while attack triggers and extras continue", () => {
+ const result = runOnce(
+ {
+ maxRounds: 1,
+ attacker: { troops: { infantry_t1: 1000 }, heroes: { Proc: { skill_1: 1 } } },
+ defender: { troops: { infantry_t1: 1000 }, heroes: { Dodge: { skill_1: 1 } } }
+ },
+ minimalConfig({
+ Proc: {
+ name: "Proc",
+ skills: {
+ Extra: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ extra: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ duration: { attacks: { count: 1 } },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]
+ }
+ }
+ }
+ }
+ },
+ Dodge: {
+ name: "Dodge",
+ skills: {
+ Evade: {
+ trigger: { type: "battle_start" },
+ effects: {
+ dodge: { type: "dodge", units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" }, duration: { attacks: { count: 1 } } }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const dodged = result.attacks.find((attack) => attack.dodged);
+ assert.equal(dodged?.kills, 0);
+ assert.equal(dodged?.trace, undefined);
+ assert.equal(dodged?.cancelReason, undefined);
+ assert.equal(dodged?.counterDeltas?.[0]?.cause, "normal_attack");
+ assert.equal(result.attacks.some((attack) => attack.kind === "skill" && attack.sourceEffectId === "extra"), true);
+});
+
+test("nested children materialize after a typed parent use, never on the consuming job", () => {
+ const result = runOnce(
+ {
+ maxRounds: 2,
+ attacker: { troops: { infantry_t1: 1000000 }, heroes: { Parent: { skill_1: 1 } } },
+ defender: { troops: { infantry_t1: 1000000 }, heroes: {} }
+ },
+ minimalConfig({
+ Parent: {
+ name: "Parent",
+ skills: {
+ ParentUse: {
+ trigger: { type: "battle_start" },
+ effects: {
+ parent: {
+ type: "active.hero.attack.up",
+ value: 10,
+ units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },
+ duration: { attacks: { count: 1 } },
+ trigger_effects: {
+ child: {
+ type: "active.hero.lethality.up",
+ value: 20,
+ units: { applies_to: "parent.use.source", applies_vs: "parent.use.target" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const first = result.attacks.find((attack) => attack.kind === "normal" && attack.dealerSide === "attacker" && attack.round === 1);
+ const second = result.attacks.find((attack) => attack.kind === "normal" && attack.dealerSide === "attacker" && attack.round === 2);
+ assert.equal(first?.appliedEffects?.some((effect) => effect.effectId === "parent"), true);
+ assert.equal(first?.appliedEffects?.some((effect) => effect.effectId === "child"), false);
+ assert.equal(second?.appliedEffects?.some((effect) => effect.effectId === "child"), true);
+});
+
+test("duration-only charging on no_attack does not materialize a modifier's children", () => {
+ const result = runOnce(
+ {
+ maxRounds: 2,
+ attacker: { troops: { infantry_t1: 1000000 }, heroes: { Parent: { skill_1: 1 } } },
+ defender: { troops: { infantry_t1: 1000000 }, heroes: {} }
+ },
+ minimalConfig({
+ Parent: {
+ name: "Parent",
+ skills: {
+ Setup: {
+ trigger: { type: "battle_start" },
+ effects: {
+ stop: { type: "no_attack", units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" }, duration: { turns: { count: 1 } } },
+ parent: {
+ type: "active.hero.attack.up",
+ value: 10,
+ units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },
+ duration: { attacks: { count: 1 } },
+ trigger_effects: {
+ child: { type: "active.hero.lethality.up", value: 100, units: { applies_to: "parent.use.source", applies_vs: "parent.use.target" } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ assert.equal(result.attacks.some((attack) => attack.appliedEffects?.some((effect) => effect.effectId === "child")), false);
+ assert.equal(result.effectActivationCounts.attacker, 2);
+});
+
+test("max-suppressed parents charge duration but only the selected primary use creates a child", () => {
+ const result = runOnce(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 1000000 },
+ heroes: [
+ { name: "Parent", levels: { skill_1: 1 } },
+ { name: "Parent", levels: { skill_1: 1 } }
+ ]
+ },
+ defender: { troops: { infantry_t1: 1000000 }, heroes: {} }
+ },
+ minimalConfig({
+ Parent: {
+ name: "Parent",
+ skills: {
+ Setup: {
+ trigger: { type: "battle_start" },
+ effects: {
+ parent: {
+ type: "active.hero.attack.up",
+ value: 10,
+ same_effect_stacking: "max",
+ units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },
+ duration: { attacks: { count: 1 } },
+ trigger_effects: {
+ child: { type: "active.hero.lethality.up", value: 100, units: { applies_to: "parent.use.source", applies_vs: "parent.use.target" } }
+ }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.equal(result.effectActivationCounts.attacker, 3);
+});
+
test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {
const maxResult = runOnce(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max"), { mode: "trace" });
const addResult = runOnce(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add"), { mode: "trace" });
@@ -1591,80 +1992,11 @@
assert.equal(marksmanEffects.has("shield"), true);
assert.equal(marksmanAttack?.trace?.atomicBuckets["active.troop.damageTaken.down"].totalPct, 46);
assert.equal(marksmanAttack?.trace?.aggregationGroups["active.troop.damageTaken.down"].factor, 1.46);
assert.equal(marksmanAttack?.trace?.offsetDamage, 0);
- assert.equal(
- marksmanAttack?.kills,
- Math.ceil((marksmanAttack?.trace?.damageBeforeOffsets ?? 0) * 1000) / 1000
- );
+ assert.equal(marksmanAttack?.kills, marksmanAttack?.trace?.damageBeforeOffsets);
});
-test("Crystal Shield and Body of Light resolve additive FC8 and FC10 damage reductions", () => {
- const config = loadSimulatorConfig();
- const expectations = [
- { troopId: "infantry_t10_fc8", level: 1, defense: 4, conditionalReduction: 10, combinedReduction: 46 },
- { troopId: "infantry_t10_fc10", level: 2, defense: 6, conditionalReduction: 15, combinedReduction: 51 }
- ] as const;
-
- for (const expected of expectations) {
- const fighter = resolveFighter({ troops: { [expected.troopId]: 1 }, heroes: {} }, "attacker", config);
- const crystalShield = fighter.troopSkills.find((skill) => skill.id === "CrystalShield");
- const bodyOfLight = fighter.troopSkills.find((skill) => skill.id === "BodyOfLight");
- const crystalReduction = crystalShield?.effects.find((effect) => effect.id === "CrystalShield/1");
- const conditionalReduction = bodyOfLight?.effects.find((effect) => effect.id === "BodyOfLight/2");
-
- assert.equal(crystalReduction?.type, "active.troop.damageTaken.down");
- assert.equal(crystalReduction?.value, 36);
- assert.equal(bodyOfLight?.level, expected.level);
- assert.equal(bodyOfLight?.effects.find((effect) => effect.id === "BodyOfLight/1")?.value, expected.defense);
- assert.equal(conditionalReduction?.type, "active.troop.damageTaken.down");
- assert.equal(conditionalReduction?.value, expected.conditionalReduction);
- assert.equal(crystalReduction!.value + conditionalReduction!.value, expected.combinedReduction);
- }
-});
-
-test("Incandescent Field upgrades its trigger chance at FC10 while retaining half damage", () => {
- const config = loadSimulatorConfig();
- const expectations = [
- { troopId: "lancer_t10_fc8", level: 1, probabilityPct: 10 },
- { troopId: "lancer_t10_fc10", level: 2, probabilityPct: 15 }
- ] as const;
-
- for (const expected of expectations) {
- const fighter = resolveFighter({ troops: { [expected.troopId]: 1 }, heroes: {} }, "attacker", config);
- const incandescentField = fighter.troopSkills.find((skill) => skill.id === "IncandescentField");
- const halfDamage = incandescentField?.effects.find((effect) => effect.id === "IncandescentField/1");
-
- assert.equal(incandescentField?.level, expected.level);
- assert.equal(compiledTriggerForSkill(incandescentField!).probabilityPct, expected.probabilityPct);
- assert.equal(halfDamage?.type, "active.troop.defense.up");
- assert.equal(halfDamage?.value, 100);
- }
-});
-
-test("Marksman Fire Crystal skills resolve Crystal Gunpowder and Flame Charge milestones", () => {
- const config = loadSimulatorConfig();
- const expectations = [
- { troopId: "marksman_t10_fc3", gunpowderLevel: 1, probabilityPct: 20, skillDamagePct: 50, flameLevel: undefined, attackPct: undefined },
- { troopId: "marksman_t10_fc5", gunpowderLevel: 2, probabilityPct: 30, skillDamagePct: 50, flameLevel: undefined, attackPct: undefined },
- { troopId: "marksman_t10_fc8", gunpowderLevel: 3, probabilityPct: 30, skillDamagePct: 75, flameLevel: 1, attackPct: 4 },
- { troopId: "marksman_t10_fc10", gunpowderLevel: 4, probabilityPct: 30, skillDamagePct: 87.5, flameLevel: 2, attackPct: 6 }
- ] as const;
-
- for (const expected of expectations) {
- const fighter = resolveFighter({ troops: { [expected.troopId]: 1 }, heroes: {} }, "attacker", config);
- const crystalGunpowder = fighter.troopSkills.find((skill) => skill.id === "CrystalGunpowder");
- const flameCharge = fighter.troopSkills.find((skill) => skill.id === "FlameCharge");
-
- assert.equal(crystalGunpowder?.level, expected.gunpowderLevel);
- assert.equal(compiledTriggerForSkill(crystalGunpowder!).probabilityPct, expected.probabilityPct);
- assert.equal(crystalGunpowder?.effects.find((effect) => effect.id === "CrystalGunpowder/1")?.value, expected.skillDamagePct);
- assert.equal(flameCharge?.level, expected.flameLevel);
- assert.equal(flameCharge?.effects.find((effect) => effect.id === "FlameCharge/1")?.value, expected.attackPct);
- assert.equal(fighter.troopSkills.some((skill) => skill.id === "FlameChargeExtra"), false);
- }
-});
-
test("fighter passive effects are added to the static profile after pre_battle skill effects", () => {
const result = runOnce(
{
maxRounds: 1,
@@ -2077,15 +2409,18 @@
const jobs = result.trace?.rounds[0]?.jobs ?? [];
const normalJobs = jobs.filter((job) => job.kind === "normal");
const skillJobs = jobs.filter((job) => job.kind === "skill");
+ const triggeringNormal = normalJobs.find(
+ (job) => job.dealerSide === "attacker" && job.dealerUnit === "marksman"
+ );
assert.equal(normalJobs.length, 2);
assert.equal(skillJobs.length, 1);
- assert.equal(skillJobs[0]?.dealerSide, normalJobs[0]?.dealerSide);
- assert.equal(skillJobs[0]?.dealerUnit, normalJobs[0]?.dealerUnit);
- assert.equal(skillJobs[0]?.takerSide, normalJobs[0]?.takerSide);
- assert.equal(skillJobs[0]?.takerUnit, normalJobs[0]?.takerUnit);
+ assert.equal(skillJobs[0]?.dealerSide, triggeringNormal?.dealerSide);
+ assert.equal(skillJobs[0]?.dealerUnit, triggeringNormal?.dealerUnit);
+ assert.equal(skillJobs[0]?.takerSide, triggeringNormal?.takerSide);
+ assert.equal(skillJobs[0]?.takerUnit, triggeringNormal?.takerUnit);
});
test("deferred shields use finalized normal plus linked skill kills as one next-turn pool", () => {
const result = runOnce(
@@ -2496,9 +2831,9 @@
const jobs = result.trace?.rounds[0]?.jobs ?? [];
assert.deepEqual(
jobs.map((job) => `${job.kind}:${job.dealerUnit}->${job.takerUnit}`),
- ["normal:marksman->infantry", "normal:infantry->marksman", "normal:lancer->marksman"]
+ ["normal:infantry->marksman", "normal:lancer->marksman", "normal:marksman->infantry"]
);
assert.deepEqual(result.extraSkillAttackJobsByEffect, {});
});
@@ -3061,7 +3396,7 @@
troopStats: MINIMAL_TROOP_STATS,
heroGenerationStats: {},
heroDefinitions,
troopSkills: { name: "troop skills", skills: {} },
- diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }
+ diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [] }
};
}
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -8,9 +8,8 @@
BattleTrace,
DamageJob,
FighterInput,
ResolvedFighter,
- ResolvedSkill,
SideId,
SimulationOptions,
SimulatorConfig,
UnitType
@@ -20,28 +19,28 @@
import { createRecorder, type BattleRecorder } from "./recorder";
import {
activateEffect,
advanceEffectAttackDelay,
- chancePasses,
- compiledTriggerForSkill,
createSeededRng,
hasAttackDurationConstraint,
isEffectAttackReady,
oppositeSide,
- skillMatchesTrigger,
type Rng
} from "./effects";
import { damageJobShapeSlot, damageJobSlot, type EffectIndex } from "./effectIndex";
-import { buildRuntimeSkills, type DeferredEffectPlan, type RuntimeSkills } from "./runtimeSkills";
+import { buildRuntimeSkills, type RuntimeSkills } from "./runtimeSkills";
import { activatePreBattleEffects, buildResolved, prepareBattle, type CompiledBattle } from "./prepare";
import {
addActiveEffect,
capJobToRemainingTarget,
chargeEffectUse,
chargeUsedEffects,
+ chargeUsedEffectsForJob,
createRuntime,
emptyRoundTargetDamage,
materializeDeferredEffects,
+ materializeTriggeredEffects,
+ preparedChancePasses,
processEffectSchedule,
targetExhausted,
triggerAttackSkills,
triggerSkills,
@@ -75,14 +74,8 @@
intent: AttackIntent;
control: Control;
}
-interface ApplicableControls {
- dodge?: Control;
- no_attack?: Control;
- attackDurationEffects: ActiveEffect[];
-}
-
interface Control {
effect: ActiveEffect;
reason: "dodge" | "no_attack";
attackDurationEffects: ActiveEffect[];
@@ -246,8 +239,9 @@
staticDamageProfile: runtime.staticDamageProfile,
scratch: runtime.damageScratch,
capToTakerTroops: loopOptions.capJobKills,
usedEffects: runtime.usedEffects,
+ primaryUsedEffects: runtime.primaryUsedEffects,
minInitialArmy: initialArmyCount
};
let rounds = 0;
@@ -256,90 +250,107 @@
if (winnerFor(runtime.troops)) break;
rounds = round;
const roundStartTroops = snapshotTroops(runtime.troops);
processEffectSchedule(runtime, round);
- triggerRoundStartSkills(round, runtime, roundStartTroops, recorder);
+ triggerRoundStartSkills(round, runtime, recorder);
- const intents = resolveAttackIntents(round, runtime, roundStartTroops, recorder);
+ const intents: AttackIntent[] = [];
const results: DamageJobResult[] = [];
const cancelled: CancelledAttack[] = [];
-
- // Phase 1: fire all attack_declared triggers for every intended attack before
- // evaluating any controls or damage, per the battle-core spec.
- const pendingNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; deferredEffects?: DeferredEffectPlan[]; control?: Control }> = [];
- const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; deferredEffects?: DeferredEffectPlan[] }> = [];
const roundTargetDamage = emptyRoundTargetDamage();
- for (const intent of intents) {
- const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[
- damageJobShapeSlot("normal", intent.dealerSide, intent.dealerUnit, intent.takerSide, intent.takerUnit)
- ];
- const deferredEffects = matchingTriggerSkills
- ? triggerAttackSkills(round, matchingTriggerSkills, runtime, recorder, intent)
- : undefined;
- const job = normalJob(intent, roundStartTroops);
- declaredNormalJobs.push({ intent, job, deferredEffects });
- }
- for (const { intent, job, deferredEffects } of declaredNormalJobs) {
- const controls = applicableControls(job, runtime);
- if (controls.no_attack || controls.dodge) {
- const control = controls.no_attack ?? controls.dodge!;
- pendingNormalJobs.push({ intent, job, deferredEffects, control });
- } else {
- pendingNormalJobs.push({ intent, job, deferredEffects });
- }
- }
+ // Resolve each normal attack as one procedural cluster. Later attacks observe effects
+ // produced by earlier attacks; no synthetic global attack-declaration phase exists.
+ const orderIndexBySide: Record<SideId, number> = { attacker: 0, defender: 0 };
+ for (const dealerUnit of UNIT_TYPES) {
+ for (const side of ["attacker", "defender"] as SideId[]) {
+ const takerSide = oppositeSide(side);
+ if ((roundStartTroops[side][dealerUnit] ?? 0) <= 0) continue;
+ const ordered = orderFromEffects(dealerUnit, side, runtime.effectIndex, true);
+ const takerUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, takerSide, roundStartTroops);
+ if (!takerUnit) continue;
+ const intent = makeNormalIntent(
+ round,
+ side,
+ dealerUnit,
+ takerSide,
+ takerUnit,
+ orderIndexBySide[side],
+ runtime
+ );
+ intents.push(intent);
+ orderIndexBySide[side] += 1;
+ if (ordered) {
+ materializeTriggeredEffects(ordered.effect, round, intent, runtime, recorder);
+ chargeEffectUse(runtime, ordered.effect);
+ recorder.recordBattleOrder(intent, ordered.effect, takerUnit);
+ }
- // Phase 2: calculate each normal job; any extra_skill_attack effect it
- // uses spawns extra DamageJobs that are calculated immediately after.
- // chargeUsedEffects runs after each job so subsequent normal jobs see the
- // correct uses count on any shared extra_skill_attack effects.
- for (const { intent, job, deferredEffects, control } of pendingNormalJobs) {
- if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;
+ const job = normalJob(intent, roundStartTroops);
+ if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;
- if (control) {
- runtime.attackControlCounts[control.reason] += 1;
- if (useEffectsOnCancel[control.reason]) {
- chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);
+ // A pre-existing no_attack prevents the attack from being declared at all.
+ // Reactive dodge skills instead roll on this declaration and can dodge this job.
+ const noAttack = applicableControl(job, runtime, "no_attack");
+ if (noAttack) {
+ const control = noAttack;
+ runtime.attackControlCounts.no_attack += 1;
+ materializeTriggeredEffects(control.effect, round, intent, runtime, recorder);
+ if (useEffectsOnCancel.no_attack) chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);
+ cancelled.push({ intent, control });
+ continue;
}
- cancelled.push({
- intent,
- control
- });
- continue;
- }
- recorder.recordScheduledDamageJob(job);
- const normalResult = calculateDamageJob(job, fighters, damageJobOptions);
- if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage, recorder);
- if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {
- score += normalResult.kills;
- }
- chargeUsedEffects(runtime);
+ const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[
+ damageJobShapeSlot("normal", intent.dealerSide, intent.dealerUnit, intent.takerSide, intent.takerUnit)
+ ];
+ const deferredEffects = matchingTriggerSkills
+ ? triggerAttackSkills(round, matchingTriggerSkills, runtime, recorder, intent)
+ : undefined;
+ const dodge = applicableControl(job, runtime, "dodge");
+ fireApplicableCarriers(round, job, intent, runtime, recorder);
- results.push({ job, result: normalResult, intent });
- const triggeredKills = processExtraSkillAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);
- if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {
- score += triggeredKills;
+ let normalKills = 0;
+ if (dodge) {
+ runtime.attackControlCounts.dodge += 1;
+ materializeTriggeredEffects(dodge.effect, round, intent, runtime, recorder);
+ if (useEffectsOnCancel.dodge) chargeCancelledAttack(job, dodge.effect, dodge.attackDurationEffects, runtime);
+ } else {
+ recorder.recordScheduledDamageJob(job);
+ const normalResult = calculateDamageJob(job, fighters, damageJobOptions);
+ if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage, recorder);
+ normalKills = normalResult.kills;
+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {
+ score += normalResult.kills;
+ }
+ chargeUsedEffectsForJob(runtime, job, recorder);
+ results.push({ job, result: normalResult, intent });
+ }
+
+ advanceNormalAttackCounters(intent, runtime);
+ const triggeredKills = processExtraSkillAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);
+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {
+ score += triggeredKills;
+ }
+ if (deferredEffects) {
+ materializeDeferredEffects(
+ deferredEffects,
+ round,
+ intent,
+ normalKills,
+ triggeredKills,
+ sourceAttackProtectionBasis(job, fighters, initialArmyCount),
+ runtime,
+ recorder
+ );
+ }
+ if (dodge) recorder.recordDodged(intent, job, dodge.effect);
}
- if (deferredEffects) {
- materializeDeferredEffects(
- deferredEffects,
- round,
- intent,
- normalResult.kills,
- triggeredKills,
- sourceAttackProtectionBasis(job, fighters, initialArmyCount),
- runtime,
- recorder
- );
- }
}
- if (loopOptions.commitLosses) commitRound(cancelled, results, runtime);
- else commitRoundCounters(cancelled, results, runtime);
+ if (loopOptions.commitLosses) commitRound(results, runtime);
- for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.control.effect, entry.control.reason);
+ for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.control.effect, "no_attack");
for (const entry of results) recorder.recordDamageJob(entry.job, entry.result, entry.intent);
recorder.recordRound(round, roundStartTroops, intents);
}
@@ -377,131 +388,64 @@
function triggerRoundStartSkills(
round: number,
runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"],
recorder: BattleRecorder
): ActiveEffect[] {
const activated: ActiveEffect[] = [];
- activated.push(...triggerSkills("round_start", round, runtime.skills.roundStartGlobal, runtime, recorder));
- for (const skill of runtime.skills.roundStartPerUnit) {
- const side = roundTriggerSourceSide(skill);
- const takerSide = roundTriggerTargetSide(skill, side);
- if (!skillMatchesTrigger(skill, "round_start", round)) continue;
+ for (const prepared of runtime.skills.roundStart) {
+ if (!preparedRoundFrequencyMatches(prepared, round)) continue;
+ const { skill } = prepared;
recorder.recordSkillTriggerAttempt(skill);
- if (!chancePasses(skill, runtime.rng)) continue;
+ if (!preparedChancePasses(prepared.probabilityPct, runtime.rng)) continue;
recorder.recordSkillTriggered(skill);
- let orderIndex = 0;
- for (const dealerUnit of roundTriggerUnits(skill, roundStartTroops)) {
- const takerUnit = chooseTakerUnit(dealerUnit, side, takerSide, roundStartTroops, runtime.effectIndex);
- if (!takerUnit) continue;
- const intent = syntheticRoundIntent(round, side, dealerUnit, takerSide, takerUnit, orderIndex, runtime);
- if (!skillMatchesTrigger(skill, "round_start", round, intent)) continue;
- for (const effectIntent of skill.effects) {
- const effect = activateEffect(skill, effectIntent, round, intent);
- addActiveEffect(runtime, effect);
- activated.push(effect);
- runtime.effectActivationCounts[skill.side] += 1;
- recorder.recordSkillEffectActivated(skill);
- }
- orderIndex += 1;
+ for (const effectIntent of skill.effects) {
+ const effect = activateEffect(skill, effectIntent, round);
+ addActiveEffect(runtime, effect);
+ activated.push(effect);
+ runtime.effectActivationCounts[skill.side] += 1;
+ recorder.recordSkillEffectActivated(skill);
}
}
return activated;
}
-function roundTriggerUnits(skill: ResolvedSkill, roundStartTroops: DamageJob["roundStartTroops"]): UnitType[] {
- const source = compiledTriggerForSkill(skill).source;
- return UNIT_TYPES.filter((unit) => (roundStartTroops[source.side][unit] ?? 0) > 0 && unitMaskHas(source.units, unit));
+function preparedRoundFrequencyMatches(
+ prepared: Runtime["skills"]["roundStart"][number],
+ round: number
+): boolean {
+ const every = prepared.everyTurn;
+ if (every === undefined) return true;
+ const first = prepared.firstTurn ?? every;
+ return round >= first && (round - first) % every === 0;
}
-function roundTriggerSourceSide(skill: ResolvedSkill): SideId {
- return compiledTriggerForSkill(skill).source.side;
-}
-
-function roundTriggerTargetSide(skill: ResolvedSkill, sourceSide: SideId): SideId {
- const targetSide = compiledTriggerForSkill(skill).target.side;
- return targetSide === sourceSide ? oppositeSide(sourceSide) : targetSide;
-}
-
-function syntheticRoundIntent(
+function makeNormalIntent(
round: number,
dealerSide: SideId,
dealerUnit: UnitType,
takerSide: SideId,
takerUnit: UnitType,
orderIndex: number,
runtime: Runtime
): AttackIntent {
+ const previousAttackCount = runtime.counters.attacks[dealerSide][dealerUnit];
+ const previousReceivedAttackCount = runtime.counters.received[takerSide][takerUnit];
return {
round,
source: "normal",
dealerSide,
dealerUnit,
takerSide,
takerUnit,
orderIndex,
- previousAttackCount: runtime.counters.attacks[dealerSide][dealerUnit],
- projectedAttackCount: runtime.counters.attacks[dealerSide][dealerUnit],
- previousReceivedAttackCount: runtime.counters.received[takerSide][takerUnit],
- projectedReceivedAttackCount: runtime.counters.received[takerSide][takerUnit]
+ previousAttackCount,
+ projectedAttackCount: previousAttackCount + 1,
+ previousReceivedAttackCount,
+ projectedReceivedAttackCount: previousReceivedAttackCount + 1
};
}
-function resolveAttackIntents(
- round: number,
- runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"],
- recorder: BattleRecorder
-): AttackIntent[] {
- const intents: AttackIntent[] = [];
- for (const side of ["attacker", "defender"] as SideId[]) {
- const takerSide = oppositeSide(side);
- let orderIndex = 0;
- for (const dealerUnit of UNIT_TYPES) {
- if ((roundStartTroops[side][dealerUnit] ?? 0) <= 0) continue;
- const ordered = orderFromEffects(dealerUnit, side, runtime.effectIndex, true);
- const takerUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, takerSide, roundStartTroops);
- if (!takerUnit) continue;
- const previousAttackCount = runtime.counters.attacks[side][dealerUnit];
- const previousReceivedAttackCount = runtime.counters.received[takerSide][takerUnit];
- const intent: AttackIntent = {
- round,
- source: "normal",
- dealerSide: side,
- dealerUnit,
- takerSide,
- takerUnit,
- orderIndex,
- previousAttackCount,
- projectedAttackCount: previousAttackCount + 1,
- previousReceivedAttackCount,
- projectedReceivedAttackCount: previousReceivedAttackCount + 1
- };
- if (ordered) {
- chargeEffectUse(runtime, ordered.effect);
- recorder.recordBattleOrder(intent, ordered.effect, takerUnit);
- }
- intents.push(intent);
- orderIndex += 1;
- }
- }
- return intents;
-}
-
-// Synthetic round-start trigger targeting reuses attack-order effects to pick a target but
-// does not charge them: no real attack intent is being ordered.
-function chooseTakerUnit(
- dealerUnit: UnitType,
- dealerSide: SideId,
- takerSide: SideId,
- roundStartTroops: DamageJob["roundStartTroops"],
- effectIndex: EffectIndex
-): UnitType | undefined {
- const order = orderFromEffects(dealerUnit, dealerSide, effectIndex, false)?.order ?? UNIT_TYPES;
- return firstLivingUnit(order, takerSide, roundStartTroops);
-}
-
function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {
return order.find((unit) => (roundStartTroops[side][unit] ?? 0) > 0);
}
@@ -520,29 +464,27 @@
}
return undefined;
}
-// Shared empty result for the common no-control-effects case; callers only read it.
-const NO_APPLICABLE_CONTROLS: ApplicableControls = { attackDurationEffects: [] };
-
-function applicableControls(
+function applicableControl(
job: DamageJob,
- runtime: Runtime
-): ApplicableControls {
- if (runtime.effectIndex.controls.length === 0) return NO_APPLICABLE_CONTROLS;
- const controls: ApplicableControls = { attackDurationEffects: [] };
+ runtime: Runtime,
+ reason: Control["reason"]
+): Control | undefined {
+ const attackDurationEffects: ActiveEffect[] = [];
+ let selected: Control | undefined;
for (const effect of runtime.effectIndex.controls) {
- const reason = controlType(effect);
+ if (controlType(effect) !== reason) continue;
if (!controlEffectApplies(effect, job, reason)) continue;
if (!advanceEffectAttackDelay(effect)) continue;
- if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);
- controls[reason] = {
+ if (hasAttackDurationConstraint(effect)) attackDurationEffects.push(effect);
+ selected = {
effect,
reason,
- attackDurationEffects: controls.attackDurationEffects
+ attackDurationEffects
};
}
- return controls;
+ return selected;
}
function controlType(effect: ActiveEffect): "dodge" | "no_attack" {
const type = effect.intent.type;
@@ -559,8 +501,41 @@
const appliesVsUnit = control === "no_attack" ? job.takerUnit : job.dealerUnit;
return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);
}
+function fireApplicableCarriers(
+ round: number,
+ job: DamageJob,
+ intent: AttackIntent,
+ runtime: Runtime,
+ recorder: BattleRecorder
+): void {
+ const carriers = runtime.effectIndex.carriers;
+ const originalCount = carriers.length;
+ let index = 0;
+ for (let inspected = 0; inspected < originalCount; inspected += 1) {
+ const carrier = carriers[index];
+ if (!carrier) break;
+ const matches = carrier.appliesTo.side === job.dealerSide &&
+ unitMaskHas(carrier.appliesTo.units, job.dealerUnit) &&
+ carrier.appliesVs.side === job.takerSide &&
+ unitMaskHas(carrier.appliesVs.units, job.takerUnit) &&
+ advanceEffectAttackDelay(carrier);
+ if (matches) {
+ materializeTriggeredEffects(carrier, round, intent, runtime, recorder);
+ chargeEffectUse(runtime, carrier);
+ }
+ // chargeEffectUse may have removed the current carrier. Keep the same index
+ // in that case so the shifted original entry is inspected next.
+ if (carriers[index] === carrier) index += 1;
+ }
+}
+
+function advanceNormalAttackCounters(intent: AttackIntent, runtime: Runtime): void {
+ runtime.counters.attacks[intent.dealerSide][intent.dealerUnit] += 1;
+ runtime.counters.received[intent.takerSide][intent.takerUnit] += 1;
+}
+
function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {
return {
round: intent.round,
kind: "normal",
@@ -572,10 +547,9 @@
sourceMultiplier: 1
};
}
-function commitRound(cancelled: CancelledAttack[], results: DamageJobResult[], runtime: Runtime): void {
- commitRoundCounters(cancelled, results, runtime);
+function commitRound(results: DamageJobResult[], runtime: Runtime): void {
const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };
for (const { job, result } of results) {
losses[job.takerSide][job.takerUnit] += result.kills;
}
@@ -585,19 +559,8 @@
}
}
}
-function commitRoundCounters(cancelled: CancelledAttack[], results: DamageJobResult[], runtime: Runtime): void {
- for (const entry of cancelled) {
- runtime.counters.attacks[entry.intent.dealerSide][entry.intent.dealerUnit] += 1;
- runtime.counters.received[entry.intent.takerSide][entry.intent.takerUnit] += 1;
- }
- for (const { job, result } of results) {
- runtime.counters.attacks[job.dealerSide][job.dealerUnit] += 1;
- runtime.counters.received[job.takerSide][job.takerUnit] += 1;
- }
-}
-
function chargeCancelledAttack(
job: DamageJob,
winningControl: ActiveEffect,
controlEffects: ActiveEffect[],
Index: simulator/src/staticDamageProfile.ts
===================================================================
--- simulator/src/staticDamageProfile.ts prev run
+++ simulator/src/staticDamageProfile.ts this run
@@ -102,9 +102,9 @@
export function selectPassiveContributions(activeEffects: ActiveEffect[]): PassiveContribution[] {
const contributions: PassiveContribution[] = [];
for (const effect of activeEffects) {
const bucket = effect.intent.type;
- if (!isPassiveBucket(bucket)) continue;
+ if (bucket === undefined || !isPassiveBucket(bucket)) continue;
const jobSide = staticBucketDefinition(bucket)!.jobSide;
const side = effect.appliesTo.side;
for (const unit of UNIT_TYPES) {
if (!unitMaskHas(effect.appliesTo.units, unit)) continue;
Index: simulator/src/tooling/testcases.test.ts
===================================================================
--- simulator/src/tooling/testcases.test.ts prev run
+++ simulator/src/tooling/testcases.test.ts this run
@@ -231,10 +231,10 @@
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.025);
- assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -1);
+ assert.equal(summary?.gameStatAdjustment?.value, 0.05);
+ assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);
});
test("deterministic testcase tolerance increases by 0.1 percent every ten rounds and caps at 0.7 percent", () => {
assert.equal(deterministicRoundTolerancePct(0), 0.2);
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -1,9 +1,9 @@
export type SideId = "attacker" | "defender";
export type UnitType = "infantry" | "lancer" | "marksman";
export type DamageKind = "normal" | "skill";
export type UnitMask = number;
-export type ActiveEffectKind = "modifier" | "shield" | "extra_attack" | "control" | "battle_order";
+export type ActiveEffectKind = "modifier" | "shield" | "extra_attack" | "control" | "battle_order" | "carrier";
export type SameEffectStacking = "add" | "max";
export const UNIT_TYPES: UnitType[] = ["infantry", "lancer", "marksman"];
export const UNIT_BITS: Record<UnitType, UnitMask> = {
@@ -20,8 +20,10 @@
export type SupportedTriggerDamageJobSelector =
| "use.source"
| "use.target"
+ | "parent.use.source"
+ | "parent.use.target"
| "effect.applies_to"
| "effect.applies_vs"
| "enemy.living"
| "self.living";
@@ -83,9 +85,10 @@
}
export interface EffectIntentDefinition {
id: string;
- type: string;
+ /** Optional only for a child-bearing normal-attack carrier. */
+ type?: string;
value?: unknown;
/** Apply this modifier only while the named effect is applicable to the same damage job. */
requires_effect?: string;
value_formula?: PercentOfValueFormula;
@@ -93,8 +96,10 @@
units?: Record<string, unknown>;
trigger_damage_jobs?: TriggerDamageJobDefinition[];
duration?: EffectDuration;
same_effect_stacking?: SameEffectStacking;
+ /** Effects activated when this effect is actually used. Keys are stable child effect IDs. */
+ trigger_effects?: Record<string, Omit<EffectIntentDefinition, "id">>;
reason?: string;
}
export type TriggerValueSource =
@@ -111,8 +116,9 @@
export interface ResolvedEffectIntentDefinition extends EffectIntentDefinition {
// Canonical config object retained across per-instance/level resolution. Duplicate
// main/joiner copies use this identity to share prepared activation groups.
sourceDefinition: Omit<EffectIntentDefinition, "id">;
+ triggerEffects?: ResolvedEffectIntentDefinition[];
// Runtime damage modifiers only: prepared groups for every resolved scope this definition can
// activate with, indexed by resolvedEffectScopeKey. Populated during battle preparation.
effectGroupsByScopeKey?: Array<ActiveEffectGroup | undefined>;
}
@@ -145,17 +151,17 @@
export interface SkillFile {
name: string;
aliases?: string[];
hero_generation?: string;
+ experimental?: boolean;
troop_type?: UnitType | string;
skills: Record<string, Omit<SkillDefinition, "id" | "name">>;
}
export interface ConfigDiagnostics {
legacyFields: Array<{ file: string; path: string; field: string }>;
effectTypes: Record<string, number>;
unsupportedEffects: Array<{ file: string; skillId: string; effectId: string; type: string; reason: string }>;
- ambiguousTurnTriggerSelectors: Array<{ file: string; skillId: string; effectId: string; selector: string; reason: string }>;
}
export interface SimulatorConfig {
troopStats: TroopStatsCatalogue;
@@ -314,8 +320,9 @@
// value evolution read it; cancelled attacks still charge attack-constrained effects
// unless useEffectsOnDodge/useEffectsOnNoAttack disable that.
uses: number;
sameEffectStacking: SameEffectStacking;
+ triggerEffects?: ResolvedEffectIntentDefinition[];
effectGroup?: ActiveEffectGroup;
effectGroupPosition?: number;
}
@@ -459,9 +466,11 @@
/** Trace-only counter bookkeeping. */
counterDeltas?: CounterDelta[];
/** Standard emits four-field summaries; trace emits detailed causal events. */
appliedEffects?: AppliedEffect[];
- cancelReason?: "dodge" | "no_attack";
+ cancelReason?: "no_attack";
+ /** The normal attack occurred and advanced cadence, but dealt zero normal damage. */
+ dodged?: true;
trace?: DamageEquationTrace;
}
export interface SkillReportEntry {
Index: testcases/emulator_verified/army_term_precision_002i_003l.json
===================================================================
--- testcases/emulator_verified/army_term_precision_002i_003l.json prev run
+++ testcases/emulator_verified/army_term_precision_002i_003l.json this run
@@ -0,0 +1,68 @@
+[
+ {
+ "test_id": "army_term_precision_002i_003l",
+ "description": "Precommitted discriminator for the army-term integer boundary using the supplied 2dp WIP/minxxx execution stats and full-precision damage. WIP attacks with 2 T6 Infantry and no heroes; minxxx defends with 3 T6 Lancers and no heroes. Exact or float-residue-aware evaluation gives ceil(sqrt(2 * 2)) = 2, accumulates 0.8835575456 damage, and predicts exactly 3 minxxx Lancers survive. Raw floating evaluation gives Math.sqrt(2) * Math.sqrt(2) = 2.0000000000000004, Math.ceil(...) = 3 for WIP while it has 2 living Infantry, accumulates 1.1044469320 damage, and predicts exactly 2 minxxx Lancers survive. minxxx's army term is 3 under both treatments.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 2
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "lancer_t6": 3
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 266.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 265.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 273.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 3
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/damage_precision_325l_008i_325m.json
===================================================================
--- testcases/emulator_verified/damage_precision_325l_008i_325m.json prev run
+++ testcases/emulator_verified/damage_precision_325l_008i_325m.json this run
@@ -0,0 +1,69 @@
+[
+ {
+ "test_id": "damage_precision_325l_008i_325m",
+ "description": "Precommitted lower control for the 3dp-ceil-versus-float damage discriminator using the supplied 2dp battle stats. WIP attacks with 325 T6 Lancers and no heroes; minxxx defends with 8 T6 Infantry and 325 T6 Marksmen and no heroes. Both the current per-attack 3dp ceiling and an otherwise identical full-float counterfactual predict minxxx wins with exactly 242 Marksmen surviving.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "lancer_t6": 325
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 8,
+ "marksman_t6": 325
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 266.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 265.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 273.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 243
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/damage_precision_325l_009i_325m.json
===================================================================
--- testcases/emulator_verified/damage_precision_325l_009i_325m.json prev run
+++ testcases/emulator_verified/damage_precision_325l_009i_325m.json this run
@@ -0,0 +1,69 @@
+[
+ {
+ "test_id": "damage_precision_325l_009i_325m",
+ "description": "Precommitted discriminator between the current per-attack 3dp damage ceiling and full-float damage, using the supplied 2dp battle stats. WIP attacks with 325 T6 Lancers and no heroes; minxxx defends with 9 T6 Infantry and 325 T6 Marksmen and no heroes. The current 3dp ceiling accumulates 9.001 Infantry damage through round 4 and predicts exactly 242 Marksmen survive. Full-float damage accumulates 8.999108727, leaves 0.000891273 Infantry for round 5, and predicts exactly 253 Marksmen survive. Across all 4,096 corners of the possible hidden values within the displayed 2dp intervals, 3dp-ceil remains 242 while float remains 252 or 253, so the result sets do not overlap.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "lancer_t6": 325
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 9,
+ "marksman_t6": 325
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 266.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 265.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 273.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 253
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/damage_precision_875i_026i_875m.json
===================================================================
--- testcases/emulator_verified/damage_precision_875i_026i_875m.json prev run
+++ testcases/emulator_verified/damage_precision_875i_026i_875m.json this run
@@ -0,0 +1,69 @@
+[
+ {
+ "test_id": "damage_precision_875i_026i_875m",
+ "description": "Precommitted lower control for the 3dp-ceil-versus-float damage discriminator. WIP attacks with 875 T6 Infantry and no heroes; minxxx defends with 26 T6 Infantry and 875 T6 Marksmen and no heroes. Both the current per-attack 3dp ceiling and an otherwise identical full-float counterfactual predict minxxx wins with exactly 679 Marksmen surviving.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 875
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 26,
+ "marksman_t6": 875
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 266.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 265.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 273.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 679
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/gordon_wayne_s1_extra_overlap_lvl2.json
===================================================================
--- testcases/emulator_verified/gordon_wayne_s1_extra_overlap_lvl2.json prev run
+++ testcases/emulator_verified/gordon_wayne_s1_extra_overlap_lvl2.json this run
@@ -0,0 +1,79 @@
+[
+ {
+ "test_id": "gordon_wayne_s1_extra_overlap_nc",
+ "description": "Unresolved Gordon S1 normal-hit versus Wayne extra-hit scope discriminator. minxxx attacks WIP with Gordon 2/2/1 and Wayne 1/0/0, fielding 2000 T6 lancers into 6000 T6 lancers. Captured result: 39 rounds from Gordon 19/13/9, Wayne 9 activations, and attacker 883 survivors. The current normal-only simulator produces 892 survivors in 38 rounds. All 256 +/-0.049 corners of the eight relevant displayed Lancer stats remain at 38 rounds and produce 891-894 survivors, so no corner attains the observation. Corner checks alone do not prove the complete non-monotonic interior envelope; a valid interior or dominance audit is still required. Do not infer Gordon S1's scope from this testcase until the deterministic endpoint residual is explained.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Gordon": {
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 1
+ },
+ "Wayne": {
+ "skill_1": 2,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "lancer_t6": 255
+ },
+ "stats": {
+ "inf": {
+ "attack": 285.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 484.9,
+ "defense": 485.2,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 413.1,
+ "defense": 415.1,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "lancer_t6": 1000
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 5,
+ "defender": 0
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/gordon_wayne_s1_extra_overlap_nc.json
===================================================================
--- testcases/emulator_verified/gordon_wayne_s1_extra_overlap_nc.json prev run
+++ testcases/emulator_verified/gordon_wayne_s1_extra_overlap_nc.json this run
@@ -0,0 +1,79 @@
+[
+ {
+ "test_id": "gordon_wayne_s1_extra_overlap_nc",
+ "description": "Unresolved Gordon S1 normal-hit versus Wayne extra-hit scope discriminator. minxxx attacks WIP with Gordon 2/2/1 and Wayne 1/0/0, fielding 2000 T6 lancers into 6000 T6 lancers. Captured result: 39 rounds from Gordon 19/13/9, Wayne 9 activations, and attacker 883 survivors. The current normal-only simulator produces 892 survivors in 38 rounds. All 256 +/-0.049 corners of the eight relevant displayed Lancer stats remain at 38 rounds and produce 891-894 survivors, so no corner attains the observation. Corner checks alone do not prove the complete non-monotonic interior envelope; a valid interior or dominance audit is still required. Do not infer Gordon S1's scope from this testcase until the deterministic endpoint residual is explained.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Gordon": {
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 1
+ },
+ "Wayne": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "lancer_t6": 260
+ },
+ "stats": {
+ "inf": {
+ "attack": 285.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 484.9,
+ "defense": 485.2,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 413.1,
+ "defense": 415.1,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "lancer_t6": 1000
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 19
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/greg_mia_combo.json
===================================================================
--- testcases/emulator_verified/greg_mia_combo.json prev run
+++ testcases/emulator_verified/greg_mia_combo.json this run
@@ -11,101 +11,8 @@
"marksman_t6": 1500
},
"stats": {
"inf": {
- "attack": 79.3,
- "defense": 81.9,
- "lethality": 31.1,
- "health": 38.1
- },
- "lanc": {
- "attack": 84.3,
- "defense": 79.1,
- "lethality": 38.1,
- "health": 31.3
- },
- "mark": {
- "attack": 88.8,
- "defense": 75.4,
- "lethality": 37.6,
- "health": 31.9
- }
- },
- "joiner_heroes": {}
- },
- "defender": {
- "name": "[ARK]Piddilyminxxx",
- "heroes": {
- "Mia": {
- "skill_1": 2,
- "skill_2": 2,
- "skill_3": 2
- },
- "Greg": {
- "skill_1": 1,
- "skill_2": 1,
- "skill_3": 0
- }
- },
- "troops": {
- "infantry_t6": 200,
- "lancer_t6": 200,
- "marksman_t6": 200
- },
- "stats": {
- "inf": {
- "attack": 221.0,
- "defense": 200.8,
- "lethality": 119.1,
- "health": 132.4
- },
- "lanc": {
- "attack": 287.2,
- "defense": 278.0,
- "lethality": 121.2,
- "health": 122.4
- },
- "mark": {
- "attack": 290.8,
- "defense": 280.6,
- "lethality": 124.1,
- "health": 125.7
- }
- },
- "joiner_heroes": {}
- },
- "game_report_result": [
- {
- "attacker": 2591,
- "defender": 0
- },
- {
- "attacker": 2464,
- "defender": 0
- },
- {
- "attacker": 2402,
- "defender": 0
- },
- {
- "attacker": 2576,
- "defender": 0
- }
- ]
- },
- {
- "test_id": "greg_mia_combo",
- "description": "Greg (Infantry class) + Mia (Lancer class) combo defender \u2014 both have chance-based skills (Greg S1 20%, Mia S2 50%/S3 40%). Run 5+ battles for statistical confidence. Tests chance-skill stacking across classes. WIP attacks clean; minxxx defends.",
- "attacker": {
- "name": "[BBQ]XxWIPxX",
- "heroes": {},
- "troops": {
- "infantry_t6": 1500,
- "lancer_t6": 1069,
- "marksman_t6": 1500
- },
- "stats": {
- "inf": {
"attack": 140.7,
"defense": 148.2,
"lethality": 117.0,
"health": 131.9
@@ -188,5 +95,5 @@
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/greg_mia_nohero_control_current.json
===================================================================
--- testcases/emulator_verified/greg_mia_nohero_control_current.json prev run
+++ testcases/emulator_verified/greg_mia_nohero_control_current.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "greg_mia_nohero_control_current",
- "description": "Greg/Mia Batch 1 exact paired no-hero control. WIP attacks clean; minxxx defends clean. Same troop counts and report path as Greg/Mia full-kit fixtures.",
+ "description": "Greg/Mia Batch 1 no-hero baseline. WIP attacks clean; minxxx defends clean.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -78,9 +78,9 @@
]
},
{
"test_id": "greg_mia_nohero_control_current",
- "description": "Greg/Mia Batch 1 exact paired no-hero control. WIP attacks clean; minxxx defends clean. Same troop counts and report path as Greg/Mia full-kit fixtures.",
+ "description": "Greg/Mia Batch 1 no-hero baseline. WIP attacks clean; minxxx defends clean.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -154,5 +154,5 @@
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/norah_s2_inf_only_A.json
===================================================================
--- testcases/emulator_verified/norah_s2_inf_only_A.json prev run
+++ testcases/emulator_verified/norah_s2_inf_only_A.json this run
@@ -1,45 +1,45 @@
[
{
"test_id": "norah_s2_inf_only_A",
- "description": "WOS-27: Norah S2 isolation test \u2014 Scenario A (Norah active). WIP infantry-only 1500 t6 attacks minxxx 50/50/50 t6 with Norah S2 level 1. Purpose: determine if Norah S2 extra_vs_all hits the primary target (lancer vs infantry = primary hit). Compare survivors to Scenario B (no heroes). Config chosen for ~61% winner casualties and ~23-troop A-B delta.",
+ "description": "WOS-27: Norah S2 primary-target test. WIP attacks with 1,000 T6 infantry and no heroes; minxxx defends with 50/50/50 T6 and Norah 2/2/2. Four captured endpoints are 643, 643, 646, and 645 WIP infantry survivors. Battle Details recorded 20, 21, 16, and 16 S2 procs across the four runs (the two 643 runs had 20 and 21), with 3 displayed kills at the game's 35% reporting scale in every run.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
- "infantry_t6": 1500,
+ "infantry_t6": 1000,
"lancer_t6": 0,
"marksman_t6": 0
},
"stats": {
"inf": {
- "attack": 73.8,
- "defense": 76.4,
- "lethality": 31.1,
- "health": 38.1
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.3,
+ "health": 181.9
},
"lanc": {
- "attack": 78.8,
- "defense": 71.2,
- "lethality": 38.1,
- "health": 28.8
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.4,
+ "health": 181.3
},
"mark": {
- "attack": 83.3,
- "defense": 69.9,
- "lethality": 37.6,
- "health": 29.4
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.8,
+ "health": 176.6
}
},
"joiner_heroes": {}
},
"defender": {
"name": "[ARK]Piddilyminxxx",
"heroes": {
"Norah": {
- "skill_1": 1,
- "skill_2": 1,
- "skill_3": 0
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 2
}
},
"troops": {
"infantry_t6": 50,
@@ -47,44 +47,44 @@
"marksman_t6": 50
},
"stats": {
"inf": {
- "attack": 209.9,
- "defense": 202.2,
- "lethality": 118.1,
- "health": 131.2
+ "attack": 282.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
},
"lanc": {
- "attack": 275.5,
- "defense": 278.8,
- "lethality": 120.3,
- "health": 121.1
+ "attack": 447.1,
+ "defense": 450.4,
+ "lethality": 216.2,
+ "health": 217.5
},
"mark": {
- "attack": 200.9,
- "defense": 203.2,
- "lethality": 123.0,
- "health": 124.0
+ "attack": 281.0,
+ "defense": 286.0,
+ "lethality": 226.3,
+ "health": 221.4
}
},
"joiner_heroes": {}
},
"game_report_result": [
{
- "attacker": 111,
+ "attacker": 643,
"defender": 0
},
{
- "attacker": 94,
+ "attacker": 643,
"defender": 0
},
{
- "attacker": 95,
+ "attacker": 646,
"defender": 0
},
{
- "attacker": 94,
+ "attacker": 645,
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/plateau_boundary_700m_006i_700l.json
===================================================================
--- testcases/emulator_verified/plateau_boundary_700m_006i_700l.json prev run
+++ testcases/emulator_verified/plateau_boundary_700m_006i_700l.json this run
@@ -0,0 +1,69 @@
+[
+ {
+ "test_id": "plateau_boundary_700m_006i_700l",
+ "description": "Precommitted deterministic plateau-boundary probe. WIP attacks with 700 T6 Marksmen and no heroes; minxxx defends with 6 T6 Infantry and 700 T6 Lancers and no heroes. Using the supplied 2dp stats plus minxxx's stated hidden +2pp Attack/Defense, the simulator predicts minxxx wins with exactly 478 Lancers. Counts 6 and 7 are the same lower plateau because the Infantry line is exhausted in round 1. The boundary is predicted between 7 and 8 Infantry.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 700
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 6,
+ "lancer_t6": 700
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 266.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 265.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 273.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 478
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/plateau_boundary_700m_007i_700l.json
===================================================================
--- testcases/emulator_verified/plateau_boundary_700m_007i_700l.json prev run
+++ testcases/emulator_verified/plateau_boundary_700m_007i_700l.json this run
@@ -0,0 +1,69 @@
+[
+ {
+ "test_id": "plateau_boundary_700m_007i_700l",
+ "description": "Precommitted deterministic plateau-boundary probe. WIP attacks with 700 T6 Marksmen and no heroes; minxxx defends with 7 T6 Infantry and 700 T6 Lancers and no heroes. Using the supplied 2dp stats plus minxxx's stated hidden +2pp Attack/Defense, the simulator predicts minxxx wins with exactly 478 Lancers. Counts 6 and 7 are the same lower plateau because the Infantry line is exhausted in round 1. The boundary is predicted between 7 and 8 Infantry.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 700
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 7,
+ "lancer_t6": 700
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 266.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 265.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 273.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 478
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/plateau_boundary_700m_008i_700l.json
===================================================================
--- testcases/emulator_verified/plateau_boundary_700m_008i_700l.json prev run
+++ testcases/emulator_verified/plateau_boundary_700m_008i_700l.json this run
@@ -0,0 +1,69 @@
+[
+ {
+ "test_id": "plateau_boundary_700m_008i_700l",
+ "description": "Precommitted deterministic plateau-boundary probe. WIP attacks with 700 T6 Marksmen and no heroes; minxxx defends with 8 T6 Infantry and 700 T6 Lancers and no heroes. Using the supplied 2dp stats plus minxxx's stated hidden +2pp Attack/Defense, the simulator predicts minxxx wins with exactly 502 Lancers. Counts 8 and 9 are the same upper plateau because the Infantry line survives round 1 and consumes WIP's round-2 attack. The boundary is predicted between 7 and 8 Infantry.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 700
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 8,
+ "lancer_t6": 700
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 266.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 265.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 273.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 502
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/plateau_boundary_700m_009i_700l.json
===================================================================
--- testcases/emulator_verified/plateau_boundary_700m_009i_700l.json prev run
+++ testcases/emulator_verified/plateau_boundary_700m_009i_700l.json this run
@@ -0,0 +1,69 @@
+[
+ {
+ "test_id": "plateau_boundary_700m_009i_700l",
+ "description": "Precommitted deterministic plateau-boundary probe. WIP attacks with 700 T6 Marksmen and no heroes; minxxx defends with 9 T6 Infantry and 700 T6 Lancers and no heroes. Using the supplied 2dp stats plus minxxx's stated hidden +2pp Attack/Defense, the simulator predicts minxxx wins with exactly 502 Lancers. Counts 8 and 9 are the same upper plateau because the Infantry line survives round 1 and consumes WIP's round-2 attack. The boundary is predicted between 7 and 8 Infantry.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 700
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 9,
+ "lancer_t6": 700
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 266.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 265.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 273.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 502
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/renee_ahmose_damage_taken_overlap_attacker_nc.json
===================================================================
--- testcases/emulator_verified/renee_ahmose_damage_taken_overlap_attacker_nc.json prev run
+++ testcases/emulator_verified/renee_ahmose_damage_taken_overlap_attacker_nc.json this run
@@ -0,0 +1,80 @@
+[
+ {
+ "test_id": "renee_ahmose_damage_taken_overlap_attacker_nc",
+ "description": "Deterministic Renee/Ahmose damage-taken bucket overlap discriminator frozen before capture. minxxx attacks WIP with Ahmose 1/1/3 and Renee 4/4/4, fielding 4000 T6 Infantry plus 500 T6 Lancers into 13400 T6 Infantry. Infantry attacks before Lancers; Blade of Light contributes 15% active.hero.damageTaken.up to the shared target before every eligible Renee marked Lancer attack. Using current account stats, Renee sharing active.hero.damageTaken.up with Ahmose predicts minxxx wins with 3757-3759 total survivors; Renee in a separate numerator bucket predicts 3809-3811. Both candidates leave all 500 Lancers and more than 3200 Ahmose Infantry alive, so the target dies first. Capture exact report stats, then rerun these unchanged candidate mechanics before interpreting the observation.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Ahmose": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 3
+ },
+ "Renee": {
+ "skill_1": 4,
+ "skill_2": 4,
+ "skill_3": 4
+ }
+ },
+ "troops": {
+ "infantry_t6": 4000,
+ "lancer_t6": 500
+ },
+ "stats": {
+ "inf": {
+ "attack": 408.4,
+ "defense": 392.2,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 591.7,
+ "defense": 582.0,
+ "lethality": 315.1,
+ "health": 271.1
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 281.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 13400
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 3777,
+ "defender": 0
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/renee_charge_single_target_bucket_nc.json
===================================================================
--- testcases/emulator_verified/renee_charge_single_target_bucket_nc.json prev run
+++ testcases/emulator_verified/renee_charge_single_target_bucket_nc.json this run
@@ -0,0 +1,74 @@
+[
+ {
+ "test_id": "renee_charge_single_target_bucket_nc",
+ "description": "Deterministic Renee/Charge bucket discriminator frozen before capture. WIP attacks minxxx with Renee 3/3/3, fielding 500 T6 Lancers into 1490 T6 Marksmen. Charge contributes 10% in type.single_target.damage.up on every Lancer-to-Marksman job. Using stats from the preceding Renee/Gwen capture, Renee in active.hero.damageTaken.up predicts WIP wins with about 52 Lancers because Charge and Renee multiply across buckets; Renee in type.single_target.damage.up predicts minxxx wins with about 49 Marksmen because Charge and Renee add within one bucket. Capture exact report stats, then rerun these unchanged candidate mechanics before interpreting the observation.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Renee": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "lancer_t6": 500
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 460.1,
+ "defense": 457.9,
+ "lethality": 243.2,
+ "health": 228.3
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 1490
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 274.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 273.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 281.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 50,
+ "defender": 0
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/renee_gwen_bucket_retry_nc.json
===================================================================
--- testcases/emulator_verified/renee_gwen_bucket_retry_nc.json prev run
+++ testcases/emulator_verified/renee_gwen_bucket_retry_nc.json this run
@@ -0,0 +1,79 @@
+[
+ {
+ "test_id": "renee_gwen_bucket_retry_nc",
+ "description": "Second deterministic Renee/Gwen grouping discriminator. WIP attacks with Renee 3/3/3 and Gwen 2/1/0, fielding 500 T6 Lancers into minxxx's 900 T6 Infantry. Using the exact stats from the preceding 620-vs-920 report, separate Renee/Gwen buckets with Gwen's stated 10% predict 330 survivors; same bucket at 10% or separate buckets at an effective 5% predict 321; same bucket at an effective 5% predicts 316; and no effective Gwen S1 predicts 312. Corresponding approximate Renee S1 activation counts are 25, 26, 27, and 28. These candidates and the decision rule are frozen before capture; the report's exact captured stats will be substituted afterward without changing the candidate models.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Renee": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ },
+ "Gwen": {
+ "skill_1": 2,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "lancer_t6": 500
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 460.1,
+ "defense": 457.9,
+ "lethality": 243.2,
+ "health": 228.3
+ },
+ "mark": {
+ "attack": 314.2,
+ "defense": 311.0,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 900
+ },
+ "stats": {
+ "inf": {
+ "attack": 282.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 275.2,
+ "defense": 278.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 281.0,
+ "defense": 286.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 318,
+ "defender": 0
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/renee_hendrik_defense_bucket_nc.json
===================================================================
--- testcases/emulator_verified/renee_hendrik_defense_bucket_nc.json prev run
+++ testcases/emulator_verified/renee_hendrik_defense_bucket_nc.json this run
@@ -0,0 +1,79 @@
+[
+ {
+ "test_id": "renee_hendrik_defense_bucket_nc",
+ "description": "Deterministic Renee Defense Down bucket discriminator. minxxx attacks with Renee and Hendrik, fielding 120 T6 lancers into WIP's 446 T9 infantry. Hendrik's only unlocked expedition skill is persistent 5% Defense Down. Using estimated current stats reconstructed from recent reports, homogeneous Renee Defense Down predicts WIP wins with about 34 infantry; homogeneous Health Down or Damage Taken Up predicts minxxx wins with about 5 lancers. The candidate configurations and comparison rule are frozen before capture; exact captured stats will be inserted into those unchanged candidates after the report.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Renee": {
+ "skill_1": 4,
+ "skill_2": 4,
+ "skill_3": 4
+ },
+ "Hendrik": {
+ "skill_1": 1,
+ "skill_2": 0,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "lancer_t6": 120
+ },
+ "stats": {
+ "inf": {
+ "attack": 285.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 586.7,
+ "defense": 587.0,
+ "lethality": 315.1,
+ "health": 271.1
+ },
+ "mark": {
+ "attack": 382.5,
+ "defense": 384.5,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t9": 446
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 14,
+ "defender": 0
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/renee_wayne_mixed_510_vs_888.json
===================================================================
--- testcases/emulator_verified/renee_wayne_mixed_510_vs_888.json prev run
+++ testcases/emulator_verified/renee_wayne_mixed_510_vs_888.json this run
@@ -0,0 +1,83 @@
+[
+ {
+ "test_id": "renee_wayne_mixed_510_vs_888",
+ "description": "XxWIPxX attacks Piddilyminxxx with Renee 3/3/3 and Wayne 3/3/3. Attacker fields 170 infantry_t6, 150 lancer_t6, and 190 marksman_t6; defender has no heroes and fields 888 infantry_t6. Game report result: attacker 427 survivors, defender wiped. Skill details report Renee triggers 20/1/1 with no listed kills, and Wayne triggers 30/20/19 with 23/-/33 listed kills.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Renee": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ },
+ "Wayne": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "infantry_t6": 170,
+ "lancer_t6": 150,
+ "marksman_t6": 190
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 460.1,
+ "defense": 457.9,
+ "lethality": 243.2,
+ "health": 228.3
+ },
+ "mark": {
+ "attack": 379.7,
+ "defense": 376.5,
+ "lethality": 236.5,
+ "health": 212.2
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddilyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 888,
+ "lancer_t6": 0,
+ "marksman_t6": 0
+ },
+ "stats": {
+ "inf": {
+ "attack": 285.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221
+ },
+ "lanc": {
+ "attack": 278.2,
+ "defense": 278.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 284,
+ "defense": 286,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 427,
+ "defender": 0
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/retarget_lancer_screen_nc.json
===================================================================
--- testcases/emulator_verified/retarget_lancer_screen_nc.json prev run
+++ testcases/emulator_verified/retarget_lancer_screen_nc.json this run
@@ -0,0 +1,70 @@
+[
+ {
+ "test_id": "retarget_lancer_screen_nc",
+ "description": "Deterministic same-round retargeting discriminator using only T6 troops and no heroes. WIP attacks with 100 Lancers followed by 400 Marksmen against minxxx's 1 Lancer followed by 499 Marksmen. The WIP Lancers are expected to kill the single defender Lancer comfortably before WIP Marksmen act. Using the latest captured account stats, no-retargeting predicts 303 defender Marksmen surviving, while attack-time retargeting predicts 282 defender Marksmen surviving. A result near 303 supports skipping the Marksman attack when its snapshotted target is exhausted; a result near 282 supports retargeting it to the living Marksmen. The captured report stats must be substituted into both unchanged interpretations before the result is classified.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "lancer_t6": 100,
+ "marksman_t6": 400
+ },
+ "stats": {
+ "inf": {
+ "attack": 230.5,
+ "defense": 230.3,
+ "lethality": 171.2,
+ "health": 181.8
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "lancer_t6": 1,
+ "marksman_t6": 499
+ },
+ "stats": {
+ "inf": {
+ "attack": 282.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 275.2,
+ "defense": 278.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 281.0,
+ "defense": 286.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 303
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json
===================================================================
--- testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json prev run
+++ testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos444_bradley_s3_all_troops_damage_control_nc",
- "description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
+ "description": "WOS-444 no-hero baseline for the Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -70,9 +70,9 @@
]
},
{
"test_id": "wos444_bradley_s3_all_troops_damage_control_nc",
- "description": "WOS-444 exact no-hero control for revised Bradley S3 all-troops damage-up bucket probe. WIP attacks 2880 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
+ "description": "WOS-444 no-hero baseline for the Bradley S3 all-troops damage-up bucket probe. WIP attacks 2880 each T6 mixed; minxxx defends 900 each T6 mixed.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
Index: testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.json
===================================================================
--- testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.json prev run
+++ testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos444_edith_s11_mark_damage_taken_control_nc",
- "description": "WOS-444 exact no-hero control for Edith S1/1 marksman damage-taken bucket probe. WIP attacks 2880 T6 marksman; minxxx defends 400 T6 marksman. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
+ "description": "WOS-444 no-hero baseline for the Edith S1/1 marksman damage-taken bucket probe. WIP attacks 2880 T6 marksman; minxxx defends 400 T6 marksman.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -70,9 +70,9 @@
]
},
{
"test_id": "wos444_edith_s11_mark_damage_taken_control_nc",
- "description": "WOS-444 exact no-hero control for revised Edith S1/1 marksman damage-taken bucket probe. WIP attacks 2560 T6 marksman; minxxx defends 500 T6 marksman. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
+ "description": "WOS-444 no-hero baseline for the Edith S1/1 marksman damage-taken bucket probe. WIP attacks 2560 T6 marksman; minxxx defends 500 T6 marksman.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -134,5 +134,5 @@
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.json
===================================================================
--- testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.json prev run
+++ testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos444_edith_s12_lancer_damage_dealt_control_nc",
- "description": "WOS-444 exact no-hero control for Edith S1/2 lancer damage-dealt bucket probe. WIP attacks 2520 T6 infantry; minxxx defends 390 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
+ "description": "WOS-444 no-hero baseline for the Edith S1/2 lancer damage-dealt bucket probe. WIP attacks 2520 T6 infantry; minxxx defends 390 T6 lancer.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -64,5 +64,5 @@
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json
===================================================================
--- testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json prev run
+++ testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos444_gordon_s22_all_enemy_damage_dealt_control_nc",
- "description": "WOS-444 exact no-hero control for Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
+ "description": "WOS-444 no-hero baseline for the Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -68,9 +68,9 @@
]
},
{
"test_id": "wos444_gordon_s22_all_enemy_damage_dealt_control_nc",
- "description": "WOS-444 exact no-hero control for Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",
+ "description": "WOS-444 no-hero baseline for the Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
Index: testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.json
===================================================================
--- testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.json prev run
+++ testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos451_bradley_s21_lancer_damage_up_control",
- "description": "WOS-451 paired no-hero control for Bradley S2/1 damage-to-lancer-up bucket discriminator. WIP attacks minxxx with 2800 T6 marksmen into 1040 T6 lancers, matching the hero fixture accounts, roles, troop counts, and report path.",
+ "description": "WOS-451 no-hero baseline for the Bradley S2/1 damage-to-lancer-up bucket discriminator. WIP attacks minxxx with 2800 T6 marksmen into 1040 T6 lancers.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -64,5 +64,5 @@
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.json
===================================================================
--- testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.json prev run
+++ testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos451_bradley_s22_infantry_damage_up_control",
- "description": "WOS-451 paired no-hero control for Bradley S2/2 damage-to-infantry-up bucket discriminator. WIP attacks minxxx with 2800 T6 marksmen into 1040 T6 infantry, matching the hero fixture accounts, roles, troop counts, and report path.",
+ "description": "WOS-451 no-hero baseline for the Bradley S2/2 damage-to-infantry-up bucket discriminator. WIP attacks minxxx with 2800 T6 marksmen into 1040 T6 infantry.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -64,5 +64,5 @@
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.json
===================================================================
--- testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.json prev run
+++ testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos451_bradley_s3_all_damage_up_control",
- "description": "WOS-451 paired no-hero control for Bradley S3 all-troops damage-up confirmation. WIP attacks minxxx with 2160 each T6 mixed into 800 each T6 mixed, matching the hero fixture accounts, roles, troop counts, and report path.",
+ "description": "WOS-451 no-hero baseline for Bradley S3 all-troops damage-up confirmation. WIP attacks minxxx with 2160 each T6 mixed into 800 each T6 mixed.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},
"troops": {
@@ -68,5 +68,5 @@
"defender": 0
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.json
===================================================================
--- testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.json prev run
+++ testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos451_gordon_s11_lancer_damage_up_control",
- "description": "WOS-451 paired no-hero control for Gordon S1/1 lancer damage-dealt-up bucket discriminator. minxxx attacks WIP with 500 T6 lancers into 2990 T6 infantry, matching the hero fixture accounts, roles, troop counts, and report path.",
+ "description": "WOS-451 no-hero baseline for the Gordon S1/1 lancer damage-dealt-up bucket discriminator. minxxx attacks WIP with 500 T6 lancers into 2990 T6 infantry.",
"attacker": {
"name": "[ARK]Piddlyminxx",
"heroes": {},
"troops": {
@@ -64,5 +64,5 @@
"defender": 2739
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.json
===================================================================
--- testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.json prev run
+++ testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.json this run
@@ -1,8 +1,8 @@
[
{
"test_id": "wos451_gordon_s21_lancer_damage_up_control",
- "description": "WOS-451 paired no-hero control for Gordon S2/1 lancer damage-dealt-up bucket discriminator. minxxx attacks WIP with 360 T6 lancers into 2100 T6 infantry, matching the hero fixture accounts, roles, troop counts, and report path.",
+ "description": "WOS-451 no-hero baseline for the Gordon S2/1 lancer damage-dealt-up bucket discriminator. minxxx attacks WIP with 360 T6 lancers into 2100 T6 infantry.",
"attacker": {
"name": "[ARK]Piddlyminxx",
"heroes": {},
"troops": {
@@ -64,5 +64,5 @@
"defender": 1917
}
]
}
-]
\ No newline at end of file
+]
Index: testcases/emulator_verified/wu_ming_bradley_current_nc.json
===================================================================
--- testcases/emulator_verified/wu_ming_bradley_current_nc.json prev run
+++ testcases/emulator_verified/wu_ming_bradley_current_nc.json this run
@@ -0,0 +1,82 @@
+[
+ {
+ "test_id": "wu_ming_bradley_current_nc",
+ "description": "Deterministic current-account Wu Ming 3/3/3 plus Bradley 4/4/4 interaction coverage. WIP attacks with 170 each T6 Infantry, Lancers, and Marksmen into minxxx's 900 each T6 Infantry and Lancers, exercising both Bradley S2 target scopes. Using Wu Ming's stats captured immediately beforehand plus Bradley's current contribution estimated from his paired report/control: Wu Ming S2 adding in Bradley S3's active.hero.damage.up bucket predicts about +9; putting Wu Ming S2 in a separate multiplicative normal-damage bucket predicts about +68; omitting Wu Ming S1, Wu Ming S2, Bradley S1, Bradley S2, or Bradley S3 predicts about -628, -604, -847, -797, or -546 respectively; and incorrectly letting Wu Ming S3 boost normal attacks predicts about +198. The exact captured stats will be substituted into these frozen interpretations after capture.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Wu Ming": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ },
+ "Bradley": {
+ "skill_1": 4,
+ "skill_2": 4,
+ "skill_3": 4
+ }
+ },
+ "troops": {
+ "infantry_t6": 170,
+ "lancer_t6": 170,
+ "marksman_t6": 170
+ },
+ "stats": {
+ "inf": {
+ "attack": 414.7,
+ "defense": 414.5,
+ "lethality": 199.3,
+ "health": 260.1
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 659.0,
+ "defense": 655.8,
+ "lethality": 308.6,
+ "health": 261.3
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 900,
+ "lancer_t6": 900
+ },
+ "stats": {
+ "inf": {
+ "attack": 282.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 275.2,
+ "defense": 278.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 281.0,
+ "defense": 286.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 112,
+ "defender": 0
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/wu_ming_marksman_vs_infantry_nc.json
===================================================================
--- testcases/emulator_verified/wu_ming_marksman_vs_infantry_nc.json prev run
+++ testcases/emulator_verified/wu_ming_marksman_vs_infantry_nc.json this run
@@ -0,0 +1,74 @@
+[
+ {
+ "test_id": "wu_ming_marksman_vs_infantry_nc",
+ "description": "Deterministic single-line discriminator for whether Wu Ming makes T6 Marksmen deal more damage to T6 Infantry than the current simulator expects, without any target-selection or retargeting boundary. WIP attacks with Wu Ming and 500 T6 Marksmen into minxxx's 350 T6 Infantry. Using stats from the latest reports and allowing every displayed stat to vary independently by +/-0.05, the current model predicted 10-14 defender survivors; Elemental Resonance amplifying only Ranged Strike's 10% bonus predicted 14-19 attacker survivors; and Elemental Resonance multiplying the whole Marksman normal attack predicted 122-123 attacker survivors. The game returned 11 defender survivors. Recalculation with the captured exact stats predicts 11 defender survivors for the current model, 17 attacker survivors for bonus-only amplification, and 123 attacker survivors for whole-attack amplification.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Wu Ming": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "marksman_t6": 500
+ },
+ "stats": {
+ "inf": {
+ "attack": 414.7,
+ "defense": 414.5,
+ "lethality": 199.3,
+ "health": 260.1
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 350
+ },
+ "stats": {
+ "inf": {
+ "attack": 290.7,
+ "defense": 271.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 283.2,
+ "defense": 270.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 289.0,
+ "defense": 278.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 11
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/wu_ming_rounding_followup_nc.json
===================================================================
--- testcases/emulator_verified/wu_ming_rounding_followup_nc.json prev run
+++ testcases/emulator_verified/wu_ming_rounding_followup_nc.json this run
@@ -0,0 +1,77 @@
+[
+ {
+ "test_id": "wu_ming_rounding_followup_nc",
+ "description": "Deterministic follow-up to the 170/170/170 Wu Ming rounding anomaly, using slightly larger but similarly proportioned T6 armies. WIP attacks with Wu Ming and 190 Infantry, 190 Lancers, and 190 Marksmen against minxxx's 417 Infantry and 417 Lancers. Using the latest captured account stats, current ceil-to-3dp, unquantized float64, float32, nearest-to-2dp, and ceil-to-2dp each predict 179 defenders surviving; global floor-to-2dp predicts 155 defenders surviving. Both groups remain at those values when all rounded attacker and defender stats are perturbed in the favorable and unfavorable directions by 0.05. A result near 179 rejects global floor-to-2dp as the explanation of the earlier exact 200; a result near 155 shows that the floor-to-2dp trajectory repeats. The captured report stats must be substituted into every unchanged interpretation before final classification.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Wu Ming": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "infantry_t6": 190,
+ "lancer_t6": 190,
+ "marksman_t6": 190
+ },
+ "stats": {
+ "inf": {
+ "attack": 414.7,
+ "defense": 414.5,
+ "lethality": 199.3,
+ "health": 260.1
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 417,
+ "lancer_t6": 417
+ },
+ "stats": {
+ "inf": {
+ "attack": 282.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 275.2,
+ "defense": 278.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 281.0,
+ "defense": 286.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 180
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/wu_ming_s1_isolation_nc.json
===================================================================
--- testcases/emulator_verified/wu_ming_s1_isolation_nc.json prev run
+++ testcases/emulator_verified/wu_ming_s1_isolation_nc.json this run
@@ -0,0 +1,74 @@
+[
+ {
+ "test_id": "wu_ming_s1_isolation_nc",
+ "description": "Deterministic Wu Ming S1 normal-damage isolation, interpreted after the dedicated S2 fixture. WIP attacks with Wu Ming 3/3/3 and 500 T6 Infantry into minxxx's 710 T6 Marksmen. Pre-capture predictions mistakenly used a temporary helper capped at 100 rounds: configured S1 plus S2 +178, no S1 or no S2 0/draw, S1 normal reduction 18% +189, and S3 affecting normal damage +231. The game returned +178. Post-capture audit found that real testcases use the simulator's 1500-round default; rerunning the unchanged candidate configurations gives configured +178 in 100 rounds, no S1 +105 in 110 rounds, no S2 +121 in 120 rounds, 18% normal reduction +189 in 99 rounds, and S3 affecting normal damage +231 in 82 rounds. The erroneous precommit and its correction are retained explicitly rather than presenting the corrected values as precommitted.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Wu Ming": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "infantry_t6": 500
+ },
+ "stats": {
+ "inf": {
+ "attack": 414.7,
+ "defense": 414.5,
+ "lethality": 199.3,
+ "health": 260.1
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 710
+ },
+ "stats": {
+ "inf": {
+ "attack": 282.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 275.2,
+ "defense": 278.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 281.0,
+ "defense": 286.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 178,
+ "defender": 0
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/wu_ming_s2_isolation_nc.json
===================================================================
--- testcases/emulator_verified/wu_ming_s2_isolation_nc.json prev run
+++ testcases/emulator_verified/wu_ming_s2_isolation_nc.json this run
@@ -0,0 +1,74 @@
+[
+ {
+ "test_id": "wu_ming_s2_isolation_nc",
+ "description": "Deterministic Wu Ming S2 isolation following the mixed solo discrepancy. WIP attacks with Wu Ming 3/3/3 and 500 T6 Lancers into minxxx's 340 T6 Marksmen. Because no Infantry or skill-damage jobs exist, Wu Ming S1 and S3 should be inert. With the exact current stats captured immediately beforehand, the configured S2 predicts +39; omitting S2 predicts -61; incorrectly allowing S3 to amplify normal attacks predicts +136; and incorrectly applying S1 to all troops predicts +137. These interpretations and decision rules are frozen before capture.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Wu Ming": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "lancer_t6": 500
+ },
+ "stats": {
+ "inf": {
+ "attack": 414.7,
+ "defense": 414.5,
+ "lethality": 199.3,
+ "health": 260.1
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "marksman_t6": 340
+ },
+ "stats": {
+ "inf": {
+ "attack": 282.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 275.2,
+ "defense": 278.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 281.0,
+ "defense": 286.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 40,
+ "defender": 0
+ }
+ ]
+ }
+]
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
@@ -0,0 +1,77 @@
+[
+ {
+ "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.",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {
+ "Wu Ming": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "troops": {
+ "infantry_t6": 170,
+ "lancer_t6": 170,
+ "marksman_t6": 170
+ },
+ "stats": {
+ "inf": {
+ "attack": 414.7,
+ "defense": 414.5,
+ "lethality": 199.3,
+ "health": 260.1
+ },
+ "lanc": {
+ "attack": 203.8,
+ "defense": 201.6,
+ "lethality": 181.3,
+ "health": 181.1
+ },
+ "mark": {
+ "attack": 208.0,
+ "defense": 204.8,
+ "lethality": 175.7,
+ "health": 176.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 380,
+ "lancer_t6": 380
+ },
+ "stats": {
+ "inf": {
+ "attack": 282.7,
+ "defense": 279.5,
+ "lethality": 226.3,
+ "health": 221.0
+ },
+ "lanc": {
+ "attack": 275.2,
+ "defense": 278.5,
+ "lethality": 216.2,
+ "health": 217.5
+ },
+ "mark": {
+ "attack": 281.0,
+ "defense": 286.0,
+ "lethality": 226.3,
+ "health": 221.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 200
+ }
+ ]
+ }
+]