WOS Sim
← Back to Runs

Compare Runs

A (baseline)
196261b1-32b3a1224f23/05/2026, 05:59:18
B (current)
7976dfa0-4745c70816e10/06/2026, 00:13:45
Avg Error A0.24%
Avg Error B0.20%
Δ Avg Error-0.04%
Improved8
Regressed19
Added8
Retired1

Testcase Delta

178 / 178
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/sergey_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc0-0.06%-1.54%+1.48%regressed
testcases/heroes_unittests/Reina_tc.jsondaut_viper_421.70%-0.46%-1.24%improved
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21-2.62%-1.66%-0.96%improved
testcases/emulator_verified/norah_zinman_combo_nc.jsonnorah_zinman_combo_nc01.26%0.39%-0.87%improved
testcases/emulator_verified/sergey_reina_combo_nc.jsonsergey_reina_combo_nc01.10%-0.35%-0.75%improved
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc02.28%-2.97%+0.69%regressed
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo0-0.82%-0.15%-0.67%improved
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1-1.57%-2.16%+0.59%regressed
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc00.11%0.65%+0.54%regressed
testcases/heroes_unittests/Reina_tc.jsondaut_viper_210.52%0.00%-0.52%improved
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_54+0.047%-0.45%0.00%-0.45%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201.98%1.56%-0.42%unchanged
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc0+0.033%0.05%-0.43%+0.38%regressed
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_10+0.043%-0.50%-0.13%-0.37%regressed
testcases/emulator_verified/jessie_solo_nc.jsonjessie_solo0-0.05%0.24%0.00%-0.24%unchanged
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0+0.05%-0.58%-0.35%-0.23%improved
testcases/emulator_verified/norah_solo.jsonnorah_solo0-0.10%-0.29%+0.19%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.03%0.59%0.41%-0.18%regressed
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo00.31%0.15%-0.16%unchanged
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender00.29%0.14%-0.15%unchanged
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0-0.05%-0.39%0.24%-0.15%regressed
testcases/emulator_verified/hector_renee_wayne.jsondeterminism_test_normal0-0.05%0.68%0.54%-0.14%regressed
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_32-0.05%0.14%0.00%-0.14%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo00.04%0.17%+0.13%unchanged
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo00.49%0.36%-0.13%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.39%-0.27%-0.12%unchanged
testcases/emulator_verified/norah_s2_inf_only_B_nc.jsonnorah_s2_inf_only_B0+0.05%-0.12%0.00%-0.12%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21+0.015%-1.09%-0.97%-0.12%improved
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_43+0.05%-0.56%-0.45%-0.11%regressed
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_10-0.33%-0.22%-0.11%unchanged
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc01.24%1.13%-0.11%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_32+0.05%-0.48%-0.38%-0.10%regressed
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0-0.39%-0.49%+0.10%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_32-0.32%-0.41%+0.09%unchanged
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.07%-0.16%+0.09%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo0+0.049%-2.42%-2.51%+0.09%unchanged
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0+0.015%-1.09%-1.18%+0.09%regressed
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32-0.039%0.09%0.00%-0.09%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43-0.024%0.09%0.00%-0.09%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_32-0.039%0.09%0.00%-0.09%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_10+0.05%0.09%-0.18%+0.09%regressed
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32-0.039%0.09%0.00%-0.09%unchanged
testcases/emulator_verified/greg_solo.jsongreg_solo0-0.07%-0.15%+0.08%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo10.37%0.29%-0.08%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_530.09%0.01%-0.08%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo00.07%0.00%-0.07%unchanged
testcases/emulator_verified/lynn_solo.jsonlynn_solo00.01%-0.08%+0.07%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0-0.04%-0.11%+0.07%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo40.26%0.19%-0.07%unchanged
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0-0.02%-0.09%+0.07%unchanged
testcases/emulator_verified/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo00.01%0.08%+0.07%unchanged
testcases/emulator_verified/norah_s2_splash_A.jsonnorah_s2_splash_A00.30%0.23%-0.07%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_10+0.05%-0.38%-0.32%-0.06%regressed
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_21+0.044%-0.06%0.00%-0.06%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_540.58%0.64%+0.06%unchanged
testcases/emulator_verified/hector_solo.jsonhector_solo0-0.08%0.14%+0.06%unchanged
testcases/emulator_verified/molly_solo.jsonmolly_solo0-0.13%-0.07%-0.06%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10+0.044%-0.06%0.00%-0.06%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65+0.027%-0.06%0.00%-0.06%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_10+0.044%-0.06%0.00%-0.06%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10+0.044%-0.06%0.00%-0.06%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2415-0.016%0.05%0.00%-0.05%unchanged
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo0-0.05%0.11%0.06%-0.05%regressed
testcases/emulator_verified/alonso_attacker_600_all.jsonalonso_attacker_600_all0-0.19%-0.14%-0.05%unchanged
testcases/emulator_verified/alonso_solo.jsonalonso_solo02.50%2.55%+0.05%unchanged
testcases/emulator_verified/attdef_jasser_patrick_minxxx_attacks_nc.jsonattdef_jasser_patrick_minxxx_attacks0+0.05%-0.05%0.00%-0.05%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks0-0.05%0.05%0.00%-0.05%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks1-0.05%0.05%0.00%-0.05%unchanged
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0-0.20%-0.25%+0.05%unchanged
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo00.00%-0.05%+0.05%unchanged
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc0-0.05%-0.08%0.13%+0.05%regressed
testcases/emulator_verified/mia_solo.jsonmia_solo10.26%0.21%-0.05%unchanged
testcases/emulator_verified/type_triangle_A_nc.jsontype_triangle_A_nc0+0.05%-0.05%0.00%-0.05%unchanged
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0-0.44%-0.39%-0.05%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.038%-0.05%0.00%-0.05%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43-0.10%-0.15%+0.05%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65-0.03%0.05%0.00%-0.05%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10-0.05%0.05%0.00%-0.05%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_167+0.009%-0.04%0.00%-0.04%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2011-0.01%0.04%0.00%-0.04%unchanged
testcases/emulator_verified/jasser_solo_nc.jsonjasser_solo0+0.05%-0.04%0.00%-0.04%unchanged
testcases/emulator_verified/logan_bahiti_combo_nc.jsonlogan_bahiti_combo_nc00.51%0.55%+0.04%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo1+0.05%-0.04%0.00%-0.04%unchanged
testcases/emulator_verified/norah_s2_splash_D.jsonnorah_s2_splash_D00.08%0.12%+0.04%unchanged
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip0-0.01%-0.05%+0.04%unchanged
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo00.20%0.16%-0.04%unchanged
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo00.47%0.43%-0.04%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76-0.034%0.06%0.02%-0.04%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_750.13%0.16%+0.03%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2-0.13%-0.16%+0.03%unchanged
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.01%-0.04%+0.03%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current0-0.04%-0.07%+0.03%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo30.07%0.04%-0.03%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo0-0.08%-0.11%+0.03%unchanged
testcases/emulator_verified/reina_attacker_wip.jsonreina_attacker_wip00.02%0.05%+0.03%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10+0.05%0.04%-0.01%-0.03%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54-0.14%-0.11%-0.03%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_430.36%0.33%-0.03%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54+0.05%-0.09%-0.06%-0.03%regressed
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21-0.032%0.03%0.00%-0.03%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_178-0.003%0.02%0.00%-0.02%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_189+0.024%-0.02%0.00%-0.02%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer10.01%0.03%+0.02%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo1-0.04%0.93%0.91%-0.02%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current1+0.05%-0.02%0.00%-0.02%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2-0.10%-0.08%-0.02%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo0+0.05%-0.02%0.00%-0.02%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo1+0.05%-0.04%-0.02%-0.02%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo0+0.05%-0.02%0.00%-0.02%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.27%-0.29%+0.02%unchanged
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo00.04%0.02%-0.02%unchanged
testcases/emulator_verified/patrick_solo_nc.jsonpatrick_solo0+0.044%-0.02%0.00%-0.02%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo10.04%0.02%-0.02%unchanged
testcases/emulator_verified/sergey_solo_nc.jsonsergey_solo0-0.05%0.02%0.00%-0.02%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%-0.02%0.00%-0.02%unchanged
testcases/heroes_unittests/Hector_tc.jsondaut_viper_10-0.02%0.00%-0.02%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_32-0.12%-0.10%-0.02%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43+0.05%-0.06%-0.04%-0.02%regressed
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_156+0.015%-0.01%0.00%-0.01%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2112+0.016%-0.01%0.00%-0.01%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2617-0.002%0.01%0.00%-0.01%unchanged
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc00.05%0.04%-0.01%unchanged
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo00.09%-0.08%-0.01%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current1-0.05%-0.06%+0.01%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo10.03%0.02%-0.01%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_540.00%-0.01%+0.01%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1010.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1120.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1230.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1340.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_1450.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_19100.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_22130.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_23140.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_25160.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_900.00%0.00%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_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/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer00.04%0.04%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo1-0.26%-0.26%0.00%unchanged
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0-0.03%-0.03%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_test_minxxx_attacks_nc.jsonattdef_test_minxxx_attacks_nc0-0.05%0.08%0.08%0.00%regressed
testcases/emulator_verified/attdef_test_wip_attacks_nc.jsonattdef_test_wip_attacks_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal00.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped00.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_solo_nc.jsonflint_solo00.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_zinman_combo_nc.jsonflint_zinman_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current00.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo00.09%0.09%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo20.01%-0.01%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/mia_only_defender_current.jsonmia_only_defender_current1-0.25%-0.25%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_B_nc.jsonnorah_s2_splash_B00.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_C_nc.jsonnorah_s2_splash_C00.00%0.00%0.00%unchanged
testcases/emulator_verified/seo_yoon_solo_nc.jsonseo_yoon_solo0-0.05%0.10%0.10%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_B_nc.jsontype_triangle_B_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo00.00%0.00%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_540.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_870.00%0.00%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.17%0.17%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61+0.05%-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator00.14%added
testcases/emulator_verified/reina_logan_combo_v3.jsonreina_logan_combo_v300.23%retired
testcases/emulator_verified/wos425_wip_nohero_control_nc.jsonwos425_wip_nohero_control_nc00.00%added
testcases/emulator_verified/wos425_wip_patrick_health_nc.jsonwos425_wip_patrick_health_nc00.00%added
testcases/emulator_verified/wos425_wip_philly_defense.jsonwos425_wip_philly_defense0-0.02%added
testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.jsonwos425_wip_sergey_damage_taken_nc00.00%added
testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.jsonwos425_wip_sergey_patrick_combo_nc00.00%added
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo0-0.01%added
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_8400-0.28%added

Code / Config Changes

Commits (3a1224f5c70816e)

5c70816eAdd testcase stat rounding correctionpiddlyminx09/06/2026, 04:45:23

Code Changes (Run A → Run B)

Index: shared/fighters_data/fighters_heroes.json
===================================================================
--- shared/fighters_data/fighters_heroes.json prev run
+++ shared/fighters_data/fighters_heroes.json this run
@@ -0,0 +1,586 @@
+{
+ "max": {
+ "Jessie": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5
+ }
+ },
+ "Jasser": {
+ "skill_levels": {
+ "skill_1": 5
+ }
+ },
+ "Sergey": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5
+ }
+ },
+ "Molly": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Zinman": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Jeronimo": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Natalia": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Bahiti": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5
+ }
+ },
+ "Seo-yoon": {
+ "skill_levels": {
+ "skill_1": 5
+ }
+ },
+ "Lumak": {
+ "skill_levels": {
+ "skill_1": 5
+ }
+ },
+ "Ling": {
+ "skill_levels": {
+ "skill_1": 5
+ }
+ },
+ "Patrick": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5
+ }
+ },
+ "Flint": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Philly": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Alonso": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Logan": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Mia": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Greg": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Ahmose": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Lynn": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Reina": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Hector": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Norah": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Gwen": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Wayne": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Renee": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Wu Ming": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Edith": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Gordon": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ },
+ "Bradley": {
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5,
+ "skill_3": 5,
+ "skill_4": 5
+ }
+ }
+ },
+ "daut": {
+ "Jessie": {
+ "stats": {
+ "attack": 33.46,
+ "defense": 33.46
+ },
+ "skill_levels": {
+ "skill_1": 2,
+ "skill_2": 2
+ }
+ },
+ "Jasser": {
+ "stats": {
+ "attack": 50.96,
+ "defense": 50.96,
+ "lethality": 3.66,
+ "health": 3.66
+ },
+ "skill_levels": {
+ "skill_1": 2
+ }
+ },
+ "Sergey": {
+ "stats": {
+ "attack": 33.46,
+ "defense": 33.46,
+ "lethality": 5.33,
+ "health": 5.33
+ },
+ "skill_levels": {
+ "skill_1": 2,
+ "skill_2": 2
+ }
+ },
+ "Molly": {
+ "stats": {
+ "attack": 47.81,
+ "defense": 47.81
+ },
+ "skill_levels": {
+ "skill_1": 2,
+ "skill_2": 2
+ }
+ },
+ "Logan": {
+ "stats": {
+ "attack": 59.78,
+ "defense": 59.78
+ },
+ "skill_levels": {
+ "skill_1": 2,
+ "skill_2": 2
+ }
+ },
+ "Seo-yoon": {
+ "stats": {
+ "attack": 47.74,
+ "defense": 47.74
+ },
+ "skill_levels": {
+ "skill_1": 3
+ }
+ },
+ "Lumak": {
+ "stats": {
+ "attack": 44.52,
+ "defense": 44.52
+ },
+ "skill_levels": {
+ "skill_1": 2
+ }
+ },
+ "Alonso": {
+ "stats": {
+ "attack": 76.32,
+ "defense": 76.32
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 1
+ }
+ },
+ "Bahiti": {
+ "stats": {
+ "attack": 40.37,
+ "defense": 40.37
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 1
+ }
+ },
+ "Mia": {
+ "stats": {
+ "attack": 36.61,
+ "defense": 36.61
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 0,
+ "skill_3": 0
+ }
+ },
+ "Ling": {
+ "stats": {
+ "attack": 17.67,
+ "defense": 17.67
+ },
+ "skill_levels": {
+ "skill_1": 1
+ }
+ },
+ "Patrick": {
+ "stats": {
+ "attack": 17.67,
+ "defense": 17.67
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 0
+ }
+ },
+ "Flint": {
+ "stats": {
+ "attack": 30.3,
+ "defense": 30.3
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 0,
+ "skill_3": 0
+ }
+ }
+ },
+ "viper": {
+ "Hector": {
+ "stats": {
+ "attack": 253.65,
+ "defense": 253.65,
+ "lethality": 20.33,
+ "health": 118.43
+ },
+ "skill_levels": {
+ "skill_1": 4,
+ "skill_2": 3,
+ "skill_3": 3
+ }
+ },
+ "Molly": {
+ "stats": {
+ "attack": 147.79,
+ "defense": 147.79,
+ "lethality": 74.32,
+ "health": 18.17
+ },
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 4,
+ "skill_3": 5
+ }
+ },
+ "Bahiti": {
+ "stats": {
+ "attack": 109.77,
+ "defense": 109.77,
+ "lethality": 45.65,
+ "health": 13.17
+ },
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 4
+ }
+ },
+ "Sergey": {
+ "stats": {
+ "attack": 116.09,
+ "defense": 116.09,
+ "lethality": 5.33,
+ "health": 18.36
+ },
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 5
+ }
+ },
+ "Flint": {
+ "stats": {
+ "attack": 129.37,
+ "defense": 129.37
+ },
+ "skill_levels": {
+ "skill_1": 4,
+ "skill_2": 4,
+ "skill_3": 4
+ }
+ },
+ "Mia": {
+ "stats": {
+ "attack": 74.09,
+ "defense": 74.09
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "Seo-yoon": {
+ "stats": {
+ "attack": 70.95,
+ "defense": 70.95,
+ "lethality": 2.66
+ },
+ "skill_levels": {
+ "skill_1": 1
+ }
+ },
+ "Jasser": {
+ "stats": {
+ "attack": 70.95,
+ "defense": 70.95
+ },
+ "skill_levels": {
+ "skill_1": 2
+ }
+ },
+ "Ahmose": {
+ "stats": {
+ "attack": 55.4,
+ "defense": 55.4
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 0,
+ "skill_3": 0
+ }
+ },
+ "Lumak": {
+ "stats": {
+ "attack": 66.44,
+ "defense": 66.44
+ },
+ "skill_levels": {
+ "skill_1": 1
+ }
+ },
+ "Ling": {
+ "stats": {
+ "attack": 60.64,
+ "defense": 60.64
+ },
+ "skill_levels": {
+ "skill_1": 1
+ }
+ },
+ "Zinman": {
+ "stats": {
+ "attack": 63.59,
+ "defense": 63.59
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_3": 1
+ }
+ },
+ "Greg": {
+ "stats": {
+ "attack": 69.32,
+ "defense": 69.32
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "Logan": {
+ "stats": {
+ "attack": 69.32,
+ "defense": 69.32
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "Philly": {
+ "stats": {
+ "attack": 53.42,
+ "defense": 53.42
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 1,
+ "skill_3": 0
+ }
+ },
+ "Alonso": {
+ "stats": {
+ "attack": 49.47,
+ "defense": 49.47
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 2
+ }
+ },
+ "Jessie": {
+ "stats": {
+ "attack": 103.45,
+ "defense": 103.45
+ },
+ "skill_levels": {
+ "skill_1": 5,
+ "skill_2": 2
+ }
+ },
+ "Patrick": {
+ "stats": {
+ "attack": 97.13,
+ "defense": 97.13
+ },
+ "skill_levels": {
+ "skill_1": 2,
+ "skill_2": 1
+ }
+ },
+ "Natalia": {
+ "stats": {
+ "attack": 41.23,
+ "defense": 41.23
+ },
+ "skill_levels": {
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 0
+ }
+ },
+ "Norah": {
+ "stats": {
+ "attack": 56.05,
+ "defense": 56.05
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 0,
+ "skill_3": 0
+ }
+ },
+ "Reina": {
+ "stats": {
+ "attack": 76.27,
+ "defense": 76.27
+ },
+ "skill_levels": {
+ "skill_1": 1,
+ "skill_2": 2,
+ "skill_3": 0
+ }
+ }
+ }
+}
Index: shared/fighters_data/fighters_stats.json
===================================================================
--- shared/fighters_data/fighters_stats.json prev run
+++ shared/fighters_data/fighters_stats.json this run
@@ -0,0 +1,227 @@
+{
+ "max": {
+ "inf": [1400.98, 1432.28, 1806.6, 1791.5],
+ "lanc": [1400.98, 1432.28, 1756.8, 1722.5],
+ "mark": [1400.98, 1431.28, 1806.4, 1785.4]
+ },
+ "daut": {
+ "inf": [
+ 9.94,
+ 9.44,
+ 1.75,
+ 1.75
+ ],
+ "lanc": [
+ 9.94,
+ 9.44,
+ 1.75,
+ 1.75
+ ],
+ "mark": [
+ 10.43,
+ 9.44,
+ 1.75,
+ 1.75
+ ]
+ },
+ "viper": {
+ "inf": [
+ 184.5,
+ 188.0,
+ 107.24,
+ 114.18
+ ],
+ "lanc": [
+ 167.0,
+ 163.0,
+ 110.31,
+ 100.21
+ ],
+ "mark": [
+ 174.75,
+ 168.25,
+ 117.75,
+ 104.74
+ ]
+ },
+ "Nitro": {
+ "inf": [
+ 602.82,
+ 607.82,
+ 420.22,
+ 403.0
+ ],
+ "lanc": [
+ 557.32,
+ 554.32,
+ 368.26,
+ 360.39
+ ],
+ "mark": [
+ 557.32,
+ 564.82,
+ 388.3,
+ 372.94
+ ]
+ },
+ "Beast_22": {
+ "infantry": [
+ 144.0,
+ 144.0,
+ 144.0,
+ 144.0
+ ],
+ "lancers": [
+ 144.0,
+ 144.0,
+ 144.0,
+ 144.0
+ ],
+ "marksmen": [
+ 144.0,
+ 144.0,
+ 144.0,
+ 144.0
+ ]
+ },
+ "Beast_30": {
+ "infantry": [
+ 455.0,
+ 455.0,
+ 455.0,
+ 455.0
+ ],
+ "lancers": [
+ 455.0,
+ 455.0,
+ 455.0,
+ 455.0
+ ],
+ "marksmen": [
+ 455.0,
+ 455.0,
+ 455.0,
+ 455.0
+ ]
+ },
+ "Kill": {
+ "inf": [
+ 119.1,
+ 123.6,
+ 47.1,
+ 54.3
+ ],
+ "lanc": [
+ 110.1,
+ 116.1,
+ 31.4,
+ 34.5
+ ],
+ "mark": [
+ 111.1,
+ 116.6,
+ 85.4,
+ 90.9
+ ]
+ },
+ "Beast_15": {
+ "infantry": [
+ 26.5,
+ 26.5,
+ 26.5,
+ 26.5
+ ],
+ "lancers": [
+ 26.5,
+ 26.5,
+ 26.5,
+ 26.5
+ ],
+ "marksmen": [
+ 26.5,
+ 26.5,
+ 26.5,
+ 26.5
+ ]
+ },
+ "Rapi": {
+ "inf": [
+ 379.4,
+ 409.9,
+ 293.6,
+ 325.4
+ ],
+ "lanc": [
+ 55.0,
+ 55.0,
+ 35.0,
+ 35.0
+ ],
+ "mark": [
+ 55.0,
+ 55.0,
+ 35.0,
+ 35.0
+ ]
+ },
+ "Beast_18": {
+ "infantry": [
+ 57.0,
+ 57.0,
+ 57.0,
+ 57.0
+ ],
+ "lancers": [
+ 57.0,
+ 57.0,
+ 57.0,
+ 57.0
+ ],
+ "marksmen": [
+ 57.0,
+ 57.0,
+ 57.0,
+ 57.0
+ ]
+ },
+ "Attacker_neutral": {
+ "inf": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "lanc": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "mark": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ]
+ },
+ "Defender_neutral": {
+ "inf": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "lanc": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "mark": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ]
+ }
+}
\ No newline at end of file
Index: simulator/config/hero_definitions/Ahmose.json
===================================================================
--- simulator/config/hero_definitions/Ahmose.json prev run
+++ simulator/config/hero_definitions/Ahmose.json this run
@@ -0,0 +1,167 @@
+{
+ "name": "Ahmose",
+ "hero_generation": "S4",
+ "troop_type": "infantry",
+ "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",
+ "every": 4,
+ "source": "infantry"
+ },
+ "effects": {
+ "ViperFormation/1": {
+ "type": "no_attack",
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ }
+ },
+ "ViperFormation/2": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 10,
+ 15,
+ 20,
+ 25,
+ 30
+ ],
+ "units": {
+ "applies_to": [
+ "lancer",
+ "marksman"
+ ]
+ },
+ "duration": {
+ "type": "turn",
+ "value": 2,
+ "delay": 1
+ },
+ "same_effect_stacking": "max"
+ },
+ "ViperFormation/3": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 10,
+ 25,
+ 40,
+ 55,
+ 70
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ },
+ "duration": {
+ "type": "turn",
+ "value": 2,
+ "delay": 1
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "PrayerOfFlame": {
+ "description": "Ahmose amplifies the combat spirit of friendly infantry with the power of Fire Crystal, increasing their damage dealt by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "PrayerOfFlame/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ }
+ }
+ }
+ },
+ "BladeOfLight": {
+ "description": "Increases his infantries' damage dealt per attack by X% and the target's damage taken by X% for 1 turn",
+ "trigger": {
+ "type": "attack",
+ "source": "infantry"
+ },
+ "effects": {
+ "BladeOfLight/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 12,
+ 24,
+ 36,
+ 48,
+ 60
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ }
+ },
+ "BladeOfLight/2": {
+ "type": "active.hero.defense.down",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "units": {
+ "applies_to": "target"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1,
+ "delay": 1
+ },
+ "same_effect_stacking": "add"
+ }
+ }
+ },
+ "GuardiansVitality": {
+ "description": "Widget bonus: Increase Health by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "GuardiansVitality/1": {
+ "type": "passive.health.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Alonso.json
===================================================================
--- simulator/config/hero_definitions/Alonso.json prev run
+++ simulator/config/hero_definitions/Alonso.json this run
@@ -0,0 +1,141 @@
+{
+ "name": "Alonso",
+ "hero_generation": "S2",
+ "troop_type": "marksman",
+ "skills": {
+ "Onslaught": {
+ "description": "Granting a 40% chance of increasing all troop's Lethality by X%",
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 40,
+ 40,
+ 40,
+ 40,
+ 40
+ ],
+ "source": "self.any"
+ },
+ "effects": {
+ "Onslaught/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "trigger",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ }
+ }
+ }
+ },
+ "IronStrength": {
+ "description": "Grants all troops' attack a 20% chance of reducing damage dealt by X% for all enemy troops for 2 turns",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 20,
+ 20,
+ 20,
+ 20,
+ 20
+ ]
+ },
+ "effects": {
+ "IronStrength/1": {
+ "type": "active.hero.lethality.down",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "target",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 2,
+ "delay": 1
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "PoisonHarpoon": {
+ "description": "Grants all troops attack a 50% chance of dealing X% more damage",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 50,
+ 50,
+ 50,
+ 50,
+ 50
+ ]
+ },
+ "effects": {
+ "PoisonHarpoon/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "same_effect_stacking": "max",
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ },
+ "RazorVolley": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when attacking",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "RazorVolley/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Bahiti.json
===================================================================
--- simulator/config/hero_definitions/Bahiti.json prev run
+++ simulator/config/hero_definitions/Bahiti.json this run
@@ -0,0 +1,64 @@
+{
+ "name": "Bahiti",
+ "hero_generation": "SR",
+ "troop_type": "marksman",
+ "skills": {
+ "SixthSense": {
+ "description": "Reducing damage taken by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "SixthSense/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ]
+ }
+ }
+ },
+ "Fluorescence": {
+ "description": "Grants all troops' attack a 50% chance of increasing damage dealt by X%",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 50,
+ 50,
+ 50,
+ 50,
+ 50
+ ]
+ },
+ "effects": {
+ "Fluorescence/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Bradley.json
===================================================================
--- simulator/config/hero_definitions/Bradley.json prev run
+++ simulator/config/hero_definitions/Bradley.json this run
@@ -0,0 +1,112 @@
+{
+ "name": "Bradley",
+ "hero_generation": "S7",
+ "troop_type": "marksman",
+ "skills": {
+ "VeteransMight": {
+ "description": "Bradley's years of combat experience enables him to destroy enemies efficiently, increasing Attack by X% for all troops.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "VeteransMight/1": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "PowerShot": {
+ "description": "Bradley uses his expertise in suppressive artillery against the enemy vanguard, increasing Damage Dealt to Lancers by X% and to Infantry by Y% for all troops.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "PowerShot/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "units": {
+ "applies_vs": [
+ "lancer"
+ ]
+ }
+ },
+ "PowerShot/2": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "units": {
+ "applies_vs": [
+ "infantry"
+ ]
+ }
+ }
+ }
+ },
+ "TacticalAssistance": {
+ "description": "Bradley will press every advantage against a beleaguered enemy, increasing Damage Dealt by X% for all troops for 2 turns every 4 turns.",
+ "trigger": {
+ "type": "turn",
+ "every": 4
+ },
+ "effects": {
+ "TacticalAssistance/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "duration": {
+ "type": "turn",
+ "value": 2
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "SiegeInsight": {
+ "description": "Widget bonus: Increase Attack by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "SiegeInsight/1": {
+ "type": "passive.attack.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Edith.json
===================================================================
--- simulator/config/hero_definitions/Edith.json prev run
+++ simulator/config/hero_definitions/Edith.json this run
@@ -0,0 +1,112 @@
+{
+ "name": "Edith",
+ "hero_generation": "S7",
+ "troop_type": "infantry",
+ "skills": {
+ "StrategicBalance": {
+ "description": "Mr. Tin's colossal presence automatically shields friendly ranged units, reducing damage taken by X% for Marksmen, and suppresses the enemy, increasing damage dealt by Y% for Lancers.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "StrategicBalance/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": [
+ "marksman"
+ ]
+ }
+ },
+ "StrategicBalance/2": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": "any"
+ }
+ }
+ }
+ },
+ "Ironclad": {
+ "description": "Mr. Tin's metallic body functions as a fortified wall on the field, reducing damage taken by X% for Infantry.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Ironclad/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ }
+ }
+ }
+ },
+ "SteelSentinel": {
+ "description": "Edith's mobile defense system is reliable, increasing Health by X% for all troops.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "SteelSentinel/1": {
+ "type": "active.hero.health.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "Fortworks": {
+ "description": "Widget bonus: Increase Health by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "Fortworks/1": {
+ "type": "passive.health.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Flint.json
===================================================================
--- simulator/config/hero_definitions/Flint.json prev run
+++ simulator/config/hero_definitions/Flint.json this run
@@ -0,0 +1,92 @@
+{
+ "name": "Flint",
+ "hero_generation": "S2",
+ "troop_type": "infantry",
+ "skills": {
+ "Pyromaniac": {
+ "description": "increases his Infantry's Damage Dealt by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Pyromaniac/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ],
+ "applies_vs": "any"
+ }
+ }
+ }
+ },
+ "BurningResolve": {
+ "description": "Increasing Attack by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "BurningResolve/1": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "Immolation": {
+ "description": "Increasing all troops' Lethality by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Immolation/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "FortressSpear": {
+ "description": "Widget bonus: Increase Attack by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "FortressSpear/1": {
+ "type": "passive.attack.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Gordon.json
===================================================================
--- simulator/config/hero_definitions/Gordon.json prev run
+++ simulator/config/hero_definitions/Gordon.json this run
@@ -0,0 +1,174 @@
+{
+ "name": "Gordon",
+ "hero_generation": "S7",
+ "troop_type": "lancer",
+ "skills": {
+ "VenomInfusion": {
+ "description": "Every 2 attacks, Lancers deal X% extra damage and apply poison to the target for 1 turn. Poisoned enemies deal Y% less damage.",
+ "trigger": {
+ "type": "attack",
+ "every": 2,
+ "source": "lancer"
+ },
+ "effects": {
+ "VenomInfusion/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ }
+ },
+ "VenomInfusion/2": {
+ "type": "active.hero.lethality.down",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": "trigger.target",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ }
+ }
+ }
+ },
+ "ChemicalTerror": {
+ "description": "Gordon's envenomed weapons terrorizes the field, increasing Lancers' Damage Dealt by X% and reducing Damage Dealt by enemy troops by Y% for 1 turn every 3 turns.",
+ "trigger": {
+ "type": "turn",
+ "every": 3
+ },
+ "effects": {
+ "ChemicalTerror/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 30,
+ 60,
+ 90,
+ 120,
+ 150
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max"
+ },
+ "ChemicalTerror/2": {
+ "type": "active.hero.lethality.down",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "units": {
+ "applies_to": "enemy.any",
+ "applies_vs": "self.any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "ToxicRelease": {
+ "description": "Gordon generates a defensive bio-toxic fog, increasing Damage Taken by enemy Infantry by X% while reducing Damage Dealt by enemy Marksmen by Y% for 2 turns every 4 turns.",
+ "trigger": {
+ "type": "turn",
+ "every": 4
+ },
+ "effects": {
+ "ToxicRelease/1": {
+ "type": "active.hero.defense.down",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "units": {
+ "applies_to": "enemy.infantry"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 2
+ },
+ "same_effect_stacking": "max"
+ },
+ "ToxicRelease/2": {
+ "type": "active.hero.lethality.down",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "units": {
+ "applies_to": "enemy.marksman"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 2
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "BioAssault": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "BioAssault/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Greg.json
===================================================================
--- simulator/config/hero_definitions/Greg.json prev run
+++ simulator/config/hero_definitions/Greg.json this run
@@ -0,0 +1,118 @@
+{
+ "name": "Greg",
+ "hero_generation": "S3",
+ "troop_type": "marksman",
+ "skills": {
+ "SwordOfJustice": {
+ "description": "Granting a 20% chance of increasing damage dealt by X% for all troops for 3 turns",
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 20,
+ 20,
+ 20,
+ 20,
+ 20
+ ]
+ },
+ "effects": {
+ "SwordOfJustice/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 8,
+ 16,
+ 24,
+ 32,
+ 40
+ ],
+ "same_effect_stacking": "add",
+ "units": {
+ "applies_to": "all",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 3
+ }
+ }
+ }
+ },
+ "DeterrenceOfLaw": {
+ "description": "Granting all troops' attack a 20% chance of reducing enemy damage dealt by X% for 2 turns",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 20,
+ 20,
+ 20,
+ 20,
+ 20
+ ]
+ },
+ "effects": {
+ "DeterrenceOfLaw/1": {
+ "type": "active.hero.lethality.down",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "same_effect_stacking": "max",
+ "units": {
+ "applies_to": "trigger.target",
+ "applies_vs": "self.any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 2
+ }
+ }
+ }
+ },
+ "LawAndOrder": {
+ "description": "Increasing Health by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "LawAndOrder/2": {
+ "type": "active.hero.health.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "VitalSurge": {
+ "description": "Widget bonus: Increase Health by X% for all troops when attacking",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "VitalSurge/1": {
+ "type": "passive.health.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Gwen.json
===================================================================
--- simulator/config/hero_definitions/Gwen.json prev run
+++ simulator/config/hero_definitions/Gwen.json this run
@@ -0,0 +1,145 @@
+{
+ "name": "Gwen",
+ "hero_generation": "S5",
+ "troop_type": "marksman",
+ "skills": {
+ "EagleVision": {
+ "description": "Increasing target's damage taken by X%",
+ "trigger": {
+ "type": "attack"
+ },
+ "effects": {
+ "EagleVision/1": {
+ "type": "active.hero.defense.down",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "units": {
+ "applies_to": "target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ }
+ }
+ }
+ },
+ "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": "attack",
+ "every": 5
+ },
+ "effects": {
+ "AirDominance/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ },
+ "AirDominance/2": {
+ "type": "active.hero.defense.down",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ],
+ "units": {
+ "applies_to": "target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1,
+ "delay": 1
+ }
+ }
+ }
+ },
+ "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",
+ "every": 4,
+ "source": "marksman"
+ },
+ "effects": {
+ "Blastmaster/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": [
+ "marksman"
+ ],
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1,
+ "delay": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "enemy.living"
+ }
+ ]
+ }
+ }
+ },
+ "LethalPrecision": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when attacking",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "LethalPrecision/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Hector.json
===================================================================
--- simulator/config/hero_definitions/Hector.json prev run
+++ simulator/config/hero_definitions/Hector.json this run
@@ -0,0 +1,159 @@
+{
+ "name": "Hector",
+ "hero_generation": "S5",
+ "troop_type": "infantry",
+ "skills": {
+ "SurvivalInstincts": {
+ "description": "Hector's presence grants a 40% chance of reducing damage taken by X% for all troops",
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 40,
+ 40,
+ 40,
+ 40,
+ 40
+ ]
+ },
+ "effects": {
+ "SurvivalInstincts/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "Rampant": {
+ "description": "Increases infantry's damage dealt by X% and marksmen's damage dealt by X%, it is effective for 10 attacks, with each attack's damage boost being 85% of the previous one.",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Rampant/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 100,
+ 125,
+ 150,
+ 175,
+ 200
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ],
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 10
+ },
+ "value_evolution": {
+ "type": "pct_decay",
+ "step": "attack",
+ "value": 15
+ }
+ },
+ "Rampant/2": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": [
+ "marksman"
+ ],
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 10
+ },
+ "value_evolution": {
+ "type": "pct_decay",
+ "step": "attack",
+ "value": 15
+ }
+ }
+ }
+ },
+ "Blitz": {
+ "description": "Grants all troops' attack a 25% chance of dealing X% damage",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 25,
+ 25,
+ 25,
+ 25,
+ 25
+ ]
+ },
+ "effects": {
+ "Blitz/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ },
+ "BulwarkCommander": {
+ "description": "Widget bonus: Increase Attack by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "BulwarkCommander/1": {
+ "type": "passive.attack.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Jasser.json
===================================================================
--- simulator/config/hero_definitions/Jasser.json prev run
+++ simulator/config/hero_definitions/Jasser.json this run
@@ -0,0 +1,32 @@
+{
+ "name": "Jasser",
+ "hero_generation": "SR",
+ "troop_type": "marksman",
+ "skills": {
+ "TacticalGenius": {
+ "description": "Increasing damage dealt by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "TacticalGenius/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "NonCombatPlaceholder": {
+ "description": "Placeholder skill to satisfy simulator config for non-combat hero data",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {}
+ }
+ }
+}
Index: simulator/config/hero_definitions/Jeronimo.json
===================================================================
--- simulator/config/hero_definitions/Jeronimo.json prev run
+++ simulator/config/hero_definitions/Jeronimo.json this run
@@ -0,0 +1,95 @@
+{
+ "name": "Jeronimo",
+ "hero_generation": "S1_jeronimo",
+ "troop_type": "infantry",
+ "skills": {
+ "BattleManifesto": {
+ "description": "Increasing damage dealt by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "BattleManifesto/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "Swordmentor": {
+ "description": "Increasing Attack by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Swordmentor/1": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "EXpertSwordsmanship": {
+ "description": "Increasing Damage Dealt by X% for all troops for 2 turns every 4 turns",
+ "trigger": {
+ "type": "turn",
+ "every": 4
+ },
+ "effects": {
+ "EXpertSwordsmanship/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "units": {
+ "applies_to": "all",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 2
+ }
+ }
+ }
+ },
+ "BattleStance": {
+ "description": "Widget bonus: Increase Attack by X% for all troops when attacking",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "BattleStance/1": {
+ "type": "passive.attack.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Jessie.json
===================================================================
--- simulator/config/hero_definitions/Jessie.json prev run
+++ simulator/config/hero_definitions/Jessie.json this run
@@ -0,0 +1,43 @@
+{
+ "name": "Jessie",
+ "hero_generation": "SR",
+ "troop_type": "lancer",
+ "skills": {
+ "StandOfArms": {
+ "description": "Increasing damage dealt by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "StandOfArms/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "Bulwarks": {
+ "description": "Reducing damage taken by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Bulwarks/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Ling.json
===================================================================
--- simulator/config/hero_definitions/Ling.json prev run
+++ simulator/config/hero_definitions/Ling.json this run
@@ -0,0 +1,35 @@
+{
+ "name": "Ling",
+ "hero_generation": "SR",
+ "troop_type": "lancer",
+ "skills": {
+ "FearsomeAura": {
+ "description": "Reducing all enemy Troops' Attack by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "FearsomeAura/1": {
+ "type": "active.hero.attack.down",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": "enemy.any"
+ }
+ }
+ }
+ },
+ "NonCombatPlaceholder": {
+ "description": "Placeholder skill to satisfy simulator config for non-combat hero data",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {}
+ }
+ }
+}
Index: simulator/config/hero_definitions/Logan.json
===================================================================
--- simulator/config/hero_definitions/Logan.json prev run
+++ simulator/config/hero_definitions/Logan.json this run
@@ -0,0 +1,89 @@
+{
+ "name": "Logan",
+ "hero_generation": "S3",
+ "troop_type": "infantry",
+ "skills": {
+ "LionStrike": {
+ "description": "Reducing all enemy Troops' Attack by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "LionStrike/1": {
+ "type": "active.hero.attack.down",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": "enemy.any"
+ }
+ }
+ }
+ },
+ "LionIntimidation": {
+ "description": "Reducing damage taken by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "LionIntimidation/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ]
+ }
+ }
+ },
+ "LeaderInspiration": {
+ "description": "Increasing Health by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "LeaderInspiration/1": {
+ "type": "active.hero.health.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "IronDen": {
+ "description": "Widget bonus: Increase Defense by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "IronDen/1": {
+ "type": "passive.defense.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Lumak.json
===================================================================
--- simulator/config/hero_definitions/Lumak.json prev run
+++ simulator/config/hero_definitions/Lumak.json this run
@@ -0,0 +1,35 @@
+{
+ "name": "Lumak",
+ "hero_generation": "SR",
+ "troop_type": "lancer",
+ "skills": {
+ "TacticalDeception": {
+ "description": "All enemy troops' damage dealt is reduced by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "TacticalDeception/1": {
+ "type": "active.hero.lethality.down",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": "enemy.any"
+ }
+ }
+ }
+ },
+ "NonCombatPlaceholder": {
+ "description": "Placeholder skill to satisfy simulator config for non-combat hero data",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {}
+ }
+ }
+}
Index: simulator/config/hero_definitions/Lynn.json
===================================================================
--- simulator/config/hero_definitions/Lynn.json prev run
+++ simulator/config/hero_definitions/Lynn.json this run
@@ -0,0 +1,109 @@
+{
+ "name": "Lynn",
+ "hero_generation": "S4",
+ "troop_type": "marksman",
+ "skills": {
+ "SongOfLion": {
+ "description": "Granting a 40% chance of increasing damage dealt by X% for all troops",
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 40,
+ 40,
+ 40,
+ 40,
+ 40
+ ]
+ },
+ "effects": {
+ "SongOfLion/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "MelancholicBallad": {
+ "description": "Reducing damage dealt by X% for all enemy troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "MelancholicBallad/1": {
+ "type": "active.hero.lethality.down",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": "enemy.any"
+ }
+ }
+ }
+ },
+ "OonaiCadenza": {
+ "description": "Increases her marksmen attack by X% for every 3 attacks. Stackable, and lasts until the end of battle",
+ "trigger": {
+ "type": "attack",
+ "every": 3,
+ "source": "marksman"
+ },
+ "effects": {
+ "OonaiCadenza/1": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5
+ ],
+ "units": {
+ "applies_to": [
+ "marksman"
+ ]
+ },
+ "same_effect_stacking": "add"
+ }
+ }
+ },
+ "SilentThreat": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "SilentThreat/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Mia.json
===================================================================
--- simulator/config/hero_definitions/Mia.json prev run
+++ simulator/config/hero_definitions/Mia.json this run
@@ -0,0 +1,135 @@
+{
+ "name": "Mia",
+ "hero_generation": "S3",
+ "troop_type": "lancer",
+ "skills": {
+ "BadLuckStreak": {
+ "description": "Grants all troops attack a 50% chance of cursing the target, increasing their damage taken by X%",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 50,
+ 50,
+ 50,
+ 50,
+ 50
+ ]
+ },
+ "effects": {
+ "BadLuckStreak/1": {
+ "type": "active.hero.defense.down",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "target"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "LuckyCharm": {
+ "description": "Grants a 50% chance of boosting troops' Attack by X%",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 50,
+ 50,
+ 50,
+ 50,
+ 50
+ ]
+ },
+ "effects": {
+ "LuckyCharm/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max",
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
+ }
+ }
+ },
+ "RitualDeciphering": {
+ "description": "Grants a 40% chance of reducing damage taken by X% for all troops",
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 40,
+ 40,
+ 40,
+ 40,
+ 40
+ ]
+ },
+ "effects": {
+ "RitualDeciphering/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "PrecisionDrive": {
+ "description": "Widget bonus: Increase Attack by X% for all troops when attacking",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "PrecisionDrive/1": {
+ "type": "passive.attack.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Molly.json
===================================================================
--- simulator/config/hero_definitions/Molly.json prev run
+++ simulator/config/hero_definitions/Molly.json this run
@@ -0,0 +1,121 @@
+{
+ "name": "Molly",
+ "hero_generation": "S1",
+ "troop_type": "lancer",
+ "skills": {
+ "CallingOfTheStorm": {
+ "description": "granting a 40% chance of reducing all troops' Damage taken by X%",
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 40,
+ 40,
+ 40,
+ 40,
+ 40
+ ]
+ },
+ "effects": {
+ "CallingOfTheStorm/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "all"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ }
+ }
+ }
+ },
+ "IceDominion": {
+ "description": "Granting all troops' attack a 50% chance of increasing damage dealt by X%",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 50,
+ 50,
+ 50,
+ 50,
+ 50
+ ]
+ },
+ "effects": {
+ "IceDominion/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ },
+ "YouthfulRage": {
+ "description": "Increasing damage dealt by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "YouthfulRage/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "WinterFang": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "WinterFang/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Natalia.json
===================================================================
--- simulator/config/hero_definitions/Natalia.json prev run
+++ simulator/config/hero_definitions/Natalia.json this run
@@ -0,0 +1,100 @@
+{
+ "name": "Natalia",
+ "hero_generation": "S1_natalia",
+ "troop_type": "infantry",
+ "skills": {
+ "WildlingRoar": {
+ "description": "granting a 40% chance of reducing all troops' Damage taken by X%",
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 40,
+ 40,
+ 40,
+ 40,
+ 40
+ ]
+ },
+ "effects": {
+ "WildlingRoar/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "all"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ }
+ }
+ }
+ },
+ "QueenOfTheWild": {
+ "description": "Increasing Attack by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "QueenOfTheWild/1": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "CallOfTheWild": {
+ "description": "Increasing damage dealt by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "CallOfTheWild/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "GaleForce": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "GaleForce/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Norah.json
===================================================================
--- simulator/config/hero_definitions/Norah.json prev run
+++ simulator/config/hero_definitions/Norah.json this run
@@ -0,0 +1,159 @@
+{
+ "name": "Norah",
+ "hero_generation": "S5",
+ "troop_type": "lancer",
+ "skills": {
+ "CombinedArms": {
+ "description": "Decreasing damage taken by X% and boosting damage dealt by X% for infantry and marksmen",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "CombinedArms/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 3,
+ 6,
+ 9,
+ 12,
+ 15
+ ],
+ "units": {
+ "applies_to": [
+ "infantry",
+ "marksman"
+ ]
+ }
+ },
+ "CombinedArms/2": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 3,
+ 6,
+ 9,
+ 12,
+ 15
+ ],
+ "units": {
+ "applies_to": [
+ "infantry",
+ "marksman"
+ ],
+ "applies_vs": "any"
+ }
+ }
+ }
+ },
+ "SneakStrike": {
+ "description": "Granting her lancers a 20% chance of dealing X% extra damage to all enemies on attack",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 20,
+ 20,
+ 20,
+ 20,
+ 20
+ ],
+ "source": "lancer"
+ },
+ "effects": {
+ "SneakStrike/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max",
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "enemy.living"
+ }
+ ]
+ }
+ }
+ },
+ "Momentum": {
+ "description": "Increasing all troops damage dealt by X% and reducing their damage taken by X%, every five attacks made by lancers for 2 turns",
+ "trigger": {
+ "type": "attack",
+ "every": 5,
+ "source": "lancer"
+ },
+ "effects": {
+ "Momentum/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "units": {
+ "applies_to": "all",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 2
+ }
+ },
+ "Momentum/2": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "duration": {
+ "type": "turn",
+ "value": 2,
+ "delay": 1
+ }
+ }
+ }
+ },
+ "SteadfastGuard": {
+ "description": "Widget bonus: Increase Defense by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "SteadfastGuard/1": {
+ "type": "passive.defense.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Patrick.json
===================================================================
--- simulator/config/hero_definitions/Patrick.json prev run
+++ simulator/config/hero_definitions/Patrick.json this run
@@ -0,0 +1,43 @@
+{
+ "name": "Patrick",
+ "hero_generation": "SR",
+ "troop_type": "lancer",
+ "skills": {
+ "SuperNutrients": {
+ "description": "Increasing Health by X% for all troop",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "SuperNutrients/1": {
+ "type": "active.hero.health.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "CaloricBooster": {
+ "description": "Increasing Attack by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "CaloricBooster/1": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Philly.json
===================================================================
--- simulator/config/hero_definitions/Philly.json prev run
+++ simulator/config/hero_definitions/Philly.json this run
@@ -0,0 +1,131 @@
+{
+ "name": "Philly",
+ "hero_generation": "S2",
+ "troop_type": "lancer",
+ "skills": {
+ "VigorTactics": {
+ "description": "Increasing Attack by X% and Defense by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "VigorTactics/1": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 3,
+ 6,
+ 9,
+ 12,
+ 15
+ ]
+ },
+ "VigorTactics/2": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 2,
+ 4,
+ 6,
+ 8,
+ 10
+ ]
+ }
+ }
+ },
+ "DosageBoost": {
+ "description": "Granting all troops' attack a 25% chance of dealing X% damage",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 25,
+ 25,
+ 25,
+ 25,
+ 25
+ ]
+ },
+ "effects": {
+ "DosageBoost/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ },
+ "NumbingSpores": {
+ "description": "granting a 40% chance of reducing all troops' Damage taken by X%",
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 40,
+ 40,
+ 40,
+ 40,
+ 40
+ ]
+ },
+ "effects": {
+ "NumbingSpores/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 10,
+ 20,
+ 30,
+ 40,
+ 50
+ ],
+ "units": {
+ "applies_to": "all"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ }
+ }
+ }
+ },
+ "GuardianBloom": {
+ "description": "Widget bonus: Increase Health by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "GuardianBloom/1": {
+ "type": "passive.health.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Reina.json
===================================================================
--- simulator/config/hero_definitions/Reina.json prev run
+++ simulator/config/hero_definitions/Reina.json this run
@@ -0,0 +1,126 @@
+{
+ "name": "Reina",
+ "hero_generation": "S4",
+ "troop_type": "lancer",
+ "skills": {
+ "AssassinsInstinct": {
+ "description": "Increasing normal attack damage by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "AssassinsInstinct/1": {
+ "type": "type.normal.damage.up",
+ "value": [
+ 10,
+ 15,
+ 20,
+ 25,
+ 30
+ ],
+ "units": {
+ "applies_to": "all",
+ "applies_vs": "any"
+ }
+ }
+ }
+ },
+ "SwiftJive": {
+ "description": "TBD dodge semantics: Grants all troops a X% chance of dodging normal attacks",
+ "status": "tbd",
+ "notes": "Modeled as an enemy attack trigger with trigger-level probability. Exact dodge response timing remains TBD for implementation.",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "source": "enemy.any",
+ "target": "self.any"
+ },
+ "effects": {
+ "SwiftJive/1": {
+ "type": "dodge",
+ "units": {
+ "applies_to": "trigger"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ }
+ }
+ }
+ },
+ "ShadowBlade": {
+ "description": "With Reina's clever tactics, her Lancers have a 25% chance of performing an extra attack, dealing X% damage",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 25,
+ 25,
+ 25,
+ 25,
+ 25
+ ],
+ "source": "lancer"
+ },
+ "effects": {
+ "ShadowBlade/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ },
+ "same_effect_stacking": "max",
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ },
+ "LancersEdge": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when attacking",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "LancersEdge/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Renee.json
===================================================================
--- simulator/config/hero_definitions/Renee.json prev run
+++ simulator/config/hero_definitions/Renee.json this run
@@ -0,0 +1,130 @@
+{
+ "name": "Renee",
+ "hero_generation": "S6",
+ "troop_type": "lancer",
+ "skills": {
+ "NightmareTrace": {
+ "description": "Troops place Dream Marks on targets every two turns, dealing extra Lancer damage once next turn",
+ "trigger": {
+ "type": "turn",
+ "every": 2
+ },
+ "effects": {
+ "NightmareTrace/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 40,
+ 80,
+ 120,
+ 160,
+ 200
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1,
+ "delay": 1
+ },
+ "same_effect_stacking": "max",
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
+ }
+ }
+ },
+ "Dreamcatcher": {
+ "description": "Dream Marks highlight enemy vulnerabilities, increasing damage dealt by Lancers to marked targets",
+ "trigger": {
+ "type": "turn",
+ "every": 2
+ },
+ "effects": {
+ "Dreamcatcher/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 30,
+ 60,
+ 90,
+ 120,
+ 150
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1,
+ "delay": 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": "self.any"
+ },
+ "effects": {
+ "Dreamslice/1": {
+ "type": "active.hero.defense.down",
+ "value": [
+ 15,
+ 30,
+ 45,
+ 60,
+ 75
+ ],
+ "units": {
+ "applies_to": "target",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1,
+ "delay": 1
+ },
+ "same_effect_stacking": "max"
+ }
+ }
+ },
+ "DreamfangRally": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when rallying",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "rally"
+ }
+ ],
+ "effects": {
+ "DreamfangRally/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Seo-yoon.json
===================================================================
--- simulator/config/hero_definitions/Seo-yoon.json prev run
+++ simulator/config/hero_definitions/Seo-yoon.json this run
@@ -0,0 +1,32 @@
+{
+ "name": "Seo-yoon",
+ "hero_generation": "SR",
+ "troop_type": "marksman",
+ "skills": {
+ "RallyingBeat": {
+ "description": "Increasing all Troops Attack by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "RallyingBeat/1": {
+ "type": "active.hero.attack.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "NonCombatPlaceholder": {
+ "description": "Placeholder skill to satisfy simulator config for non-combat hero data",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {}
+ }
+ }
+}
Index: simulator/config/hero_definitions/Sergey.json
===================================================================
--- simulator/config/hero_definitions/Sergey.json prev run
+++ simulator/config/hero_definitions/Sergey.json this run
@@ -0,0 +1,46 @@
+{
+ "name": "Sergey",
+ "hero_generation": "SR",
+ "troop_type": "infantry",
+ "skills": {
+ "DefendersEdge": {
+ "description": "Reduces damage taken by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "DefendersEdge/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ]
+ }
+ }
+ },
+ "Weaken": {
+ "description": "Reduces Attack by X% for all enemy troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Weaken/1": {
+ "type": "active.hero.attack.down",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": "enemy.any"
+ }
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Wayne.json
===================================================================
--- simulator/config/hero_definitions/Wayne.json prev run
+++ simulator/config/hero_definitions/Wayne.json this run
@@ -0,0 +1,168 @@
+{
+ "name": "Wayne",
+ "hero_generation": "S6",
+ "troop_type": "marksman",
+ "skills": {
+ "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"
+ },
+ "effects": {
+ "ThunderStrike/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 20,
+ 40,
+ 60,
+ 80,
+ 100
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "same_effect_stacking": "max",
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
+ }
+ }
+ },
+ "RoundaboutHit": {
+ "description": "Wayne's stratagems can pierce the thickest of defenses. On every other attack, his Marksmen deal X% extra damage to enemy Lancers and Y% extra damage to enemy Marksmen.",
+ "trigger": {
+ "type": "attack",
+ "every": 2,
+ "source": "marksman"
+ },
+ "effects": {
+ "RoundaboutHit/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 8,
+ 16,
+ 24,
+ 32,
+ 40
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": [
+ "lancer"
+ ]
+ }
+ ]
+ },
+ "RoundaboutHit/2": {
+ "type": "extra_skill_attack",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "any"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": [
+ "marksman"
+ ]
+ }
+ ]
+ }
+ }
+ },
+ "Fleet": {
+ "description": "Wayne ensures no misstep goes unpunished with an eagle's eye for weakness, granting all troops' attacks a X% Crit Rate.",
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ },
+ "effects": {
+ "Fleet/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 100,
+ 100,
+ 100,
+ 100,
+ 100
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "use.target"
+ }
+ ]
+ }
+ }
+ },
+ "TacticalEdge": {
+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "TacticalEdge/1": {
+ "type": "passive.lethality.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/WuMing.json
===================================================================
--- simulator/config/hero_definitions/WuMing.json prev run
+++ simulator/config/hero_definitions/WuMing.json this run
@@ -0,0 +1,106 @@
+{
+ "name": "Wu Ming",
+ "hero_generation": "S6",
+ "troop_type": "infantry",
+ "skills": {
+ "ShadowsEvasion": {
+ "description": "Reducing troops' damage taken from normal attacks by X% and from skill attacks by Y%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "ShadowsEvasion/1": {
+ "type": "type.normal.defense.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ }
+ },
+ "ShadowsEvasion/2": {
+ "type": "type.skill.defense.up",
+ "value": [
+ 6,
+ 12,
+ 18,
+ 24,
+ 30
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ]
+ }
+ }
+ }
+ },
+ "CrescentUplift": {
+ "description": "Increasing all troops' damage dealt by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "CrescentUplift/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20
+ ]
+ }
+ }
+ },
+ "ElementalResonance": {
+ "description": "Increasing skill damage dealt by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "ElementalResonance/1": {
+ "type": "type.skill.damage.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "IronBastion": {
+ "description": "Widget bonus: Increase Defense by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "IronBastion/1": {
+ "type": "passive.defense.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_definitions/Zinman.json
===================================================================
--- simulator/config/hero_definitions/Zinman.json prev run
+++ simulator/config/hero_definitions/Zinman.json this run
@@ -0,0 +1,85 @@
+{
+ "name": "Zinman",
+ "hero_generation": "S1",
+ "troop_type": "marksman",
+ "skills": {
+ "Implacable": {
+ "description": "Increasing all troops' Defense by X% and Health by X%",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Implacable/1": {
+ "type": "active.hero.defense.up",
+ "value": [
+ 2,
+ 4,
+ 6,
+ 8,
+ 10
+ ]
+ },
+ "Implacable/2": {
+ "type": "active.hero.health.up",
+ "value": [
+ 2,
+ 4,
+ 6,
+ 8,
+ 10
+ ]
+ }
+ }
+ },
+ "Bastionist": {
+ "description": "Increase building speed (non combat placeholder skill)",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {}
+ },
+ "PositionalBattler": {
+ "description": "Increasing damage dealt by X% for all troops",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "PositionalBattler/1": {
+ "type": "active.hero.lethality.up",
+ "value": [
+ 5,
+ 10,
+ 15,
+ 20,
+ 25
+ ]
+ }
+ }
+ },
+ "Woodpecker": {
+ "description": "Widget bonus: Increase Attack by X% for all troops when defending",
+ "trigger": {
+ "type": "battle_start"
+ },
+ "requirements": [
+ {
+ "level": 1,
+ "type": "engagement_type",
+ "value": "garrison"
+ }
+ ],
+ "effects": {
+ "Woodpecker/1": {
+ "type": "passive.attack.up",
+ "value": [
+ 5,
+ 7.5,
+ 10,
+ 12.5,
+ 15
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/hero_generation_stats.json
===================================================================
--- simulator/config/hero_generation_stats.json prev run
+++ simulator/config/hero_generation_stats.json this run
@@ -0,0 +1,60 @@
+{
+ "SR": {
+ "attack": 140.11,
+ "defense": 140.11
+ },
+ "S1": {
+ "attack": 200.16,
+ "defense": 200.16,
+ "lethality": 50,
+ "health": 50
+ },
+ "S1_natalia": {
+ "attack": 200.16,
+ "defense": 200.16,
+ "lethality": 55.5,
+ "health": 55.5
+ },
+ "S1_jeronimo": {
+ "attack": 260.2,
+ "defense": 260.2,
+ "lethality": 62.5,
+ "health": 62.5
+ },
+ "S2": {
+ "attack": 240.19,
+ "defense": 240.19,
+ "lethality": 60,
+ "health": 60
+ },
+ "S3": {
+ "attack": 290.23,
+ "defense": 290.23,
+ "lethality": 70,
+ "health": 70
+ },
+ "S4": {
+ "attack": 370.29,
+ "defense": 370.29,
+ "lethality": 92.5,
+ "health": 92.5
+ },
+ "S5": {
+ "attack": 444.35,
+ "defense": 444.35,
+ "lethality": 111,
+ "health": 111
+ },
+ "S6": {
+ "attack": 540.43,
+ "defense": 540.43,
+ "lethality": 133.5,
+ "health": 133.5
+ },
+ "S7": {
+ "attack": 650.52,
+ "defense": 650.52,
+ "lethality": 160.5,
+ "health": 160.5
+ }
+}
Index: simulator/config/troop_skills.json
===================================================================
--- simulator/config/troop_skills.json prev run
+++ simulator/config/troop_skills.json this run
@@ -0,0 +1,486 @@
+{
+ "name": "Troop Skills",
+ "skills": {
+ "MasterBrawler": {
+ "description": "Increase Attack Damage to Lancers by 10%",
+ "troop_type": "infantry",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "tier",
+ "value": 0
+ }
+ ],
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "MasterBrawler/1": {
+ "type": "active.troop.lethality.up",
+ "value": [
+ 10
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ],
+ "applies_vs": [
+ "lancer"
+ ]
+ }
+ }
+ }
+ },
+ "BandsOfSteel": {
+ "description": "Increase Defense against Lancers by 10%",
+ "troop_type": "infantry",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "tier",
+ "value": 7
+ }
+ ],
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "BandsOfSteel/1": {
+ "type": "active.troop.defense.up",
+ "value": [
+ 10
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ],
+ "applies_vs": [
+ "lancer"
+ ]
+ }
+ }
+ }
+ },
+ "CrystalShield": {
+ "description": "Infantry has a chance to reduce damage when under attack.",
+ "troop_type": "infantry",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "fc",
+ "value": 3
+ },
+ {
+ "level": 2,
+ "type": "fc",
+ "value": 5
+ }
+ ],
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 25,
+ 37.5
+ ],
+ "source": "enemy.any",
+ "target": "self.infantry"
+ },
+ "effects": {
+ "CrystalShield/1": {
+ "type": "active.troop.defense.up",
+ "value": [
+ 36,
+ 36
+ ],
+ "units": {
+ "applies_to": "trigger.target",
+ "applies_vs": "trigger.source"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ }
+ }
+ }
+ },
+ "BodyOfLight": {
+ "description": "Increase Infantry Defense by 4%, and reduce extra damage while Crystal Shield is active.",
+ "troop_type": "infantry",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "fc",
+ "value": 8
+ }
+ ],
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "BodyOfLight/1": {
+ "type": "active.troop.defense.up",
+ "value": [
+ 4
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ],
+ "applies_vs": "any"
+ }
+ },
+ "BodyOfLight/2": {
+ "type": "active.troop.defense.up",
+ "value": [
+ 10
+ ],
+ "units": {
+ "applies_to": [
+ "infantry"
+ ],
+ "applies_vs": "any"
+ }
+ }
+ }
+ },
+ "Charge": {
+ "description": "Increase Attack Damage to Marksmen by 10%",
+ "troop_type": "lancer",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "tier",
+ "value": 0
+ }
+ ],
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "Charge/1": {
+ "type": "active.troop.lethality.up",
+ "value": [
+ 10
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": [
+ "marksman"
+ ]
+ }
+ }
+ }
+ },
+ "Ambusher": {
+ "description": "Lancer attacks have a chance to strike Marksmen behind Infantry.",
+ "troop_type": "lancer",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "tier",
+ "value": 7
+ }
+ ],
+ "trigger": {
+ "type": "turn",
+ "probability": [
+ 20
+ ]
+ },
+ "effects": {
+ "Ambusher/1": {
+ "type": "attack_order",
+ "value": [
+ "marksman",
+ "infantry",
+ "lancer"
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": [
+ "marksman"
+ ]
+ },
+ "duration": {
+ "type": "turn",
+ "value": 1
+ }
+ }
+ }
+ },
+ "CrystalLance": {
+ "description": "Lancers have a chance to make an extra skill attack for 100% damage.",
+ "troop_type": "lancer",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "fc",
+ "value": 3
+ },
+ {
+ "level": 2,
+ "type": "fc",
+ "value": 5
+ }
+ ],
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 10,
+ 15
+ ],
+ "source": "lancer"
+ },
+ "effects": {
+ "CrystalLance/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 100,
+ 100
+ ],
+ "units": {
+ "applies_to": [
+ "lancer"
+ ],
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ },
+ "IncandescentField": {
+ "description": "Lancers have a chance to take half damage when under attack.",
+ "troop_type": "lancer",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "fc",
+ "value": 8
+ }
+ ],
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 10
+ ],
+ "source": "enemy.any",
+ "target": "self.lancer"
+ },
+ "effects": {
+ "IncandescentField/1": {
+ "type": "active.troop.defense.up",
+ "value": [
+ 100
+ ],
+ "units": {
+ "applies_to": "trigger.target",
+ "applies_vs": "trigger.source"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ }
+ }
+ }
+ },
+ "RangedStrike": {
+ "description": "Increase Attack Damage to Infantry by 10%",
+ "troop_type": "marksman",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "tier",
+ "value": 0
+ }
+ ],
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "RangedStrike/1": {
+ "type": "active.troop.lethality.up",
+ "value": [
+ 10
+ ],
+ "units": {
+ "applies_to": [
+ "marksman"
+ ],
+ "applies_vs": [
+ "infantry"
+ ]
+ }
+ }
+ }
+ },
+ "Volley": {
+ "description": "Marksman attacks have a chance to make an extra skill attack for 100% damage.",
+ "troop_type": "marksman",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "tier",
+ "value": 7
+ }
+ ],
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 10
+ ],
+ "source": "marksman"
+ },
+ "effects": {
+ "Volley/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 100
+ ],
+ "units": {
+ "applies_to": "trigger.source",
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ },
+ "CrystalGunpowder": {
+ "description": "Marksmen have a chance to make an extra skill attack for 50% damage.",
+ "troop_type": "marksman",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "fc",
+ "value": 3
+ },
+ {
+ "level": 2,
+ "type": "fc",
+ "value": 5
+ }
+ ],
+ "trigger": {
+ "type": "attack",
+ "probability": [
+ 20,
+ 30
+ ],
+ "source": "marksman"
+ },
+ "effects": {
+ "CrystalGunpowder/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 50,
+ 50
+ ],
+ "units": {
+ "applies_to": [
+ "marksman"
+ ],
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ },
+ "FlameCharge": {
+ "description": "Increase Marksmen's basic Attack by 4%.",
+ "troop_type": "marksman",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "fc",
+ "value": 8
+ }
+ ],
+ "trigger": {
+ "type": "battle_start"
+ },
+ "effects": {
+ "FlameCharge/1": {
+ "type": "active.troop.attack.up",
+ "value": [
+ 4
+ ],
+ "units": {
+ "applies_to": [
+ "marksman"
+ ],
+ "applies_vs": "any"
+ }
+ }
+ }
+ },
+ "FlameChargeExtra": {
+ "description": "Marksmen can make an additional extra skill attack while Crystal Gunpowder is active.",
+ "troop_type": "marksman",
+ "requirements": [
+ {
+ "level": 1,
+ "type": "fc",
+ "value": 8
+ }
+ ],
+ "trigger": {
+ "type": "attack",
+ "source": "marksman"
+ },
+ "effects": {
+ "FlameChargeExtra/1": {
+ "type": "extra_skill_attack",
+ "value": [
+ 25
+ ],
+ "units": {
+ "applies_to": [
+ "marksman"
+ ],
+ "applies_vs": "trigger.target"
+ },
+ "duration": {
+ "type": "attack",
+ "value": 1
+ },
+ "trigger_damage_jobs": [
+ {
+ "source": "use.source",
+ "target": "effect.applies_vs"
+ }
+ ]
+ }
+ }
+ }
+ }
+}
Index: simulator/config/troop_stats.json
===================================================================
--- simulator/config/troop_stats.json prev run
+++ simulator/config/troop_stats.json this run
@@ -0,0 +1,2570 @@
+{
+ "infantry_t1": {
+ "id": "infantry_t1",
+ "type": "infantry",
+ "tier": 1,
+ "fc": 0,
+ "stats": {
+ "Attack": 63,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 189
+ }
+ },
+ "alliance_boss_infantry_t1": {
+ "id": "alliance_boss_infantry_t1",
+ "type": "infantry",
+ "tier": 1,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 60,
+ "Lethality": 20,
+ "Health": 27
+ }
+ },
+ "infantry_t1_fc1": {
+ "id": "infantry_t1_fc1",
+ "type": "infantry",
+ "tier": 1,
+ "fc": 1,
+ "stats": {
+ "Attack": 66,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 197
+ }
+ },
+ "infantry_t1_fc2": {
+ "id": "infantry_t1_fc2",
+ "type": "infantry",
+ "tier": 1,
+ "fc": 2,
+ "stats": {
+ "Attack": 69,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 206
+ }
+ },
+ "infantry_t1_fc3": {
+ "id": "infantry_t1_fc3",
+ "type": "infantry",
+ "tier": 1,
+ "fc": 3,
+ "stats": {
+ "Attack": 72,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 217
+ }
+ },
+ "infantry_t1_fc4": {
+ "id": "infantry_t1_fc4",
+ "type": "infantry",
+ "tier": 1,
+ "fc": 4,
+ "stats": {
+ "Attack": 76,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 228
+ }
+ },
+ "infantry_t1_fc5": {
+ "id": "infantry_t1_fc5",
+ "type": "infantry",
+ "tier": 1,
+ "fc": 5,
+ "stats": {
+ "Attack": 80,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 239
+ }
+ },
+ "infantry_t2": {
+ "id": "infantry_t2",
+ "type": "infantry",
+ "tier": 2,
+ "fc": 0,
+ "stats": {
+ "Attack": 94,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 283
+ }
+ },
+ "alliance_boss_infantry_t2": {
+ "id": "alliance_boss_infantry_t2",
+ "type": "infantry",
+ "tier": 2,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 75,
+ "Lethality": 22,
+ "Health": 28
+ }
+ },
+ "infantry_t2_fc1": {
+ "id": "infantry_t2_fc1",
+ "type": "infantry",
+ "tier": 2,
+ "fc": 1,
+ "stats": {
+ "Attack": 98,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 294
+ }
+ },
+ "infantry_t2_fc2": {
+ "id": "infantry_t2_fc2",
+ "type": "infantry",
+ "tier": 2,
+ "fc": 2,
+ "stats": {
+ "Attack": 103,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 309
+ }
+ },
+ "infantry_t2_fc3": {
+ "id": "infantry_t2_fc3",
+ "type": "infantry",
+ "tier": 2,
+ "fc": 3,
+ "stats": {
+ "Attack": 108,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 324
+ }
+ },
+ "infantry_t2_fc4": {
+ "id": "infantry_t2_fc4",
+ "type": "infantry",
+ "tier": 2,
+ "fc": 4,
+ "stats": {
+ "Attack": 113,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 341
+ }
+ },
+ "infantry_t2_fc5": {
+ "id": "infantry_t2_fc5",
+ "type": "infantry",
+ "tier": 2,
+ "fc": 5,
+ "stats": {
+ "Attack": 119,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 358
+ }
+ },
+ "infantry_t3": {
+ "id": "infantry_t3",
+ "type": "infantry",
+ "tier": 3,
+ "fc": 0,
+ "stats": {
+ "Attack": 132,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 397
+ }
+ },
+ "alliance_boss_infantry_t3": {
+ "id": "alliance_boss_infantry_t3",
+ "type": "infantry",
+ "tier": 3,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 95,
+ "Lethality": 25,
+ "Health": 30
+ }
+ },
+ "infantry_t3_fc1": {
+ "id": "infantry_t3_fc1",
+ "type": "infantry",
+ "tier": 3,
+ "fc": 1,
+ "stats": {
+ "Attack": 137,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 413
+ }
+ },
+ "infantry_t3_fc2": {
+ "id": "infantry_t3_fc2",
+ "type": "infantry",
+ "tier": 3,
+ "fc": 2,
+ "stats": {
+ "Attack": 144,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 434
+ }
+ },
+ "infantry_t3_fc3": {
+ "id": "infantry_t3_fc3",
+ "type": "infantry",
+ "tier": 3,
+ "fc": 3,
+ "stats": {
+ "Attack": 151,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 455
+ }
+ },
+ "infantry_t3_fc4": {
+ "id": "infantry_t3_fc4",
+ "type": "infantry",
+ "tier": 3,
+ "fc": 4,
+ "stats": {
+ "Attack": 159,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 478
+ }
+ },
+ "infantry_t3_fc5": {
+ "id": "infantry_t3_fc5",
+ "type": "infantry",
+ "tier": 3,
+ "fc": 5,
+ "stats": {
+ "Attack": 167,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 502
+ }
+ },
+ "infantry_t4": {
+ "id": "infantry_t4",
+ "type": "infantry",
+ "tier": 4,
+ "fc": 0,
+ "stats": {
+ "Attack": 172,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 516
+ }
+ },
+ "alliance_boss_infantry_t4": {
+ "id": "alliance_boss_infantry_t4",
+ "type": "infantry",
+ "tier": 4,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 129,
+ "Lethality": 29,
+ "Health": 29
+ }
+ },
+ "infantry_t4_fc1": {
+ "id": "infantry_t4_fc1",
+ "type": "infantry",
+ "tier": 4,
+ "fc": 1,
+ "stats": {
+ "Attack": 179,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 537
+ }
+ },
+ "infantry_t4_fc2": {
+ "id": "infantry_t4_fc2",
+ "type": "infantry",
+ "tier": 4,
+ "fc": 2,
+ "stats": {
+ "Attack": 188,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 563
+ }
+ },
+ "infantry_t4_fc3": {
+ "id": "infantry_t4_fc3",
+ "type": "infantry",
+ "tier": 4,
+ "fc": 3,
+ "stats": {
+ "Attack": 197,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 592
+ }
+ },
+ "infantry_t4_fc4": {
+ "id": "infantry_t4_fc4",
+ "type": "infantry",
+ "tier": 4,
+ "fc": 4,
+ "stats": {
+ "Attack": 207,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 621
+ }
+ },
+ "infantry_t4_fc5": {
+ "id": "infantry_t4_fc5",
+ "type": "infantry",
+ "tier": 4,
+ "fc": 5,
+ "stats": {
+ "Attack": 217,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 652
+ }
+ },
+ "infantry_t5": {
+ "id": "infantry_t5",
+ "type": "infantry",
+ "tier": 5,
+ "fc": 0,
+ "stats": {
+ "Attack": 206,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 619
+ }
+ },
+ "alliance_boss_infantry_t5": {
+ "id": "alliance_boss_infantry_t5",
+ "type": "infantry",
+ "tier": 5,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 175,
+ "Lethality": 34,
+ "Health": 28
+ }
+ },
+ "infantry_t5_fc1": {
+ "id": "infantry_t5_fc1",
+ "type": "infantry",
+ "tier": 5,
+ "fc": 1,
+ "stats": {
+ "Attack": 214,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 644
+ }
+ },
+ "infantry_t5_fc2": {
+ "id": "infantry_t5_fc2",
+ "type": "infantry",
+ "tier": 5,
+ "fc": 2,
+ "stats": {
+ "Attack": 225,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 676
+ }
+ },
+ "infantry_t5_fc3": {
+ "id": "infantry_t5_fc3",
+ "type": "infantry",
+ "tier": 5,
+ "fc": 3,
+ "stats": {
+ "Attack": 236,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 710
+ }
+ },
+ "infantry_t5_fc4": {
+ "id": "infantry_t5_fc4",
+ "type": "infantry",
+ "tier": 5,
+ "fc": 4,
+ "stats": {
+ "Attack": 248,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 745
+ }
+ },
+ "infantry_t5_fc5": {
+ "id": "infantry_t5_fc5",
+ "type": "infantry",
+ "tier": 5,
+ "fc": 5,
+ "stats": {
+ "Attack": 260,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 782
+ }
+ },
+ "infantry_t6": {
+ "id": "infantry_t6",
+ "type": "infantry",
+ "tier": 6,
+ "fc": 0,
+ "stats": {
+ "Attack": 243,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 730
+ }
+ },
+ "alliance_boss_infantry_t6": {
+ "id": "alliance_boss_infantry_t6",
+ "type": "infantry",
+ "tier": 6,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 190,
+ "Lethality": 36,
+ "Health": 35
+ }
+ },
+ "infantry_t6_fc1": {
+ "id": "infantry_t6_fc1",
+ "type": "infantry",
+ "tier": 6,
+ "fc": 1,
+ "stats": {
+ "Attack": 253,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 759
+ }
+ },
+ "infantry_t6_fc2": {
+ "id": "infantry_t6_fc2",
+ "type": "infantry",
+ "tier": 6,
+ "fc": 2,
+ "stats": {
+ "Attack": 265,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 797
+ }
+ },
+ "infantry_t6_fc3": {
+ "id": "infantry_t6_fc3",
+ "type": "infantry",
+ "tier": 6,
+ "fc": 3,
+ "stats": {
+ "Attack": 279,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 837
+ }
+ },
+ "infantry_t6_fc4": {
+ "id": "infantry_t6_fc4",
+ "type": "infantry",
+ "tier": 6,
+ "fc": 4,
+ "stats": {
+ "Attack": 293,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 879
+ }
+ },
+ "infantry_t6_fc5": {
+ "id": "infantry_t6_fc5",
+ "type": "infantry",
+ "tier": 6,
+ "fc": 5,
+ "stats": {
+ "Attack": 307,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 923
+ }
+ },
+ "infantry_t7": {
+ "id": "infantry_t7",
+ "type": "infantry",
+ "tier": 7,
+ "fc": 0,
+ "stats": {
+ "Attack": 287,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 862
+ }
+ },
+ "alliance_boss_infantry_t7": {
+ "id": "alliance_boss_infantry_t7",
+ "type": "infantry",
+ "tier": 7,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 230,
+ "Lethality": 39,
+ "Health": 39
+ }
+ },
+ "infantry_t7_fc1": {
+ "id": "infantry_t7_fc1",
+ "type": "infantry",
+ "tier": 7,
+ "fc": 1,
+ "stats": {
+ "Attack": 298,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 896
+ }
+ },
+ "infantry_t7_fc2": {
+ "id": "infantry_t7_fc2",
+ "type": "infantry",
+ "tier": 7,
+ "fc": 2,
+ "stats": {
+ "Attack": 313,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 941
+ }
+ },
+ "infantry_t7_fc3": {
+ "id": "infantry_t7_fc3",
+ "type": "infantry",
+ "tier": 7,
+ "fc": 3,
+ "stats": {
+ "Attack": 329,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 988
+ }
+ },
+ "infantry_t7_fc4": {
+ "id": "infantry_t7_fc4",
+ "type": "infantry",
+ "tier": 7,
+ "fc": 4,
+ "stats": {
+ "Attack": 346,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1038
+ }
+ },
+ "infantry_t7_fc5": {
+ "id": "infantry_t7_fc5",
+ "type": "infantry",
+ "tier": 7,
+ "fc": 5,
+ "stats": {
+ "Attack": 363,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1090
+ }
+ },
+ "infantry_t8": {
+ "id": "infantry_t8",
+ "type": "infantry",
+ "tier": 8,
+ "fc": 0,
+ "stats": {
+ "Attack": 339,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1017
+ }
+ },
+ "alliance_boss_infantry_t8": {
+ "id": "alliance_boss_infantry_t8",
+ "type": "infantry",
+ "tier": 8,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 262,
+ "Lethality": 42,
+ "Health": 47
+ }
+ },
+ "infantry_t8_fc1": {
+ "id": "infantry_t8_fc1",
+ "type": "infantry",
+ "tier": 8,
+ "fc": 1,
+ "stats": {
+ "Attack": 353,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1058
+ }
+ },
+ "infantry_t8_fc2": {
+ "id": "infantry_t8_fc2",
+ "type": "infantry",
+ "tier": 8,
+ "fc": 2,
+ "stats": {
+ "Attack": 370,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1111
+ }
+ },
+ "infantry_t8_fc3": {
+ "id": "infantry_t8_fc3",
+ "type": "infantry",
+ "tier": 8,
+ "fc": 3,
+ "stats": {
+ "Attack": 389,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1166
+ }
+ },
+ "infantry_t8_fc4": {
+ "id": "infantry_t8_fc4",
+ "type": "infantry",
+ "tier": 8,
+ "fc": 4,
+ "stats": {
+ "Attack": 408,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1224
+ }
+ },
+ "infantry_t8_fc5": {
+ "id": "infantry_t8_fc5",
+ "type": "infantry",
+ "tier": 8,
+ "fc": 5,
+ "stats": {
+ "Attack": 429,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1286
+ }
+ },
+ "infantry_t9": {
+ "id": "infantry_t9",
+ "type": "infantry",
+ "tier": 9,
+ "fc": 0,
+ "stats": {
+ "Attack": 400,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1200
+ }
+ },
+ "alliance_boss_infantry_t9": {
+ "id": "alliance_boss_infantry_t9",
+ "type": "infantry",
+ "tier": 9,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 292,
+ "Lethality": 44,
+ "Health": 57
+ }
+ },
+ "infantry_t9_fc1": {
+ "id": "infantry_t9_fc1",
+ "type": "infantry",
+ "tier": 9,
+ "fc": 1,
+ "stats": {
+ "Attack": 416,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1248
+ }
+ },
+ "infantry_t9_fc2": {
+ "id": "infantry_t9_fc2",
+ "type": "infantry",
+ "tier": 9,
+ "fc": 2,
+ "stats": {
+ "Attack": 437,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1310
+ }
+ },
+ "infantry_t9_fc3": {
+ "id": "infantry_t9_fc3",
+ "type": "infantry",
+ "tier": 9,
+ "fc": 3,
+ "stats": {
+ "Attack": 459,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1376
+ }
+ },
+ "infantry_t9_fc4": {
+ "id": "infantry_t9_fc4",
+ "type": "infantry",
+ "tier": 9,
+ "fc": 4,
+ "stats": {
+ "Attack": 482,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1445
+ }
+ },
+ "infantry_t9_fc5": {
+ "id": "infantry_t9_fc5",
+ "type": "infantry",
+ "tier": 9,
+ "fc": 5,
+ "stats": {
+ "Attack": 506,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1517
+ }
+ },
+ "infantry_t10": {
+ "id": "infantry_t10",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 0,
+ "stats": {
+ "Attack": 472,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1416
+ }
+ },
+ "alliance_boss_infantry_t10": {
+ "id": "alliance_boss_infantry_t10",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 0,
+ "stats": {
+ "Attack": 0,
+ "Defense": 320,
+ "Lethality": 46,
+ "Health": 70
+ }
+ },
+ "infantry_t10_fc1": {
+ "id": "infantry_t10_fc1",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 1,
+ "stats": {
+ "Attack": 491,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1473
+ }
+ },
+ "infantry_t10_fc2": {
+ "id": "infantry_t10_fc2",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 2,
+ "stats": {
+ "Attack": 515,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1546
+ }
+ },
+ "infantry_t10_fc3": {
+ "id": "infantry_t10_fc3",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 3,
+ "stats": {
+ "Attack": 541,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1624
+ }
+ },
+ "infantry_t10_fc4": {
+ "id": "infantry_t10_fc4",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 4,
+ "stats": {
+ "Attack": 568,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1705
+ }
+ },
+ "infantry_t10_fc5": {
+ "id": "infantry_t10_fc5",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 5,
+ "stats": {
+ "Attack": 597,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1790
+ }
+ },
+ "infantry_t10_fc6": {
+ "id": "infantry_t10_fc6",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 6,
+ "stats": {
+ "Attack": 627,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1880
+ }
+ },
+ "infantry_t10_fc7": {
+ "id": "infantry_t10_fc7",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 7,
+ "stats": {
+ "Attack": 658,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1974
+ }
+ },
+ "infantry_t10_fc8": {
+ "id": "infantry_t10_fc8",
+ "type": "infantry",
+ "tier": 10,
+ "fc": 8,
+ "stats": {
+ "Attack": 691,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 2073
+ }
+ },
+ "infantry_t11": {
+ "id": "infantry_t11",
+ "type": "infantry",
+ "tier": 11,
+ "fc": 0,
+ "stats": {
+ "Attack": 551,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1653
+ }
+ },
+ "infantry_t11_fc5": {
+ "id": "infantry_t11_fc5",
+ "type": "infantry",
+ "tier": 11,
+ "fc": 5,
+ "stats": {
+ "Attack": 658,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 1973
+ }
+ },
+ "infantry_t11_fc6": {
+ "id": "infantry_t11_fc6",
+ "type": "infantry",
+ "tier": 11,
+ "fc": 6,
+ "stats": {
+ "Attack": 691,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 2072
+ }
+ },
+ "infantry_t11_fc7": {
+ "id": "infantry_t11_fc7",
+ "type": "infantry",
+ "tier": 11,
+ "fc": 7,
+ "stats": {
+ "Attack": 726,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 2176
+ }
+ },
+ "infantry_t11_fc8": {
+ "id": "infantry_t11_fc8",
+ "type": "infantry",
+ "tier": 11,
+ "fc": 8,
+ "stats": {
+ "Attack": 762,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 2285
+ }
+ },
+ "lancer_t10_fc6": {
+ "id": "lancer_t10_fc6",
+ "type": "lancer",
+ "tier": 10,
+ "fc": 6,
+ "stats": {
+ "Attack": 1880,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 627
+ }
+ },
+ "lancer_t10_fc7": {
+ "id": "lancer_t10_fc7",
+ "type": "lancer",
+ "tier": 10,
+ "fc": 7,
+ "stats": {
+ "Attack": 1974,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 658
+ }
+ },
+ "lancer_t10_fc8": {
+ "id": "lancer_t10_fc8",
+ "type": "lancer",
+ "tier": 10,
+ "fc": 8,
+ "stats": {
+ "Attack": 2073,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 691
+ }
+ },
+ "lancer_t11_fc6": {
+ "id": "lancer_t11_fc6",
+ "type": "lancer",
+ "tier": 11,
+ "fc": 6,
+ "stats": {
+ "Attack": 2072,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 691
+ }
+ },
+ "lancer_t11_fc7": {
+ "id": "lancer_t11_fc7",
+ "type": "lancer",
+ "tier": 11,
+ "fc": 7,
+ "stats": {
+ "Attack": 2176,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 726
+ }
+ },
+ "lancer_t11_fc8": {
+ "id": "lancer_t11_fc8",
+ "type": "lancer",
+ "tier": 11,
+ "fc": 8,
+ "stats": {
+ "Attack": 2285,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 762
+ }
+ },
+ "marksman_t10_fc6": {
+ "id": "marksman_t10_fc6",
+ "type": "marksman",
+ "tier": 10,
+ "fc": 6,
+ "stats": {
+ "Attack": 2506,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 470
+ }
+ },
+ "marksman_t10_fc7": {
+ "id": "marksman_t10_fc7",
+ "type": "marksman",
+ "tier": 10,
+ "fc": 7,
+ "stats": {
+ "Attack": 2631,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 494
+ }
+ },
+ "marksman_t10_fc8": {
+ "id": "marksman_t10_fc8",
+ "type": "marksman",
+ "tier": 10,
+ "fc": 8,
+ "stats": {
+ "Attack": 2763,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 519
+ }
+ },
+ "marksman_t11_fc6": {
+ "id": "marksman_t11_fc6",
+ "type": "marksman",
+ "tier": 11,
+ "fc": 6,
+ "stats": {
+ "Attack": 2764,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 519
+ }
+ },
+ "marksman_t11_fc7": {
+ "id": "marksman_t11_fc7",
+ "type": "marksman",
+ "tier": 11,
+ "fc": 7,
+ "stats": {
+ "Attack": 2902,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 545
+ }
+ },
+ "marksman_t11_fc8": {
+ "id": "marksman_t11_fc8",
+ "type": "marksman",
+ "tier": 11,
+ "fc": 8,
+ "stats": {
+ "Attack": 3047,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 572
+ }
+ },
+ "lancer_t1": {
+ "id": "lancer_t1",
+ "type": "lancers",
+ "tier": 1,
+ "fc": 0,
+ "stats": {
+ "Attack": 189,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 63
+ }
+ },
+ "marksman_t1": {
+ "id": "marksman_t1",
+ "type": "marksmen",
+ "tier": 1,
+ "fc": 0,
+ "stats": {
+ "Attack": 252,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 47
+ }
+ },
+ "lancer_t1_fc1": {
+ "id": "lancer_t1_fc1",
+ "type": "lancers",
+ "tier": 1,
+ "fc": 1,
+ "stats": {
+ "Attack": 197,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 66
+ }
+ },
+ "marksman_t1_fc1": {
+ "id": "marksman_t1_fc1",
+ "type": "marksmen",
+ "tier": 1,
+ "fc": 1,
+ "stats": {
+ "Attack": 262,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 49
+ }
+ },
+ "lancer_t1_fc2": {
+ "id": "lancer_t1_fc2",
+ "type": "lancers",
+ "tier": 1,
+ "fc": 2,
+ "stats": {
+ "Attack": 206,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 69
+ }
+ },
+ "marksman_t1_fc2": {
+ "id": "marksman_t1_fc2",
+ "type": "marksmen",
+ "tier": 1,
+ "fc": 2,
+ "stats": {
+ "Attack": 275,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 51
+ }
+ },
+ "lancer_t1_fc3": {
+ "id": "lancer_t1_fc3",
+ "type": "lancers",
+ "tier": 1,
+ "fc": 3,
+ "stats": {
+ "Attack": 217,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 72
+ }
+ },
+ "marksman_t1_fc3": {
+ "id": "marksman_t1_fc3",
+ "type": "marksmen",
+ "tier": 1,
+ "fc": 3,
+ "stats": {
+ "Attack": 289,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 54
+ }
+ },
+ "lancer_t1_fc4": {
+ "id": "lancer_t1_fc4",
+ "type": "lancers",
+ "tier": 1,
+ "fc": 4,
+ "stats": {
+ "Attack": 228,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 76
+ }
+ },
+ "marksman_t1_fc4": {
+ "id": "marksman_t1_fc4",
+ "type": "marksmen",
+ "tier": 1,
+ "fc": 4,
+ "stats": {
+ "Attack": 303,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 57
+ }
+ },
+ "lancer_t1_fc5": {
+ "id": "lancer_t1_fc5",
+ "type": "lancers",
+ "tier": 1,
+ "fc": 5,
+ "stats": {
+ "Attack": 239,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 80
+ }
+ },
+ "marksman_t1_fc5": {
+ "id": "marksman_t1_fc5",
+ "type": "marksmen",
+ "tier": 1,
+ "fc": 5,
+ "stats": {
+ "Attack": 319,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 59
+ }
+ },
+ "lancer_t2": {
+ "id": "lancer_t2",
+ "type": "lancers",
+ "tier": 2,
+ "fc": 0,
+ "stats": {
+ "Attack": 283,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 94
+ }
+ },
+ "marksman_t2": {
+ "id": "marksman_t2",
+ "type": "marksmen",
+ "tier": 2,
+ "fc": 0,
+ "stats": {
+ "Attack": 378,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 71
+ }
+ },
+ "lancer_t2_fc1": {
+ "id": "lancer_t2_fc1",
+ "type": "lancers",
+ "tier": 2,
+ "fc": 1,
+ "stats": {
+ "Attack": 294,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 98
+ }
+ },
+ "marksman_t2_fc1": {
+ "id": "marksman_t2_fc1",
+ "type": "marksmen",
+ "tier": 2,
+ "fc": 1,
+ "stats": {
+ "Attack": 393,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 74
+ }
+ },
+ "lancer_t2_fc2": {
+ "id": "lancer_t2_fc2",
+ "type": "lancers",
+ "tier": 2,
+ "fc": 2,
+ "stats": {
+ "Attack": 309,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 103
+ }
+ },
+ "marksman_t2_fc2": {
+ "id": "marksman_t2_fc2",
+ "type": "marksmen",
+ "tier": 2,
+ "fc": 2,
+ "stats": {
+ "Attack": 413,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 78
+ }
+ },
+ "lancer_t2_fc3": {
+ "id": "lancer_t2_fc3",
+ "type": "lancers",
+ "tier": 2,
+ "fc": 3,
+ "stats": {
+ "Attack": 324,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 108
+ }
+ },
+ "marksman_t2_fc3": {
+ "id": "marksman_t2_fc3",
+ "type": "marksmen",
+ "tier": 2,
+ "fc": 3,
+ "stats": {
+ "Attack": 433,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 81
+ }
+ },
+ "lancer_t2_fc4": {
+ "id": "lancer_t2_fc4",
+ "type": "lancers",
+ "tier": 2,
+ "fc": 4,
+ "stats": {
+ "Attack": 341,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 113
+ }
+ },
+ "marksman_t2_fc4": {
+ "id": "marksman_t2_fc4",
+ "type": "marksmen",
+ "tier": 2,
+ "fc": 4,
+ "stats": {
+ "Attack": 455,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 85
+ }
+ },
+ "lancer_t2_fc5": {
+ "id": "lancer_t2_fc5",
+ "type": "lancers",
+ "tier": 2,
+ "fc": 5,
+ "stats": {
+ "Attack": 358,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 119
+ }
+ },
+ "marksman_t2_fc5": {
+ "id": "marksman_t2_fc5",
+ "type": "marksmen",
+ "tier": 2,
+ "fc": 5,
+ "stats": {
+ "Attack": 478,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 90
+ }
+ },
+ "lancer_t3": {
+ "id": "lancer_t3",
+ "type": "lancers",
+ "tier": 3,
+ "fc": 0,
+ "stats": {
+ "Attack": 397,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 132
+ }
+ },
+ "marksman_t3": {
+ "id": "marksman_t3",
+ "type": "marksmen",
+ "tier": 3,
+ "fc": 0,
+ "stats": {
+ "Attack": 529,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 99
+ }
+ },
+ "lancer_t3_fc1": {
+ "id": "lancer_t3_fc1",
+ "type": "lancers",
+ "tier": 3,
+ "fc": 1,
+ "stats": {
+ "Attack": 413,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 137
+ }
+ },
+ "marksman_t3_fc1": {
+ "id": "marksman_t3_fc1",
+ "type": "marksmen",
+ "tier": 3,
+ "fc": 1,
+ "stats": {
+ "Attack": 550,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 103
+ }
+ },
+ "lancer_t3_fc2": {
+ "id": "lancer_t3_fc2",
+ "type": "lancers",
+ "tier": 3,
+ "fc": 2,
+ "stats": {
+ "Attack": 434,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 144
+ }
+ },
+ "marksman_t3_fc2": {
+ "id": "marksman_t3_fc2",
+ "type": "marksmen",
+ "tier": 3,
+ "fc": 2,
+ "stats": {
+ "Attack": 578,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 108
+ }
+ },
+ "lancer_t3_fc3": {
+ "id": "lancer_t3_fc3",
+ "type": "lancers",
+ "tier": 3,
+ "fc": 3,
+ "stats": {
+ "Attack": 455,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 151
+ }
+ },
+ "marksman_t3_fc3": {
+ "id": "marksman_t3_fc3",
+ "type": "marksmen",
+ "tier": 3,
+ "fc": 3,
+ "stats": {
+ "Attack": 607,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 114
+ }
+ },
+ "lancer_t3_fc4": {
+ "id": "lancer_t3_fc4",
+ "type": "lancers",
+ "tier": 3,
+ "fc": 4,
+ "stats": {
+ "Attack": 478,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 159
+ }
+ },
+ "marksman_t3_fc4": {
+ "id": "marksman_t3_fc4",
+ "type": "marksmen",
+ "tier": 3,
+ "fc": 4,
+ "stats": {
+ "Attack": 637,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 119
+ }
+ },
+ "lancer_t3_fc5": {
+ "id": "lancer_t3_fc5",
+ "type": "lancers",
+ "tier": 3,
+ "fc": 5,
+ "stats": {
+ "Attack": 502,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 167
+ }
+ },
+ "marksman_t3_fc5": {
+ "id": "marksman_t3_fc5",
+ "type": "marksmen",
+ "tier": 3,
+ "fc": 5,
+ "stats": {
+ "Attack": 669,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 125
+ }
+ },
+ "lancer_t4": {
+ "id": "lancer_t4",
+ "type": "lancers",
+ "tier": 4,
+ "fc": 0,
+ "stats": {
+ "Attack": 516,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 172
+ }
+ },
+ "marksman_t4": {
+ "id": "marksman_t4",
+ "type": "marksmen",
+ "tier": 4,
+ "fc": 0,
+ "stats": {
+ "Attack": 688,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 129
+ }
+ },
+ "lancer_t4_fc1": {
+ "id": "lancer_t4_fc1",
+ "type": "lancers",
+ "tier": 4,
+ "fc": 1,
+ "stats": {
+ "Attack": 537,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 179
+ }
+ },
+ "marksman_t4_fc1": {
+ "id": "marksman_t4_fc1",
+ "type": "marksmen",
+ "tier": 4,
+ "fc": 1,
+ "stats": {
+ "Attack": 716,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 134
+ }
+ },
+ "lancer_t4_fc2": {
+ "id": "lancer_t4_fc2",
+ "type": "lancers",
+ "tier": 4,
+ "fc": 2,
+ "stats": {
+ "Attack": 563,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 188
+ }
+ },
+ "marksman_t4_fc2": {
+ "id": "marksman_t4_fc2",
+ "type": "marksmen",
+ "tier": 4,
+ "fc": 2,
+ "stats": {
+ "Attack": 751,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 141
+ }
+ },
+ "lancer_t4_fc3": {
+ "id": "lancer_t4_fc3",
+ "type": "lancers",
+ "tier": 4,
+ "fc": 3,
+ "stats": {
+ "Attack": 592,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 197
+ }
+ },
+ "marksman_t4_fc3": {
+ "id": "marksman_t4_fc3",
+ "type": "marksmen",
+ "tier": 4,
+ "fc": 3,
+ "stats": {
+ "Attack": 789,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 148
+ }
+ },
+ "lancer_t4_fc4": {
+ "id": "lancer_t4_fc4",
+ "type": "lancers",
+ "tier": 4,
+ "fc": 4,
+ "stats": {
+ "Attack": 621,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 207
+ }
+ },
+ "marksman_t4_fc4": {
+ "id": "marksman_t4_fc4",
+ "type": "marksmen",
+ "tier": 4,
+ "fc": 4,
+ "stats": {
+ "Attack": 828,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 155
+ }
+ },
+ "lancer_t4_fc5": {
+ "id": "lancer_t4_fc5",
+ "type": "lancers",
+ "tier": 4,
+ "fc": 5,
+ "stats": {
+ "Attack": 652,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 217
+ }
+ },
+ "marksman_t4_fc5": {
+ "id": "marksman_t4_fc5",
+ "type": "marksmen",
+ "tier": 4,
+ "fc": 5,
+ "stats": {
+ "Attack": 870,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 163
+ }
+ },
+ "lancer_t5": {
+ "id": "lancer_t5",
+ "type": "lancers",
+ "tier": 5,
+ "fc": 0,
+ "stats": {
+ "Attack": 619,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 206
+ }
+ },
+ "marksman_t5": {
+ "id": "marksman_t5",
+ "type": "marksmen",
+ "tier": 5,
+ "fc": 0,
+ "stats": {
+ "Attack": 825,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 155
+ }
+ },
+ "lancer_t5_fc1": {
+ "id": "lancer_t5_fc1",
+ "type": "lancers",
+ "tier": 5,
+ "fc": 1,
+ "stats": {
+ "Attack": 644,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 214
+ }
+ },
+ "marksman_t5_fc1": {
+ "id": "marksman_t5_fc1",
+ "type": "marksmen",
+ "tier": 5,
+ "fc": 1,
+ "stats": {
+ "Attack": 858,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 161
+ }
+ },
+ "lancer_t5_fc2": {
+ "id": "lancer_t5_fc2",
+ "type": "lancers",
+ "tier": 5,
+ "fc": 2,
+ "stats": {
+ "Attack": 676,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 225
+ }
+ },
+ "marksman_t5_fc2": {
+ "id": "marksman_t5_fc2",
+ "type": "marksmen",
+ "tier": 5,
+ "fc": 2,
+ "stats": {
+ "Attack": 901,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 169
+ }
+ },
+ "lancer_t5_fc3": {
+ "id": "lancer_t5_fc3",
+ "type": "lancers",
+ "tier": 5,
+ "fc": 3,
+ "stats": {
+ "Attack": 710,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 236
+ }
+ },
+ "marksman_t5_fc3": {
+ "id": "marksman_t5_fc3",
+ "type": "marksmen",
+ "tier": 5,
+ "fc": 3,
+ "stats": {
+ "Attack": 946,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 178
+ }
+ },
+ "lancer_t5_fc4": {
+ "id": "lancer_t5_fc4",
+ "type": "lancers",
+ "tier": 5,
+ "fc": 4,
+ "stats": {
+ "Attack": 745,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 248
+ }
+ },
+ "marksman_t5_fc4": {
+ "id": "marksman_t5_fc4",
+ "type": "marksmen",
+ "tier": 5,
+ "fc": 4,
+ "stats": {
+ "Attack": 993,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 187
+ }
+ },
+ "lancer_t5_fc5": {
+ "id": "lancer_t5_fc5",
+ "type": "lancers",
+ "tier": 5,
+ "fc": 5,
+ "stats": {
+ "Attack": 782,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 260
+ }
+ },
+ "marksman_t5_fc5": {
+ "id": "marksman_t5_fc5",
+ "type": "marksmen",
+ "tier": 5,
+ "fc": 5,
+ "stats": {
+ "Attack": 1043,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 196
+ }
+ },
+ "lancer_t6": {
+ "id": "lancer_t6",
+ "type": "lancers",
+ "tier": 6,
+ "fc": 0,
+ "stats": {
+ "Attack": 730,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 243
+ }
+ },
+ "marksman_t6": {
+ "id": "marksman_t6",
+ "type": "marksmen",
+ "tier": 6,
+ "fc": 0,
+ "stats": {
+ "Attack": 974,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 183
+ }
+ },
+ "lancer_t6_fc1": {
+ "id": "lancer_t6_fc1",
+ "type": "lancers",
+ "tier": 6,
+ "fc": 1,
+ "stats": {
+ "Attack": 759,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 253
+ }
+ },
+ "marksman_t6_fc1": {
+ "id": "marksman_t6_fc1",
+ "type": "marksmen",
+ "tier": 6,
+ "fc": 1,
+ "stats": {
+ "Attack": 1013,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 190
+ }
+ },
+ "lancer_t6_fc2": {
+ "id": "lancer_t6_fc2",
+ "type": "lancers",
+ "tier": 6,
+ "fc": 2,
+ "stats": {
+ "Attack": 797,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 265
+ }
+ },
+ "marksman_t6_fc2": {
+ "id": "marksman_t6_fc2",
+ "type": "marksmen",
+ "tier": 6,
+ "fc": 2,
+ "stats": {
+ "Attack": 1064,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 200
+ }
+ },
+ "lancer_t6_fc3": {
+ "id": "lancer_t6_fc3",
+ "type": "lancers",
+ "tier": 6,
+ "fc": 3,
+ "stats": {
+ "Attack": 837,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 279
+ }
+ },
+ "marksman_t6_fc3": {
+ "id": "marksman_t6_fc3",
+ "type": "marksmen",
+ "tier": 6,
+ "fc": 3,
+ "stats": {
+ "Attack": 1117,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 210
+ }
+ },
+ "lancer_t6_fc4": {
+ "id": "lancer_t6_fc4",
+ "type": "lancers",
+ "tier": 6,
+ "fc": 4,
+ "stats": {
+ "Attack": 879,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 293
+ }
+ },
+ "marksman_t6_fc4": {
+ "id": "marksman_t6_fc4",
+ "type": "marksmen",
+ "tier": 6,
+ "fc": 4,
+ "stats": {
+ "Attack": 1173,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 220
+ }
+ },
+ "lancer_t6_fc5": {
+ "id": "lancer_t6_fc5",
+ "type": "lancers",
+ "tier": 6,
+ "fc": 5,
+ "stats": {
+ "Attack": 923,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 307
+ }
+ },
+ "marksman_t6_fc5": {
+ "id": "marksman_t6_fc5",
+ "type": "marksmen",
+ "tier": 6,
+ "fc": 5,
+ "stats": {
+ "Attack": 1231,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 231
+ }
+ },
+ "lancer_t7": {
+ "id": "lancer_t7",
+ "type": "lancers",
+ "tier": 7,
+ "fc": 0,
+ "stats": {
+ "Attack": 862,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 287
+ }
+ },
+ "marksman_t7": {
+ "id": "marksman_t7",
+ "type": "marksmen",
+ "tier": 7,
+ "fc": 0,
+ "stats": {
+ "Attack": 1149,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 215
+ }
+ },
+ "lancer_t7_fc1": {
+ "id": "lancer_t7_fc1",
+ "type": "lancers",
+ "tier": 7,
+ "fc": 1,
+ "stats": {
+ "Attack": 896,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 298
+ }
+ },
+ "marksman_t7_fc1": {
+ "id": "marksman_t7_fc1",
+ "type": "marksmen",
+ "tier": 7,
+ "fc": 1,
+ "stats": {
+ "Attack": 1195,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 224
+ }
+ },
+ "lancer_t7_fc2": {
+ "id": "lancer_t7_fc2",
+ "type": "lancers",
+ "tier": 7,
+ "fc": 2,
+ "stats": {
+ "Attack": 941,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 313
+ }
+ },
+ "marksman_t7_fc2": {
+ "id": "marksman_t7_fc2",
+ "type": "marksmen",
+ "tier": 7,
+ "fc": 2,
+ "stats": {
+ "Attack": 1255,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 235
+ }
+ },
+ "lancer_t7_fc3": {
+ "id": "lancer_t7_fc3",
+ "type": "lancers",
+ "tier": 7,
+ "fc": 3,
+ "stats": {
+ "Attack": 988,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 329
+ }
+ },
+ "marksman_t7_fc3": {
+ "id": "marksman_t7_fc3",
+ "type": "marksmen",
+ "tier": 7,
+ "fc": 3,
+ "stats": {
+ "Attack": 1317,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 247
+ }
+ },
+ "lancer_t7_fc4": {
+ "id": "lancer_t7_fc4",
+ "type": "lancers",
+ "tier": 7,
+ "fc": 4,
+ "stats": {
+ "Attack": 1038,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 346
+ }
+ },
+ "marksman_t7_fc4": {
+ "id": "marksman_t7_fc4",
+ "type": "marksmen",
+ "tier": 7,
+ "fc": 4,
+ "stats": {
+ "Attack": 1383,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 259
+ }
+ },
+ "lancer_t7_fc5": {
+ "id": "lancer_t7_fc5",
+ "type": "lancers",
+ "tier": 7,
+ "fc": 5,
+ "stats": {
+ "Attack": 1090,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 363
+ }
+ },
+ "marksman_t7_fc5": {
+ "id": "marksman_t7_fc5",
+ "type": "marksmen",
+ "tier": 7,
+ "fc": 5,
+ "stats": {
+ "Attack": 1452,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 272
+ }
+ },
+ "lancer_t8": {
+ "id": "lancer_t8",
+ "type": "lancers",
+ "tier": 8,
+ "fc": 0,
+ "stats": {
+ "Attack": 1017,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 339
+ }
+ },
+ "marksman_t8": {
+ "id": "marksman_t8",
+ "type": "marksmen",
+ "tier": 8,
+ "fc": 0,
+ "stats": {
+ "Attack": 1356,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 254
+ }
+ },
+ "lancer_t8_fc1": {
+ "id": "lancer_t8_fc1",
+ "type": "lancers",
+ "tier": 8,
+ "fc": 1,
+ "stats": {
+ "Attack": 1058,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 353
+ }
+ },
+ "marksman_t8_fc1": {
+ "id": "marksman_t8_fc1",
+ "type": "marksmen",
+ "tier": 8,
+ "fc": 1,
+ "stats": {
+ "Attack": 1410,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 264
+ }
+ },
+ "lancer_t8_fc2": {
+ "id": "lancer_t8_fc2",
+ "type": "lancers",
+ "tier": 8,
+ "fc": 2,
+ "stats": {
+ "Attack": 1111,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 370
+ }
+ },
+ "marksman_t8_fc2": {
+ "id": "marksman_t8_fc2",
+ "type": "marksmen",
+ "tier": 8,
+ "fc": 2,
+ "stats": {
+ "Attack": 1481,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 277
+ }
+ },
+ "lancer_t8_fc3": {
+ "id": "lancer_t8_fc3",
+ "type": "lancers",
+ "tier": 8,
+ "fc": 3,
+ "stats": {
+ "Attack": 1166,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 389
+ }
+ },
+ "marksman_t8_fc3": {
+ "id": "marksman_t8_fc3",
+ "type": "marksmen",
+ "tier": 8,
+ "fc": 3,
+ "stats": {
+ "Attack": 1555,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 291
+ }
+ },
+ "lancer_t8_fc4": {
+ "id": "lancer_t8_fc4",
+ "type": "lancers",
+ "tier": 8,
+ "fc": 4,
+ "stats": {
+ "Attack": 1224,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 408
+ }
+ },
+ "marksman_t8_fc4": {
+ "id": "marksman_t8_fc4",
+ "type": "marksmen",
+ "tier": 8,
+ "fc": 4,
+ "stats": {
+ "Attack": 1633,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 306
+ }
+ },
+ "lancer_t8_fc5": {
+ "id": "lancer_t8_fc5",
+ "type": "lancers",
+ "tier": 8,
+ "fc": 5,
+ "stats": {
+ "Attack": 1286,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 429
+ }
+ },
+ "marksman_t8_fc5": {
+ "id": "marksman_t8_fc5",
+ "type": "marksmen",
+ "tier": 8,
+ "fc": 5,
+ "stats": {
+ "Attack": 1714,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 321
+ }
+ },
+ "lancer_t9": {
+ "id": "lancer_t9",
+ "type": "lancers",
+ "tier": 9,
+ "fc": 0,
+ "stats": {
+ "Attack": 1200,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 400
+ }
+ },
+ "marksman_t9": {
+ "id": "marksman_t9",
+ "type": "marksmen",
+ "tier": 9,
+ "fc": 0,
+ "stats": {
+ "Attack": 1600,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 300
+ }
+ },
+ "lancer_t9_fc1": {
+ "id": "lancer_t9_fc1",
+ "type": "lancers",
+ "tier": 9,
+ "fc": 1,
+ "stats": {
+ "Attack": 1248,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 416
+ }
+ },
+ "marksman_t9_fc1": {
+ "id": "marksman_t9_fc1",
+ "type": "marksmen",
+ "tier": 9,
+ "fc": 1,
+ "stats": {
+ "Attack": 1664,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 312
+ }
+ },
+ "lancer_t9_fc2": {
+ "id": "lancer_t9_fc2",
+ "type": "lancers",
+ "tier": 9,
+ "fc": 2,
+ "stats": {
+ "Attack": 1310,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 437
+ }
+ },
+ "marksman_t9_fc2": {
+ "id": "marksman_t9_fc2",
+ "type": "marksmen",
+ "tier": 9,
+ "fc": 2,
+ "stats": {
+ "Attack": 1747,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 328
+ }
+ },
+ "lancer_t9_fc3": {
+ "id": "lancer_t9_fc3",
+ "type": "lancers",
+ "tier": 9,
+ "fc": 3,
+ "stats": {
+ "Attack": 1376,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 459
+ }
+ },
+ "marksman_t9_fc3": {
+ "id": "marksman_t9_fc3",
+ "type": "marksmen",
+ "tier": 9,
+ "fc": 3,
+ "stats": {
+ "Attack": 1835,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 344
+ }
+ },
+ "lancer_t9_fc4": {
+ "id": "lancer_t9_fc4",
+ "type": "lancers",
+ "tier": 9,
+ "fc": 4,
+ "stats": {
+ "Attack": 1445,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 482
+ }
+ },
+ "marksman_t9_fc4": {
+ "id": "marksman_t9_fc4",
+ "type": "marksmen",
+ "tier": 9,
+ "fc": 4,
+ "stats": {
+ "Attack": 1926,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 361
+ }
+ },
+ "lancer_t9_fc5": {
+ "id": "lancer_t9_fc5",
+ "type": "lancers",
+ "tier": 9,
+ "fc": 5,
+ "stats": {
+ "Attack": 1517,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 506
+ }
+ },
+ "marksman_t9_fc5": {
+ "id": "marksman_t9_fc5",
+ "type": "marksmen",
+ "tier": 9,
+ "fc": 5,
+ "stats": {
+ "Attack": 2023,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 379
+ }
+ },
+ "lancer_t10": {
+ "id": "lancer_t10",
+ "type": "lancers",
+ "tier": 10,
+ "fc": 0,
+ "stats": {
+ "Attack": 1416,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 472
+ }
+ },
+ "marksman_t10": {
+ "id": "marksman_t10",
+ "type": "marksmen",
+ "tier": 10,
+ "fc": 0,
+ "stats": {
+ "Attack": 1888,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 354
+ }
+ },
+ "lancer_t10_fc1": {
+ "id": "lancer_t10_fc1",
+ "type": "lancers",
+ "tier": 10,
+ "fc": 1,
+ "stats": {
+ "Attack": 1473,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 491
+ }
+ },
+ "marksman_t10_fc1": {
+ "id": "marksman_t10_fc1",
+ "type": "marksmen",
+ "tier": 10,
+ "fc": 1,
+ "stats": {
+ "Attack": 1964,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 368
+ }
+ },
+ "lancer_t10_fc2": {
+ "id": "lancer_t10_fc2",
+ "type": "lancers",
+ "tier": 10,
+ "fc": 2,
+ "stats": {
+ "Attack": 1546,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 515
+ }
+ },
+ "marksman_t10_fc2": {
+ "id": "marksman_t10_fc2",
+ "type": "marksmen",
+ "tier": 10,
+ "fc": 2,
+ "stats": {
+ "Attack": 2062,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 387
+ }
+ },
+ "lancer_t10_fc3": {
+ "id": "lancer_t10_fc3",
+ "type": "lancers",
+ "tier": 10,
+ "fc": 3,
+ "stats": {
+ "Attack": 1624,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 541
+ }
+ },
+ "marksman_t10_fc3": {
+ "id": "marksman_t10_fc3",
+ "type": "marksmen",
+ "tier": 10,
+ "fc": 3,
+ "stats": {
+ "Attack": 2165,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 406
+ }
+ },
+ "lancer_t10_fc4": {
+ "id": "lancer_t10_fc4",
+ "type": "lancers",
+ "tier": 10,
+ "fc": 4,
+ "stats": {
+ "Attack": 1705,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 568
+ }
+ },
+ "marksman_t10_fc4": {
+ "id": "marksman_t10_fc4",
+ "type": "marksmen",
+ "tier": 10,
+ "fc": 4,
+ "stats": {
+ "Attack": 2273,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 426
+ }
+ },
+ "lancer_t10_fc5": {
+ "id": "lancer_t10_fc5",
+ "type": "lancers",
+ "tier": 10,
+ "fc": 5,
+ "stats": {
+ "Attack": 1790,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 597
+ }
+ },
+ "marksman_t10_fc5": {
+ "id": "marksman_t10_fc5",
+ "type": "marksmen",
+ "tier": 10,
+ "fc": 5,
+ "stats": {
+ "Attack": 2387,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 448
+ }
+ },
+ "lancer_t11": {
+ "id": "lancer_t11",
+ "type": "lancers",
+ "tier": 11,
+ "fc": 0,
+ "stats": {
+ "Attack": 1653,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 551
+ }
+ },
+ "marksman_t11": {
+ "id": "marksman_t11",
+ "type": "marksmen",
+ "tier": 11,
+ "fc": 0,
+ "stats": {
+ "Attack": 2204,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 413
+ }
+ },
+ "lancer_t11_fc5": {
+ "id": "lancer_t11_fc5",
+ "type": "lancers",
+ "tier": 11,
+ "fc": 5,
+ "stats": {
+ "Attack": 1973,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 658
+ }
+ },
+ "marksman_t11_fc5": {
+ "id": "marksman_t11_fc5",
+ "type": "marksmen",
+ "tier": 11,
+ "fc": 5,
+ "stats": {
+ "Attack": 2632,
+ "Defense": 10,
+ "Lethality": 10,
+ "Health": 494
+ }
+ }
+}
Index: simulator/package-lock.json
===================================================================
--- simulator/package-lock.json prev run
+++ simulator/package-lock.json this run
@@ -0,0 +1,566 @@
+{
+ "name": "wos-simulator",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "wos-simulator",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "tsx": "^4.0.0",
+ "typescript": "^5.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
+ "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
+ "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
Index: simulator/package.json
===================================================================
--- simulator/package.json prev run
+++ simulator/package.json this run
@@ -0,0 +1,15 @@
+{
+ "name": "wos-simulator",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "npx --yes tsx --test \"src/**/*.test.ts\"",
+ "typecheck": "npx --yes --package typescript --package @types/node tsc --noEmit"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "tsx": "^4.0.0",
+ "typescript": "^5.0.0"
+ }
+}
Index: simulator/src/calibration.ts
===================================================================
--- simulator/src/calibration.ts prev run
+++ simulator/src/calibration.ts this run
@@ -0,0 +1,384 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export interface CalibrationCaseComparison {
+ file: string;
+ testcaseId: string;
+ idx: number;
+ nSim?: number;
+ muSim?: number;
+ sigmaSim?: number;
+ nGame?: number;
+ muGame?: number;
+ sigmaGame?: number;
+ biasRaw?: number;
+ biasPct?: number;
+ sem?: number;
+ p?: number | null;
+ q?: number | null;
+ statType?: string;
+ passes?: boolean;
+}
+
+export interface SampleStats {
+ n: number;
+ mu: number;
+ sigma: number;
+ sem: number;
+ samples?: number[];
+}
+
+export interface DistributionCompatibility {
+ biasRaw?: number;
+ biasPct?: number;
+ z?: number;
+ passes?: boolean;
+ statType: "deterministic" | "distribution" | "unmatched";
+}
+
+export interface CalibrationComparison {
+ calibrationAvailable: boolean;
+ reportPath?: string;
+ startedAt?: string;
+ finishedAt?: string;
+ gitSha?: string;
+ totalReferenceCases: number;
+ referencePassedCases: number;
+ referencePassRate: number;
+ thresholds?: Record<string, number>;
+ cases: CalibrationCaseComparison[];
+ table: CalibrationComparisonRow[];
+}
+
+export interface CalibrationComparisonRow extends CalibrationCaseComparison {
+ matched: boolean;
+ referencePasses?: boolean;
+ referenceBiasPct?: number;
+ simulatorScoreDelta?: number;
+ simulator?: SampleStats;
+ simulatorVsBaseline?: DistributionCompatibility;
+ simulatorVsGame?: DistributionCompatibility;
+ simulatorVsGameRaw?: number;
+ simulatorVsGamePct?: number;
+ simulatorPasses?: boolean;
+ simulatorN?: number;
+ simulatorMu?: number;
+ simulatorSigma?: number;
+ simulatorSem?: number;
+ simulatorVsBaselineBiasRaw?: number;
+ simulatorVsBaselineBiasPct?: number;
+ simulatorVsBaselineZ?: number;
+ simulatorVsBaselinePasses?: boolean;
+ simulatorVsGameBiasRaw?: number;
+ simulatorVsGameBiasPct?: number;
+ simulatorVsGameZ?: number;
+ simulatorVsGamePasses?: boolean;
+}
+
+export function defaultCalibrationDir(): string {
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "testcase_results");
+}
+
+export function loadCalibrationComparison(path?: string): CalibrationComparison {
+ const reportPath = path ? resolve(path) : latestCalibrationReportPath(defaultCalibrationDir());
+ if (!reportPath || !fileExists(reportPath)) return emptyCalibrationComparison();
+
+ const parsed = JSON.parse(readFileSync(reportPath, "utf8")) as CalibrationReportJson;
+ const cases = Object.values(parsed.testcases ?? {}).map(normalizeCalibrationCase).filter((entry): entry is CalibrationCaseComparison => !!entry);
+ const referencePassedCases = cases.filter((entry) => entry.passes).length;
+ return {
+ calibrationAvailable: true,
+ reportPath,
+ startedAt: stringOrUndefined(parsed.started_at),
+ finishedAt: stringOrUndefined(parsed.finished_at),
+ gitSha: stringOrUndefined(parsed.git_sha),
+ totalReferenceCases: cases.length,
+ referencePassedCases,
+ referencePassRate: cases.length > 0 ? referencePassedCases / cases.length : 0,
+ thresholds: normalizeThresholds(parsed.thresholds),
+ cases,
+ table: []
+ };
+}
+
+export function readCalibrationCase(
+ comparison: CalibrationComparison,
+ testcaseFile: string,
+ testcaseId: string,
+ options: { index?: number } = {}
+): CalibrationCaseComparison | undefined {
+ if (!comparison.calibrationAvailable) return undefined;
+ const files = new Set(testcaseFileLookupVariants(testcaseFile));
+ return comparison.cases.find((entry) => {
+ if (entry.testcaseId !== testcaseId) return false;
+ if (options.index !== undefined && entry.idx !== options.index) return false;
+ return testcaseFileLookupVariants(entry.file).some((variant) => files.has(variant));
+ });
+}
+
+export function testcaseFileLookupVariants(path: string): string[] {
+ const normalized = normalizePath(path);
+ const variants = new Set<string>([normalized]);
+ // Current parity reports may embed "simulator/testcases/"; both that form and
+ // the root "testcases/" form normalize to the canonical testcase id.
+ for (const prefix of ["simulator/testcases/"]) {
+ const idx = normalized.indexOf(prefix);
+ if (idx >= 0) variants.add(`testcases/${normalized.slice(idx + prefix.length)}`);
+ }
+ const testcaseIndex = normalized.indexOf("testcases/");
+ if (testcaseIndex >= 0) {
+ const testcasePath = normalized.slice(testcaseIndex);
+ variants.add(testcasePath);
+ variants.add(`simulator/${testcasePath}`);
+ }
+ return [...variants];
+}
+
+export function addCalibrationTableRow(
+ comparison: CalibrationComparison,
+ caseReport: {
+ file: string;
+ testcaseId: string;
+ index: number;
+ simulatorScoreDelta?: number;
+ simulatorStats?: SampleStats;
+ calibration?: CalibrationCaseComparison;
+ }
+): CalibrationComparisonRow {
+ const calibration = caseReport.calibration;
+ const simulator = caseReport.simulatorStats;
+ const simulatorVsBaseline = compareDistributions(simulator, calibration?.muSim, calibration?.sigmaSim, calibration?.nSim, comparison.thresholds, calibration?.statType);
+ const simulatorVsGame = compareDistributions(simulator, calibration?.muGame, calibration?.sigmaGame, calibration?.nGame, comparison.thresholds, calibration?.statType);
+ const simulatorVsGameRaw = simulatorVsGame.biasRaw;
+ const simulatorVsGamePct = simulatorVsGame.biasPct;
+ const row: CalibrationComparisonRow = {
+ file: relativeDisplayPath(caseReport.file),
+ testcaseId: caseReport.testcaseId,
+ idx: caseReport.index,
+ matched: !!calibration,
+ nSim: calibration?.nSim,
+ muSim: calibration?.muSim,
+ sigmaSim: calibration?.sigmaSim,
+ nGame: calibration?.nGame,
+ muGame: calibration?.muGame,
+ sigmaGame: calibration?.sigmaGame,
+ statType: calibration?.statType,
+ referenceBiasPct: calibration?.biasPct,
+ referencePasses: calibration?.passes,
+ passes: calibration?.passes,
+ biasRaw: calibration?.biasRaw,
+ biasPct: calibration?.biasPct,
+ sem: calibration?.sem,
+ p: calibration?.p,
+ q: calibration?.q,
+ simulatorScoreDelta: caseReport.simulatorScoreDelta,
+ simulator,
+ simulatorVsBaseline,
+ simulatorVsGame,
+ simulatorVsGameRaw,
+ simulatorVsGamePct,
+ simulatorPasses: simulatorVsGame.passes,
+ simulatorN: simulator?.n,
+ simulatorMu: simulator?.mu,
+ simulatorSigma: simulator?.sigma,
+ simulatorSem: simulator?.sem,
+ simulatorVsBaselineBiasRaw: simulatorVsBaseline.biasRaw,
+ simulatorVsBaselineBiasPct: simulatorVsBaseline.biasPct,
+ simulatorVsBaselineZ: simulatorVsBaseline.z,
+ simulatorVsBaselinePasses: simulatorVsBaseline.passes,
+ simulatorVsGameBiasRaw: simulatorVsGame.biasRaw,
+ simulatorVsGameBiasPct: simulatorVsGame.biasPct,
+ simulatorVsGameZ: simulatorVsGame.z,
+ simulatorVsGamePasses: simulatorVsGame.passes
+ };
+ comparison.table.push(row);
+ return row;
+}
+
+export function sampleStats(samples: number[], options: { includeSamples?: boolean } = {}): SampleStats {
+ const n = samples.length;
+ const mu = n > 0 ? samples.reduce((sum, value) => sum + value, 0) / n : 0;
+ const variance = n > 1 ? samples.reduce((sum, value) => sum + (value - mu) ** 2, 0) / (n - 1) : 0;
+ const sigma = Math.sqrt(variance);
+ const sem = n > 0 ? sigma / Math.sqrt(n) : 0;
+ return {
+ n,
+ mu,
+ sigma,
+ sem,
+ ...(options.includeSamples ? { samples: [...samples] } : {})
+ };
+}
+
+function latestCalibrationReportPath(dir: string): string | undefined {
+ if (!directoryExists(dir)) return undefined;
+ const candidates = readdirSync(dir)
+ .filter((name) => name.endsWith(".json"))
+ .map((name) => resolve(dir, name))
+ .filter((path) => statSync(path).isFile())
+ .map((path) => ({ path, finishedAt: calibrationFinishedAt(path) }))
+ .sort((a, b) => b.finishedAt - a.finishedAt || a.path.localeCompare(b.path));
+ return candidates[0]?.path;
+}
+
+function fileExists(path: string): boolean {
+ try {
+ return statSync(path).isFile();
+ } catch {
+ return false;
+ }
+}
+
+function directoryExists(path: string): boolean {
+ try {
+ return statSync(path).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+function calibrationFinishedAt(path: string): number {
+ try {
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { finished_at?: unknown };
+ const time = Date.parse(String(parsed.finished_at ?? ""));
+ return Number.isFinite(time) ? time : 0;
+ } catch {
+ return 0;
+ }
+}
+
+function normalizeCalibrationCase(value: unknown): CalibrationCaseComparison | undefined {
+ if (!value || typeof value !== "object") return undefined;
+ const object = value as Record<string, unknown>;
+ const file = stringOrUndefined(object.file);
+ const testcaseId = stringOrUndefined(object.testcase_id ?? object.testcaseId);
+ if (!file || !testcaseId) return undefined;
+ return {
+ file: normalizePath(file),
+ testcaseId,
+ idx: numberOrDefault(object.idx, 0),
+ nSim: numberOrUndefined(object.n_sim),
+ muSim: numberOrUndefined(object.mu_sim),
+ sigmaSim: numberOrUndefined(object.sigma_sim),
+ nGame: numberOrUndefined(object.n_game),
+ muGame: numberOrUndefined(object.mu_game),
+ sigmaGame: numberOrUndefined(object.sigma_game),
+ biasRaw: numberOrUndefined(object.bias_raw),
+ biasPct: numberOrUndefined(object.bias_pct),
+ sem: numberOrUndefined(object.sem),
+ p: nullableNumber(object.p),
+ q: nullableNumber(object.q),
+ statType: stringOrUndefined(object.stat_type),
+ passes: booleanOrUndefined(object.passes)
+ };
+}
+
+function emptyCalibrationComparison(): CalibrationComparison {
+ return {
+ calibrationAvailable: false,
+ totalReferenceCases: 0,
+ referencePassedCases: 0,
+ referencePassRate: 0,
+ cases: [],
+ table: []
+ };
+}
+
+function normalizeThresholds(value: unknown): Record<string, number> | undefined {
+ if (!value || typeof value !== "object") return undefined;
+ const entries = Object.entries(value as Record<string, unknown>)
+ .map(([key, raw]) => [key, numberOrUndefined(raw)] as const)
+ .filter((entry): entry is readonly [string, number] => entry[1] !== undefined);
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
+}
+
+function compareDistributions(
+ simulator: SampleStats | undefined,
+ referenceMu: number | undefined,
+ referenceSigma: number | undefined,
+ referenceN: number | undefined,
+ thresholds?: Record<string, number>,
+ referenceStatType?: string
+): DistributionCompatibility {
+ if (!simulator || referenceMu === undefined || referenceSigma === undefined || referenceN === undefined) return { statType: "unmatched" };
+ const biasRaw = simulator.mu - referenceMu;
+ const biasPct = percentDelta(biasRaw, referenceMu);
+ const combinedSem = Math.sqrt((simulator.sigma ** 2) / Math.max(1, simulator.n) + (referenceSigma ** 2) / Math.max(1, referenceN));
+ const deterministic = referenceStatType === "deterministic" || (simulator.sigma === 0 && referenceSigma === 0);
+ if (deterministic || combinedSem === 0) {
+ return {
+ biasRaw,
+ biasPct,
+ statType: "deterministic",
+ passes: deterministicPasses(biasRaw, biasPct, thresholds)
+ };
+ }
+ const z = biasRaw / combinedSem;
+ return {
+ biasRaw,
+ biasPct,
+ z,
+ statType: "distribution",
+ passes: distributionPasses(z, biasPct, thresholds)
+ };
+}
+
+function deterministicPasses(raw: number, pct: number | undefined, thresholds?: Record<string, number>): boolean {
+ if (pct === undefined) return raw === 0;
+ const maxDiffRatio = thresholds?.max_diff_ratio_deterministic ?? thresholds?.max_diff_ratio ?? 0.01;
+ return Math.abs(pct) <= maxDiffRatio * 100;
+}
+
+function distributionPasses(z: number, pct: number | undefined, thresholds?: Record<string, number>): boolean {
+ const minBiasPct = thresholds?.min_bias_pct ?? 0.5;
+ if (pct !== undefined && Math.abs(pct) < minBiasPct) return true;
+ const zThreshold = thresholds?.z_threshold ?? 2;
+ return Math.abs(z) <= zThreshold;
+}
+
+function percentDelta(rawDelta: number, expected: number): number | undefined {
+ if (expected === 0) return rawDelta === 0 ? 0 : undefined;
+ return (rawDelta / Math.abs(expected)) * 100;
+}
+
+function relativeDisplayPath(path: string): string {
+ const normalized = normalizePath(path);
+ const cwdRelative = normalizePath(relative(process.cwd(), path));
+ if (!cwdRelative.startsWith("..")) return cwdRelative;
+ return normalized;
+}
+
+function normalizePath(path: string): string {
+ return path.replaceAll("\\", "/");
+}
+
+function stringOrUndefined(value: unknown): string | undefined {
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+function numberOrUndefined(value: unknown): number | undefined {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : undefined;
+}
+
+function nullableNumber(value: unknown): number | null | undefined {
+ if (value === null) return null;
+ return numberOrUndefined(value);
+}
+
+function numberOrDefault(value: unknown, fallback: number): number {
+ return numberOrUndefined(value) ?? fallback;
+}
+
+function booleanOrUndefined(value: unknown): boolean | undefined {
+ return typeof value === "boolean" ? value : undefined;
+}
+
+interface CalibrationReportJson {
+ started_at?: unknown;
+ finished_at?: unknown;
+ git_sha?: unknown;
+ thresholds?: unknown;
+ testcases?: Record<string, unknown>;
+}
Index: simulator/src/classifier.ts
===================================================================
--- simulator/src/classifier.ts prev run
+++ simulator/src/classifier.ts this run
@@ -0,0 +1,54 @@
+import type { ActiveEffect, DamageJob, SideId, UnitType } from "./types";
+import { unitMaskHas } from "./types";
+import { bucketDefinition, type BucketName } from "./damageBuckets";
+import { staticPassiveBucketRole, type StaticPassiveBucket } from "./staticDamageProfile";
+
+export interface Classification {
+ kind: "bucket" | "control" | "extra_skill_attack" | "battle_order" | "report_only";
+ bucket?: BucketName | StaticPassiveBucket;
+ control?: "dodge" | "no_attack";
+ reason?: string;
+}
+
+export function classifyEffectForJob(effect: ActiveEffect, job: DamageJob): Classification | undefined {
+ if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };
+
+ const type = effect.intent.type;
+ if (type === "dodge" || type === "no_attack") return { kind: "control", control: type };
+ if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };
+ if (type === "attack_order") return { kind: "battle_order" };
+
+ const definition = bucketDefinition(type);
+ const staticPassiveRole = staticPassiveBucketRole(type);
+ if ((!definition || definition.valueType !== "pct") && !staticPassiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };
+ if (staticPassiveRole) {
+ if (staticPassiveRole === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };
+ if (staticPassiveRole === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };
+ return { kind: "report_only", reason: unsupportedReason(effect, job) };
+ }
+ if (!definition) return { kind: "report_only", reason: unsupportedReason(effect, job) };
+ if (definition.appliesTo !== undefined && definition.appliesTo !== job.kind) return { kind: "report_only", reason: "not_applicable_to_job_kind" };
+ if (definition.role === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type };
+ if (definition.role === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type };
+ return { kind: "report_only", reason: unsupportedReason(effect, job) };
+}
+
+export function basicEffectApplies(effect: ActiveEffect, job: DamageJob): boolean {
+ const affectedUnit = unitForSide(effect.appliesTo.side, job);
+ if (!affectedUnit || !unitMaskHas(effect.appliesTo.units, affectedUnit)) return false;
+ const opposingUnit = unitForSide(effect.appliesVs.side, job);
+ if (!opposingUnit || !unitMaskHas(effect.appliesVs.units, opposingUnit)) return false;
+ return true;
+}
+
+function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {
+ if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";
+ if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";
+ return "wrong_side";
+}
+
+function unitForSide(side: SideId, job: DamageJob): UnitType | undefined {
+ if (side === job.attackerSide) return job.attackerUnit;
+ if (side === job.defenderSide) return job.defenderUnit;
+ return undefined;
+}
Index: simulator/src/classifierDamage.test.ts
===================================================================
--- simulator/src/classifierDamage.test.ts prev run
+++ simulator/src/classifierDamage.test.ts this run
@@ -0,0 +1,649 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { classifyEffectForJob } from "./classifier";
+import { calculateDamageJob } from "./damage";
+import { ATOMIC_BUCKETS } from "./damageBuckets";
+import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";
+import { activateEffect } from "./effects";
+import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";
+import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";
+import { ALL_UNIT_MASK, unitMask } from "./types";
+
+const job: DamageJob = {
+ id: "job-1",
+ round: 1,
+ kind: "normal",
+ sourceIntentId: "intent-1",
+ roundStartTroops: {
+ attacker: { infantry: 1000, lancer: 0, marksman: 0 },
+ defender: { infantry: 0, lancer: 1000, marksman: 0 }
+ },
+ attackerSide: "attacker",
+ attackerUnit: "infantry",
+ defenderSide: "defender",
+ defenderUnit: "lancer",
+ sourceMultiplier: 1
+};
+
+test("resolved effect scope matches concrete side and unit masks", () => {
+ const active: ActiveEffect = {
+ id: "resolved-scope",
+ source: { kind: "hero_skill", side: "attacker", heroName: "Example", skillId: "Scope", effectId: "scope/1" },
+ intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },
+ ownerSide: "attacker",
+ kind: "modifier",
+ valuePct: 25,
+ appliesTo: { side: "attacker", units: unitMask(["infantry"]) },
+ appliesVs: { side: "defender", units: unitMask(["lancer"]) },
+ createdRound: 1,
+ startRound: 1,
+ duration: { type: "battle", value: 0 },
+ uses: 0,
+ sameEffectStacking: "add"
+ };
+
+ assert.equal(classifyEffectForJob(active, job)?.bucket, "active.hero.lethality.up");
+ assert.equal(
+ classifyEffectForJob(active, {
+ ...job,
+ id: "job-attacker-marksman",
+ attackerUnit: "marksman"
+ })?.reason,
+ "not_applicable_to_job"
+ );
+ assert.equal(
+ classifyEffectForJob(active, {
+ ...job,
+ id: "job-defender-marksman",
+ defenderUnit: "marksman"
+ })?.reason,
+ "not_applicable_to_job"
+ );
+});
+
+function effect(
+ type: string,
+ ownerSide: "attacker" | "defender",
+ valuePct = 25,
+ sourceKind: ActiveEffect["source"]["kind"] = "hero_skill"
+): ActiveEffect {
+ const source =
+ sourceKind === "troop_skill"
+ ? { kind: sourceKind, side: ownerSide, troopType: "infantry" as const, skillId: "Skill", effectId: `${type}/1` }
+ : { kind: sourceKind, side: ownerSide, heroName: "Example", skillId: "Skill", effectId: `${type}/1` };
+ return {
+ id: `${type}-1`,
+ source,
+ intent: { id: `${type}/1`, type, value: [valuePct] },
+ ownerSide,
+ kind: "modifier",
+ valuePct,
+ appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },
+ appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },
+ createdRound: 1,
+ startRound: 1,
+ duration: { type: "battle", value: 0 },
+ uses: 0,
+ sameEffectStacking: "add"
+ };
+}
+
+function calculateIndexedDamageJob(
+ damageJob: DamageJob,
+ fighters: Record<"attacker" | "defender", ResolvedFighter>,
+ effects: ActiveEffect[],
+ options: Partial<Parameters<typeof calculateDamageJob>[3]> = {}
+) {
+ const effectIndex = options.effectIndex ?? createEffectIndex();
+ if (!options.effectIndex) {
+ for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);
+ }
+ const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
+ removeStaticProfileBucketEffects(effectIndex);
+ return calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile });
+}
+
+test("classifier routes up/down effects into neutral atomic buckets", () => {
+ assert.equal(classifyEffectForJob(effect("active.hero.health.up", "defender"), job)?.bucket, "active.hero.health.up");
+ assert.equal(classifyEffectForJob(effect("active.hero.health.down", "defender"), job)?.bucket, "active.hero.health.down");
+ assert.equal(classifyEffectForJob(effect("active.hero.attack.up", "attacker"), job)?.bucket, "active.hero.attack.up");
+ assert.equal(classifyEffectForJob(effect("active.hero.attack.down", "attacker"), job)?.bucket, "active.hero.attack.down");
+});
+
+test("classifier keeps hero and troop active effects in separate atomic buckets", () => {
+ assert.equal(classifyEffectForJob(effect("active.hero.lethality.up", "attacker", 20, "hero_skill"), job)?.bucket, "active.hero.lethality.up");
+ assert.equal(classifyEffectForJob(effect("active.troop.lethality.up", "attacker", 20, "troop_skill"), job)?.bucket, "active.troop.lethality.up");
+ assert.equal(classifyEffectForJob(effect("active.hero.lethality.up", "attacker", 10, "hero_skill"), job)?.bucket, "active.hero.lethality.up");
+ assert.equal(classifyEffectForJob(effect("active.troop.lethality.up", "attacker", 10, "troop_skill"), job)?.bucket, "active.troop.lethality.up");
+});
+
+test("classifier routes the complete native bucket policy into atomic buckets", () => {
+ const atomicBuckets = new Set(ATOMIC_BUCKETS);
+ const expected = new Map([
+ ["active.hero.lethality.up", "active.hero.lethality.up"],
+ ["active.hero.lethality.down", "active.hero.lethality.down"],
+ ["active.hero.attack.up", "active.hero.attack.up"],
+ ["active.hero.attack.down", "active.hero.attack.down"],
+ ["active.hero.lethality.up", "active.hero.lethality.up"],
+ ["active.hero.lethality.down", "active.hero.lethality.down"],
+ ["active.hero.lethality.up", "active.hero.lethality.up"],
+ ["type.normal.damage.up", "type.normal.damage.up"],
+ ["type.normal.damage.down", "type.normal.damage.down"],
+ ["type.skill.damage.up", "type.skill.damage.up"],
+ ["type.skill.damage.down", "type.skill.damage.down"],
+ ["active.hero.defense.up", "active.hero.defense.up"],
+ ["active.hero.defense.down", "active.hero.defense.down"],
+ ["active.hero.health.up", "active.hero.health.up"],
+ ["active.hero.health.down", "active.hero.health.down"],
+ ["active.hero.defense.up", "active.hero.defense.up"],
+ ["active.hero.defense.down", "active.hero.defense.down"],
+ ["type.normal.defense.up", "type.normal.defense.up"],
+ ["type.normal.defense.down", "type.normal.defense.down"],
+ ["type.skill.defense.up", "type.skill.defense.up"],
+ ["type.skill.defense.down", "type.skill.defense.down"]
+ ]);
+ const defenderEffectTypes = new Set([
+ "active.hero.defense.up",
+ "active.hero.defense.down",
+ "active.hero.health.up",
+ "active.hero.health.down",
+ "active.hero.defense.up",
+ "active.hero.defense.down",
+ "type.normal.defense.up",
+ "type.normal.defense.down",
+ "type.skill.defense.up",
+ "type.skill.defense.down"
+ ]);
+
+ for (const [type, expectedBucket] of expected) {
+ const ownerSide = defenderEffectTypes.has(type) ? "defender" : "attacker";
+ const classifierJob = type.startsWith("type.skill.") ? { ...job, id: "job-skill-route", kind: "skill" as const } : job;
+ const bucket = classifyEffectForJob(effect(type, ownerSide), classifierJob)?.bucket;
+ assert.equal(bucket, expectedBucket, type);
+ assert.ok(bucket !== undefined && atomicBuckets.has(bucket), `${type} route ${bucket} is not an atomic bucket`);
+ }
+
+ const passiveExpected = new Map([
+ ["passive.attack.up", "passive.attack.up"],
+ ["passive.lethality.up", "passive.lethality.up"],
+ ["passive.health.up", "passive.health.up"],
+ ["passive.defense.up", "passive.defense.up"]
+ ]);
+
+ for (const [type, expectedBucket] of passiveExpected) {
+ const ownerSide = type.includes(".attack.") || type.includes(".lethality.") ? "attacker" : "defender";
+ const bucket = classifyEffectForJob(effect(type, ownerSide), job)?.bucket;
+ assert.equal(bucket, expectedBucket, type);
+ assert.ok(bucket !== undefined && (STATIC_PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a static passive bucket`);
+ }
+
+ assert.equal(classifyEffectForJob(effect("passive.attack.down", "attacker", 5), job)?.bucket, "passive.attack.down");
+});
+
+test("passive stat bonuses only apply in the damage role that consumes that stat", () => {
+ const reversedJob: DamageJob = {
+ ...job,
+ id: "job-reversed",
+ attackerSide: "defender",
+ attackerUnit: "lancer",
+ defenderSide: "attacker",
+ defenderUnit: "infantry"
+ };
+ const passiveAttack = effect("passive.attack.up", "attacker", 10);
+ const passiveHealth = effect("passive.health.up", "defender", 10);
+
+ assert.equal(classifyEffectForJob(passiveAttack, job)?.bucket, "passive.attack.up");
+ assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_defender_effect");
+ assert.equal(classifyEffectForJob(passiveHealth, job)?.bucket, "passive.health.up");
+ assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_attacker_effect");
+});
+
+test("damage calculator requires indexed effect candidates", () => {
+ assert.throws(() => calculateDamageJob(job, simpleFighters(), [], { trace: true } as never), /effectIndex/i);
+});
+
+test("damage calculator uses centralized bucket definitions for player stat routing", () => {
+ const attacker: ResolvedFighter = {
+ side: "attacker",
+ name: "A",
+ troops: { infantry: 1000, lancer: 0, marksman: 0 },
+ initialTroops: { infantry: 1000, lancer: 0, marksman: 0 },
+ troopDetails: {
+ infantry: { id: "infantry_t1", type: "infantry", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ },
+ statBonuses: {
+ infantry: { attack: 100, lethality: 0, defense: 0, health: 0 },
+ lancer: { attack: 0, lethality: 0, defense: 0, health: 0 },
+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
+ },
+ heroes: [],
+ troopSkills: [],
+ diagnostics: []
+ };
+ const defender: ResolvedFighter = {
+ ...attacker,
+ side: "defender",
+ name: "D",
+ troops: { infantry: 0, lancer: 1000, marksman: 0 },
+ initialTroops: { infantry: 0, lancer: 1000, marksman: 0 },
+ troopDetails: {
+ lancer: { id: "lancer_t1", type: "lancer", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ },
+ statBonuses: {
+ infantry: { attack: 0, lethality: 0, defense: 0, health: 0 },
+ lancer: { attack: 0, lethality: 0, defense: 0, health: 100 },
+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
+ }
+ };
+
+ const outcome = calculateIndexedDamageJob(job, { attacker, defender }, [], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["player.attack"].totalPct, 100);
+ assert.equal(outcome.trace?.atomicBuckets["player.health"].totalPct, 100);
+ assert.ok(outcome.kills > 0);
+ assert.ok(outcome.kills < 1000);
+});
+
+test("active stat-up effects multiply separately from player stat bonuses", () => {
+ const fighters = simpleFighters();
+ fighters.attacker.statBonuses.infantry.attack = 100;
+
+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const withRuntimeAttackUp = calculateIndexedDamageJob(job, fighters, [effect("active.hero.attack.up", "attacker", 100)], { trace: true });
+
+ assert.equal(baseline.trace?.atomicBuckets["player.attack"].totalPct, 100);
+ assert.equal(baseline.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 0);
+ assert.equal(withRuntimeAttackUp.trace?.atomicBuckets["player.attack"].totalPct, 100);
+ assert.equal(withRuntimeAttackUp.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
+ assert.equal(withRuntimeAttackUp.kills, baseline.kills * 2);
+});
+
+test("traced damage uses the effect index for applied bucket candidates", () => {
+ const indexedEffect = effect("active.hero.attack.up", "attacker", 100);
+ const index = createEffectIndex();
+ indexEffect(index, indexedEffect);
+
+ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true, effectIndex: index });
+
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
+ assert.equal(outcome.kills, baseline.kills * 2);
+});
+
+test("passive stat bonuses aggregate as up sum over down sum on top of player stats", () => {
+ const fighters = simpleFighters();
+ fighters.attacker.statBonuses.infantry.attack = 2015.2;
+ const passiveAttack = (value: number): ActiveEffect => ({
+ ...effect(value < 0 ? "passive.attack.down" : "passive.attack.up", "attacker", Math.abs(value)),
+ id: `passive-attack-${value}`,
+ intent: { id: `passive/attack/${value}`, type: value < 0 ? "passive.attack.down" : "passive.attack.up", value: Math.abs(value) }
+ });
+
+ const baseline = calculateIndexedDamageJob(job, fighters, [], { trace: true });
+ const outcome = calculateIndexedDamageJob(job, fighters, [passiveAttack(20), passiveAttack(10), passiveAttack(-5)], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);
+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
+ assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.3);
+ assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.down"].factor, 1.05);
+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), Number((1.3 / 1.05).toFixed(6)));
+});
+
+test("static damage profile produces the same static bucket damage as per-job passive processing", () => {
+ const fighters = simpleFighters();
+ fighters.attacker.statBonuses.infantry.attack = 50;
+ fighters.attacker.statBonuses.infantry.lethality = 25;
+ fighters.defender.statBonuses.lancer.health = 40;
+ fighters.defender.statBonuses.lancer.defense = 10;
+
+ const passives = [
+ effect("passive.attack.up", "attacker", 20),
+ effect("passive.attack.down", "attacker", 5),
+ effect("passive.lethality.up", "attacker", 10),
+ effect("passive.health.up", "defender", 15),
+ effect("passive.health.down", "defender", 30),
+ effect("passive.defense.up", "defender", 5)
+ ];
+ const baseline = calculateIndexedDamageJob(job, fighters, passives, { trace: true });
+ const profile = buildStaticDamageProfile(fighters, passives);
+ const profiled = calculateIndexedDamageJob(job, fighters, passives, { trace: true, staticDamageProfile: profile });
+
+ assert.ok(Math.abs(profiled.kills - baseline.kills) < 1e-12);
+ assert.equal(profiled.trace?.atomicBuckets["player.attack"].totalPct, 50);
+ assert.equal(profiled.trace?.atomicBuckets["passive.attack.up"].totalPct, 20);
+ assert.equal(profiled.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
+ assert.equal(profiled.trace?.atomicBuckets["passive.health.down"].totalPct, 30);
+ assert.equal(profiled.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.2);
+});
+
+test("static damage profile preserves max stacking for duplicate passive effects", () => {
+ const fighters = simpleFighters();
+ const weaker = { ...effect("passive.attack.up", "attacker", 20), id: "weak", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };
+ const stronger = { ...effect("passive.attack.up", "attacker", 50), id: "strong", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };
+ const profile = buildStaticDamageProfile(fighters, [weaker, stronger]);
+ const outcome = calculateIndexedDamageJob(job, fighters, [weaker, stronger], { trace: true, staticDamageProfile: profile });
+
+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 50);
+ assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].contributors.length, 1);
+ assert.equal(outcome.kills, calculateIndexedDamageJob(job, fighters, [], { trace: true }).kills * 1.5);
+});
+
+test("default aggregation multiplies hero and troop active damage buckets", () => {
+ const fighters = simpleFighters();
+ 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")],
+ { 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?.aggregationGroups["active.hero.lethality.up"].factor, 1.2);
+ assert.equal(combined.trace?.aggregationGroups["active.troop.lethality.up"].factor, 1.1);
+ assert.ok(Math.abs(combined.kills - baseline.kills * 1.2 * 1.1) < 1e-12);
+});
+
+test("negative passive stat bonuses route to down buckets with positive factors", () => {
+ const passiveHealthDown = {
+ ...effect("passive.health.down", "defender", 105),
+ intent: { id: "passive-health-down", type: "passive.health.down", value: 105 }
+ };
+ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [passiveHealthDown], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["passive.health.down"].totalPct, 105);
+ assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].factor, 2.05);
+ assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].placement, "numerator");
+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);
+});
+
+test("pass-specific buckets only apply to matching damage job kind", () => {
+ const normalEffect = effect("type.normal.damage.up", "attacker", 100);
+ const skillEffect = effect("type.skill.damage.up", "attacker", 100);
+ const fighters = simpleFighters();
+ const normalOutcome = calculateIndexedDamageJob(job, fighters, [normalEffect, skillEffect], { trace: true });
+ const skillOutcome = calculateIndexedDamageJob({ ...job, id: "job-skill", kind: "skill", sourceMultiplier: 1 }, fighters, [normalEffect, skillEffect], {
+ trace: true
+ });
+
+ assert.equal(normalOutcome.trace?.atomicBuckets["type.normal.damage.up"].totalPct, 100);
+ assert.equal(normalOutcome.trace?.aggregationGroups["type.skill.damage.up"], undefined);
+ assert.equal(skillOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);
+ assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);
+});
+
+test("attack-duration bucket effects are consumed by the applicable attack job", () => {
+ const oneAttackEffect = {
+ ...effect("active.hero.attack.up", "attacker", 100),
+ id: "attack-up-active",
+ duration: { type: "attack" as const, value: 1 }
+ };
+
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
+
+ assert.ok(outcome.consumedEffectIds.includes("attack-up-active"));
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
+});
+
+test("turn-duration bucket effects are visible on the attack outcome without being consumed", () => {
+ const turnEffect = {
+ ...effect("active.hero.defense.down", "defender", 30),
+ id: "bad-luck-like",
+ source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },
+ duration: { type: "round" as const, value: 1 }
+ };
+
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: false });
+
+ assert.equal(outcome.consumedEffectIds.includes("bad-luck-like"), false);
+ assert.ok(outcome.appliedEffectIds.includes("BadLuckStreak/1"));
+ assert.deepEqual(outcome.appliedEffects, [
+ {
+ effectId: "BadLuckStreak/1",
+ bucket: "active.hero.defense.down",
+ valuePct: 30,
+ source: "Example/Skill/BadLuckStreak/1",
+ sourceSide: "defender",
+ sameEffectStacking: "add"
+ }
+ ]);
+});
+
+test("attack-duration effects are only consumed when they participate in the calculation", () => {
+ const defenderOutgoingBuff = {
+ ...effect("active.hero.lethality.up", "defender", 100),
+ id: "defender-outgoing-buff",
+ duration: { type: "attack" as const, value: 1 }
+ };
+
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);
+ assert.equal(outcome.consumedEffectIds.includes("defender-outgoing-buff"), false);
+});
+
+test("pct attack value evolution uses the effect use count for the current bucket value", () => {
+ const decayedAttackUp = {
+ ...effect("active.hero.attack.up", "attacker", 100),
+ id: "decayed-attack-up",
+ intent: {
+ id: "active.hero.attack.up/decay",
+ type: "active.hero.attack.up",
+ value: 100,
+ value_evolution: { type: "pct_decay", step: "attack", value: 15 }
+ },
+ duration: { type: "attack" as const, value: 10 },
+ uses: 2
+ };
+
+ const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [decayedAttackUp], { trace: true });
+
+ assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);
+ assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);
+ assert.ok(outcome.consumedEffectIds.includes("decayed-attack-up"));
+});
+
+test("pct turn value evolution starts decaying after the first active turn", () => {
+ const turnDecayAttackUp = {
+ ...effect("active.hero.attack.up", "attacker", 100),
+ id: "turn-decay-attack-up",
+ intent: {
+ id: "active.hero.attack.up/turn_decay",
+ type: "active.hero.attack.up",
+ value: 100,
+ value_evolution: { type: "pct_decay", step: "turn", value: 15 }
+ },
+ createdRound: 0,
+ startRound: 0,
+ duration: { type: "attack" as const, value: 10 }
+ };
+
+ const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
+ const secondTurn = calculateIndexedDamageJob({ ...job, round: 2 }, simpleFighters(), [turnDecayAttackUp], { trace: true });
+
+ assert.equal(firstTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
+ assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);
+});
+
+test("max-stacked attack-duration effects consume the whole eligible group and output only the max current value", () => {
+ const weaker = {
+ ...effect("active.hero.lethality.up", "attacker", 50),
+ id: "max-weaker",
+ duration: { type: "attack" as const, value: 3 },
+ stackingKey: "same-max-group",
+ sameEffectStacking: "max" as const
+ };
+ const strongerButDecayed = {
+ ...effect("active.hero.lethality.up", "attacker", 100),
+ id: "max-stronger-decayed",
+ intent: {
+ id: "damage_up/decay",
+ type: "active.hero.lethality.up",
+ value: 100,
+ value_evolution: { type: "pct_decay", step: "attack", value: 50 }
+ },
+ duration: { type: "attack" as const, value: 3 },
+ uses: 1,
+ stackingKey: "same-max-group",
+ sameEffectStacking: "max" as const
+ };
+
+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [weaker, strongerButDecayed], { trace: true });
+
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);
+ assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
+ assert.deepEqual(new Set(outcome.consumedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
+});
+
+test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {
+ const active = activateEffect(
+ {
+ id: "TargetSkill",
+ name: "TargetSkill",
+ sourceKind: "hero_skill",
+ side: "defender",
+ heroName: "Targeter",
+ level: 1,
+ trigger: { type: "attack", source: "enemy.any", target: "self.any" },
+ effects: []
+ },
+ {
+ id: "targeted-defense",
+ type: "active.hero.defense.up",
+ value: 50,
+ units: { applies_to: "trigger.target", applies_vs: "trigger.source" }
+ },
+ 1,
+ {
+ id: "intent",
+ round: 1,
+ source: "normal",
+ attackerSide: "attacker",
+ attackerUnit: "infantry",
+ defenderSide: "defender",
+ defenderUnit: "lancer",
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 1,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 1
+ }
+ );
+
+ assert.deepEqual(active.appliesTo, { side: "defender", units: unitMask("lancer") });
+ assert.deepEqual(active.appliesVs, { side: "attacker", units: unitMask("infantry") });
+ assert.equal(classifyEffectForJob(active, job)?.bucket, "active.hero.defense.up");
+});
+
+test('applies_vs "target" resolves to the trigger target', () => {
+ const active = activateEffect(
+ {
+ id: "TargetSkill",
+ name: "TargetSkill",
+ sourceKind: "hero_skill",
+ side: "attacker",
+ heroName: "Targeter",
+ level: 1,
+ trigger: { type: "attack", source: "self.any", target: "enemy.any" },
+ effects: []
+ },
+ {
+ id: "targeted-offense",
+ type: "active.hero.lethality.up",
+ value: 50,
+ units: { applies_to: "trigger.source", applies_vs: "target" }
+ },
+ 1,
+ {
+ id: "intent",
+ round: 1,
+ source: "normal",
+ attackerSide: "attacker",
+ attackerUnit: "infantry",
+ defenderSide: "defender",
+ defenderUnit: "lancer",
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 1,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 1
+ }
+ );
+
+ assert.deepEqual(active.appliesTo, { side: "attacker", units: unitMask("infantry") });
+ assert.deepEqual(active.appliesVs, { side: "defender", units: unitMask("lancer") });
+});
+
+test('applies_vs "target" still resolves to the trigger target for defensive effects', () => {
+ const active = activateEffect(
+ {
+ id: "TargetSkill",
+ name: "TargetSkill",
+ sourceKind: "hero_skill",
+ side: "defender",
+ heroName: "Targeter",
+ level: 1,
+ trigger: { type: "attack", source: "enemy.any", target: "self.any" },
+ effects: []
+ },
+ {
+ id: "targeted-defense",
+ type: "active.hero.defense.up",
+ value: 50,
+ units: { applies_to: "trigger.target", applies_vs: "target" }
+ },
+ 1,
+ {
+ id: "intent",
+ round: 1,
+ source: "normal",
+ attackerSide: "attacker",
+ attackerUnit: "infantry",
+ defenderSide: "defender",
+ defenderUnit: "lancer",
+ orderIndex: 0,
+ previousAttackCount: 0,
+ projectedAttackCount: 1,
+ previousReceivedAttackCount: 0,
+ projectedReceivedAttackCount: 1
+ }
+ );
+
+ assert.deepEqual(active.appliesTo, { side: "defender", units: unitMask("lancer") });
+ assert.deepEqual(active.appliesVs, { side: "defender", units: unitMask("lancer") });
+});
+
+function simpleFighters(): Record<"attacker" | "defender", ResolvedFighter> {
+ const attacker: ResolvedFighter = {
+ side: "attacker",
+ name: "A",
+ troops: { infantry: 1000, lancer: 0, marksman: 0 },
+ initialTroops: { infantry: 1000, lancer: 0, marksman: 0 },
+ troopDetails: {
+ infantry: { id: "infantry_t1", type: "infantry", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ },
+ statBonuses: {
+ infantry: { attack: 0, lethality: 0, defense: 0, health: 0 },
+ lancer: { attack: 0, lethality: 0, defense: 0, health: 0 },
+ marksman: { attack: 0, lethality: 0, defense: 0, health: 0 }
+ },
+ heroes: [],
+ troopSkills: [],
+ diagnostics: []
+ };
+ const defender: ResolvedFighter = {
+ ...attacker,
+ side: "defender",
+ name: "D",
+ troops: { infantry: 0, lancer: 1000, marksman: 0 },
+ initialTroops: { infantry: 0, lancer: 1000, marksman: 0 },
+ troopDetails: {
+ lancer: { id: "lancer_t1", type: "lancer", tier: 1, fc: 0, count: 1000, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ }
+ };
+ return { attacker, defender };
+}
Index: simulator/src/config-node.ts
===================================================================
--- simulator/src/config-node.ts prev run
+++ simulator/src/config-node.ts this run
@@ -0,0 +1,31 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join, relative, resolve } from "node:path";
+
+import { buildSimulatorConfig, type RawSimulatorConfig } from "./config";
+import type { SimulatorConfig, SkillFile } from "./types";
+
+export function loadSimulatorConfigFromDir(configDir: string): SimulatorConfig {
+ const root = resolve(configDir);
+ const heroDir = join(root, "hero_definitions");
+ const heroDefinitions: Record<string, SkillFile> = {};
+ for (const file of readdirSync(heroDir).filter((name) => name.endsWith(".json")).sort()) {
+ heroDefinitions[file.slice(0, -".json".length)] = readJson(join(heroDir, file)) as SkillFile;
+ }
+ const raw: RawSimulatorConfig = {
+ troopStats: readJson(join(root, "troop_stats.json")) as SimulatorConfig["troopStats"],
+ heroGenerationStats: readJson(join(root, "hero_generation_stats.json")) as SimulatorConfig["heroGenerationStats"],
+ troopSkills: readJson(join(root, "troop_skills.json")) as SkillFile,
+ heroDefinitions,
+ fileLabel(kind, key) {
+ if (kind === "hero_definition") return relative(process.cwd(), join(heroDir, `${key}.json`));
+ if (kind === "troop_stats") return relative(process.cwd(), join(root, "troop_stats.json"));
+ if (kind === "hero_generation_stats") return relative(process.cwd(), join(root, "hero_generation_stats.json"));
+ return relative(process.cwd(), join(root, "troop_skills.json"));
+ }
+ };
+ return buildSimulatorConfig(raw);
+}
+
+function readJson(path: string): unknown {
+ return JSON.parse(readFileSync(path, "utf8"));
+}
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -0,0 +1,299 @@
+import assert from "node:assert/strict";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join, relative } from "node:path";
+import { test } from "node:test";
+import { tmpdir } from "node:os";
+import { fileURLToPath } from "node:url";
+
+import { loadSimulatorConfigFromDir } from "./config-node";
+import { UNIT_TYPES } from "./types";
+import type { SkillFile, TriggerDamageJobDefinition } from "./types";
+
+test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "trigger.source", applies_vs: "target" }
+ });
+
+ const config = loadSimulatorConfigFromDir(root);
+
+ 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 legacy fields in simulator config", () => {
+ const root = join(tmpdir(), `wos-simulator-config-${Date.now()}`);
+ mkdirSync(join(root, "hero_definitions"), { recursive: true });
+ writeFileSync(
+ join(root, "troop_stats.json"),
+ JSON.stringify({
+ infantry_t1: {
+ id: "infantry_t1",
+ type: "infantry",
+ tier: 1,
+ fc: 0,
+ legacy: true,
+ stats: { Attack: 1, Defense: 1, Lethality: 1, Health: 1 }
+ }
+ })
+ );
+ writeFileSync(join(root, "hero_generation_stats.json"), JSON.stringify({ S1: { attack: 1, defense: 1, lethality: 1, health: 1 } }));
+ writeFileSync(join(root, "troop_skills.json"), JSON.stringify({ name: "Troop Skills", skills: {} }));
+ writeFileSync(join(root, "hero_definitions", "Example.json"), JSON.stringify({ name: "Example", hero_generation: "S1", skills: {} }));
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);
+});
+
+test("loadSimulatorConfig rejects legacy effect metadata fields without naming them in source", () => {
+ const legacyEffectMetadataKey = ["effect", "op"].join("_");
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "trigger.source", applies_vs: "target" },
+ [legacyEffectMetadataKey]: 101
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);
+});
+
+test("simulator config source does not reference legacy effect metadata names", () => {
+ const legacyEffectMetadataKey = ["effect", "op"].join("_");
+ const source = readFileSync(fileURLToPath(new URL("./config.ts", import.meta.url)), "utf8");
+
+ assert.equal(source.includes(legacyEffectMetadataKey), false);
+});
+
+test("loadSimulatorConfig rejects duplicate normalized hero aliases", () => {
+ const root = join(tmpdir(), `wos-simulator-config-alias-${Date.now()}`);
+ mkdirSync(join(root, "hero_definitions"), { recursive: true });
+ writeFileSync(
+ join(root, "troop_stats.json"),
+ JSON.stringify({
+ infantry_t1: {
+ id: "infantry_t1",
+ type: "infantry",
+ tier: 1,
+ fc: 0,
+ stats: { Attack: 1, Defense: 1, Lethality: 1, Health: 1 }
+ }
+ })
+ );
+ writeFileSync(join(root, "hero_generation_stats.json"), JSON.stringify({ S1: { attack: 1, defense: 1, lethality: 1, health: 1 } }));
+ writeFileSync(join(root, "troop_skills.json"), JSON.stringify({ name: "Troop Skills", skills: {} }));
+ writeFileSync(join(root, "hero_definitions", "Alpha.json"), JSON.stringify({ name: "Same Hero", hero_generation: "S1", skills: {} }));
+ writeFileSync(join(root, "hero_definitions", "Beta.json"), JSON.stringify({ name: "Same-Hero", hero_generation: "S1", skills: {} }));
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /duplicate hero alias.*samehero/i);
+});
+
+test("loadSimulatorConfig rejects legacy trigger units filters", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "trigger.source", applies_vs: "target" }
+ });
+ const troopSkills = JSON.parse(readFileSync(join(root, "troop_skills.json"), "utf8")) as SkillFile;
+ troopSkills.skills.ExampleSkill.trigger = { type: "attack", units: { side: "enemy", applies_vs: ["infantry"] } } as never;
+ writeFileSync(join(root, "troop_skills.json"), JSON.stringify(troopSkills));
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /legacy trigger units/i);
+});
+
+test('loadSimulatorConfig rejects native effect applies_vs "all"', () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "trigger.source", applies_vs: "all" }
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /applies_vs.*all/i);
+});
+
+test("loadSimulatorConfig rejects native effect units.side", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { side: "enemy", applies_to: "target" }
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /units\.side/i);
+});
+
+test("loadSimulatorConfig rejects trigger-relative effect selectors on battle_start triggers", () => {
+ const root = writeConfigWithTroopEffect(
+ {
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "trigger" }
+ },
+ { type: "battle_start" }
+ );
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /battle_start.*trigger-relative.*applies_to.*trigger/i);
+});
+
+test("loadSimulatorConfig rejects negative native bucket effect values", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.attack.down",
+ value: -10,
+ units: { applies_to: "trigger.source", applies_vs: "target" }
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /negative.*active\.hero\.attack\.down.*value/i);
+});
+
+test("loadSimulatorConfig rejects passive effects that are not battle-start static", () => {
+ const turnRoot = writeConfigWithTroopEffect({
+ type: "passive.attack.up",
+ value: 10
+ });
+ const durationRoot = writeConfigWithTroopEffect(
+ {
+ type: "passive.attack.up",
+ value: 10,
+ duration: { type: "round", value: 1 }
+ },
+ { type: "battle_start" }
+ );
+ const evolvingRoot = writeConfigWithTroopEffect(
+ {
+ type: "passive.attack.up",
+ value: 10,
+ value_evolution: { type: "fixed_decay", step: "round", value: 1 }
+ },
+ { type: "battle_start" }
+ );
+
+ assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /passive.*battle_start/i);
+ assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /passive.*battle.*duration/i);
+ assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /passive.*value_evolution/i);
+});
+
+test("loadSimulatorConfig rejects invalid trigger_damage_jobs selector strings", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "effect.applies_v" }]
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /invalid trigger_damage_jobs target selector.*effect\.applies_v/i);
+});
+
+test("loadSimulatorConfig rejects trigger_damage_jobs with typoed keys", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ soruce: "use.source", target: "effect.applies_vs" }]
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /unknown trigger_damage_jobs key soruce/i);
+});
+
+test("loadSimulatorConfig requires trigger_damage_jobs source and target", () => {
+ const missingSourceRoot = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ target: "effect.applies_vs" }]
+ });
+ const missingTargetRoot = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source" }]
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(missingSourceRoot), /requires source/i);
+ assert.throws(() => loadSimulatorConfigFromDir(missingTargetRoot), /requires target/i);
+});
+
+test('loadSimulatorConfig rejects effect.applies_vs jobs gated by applies_vs "any"', () => {
+ const root = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "effect.applies_vs" }]
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /effect\.applies_vs.*applies_vs.*any/i);
+});
+
+test("loadSimulatorConfig rejects effect.applies_vs jobs without concrete applies_vs", () => {
+ const omittedRoot = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source" },
+ trigger_damage_jobs: [{ source: "use.source", target: "effect.applies_vs" }]
+ });
+ const allRoot = writeConfigWithTroopEffect({
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "all" },
+ trigger_damage_jobs: [{ source: "use.source", target: "effect.applies_vs" }]
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(omittedRoot), /effect\.applies_vs.*concrete applies_vs/i);
+ assert.throws(() => loadSimulatorConfigFromDir(allRoot), /applies_vs.*all/i);
+});
+
+function isAllowedTriggerDamageJobSelector(selector: TriggerDamageJobDefinition["source"]): boolean {
+ const supported = new Set(["use.source", "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;
+}
+
+function isEffectAppliesVsConcrete(selector: unknown): boolean {
+ if (selector === "trigger.target" || selector === "target") return true;
+ if (typeof selector === "string") return (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;
+}
+
+function writeConfigWithTroopEffect(effect: Record<string, unknown>, trigger: Record<string, unknown> = { type: "turn" }): 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, "troop_stats.json"),
+ JSON.stringify({
+ infantry_t1: {
+ id: "infantry_t1",
+ type: "infantry",
+ tier: 1,
+ fc: 0,
+ stats: { Attack: 1, Defense: 1, Lethality: 1, Health: 1 }
+ }
+ })
+ );
+ writeFileSync(join(root, "hero_generation_stats.json"), JSON.stringify({ S1: { attack: 1, defense: 1, lethality: 1, health: 1 } }));
+ writeFileSync(
+ join(root, "troop_skills.json"),
+ JSON.stringify({
+ name: "Troop Skills",
+ skills: {
+ ExampleSkill: {
+ trigger,
+ effects: { "ExampleSkill/1": effect }
+ }
+ }
+ })
+ );
+ return root;
+}
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -0,0 +1,350 @@
+import troopStatsJson from "../config/troop_stats.json" with { type: "json" };
+import heroGenerationStatsJson from "../config/hero_generation_stats.json" with { type: "json" };
+import troopSkillsJson from "../config/troop_skills.json" with { type: "json" };
+import Ahmose from "../config/hero_definitions/Ahmose.json" with { type: "json" };
+import Alonso from "../config/hero_definitions/Alonso.json" with { type: "json" };
+import Bahiti from "../config/hero_definitions/Bahiti.json" with { type: "json" };
+import Bradley from "../config/hero_definitions/Bradley.json" with { type: "json" };
+import Edith from "../config/hero_definitions/Edith.json" with { type: "json" };
+import Flint from "../config/hero_definitions/Flint.json" with { type: "json" };
+import Gordon from "../config/hero_definitions/Gordon.json" with { type: "json" };
+import Greg from "../config/hero_definitions/Greg.json" with { type: "json" };
+import Gwen from "../config/hero_definitions/Gwen.json" with { type: "json" };
+import Hector from "../config/hero_definitions/Hector.json" with { type: "json" };
+import Jasser from "../config/hero_definitions/Jasser.json" with { type: "json" };
+import Jeronimo from "../config/hero_definitions/Jeronimo.json" with { type: "json" };
+import Jessie from "../config/hero_definitions/Jessie.json" with { type: "json" };
+import Ling from "../config/hero_definitions/Ling.json" with { type: "json" };
+import Logan from "../config/hero_definitions/Logan.json" with { type: "json" };
+import Lumak from "../config/hero_definitions/Lumak.json" with { type: "json" };
+import Lynn from "../config/hero_definitions/Lynn.json" with { type: "json" };
+import Mia from "../config/hero_definitions/Mia.json" with { type: "json" };
+import Molly from "../config/hero_definitions/Molly.json" with { type: "json" };
+import Natalia from "../config/hero_definitions/Natalia.json" with { type: "json" };
+import Norah from "../config/hero_definitions/Norah.json" with { type: "json" };
+import Patrick from "../config/hero_definitions/Patrick.json" with { type: "json" };
+import Philly from "../config/hero_definitions/Philly.json" with { type: "json" };
+import Reina from "../config/hero_definitions/Reina.json" with { type: "json" };
+import Renee from "../config/hero_definitions/Renee.json" with { type: "json" };
+import SeoYoon from "../config/hero_definitions/Seo-yoon.json" with { type: "json" };
+import Sergey from "../config/hero_definitions/Sergey.json" with { type: "json" };
+import Wayne from "../config/hero_definitions/Wayne.json" with { type: "json" };
+import WuMing from "../config/hero_definitions/WuMing.json" with { type: "json" };
+import Zinman from "../config/hero_definitions/Zinman.json" with { type: "json" };
+
+import { UNIT_TYPES } from "./types";
+import type { ConfigDiagnostics, EffectIntentDefinition, SimulatorConfig, SkillFile, TriggerDamageJobDefinition } from "./types";
+import { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";
+import { assertStaticPassiveEffectDefinition, isPassiveBucket, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";
+
+const KNOWN_EFFECT_TYPES = new Set([
+ ...ATOMIC_BUCKETS.filter((bucket) => bucket.startsWith("active.") || bucket.startsWith("type.")),
+ ...STATIC_PASSIVE_BUCKETS,
+ "extra_skill_attack",
+ "dodge",
+ "no_attack",
+ "attack_order"
+]);
+
+const DEFAULT_HERO_DEFINITIONS = {
+ Ahmose,
+ Alonso,
+ Bahiti,
+ Bradley,
+ Edith,
+ Flint,
+ Gordon,
+ Greg,
+ Gwen,
+ Hector,
+ Jasser,
+ Jeronimo,
+ Jessie,
+ Ling,
+ Logan,
+ Lumak,
+ Lynn,
+ Mia,
+ Molly,
+ Natalia,
+ Norah,
+ Patrick,
+ Philly,
+ Reina,
+ Renee,
+ "Seo-yoon": SeoYoon,
+ Sergey,
+ Wayne,
+ WuMing,
+ Zinman
+} as unknown as Record<string, SkillFile>;
+
+export interface RawSimulatorConfig {
+ troopStats: SimulatorConfig["troopStats"];
+ heroGenerationStats: SimulatorConfig["heroGenerationStats"];
+ troopSkills: SkillFile;
+ heroDefinitions: Record<string, SkillFile>;
+ fileLabel?: (kind: "troop_stats" | "hero_generation_stats" | "troop_skills" | "hero_definition", key?: string) => string;
+}
+
+export function loadSimulatorConfig(): SimulatorConfig {
+ return buildSimulatorConfig({
+ troopStats: troopStatsJson as SimulatorConfig["troopStats"],
+ heroGenerationStats: heroGenerationStatsJson as SimulatorConfig["heroGenerationStats"],
+ troopSkills: troopSkillsJson as SkillFile,
+ heroDefinitions: DEFAULT_HERO_DEFINITIONS
+ });
+}
+
+export function buildSimulatorConfig(raw: RawSimulatorConfig): SimulatorConfig {
+ const diagnostics: ConfigDiagnostics = {
+ legacyFields: [],
+ effectTypes: {},
+ unsupportedEffects: [],
+ ambiguousTurnTriggerSelectors: []
+ };
+
+ scanLegacyFields(raw.troopStats, raw.fileLabel?.("troop_stats") ?? "config/troop_stats.json", "$", diagnostics);
+ 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);
+
+ for (const [name, hero] of Object.entries(raw.heroDefinitions)) {
+ const file = raw.fileLabel?.("hero_definition", name) ?? `config/hero_definitions/${name}.json`;
+ scanLegacyFields(hero, file, "$", diagnostics);
+ if (hero.hero_generation && !raw.heroGenerationStats[hero.hero_generation]) {
+ diagnostics.unsupportedEffects.push({
+ file,
+ skillId: "(hero_generation)",
+ effectId: hero.hero_generation,
+ type: "missing_hero_generation",
+ reason: `Referenced hero_generation ${hero.hero_generation} is not defined`
+ });
+ }
+ }
+
+ collectEffectDiagnostics(raw.troopSkills, raw.fileLabel?.("troop_skills") ?? "config/troop_skills.json", diagnostics);
+ for (const [name, hero] of Object.entries(raw.heroDefinitions)) {
+ collectEffectDiagnostics(hero, raw.fileLabel?.("hero_definition", name) ?? `config/hero_definitions/${name}.json`, diagnostics);
+ }
+
+ const heroAliasIndex = buildHeroAliasIndex(raw.heroDefinitions);
+
+ if (diagnostics.legacyFields.length > 0) {
+ const first = diagnostics.legacyFields[0];
+ throw new Error(`Legacy field found in simulator config: ${first.field} at ${first.file}:${first.path}`);
+ }
+
+ return {
+ troopStats: raw.troopStats,
+ heroGenerationStats: raw.heroGenerationStats,
+ heroDefinitions: raw.heroDefinitions,
+ heroAliasIndex,
+ troopSkills: raw.troopSkills,
+ diagnostics
+ };
+}
+
+function scanLegacyFields(value: unknown, file: string, path: string, diagnostics: ConfigDiagnostics): void {
+ if (!value || typeof value !== "object") return;
+ if (Array.isArray(value)) {
+ value.forEach((entry, index) => scanLegacyFields(entry, file, `${path}[${index}]`, diagnostics));
+ return;
+ }
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
+ if (isLegacyConfigField(key)) diagnostics.legacyFields.push({ file, path, field: key });
+ scanLegacyFields(child, file, `${path}.${key}`, diagnostics);
+ }
+}
+
+function isLegacyConfigField(key: string): boolean {
+ if (key === "legacy") return true;
+ if (!key.startsWith("effect_")) return false;
+ const suffix = key.slice("effect_".length);
+ return suffix === "op" || suffix === "type";
+}
+
+function collectEffectDiagnostics(skillFile: SkillFile, file: string, diagnostics: ConfigDiagnostics): void {
+ for (const [skillId, skill] of Object.entries(skillFile.skills ?? {})) {
+ validateTriggerDefinition(skill.trigger, 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);
+ validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
+ validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);
+ validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);
+ assertStaticPassiveEffectDefinition(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)) {
+ diagnostics.unsupportedEffects.push({
+ file,
+ skillId,
+ effectId,
+ type,
+ reason: "Effect type is not in the initial simulator classifier policy"
+ });
+ }
+ }
+ }
+}
+
+function collectAmbiguousTurnTriggerSelectorDiagnostics(
+ trigger: SkillFile["skills"][string]["trigger"],
+ effect: EffectIntentDefinition,
+ file: string,
+ skillId: string,
+ effectId: string,
+ diagnostics: ConfigDiagnostics
+): void {
+ if (trigger.type !== "turn" || trigger.source !== undefined) 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"
+ });
+ }
+}
+
+function validateTriggerDefinition(trigger: SkillFile["skills"][string]["trigger"], file: string, skillId: string): void {
+ const legacyUnits = (trigger as unknown as Record<string, unknown>).units;
+ if (legacyUnits !== undefined) {
+ throw new Error(`legacy trigger units filters are not supported at ${file}:${skillId}.trigger.units; use trigger.source and trigger.target`);
+ }
+}
+
+function isTriggerRelativeUnitSelector(selector: unknown): selector is string {
+ return selector === "trigger" || selector === "trigger.source" || selector === "target" || selector === "trigger.target";
+}
+
+function validateBattleStartEffectSelectors(
+ trigger: SkillFile["skills"][string]["trigger"],
+ effect: EffectIntentDefinition,
+ file: string,
+ skillId: string,
+ effectId: string
+): void {
+ if (trigger.type !== "battle_start") return;
+ for (const field of ["applies_to", "applies_vs"] as const) {
+ const selector = effect.units?.[field];
+ if (!isTriggerRelativeUnitSelector(selector)) continue;
+ throw new Error(
+ `battle_start effect cannot use trigger-relative units.${field} selector ${JSON.stringify(selector)} at ${file}:${skillId}.${effectId}; use a concrete selector such as self.any`
+ );
+ }
+}
+
+function validateNativeEffectUnits(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
+ const path = `${file}:${skillId}.${effectId}`;
+ if (effect.units && "side" in effect.units) {
+ throw new Error(`native effect units.side is not supported at ${path}; use relation-qualified applies_to selectors such as enemy.any or trigger.target`);
+ }
+ if (effect.units?.applies_vs !== "all") return;
+ throw new Error(
+ `native effect units.applies_vs cannot be "all" at ${path}; use "any" for an unrestricted usage gate or trigger_damage_jobs target selectors for multi-target damage`
+ );
+}
+
+function validateNativeEffectValue(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
+ const definition = bucketDefinition(effect.type);
+ if ((!definition || definition.valueType !== "pct") && !isPassiveBucket(effect.type)) return;
+ const values = Array.isArray(effect.value) ? effect.value : [effect.value];
+ for (const value of values) {
+ if (value === undefined) continue;
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ throw new Error(`native bucket effect ${effect.type} value must be a finite number at ${file}:${skillId}.${effectId}`);
+ }
+ if (value < 0) {
+ throw new Error(`negative native bucket effect ${effect.type} value is not supported at ${file}:${skillId}.${effectId}; use positive magnitudes`);
+ }
+ }
+}
+
+function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
+ const path = `${file}:${skillId}.${effectId}`;
+ if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {
+ throw new Error(`extra_skill_attack requires non-empty trigger_damage_jobs at ${path}`);
+ }
+ effect.trigger_damage_jobs.forEach((job, index) => {
+ validateTriggerDamageJobShape(job, path, index);
+ validateTriggerDamageJobSelector(job.source, "source", path, index);
+ validateTriggerDamageJobSelector(job.target, "target", path, index);
+ if (job.target === "effect.applies_vs" && !isEffectAppliesVsConcrete(effect.units?.applies_vs)) {
+ throw new Error(
+ `trigger_damage_jobs target effect.applies_vs requires a concrete applies_vs, not ${JSON.stringify(effect.units?.applies_vs)}, at ${path}.trigger_damage_jobs[${index}]`
+ );
+ }
+ if (job.multiplier !== undefined && typeof job.multiplier !== "number") {
+ throw new Error(`trigger_damage_jobs multiplier must be a number at ${path}.trigger_damage_jobs[${index}]`);
+ }
+ });
+}
+
+function validateTriggerDamageJobShape(job: unknown, path: string, jobIndex: number): asserts job is TriggerDamageJobDefinition {
+ if (!job || typeof job !== "object" || Array.isArray(job)) {
+ throw new Error(`trigger_damage_jobs entry must be an object at ${path}.trigger_damage_jobs[${jobIndex}]`);
+ }
+ const allowedKeys = new Set(["source", "target", "multiplier"]);
+ for (const key of Object.keys(job)) {
+ if (!allowedKeys.has(key)) {
+ throw new Error(`unknown trigger_damage_jobs key ${key} at ${path}.trigger_damage_jobs[${jobIndex}]`);
+ }
+ }
+ const record = job as Record<string, unknown>;
+ if (record.source === undefined) {
+ throw new Error(`trigger_damage_jobs entry requires source at ${path}.trigger_damage_jobs[${jobIndex}]`);
+ }
+ if (record.target === undefined) {
+ throw new Error(`trigger_damage_jobs entry requires target at ${path}.trigger_damage_jobs[${jobIndex}]`);
+ }
+}
+
+function validateTriggerDamageJobSelector(
+ selector: TriggerDamageJobDefinition["source"],
+ role: "source" | "target",
+ path: string,
+ jobIndex: number
+): void {
+ if (isAllowedTriggerDamageJobSelector(selector)) return;
+ 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"]);
+ 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;
+}
+
+function isEffectAppliesVsConcrete(selector: unknown): boolean {
+ if (selector === "trigger.target" || selector === "target") return true;
+ if (typeof selector === "string") return (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;
+}
+
+function buildHeroAliasIndex(heroDefinitions: Record<string, SkillFile>): Record<string, string> {
+ const index: Record<string, string> = {};
+ for (const [key, definition] of Object.entries(heroDefinitions)) {
+ addHeroAlias(index, key, key);
+ if (definition.name) addHeroAlias(index, definition.name, key);
+ }
+ return index;
+}
+
+function addHeroAlias(index: Record<string, string>, alias: string, heroKey: string): void {
+ const normalized = normalizeHeroAlias(alias);
+ if (!normalized) return;
+ const existing = index[normalized];
+ if (existing && existing !== heroKey) {
+ throw new Error(`Duplicate hero alias ${normalized} resolves to both ${existing} and ${heroKey}`);
+ }
+ index[normalized] = heroKey;
+}
+
+function normalizeHeroAlias(name: string): string {
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
+}
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -0,0 +1,542 @@
+import type {
+ ActiveEffect,
+ DamageAggregationGroupTrace,
+ AttackOutcome,
+ DamageBucketTrace,
+ DamageEquationTrace,
+ DamageJob,
+ ResolvedFighter,
+ SameEffectStacking,
+ SideId,
+ UnitType
+} from "./types";
+import { UNIT_TYPES } from "./types";
+import { classifyEffectForJob } from "./classifier";
+import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";
+import { currentEffectValuePct, isEffectActive } from "./effects";
+import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";
+import {
+ buildStaticDamageProfile,
+ type StaticDamageBucket,
+ type StaticDamageProfile,
+ type StaticDamageProfileEntry
+} from "./staticDamageProfile";
+
+type AtomicBuckets = Record<string, DamageBucketTrace>;
+type GroupPlacement = "numerator" | "denominator";
+type DamageDetail = "full" | "fast";
+type NumericBucketId = number;
+interface DamageFactorTerm {
+ id: string;
+ bucket: NumericBucketId;
+ bucketName: AtomicBucket;
+ placement: GroupPlacement;
+ appliesTo?: DamageJob["kind"];
+}
+
+interface BucketCandidate {
+ effect: ActiveEffect;
+ bucket: AtomicBucket;
+ valuePct: number;
+}
+
+interface MaxBucketCandidateGroup {
+ selected: BucketCandidate;
+ candidates: BucketCandidate[];
+}
+
+interface DamageExpressionResult {
+ rawDamage: number;
+ aggregationGroups: Record<string, DamageAggregationGroupTrace>;
+}
+
+interface NumericDamageBuckets {
+ factors: Float64Array;
+ contributors?: DamageBucketTrace["contributors"][];
+}
+
+export type DamageScratch = NumericDamageBuckets;
+
+const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;
+const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};
+const EMPTY_APPLIED_EFFECT_IDS: AttackOutcome["appliedEffectIds"] = [];
+const EMPTY_COUNTER_DELTAS: AttackOutcome["counterDeltas"] = [];
+const EMPTY_CONSUMED_EFFECT_IDS: AttackOutcome["consumedEffectIds"] = [];
+const TROOPS_COUNT_TERM = factorTerm("troops.count");
+const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");
+const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));
+const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");
+const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");
+const PROFILED_NUMERATOR_TERMS = DEFAULT_NUMERATOR_TERMS.filter((term) => term.bucketName !== "troops.count" && term.bucketName !== "source.extraSkill");
+const PROFILED_DENOMINATOR_TERMS = DEFAULT_DENOMINATOR_TERMS;
+
+export class DamageAggregationError extends Error {
+ readonly groupId: string;
+ readonly round: number;
+ readonly jobId: string;
+ readonly netPct: number | undefined;
+ readonly factor: number;
+ readonly contributors: DamageBucketTrace["contributors"];
+
+ constructor(args: {
+ groupId: string;
+ round: number;
+ jobId: string;
+ netPct?: number;
+ factor: number;
+ contributors: DamageBucketTrace["contributors"];
+ }) {
+ super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor}${args.netPct === undefined ? "" : ` netPct=${args.netPct}`}`);
+ this.name = "DamageAggregationError";
+ this.groupId = args.groupId;
+ this.round = args.round;
+ this.jobId = args.jobId;
+ this.netPct = args.netPct;
+ this.factor = args.factor;
+ this.contributors = args.contributors;
+ }
+}
+
+export function calculateDamageJob(
+ job: DamageJob,
+ fighters: Record<SideId, ResolvedFighter>,
+ activeEffects: ActiveEffect[],
+ options: { trace?: boolean; effectIndex: EffectIndex; detail?: DamageDetail; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch }
+): AttackOutcome {
+ if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
+ const detail = options.detail ?? "full";
+ const traceEnabled = detail === "full" && options.trace === true;
+ const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);
+ const attacker = fighters[job.attackerSide];
+ const defender = fighters[job.defenderSide];
+ const attackerTroops = job.roundStartTroops[job.attackerSide][job.attackerUnit] ?? 0;
+ const defenderTroops = job.roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0;
+ const minInitialArmy = Math.max(1, Math.min(totalTroops(attacker.initialTroops), totalTroops(defender.initialTroops)));
+ const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));
+ const needsTraceBuckets = traceEnabled;
+ const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);
+ applyBucketValue(buckets, "troops.count", armyTerm);
+ applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
+
+ const appliedEffects: DamageEquationTrace["appliedEffects"] = detail === "full" ? [] : [];
+ const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];
+ const consumedEffectIds = new Set<string>();
+ const candidates: BucketCandidate[] = [];
+ const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
+ for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
+ if (!isEffectActive(candidate.effect, job.round)) continue;
+ handledCandidateEffectIds?.add(candidate.effect.id);
+ candidates.push({
+ effect: candidate.effect,
+ bucket: candidate.bucket,
+ valuePct: currentEffectValuePct(candidate.effect, job.round)
+ });
+ }
+
+ if (traceEnabled) {
+ for (const effect of options.effectIndex.all) {
+ if (handledCandidateEffectIds?.has(effect.id)) continue;
+ if (!isEffectActive(effect, job.round)) {
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });
+ continue;
+ }
+ const classification = classifyEffectForJob(effect, job);
+ if (classification?.kind === "bucket" && classification.bucket) {
+ throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);
+ }
+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });
+ }
+ }
+
+ applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
+ if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
+
+ const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];
+ const traceBuckets = needsTraceBuckets ? toTraceBuckets(buckets, staticTraceEntries) : undefined;
+ const expressionDetail = traceEnabled ? "full" : "fast";
+ const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, expressionDetail, staticProfile);
+ const kills = Math.min(defenderTroops, Math.max(0, rawDamage));
+ const trace = traceEnabled
+ ? {
+ roundStartTroops: {
+ attacker: { ...job.roundStartTroops.attacker },
+ defender: { ...job.roundStartTroops.defender }
+ },
+ armyTerm,
+ atomicBuckets: traceBuckets ?? toTraceBuckets(buckets, staticTraceEntries),
+ aggregationGroups,
+ appliedEffects,
+ rejectedEffects,
+ rawDamage,
+ finalKills: kills
+ }
+ : undefined;
+
+ const fast = detail === "fast";
+ const returnedConsumedEffectIds =
+ fast && consumedEffectIds.size === 0 ? job.consumedEffectIds ?? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds, ...(job.consumedEffectIds ?? [])];
+
+ return {
+ jobId: job.id,
+ kind: job.kind,
+ sourceEffectId: job.sourceEffectId,
+ sourceSkillReportKey: job.sourceSkillReportKey,
+ attackerSide: job.attackerSide,
+ attackerUnit: job.attackerUnit,
+ defenderSide: job.defenderSide,
+ defenderUnit: job.defenderUnit,
+ kills,
+ counterDeltas: fast
+ ? EMPTY_COUNTER_DELTAS
+ : [
+ { side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause: job.kind === "skill" ? "extra_skill_attack" : "normal_attack" },
+ { side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause: job.kind === "skill" ? "extra_skill_attack" : "normal_attack" }
+ ],
+ appliedEffectIds: fast ? EMPTY_APPLIED_EFFECT_IDS : appliedEffects.map((effect) => effect.effectId),
+ appliedEffects,
+ consumedEffectIds: returnedConsumedEffectIds,
+ consumedEffectUseKey: job.consumedEffectUseKey,
+ consumedEffectUseId: job.consumedEffectUseId,
+ consumedEffectUseIds: job.consumedEffectUseIds,
+ trace
+ };
+}
+
+function applyBucketCandidates(
+ candidates: BucketCandidate[],
+ buckets: NumericDamageBuckets,
+ detail: DamageDetail,
+ appliedEffects: DamageEquationTrace["appliedEffects"],
+ rejectedEffects: DamageEquationTrace["rejectedEffects"],
+ consumedEffectIds: Set<string>
+): void {
+ let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;
+ for (const candidate of candidates) {
+ if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {
+ maxGroups ??= new Map();
+ const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;
+ const group = maxGroups.get(key);
+ if (group) {
+ group.candidates.push(candidate);
+ if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;
+ } else {
+ maxGroups.set(key, { selected: candidate, candidates: [candidate] });
+ }
+ } else {
+ applyBucketCandidateGroup(candidate, [candidate], buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ }
+ }
+ if (!maxGroups) return;
+ for (const group of maxGroups.values()) {
+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ }
+}
+
+function applyBucketCandidateGroup(
+ selected: BucketCandidate,
+ candidates: BucketCandidate[],
+ buckets: NumericDamageBuckets,
+ detail: DamageDetail,
+ appliedEffects: DamageEquationTrace["appliedEffects"],
+ rejectedEffects: DamageEquationTrace["rejectedEffects"],
+ consumedEffectIds: Set<string>
+): void {
+ const appliedValuePct = applyBucketValue(
+ buckets,
+ selected.bucket,
+ selected.valuePct,
+ detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",
+ detail === "full" ? sourceLabel(selected.effect) : "",
+ detail === "full" ? selected.effect.ownerSide : undefined,
+ selected.bucket,
+ selected.effect.stackingKey,
+ selected.effect.sameEffectStacking
+ );
+ if (appliedValuePct !== 0) {
+ if (detail === "full") {
+ const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
+ effectId: selected.effect.source.effectId ?? selected.effect.id,
+ bucket: selected.bucket,
+ valuePct: appliedValuePct,
+ source: sourceLabel(selected.effect),
+ sourceSide: selected.effect.ownerSide,
+ sameEffectStacking: selected.effect.sameEffectStacking
+ };
+ if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;
+ appliedEffects.push(appliedEffect);
+ }
+ for (const candidate of candidates) {
+ if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ if (detail === "full" && candidate !== selected) {
+ rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
+ }
+ }
+ } else if (detail === "full") {
+ for (const candidate of candidates) {
+ rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
+ }
+ }
+}
+
+function factorTerm(bucket: AtomicBucket): DamageFactorTerm {
+ const definition = BUCKET_DEFINITIONS[bucket];
+ return {
+ id: bucket,
+ bucket: BUCKET_IDS[bucket],
+ bucketName: bucket,
+ placement: definition.placement,
+ appliesTo: definition.appliesTo
+ };
+}
+
+function createNumericDamageBuckets(collectTrace: boolean): NumericDamageBuckets {
+ const factors = new Float64Array(ATOMIC_BUCKETS.length);
+ factors.fill(1);
+ return {
+ factors,
+ contributors: collectTrace ? Array.from({ length: ATOMIC_BUCKETS.length }, () => []) : undefined
+ };
+}
+
+export function createFastDamageScratch(): DamageScratch {
+ return createNumericDamageBuckets(false);
+}
+
+function resetDamageScratch(buckets: DamageScratch): DamageScratch {
+ buckets.factors.fill(1);
+ return buckets;
+}
+
+function applyBucketValue(
+ buckets: NumericDamageBuckets,
+ bucketName: AtomicBucket,
+ value: number,
+ effectId = "",
+ source = "",
+ sourceSide: SideId | undefined = undefined,
+ traceBucketName = bucketName,
+ stackingKey?: string,
+ sameEffectStacking: SameEffectStacking = "add"
+): number {
+ const index = BUCKET_IDS[bucketName];
+ const definition = BUCKET_DEFINITIONS[bucketName];
+ if (definition.update === "assign_factor") buckets.factors[index] = Math.max(0, value);
+ else buckets.factors[index] += value / 100;
+ if (effectId) buckets.contributors?.[index].push({ effectId, source, sourceSide, valuePct: value, bucket: traceBucketName, stackingKey, sameEffectStacking });
+ return value;
+}
+
+function appendStaticProfileAppliedEffects(appliedEffects: DamageEquationTrace["appliedEffects"], entry: StaticDamageProfileEntry): void {
+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {
+ if (!bucket.startsWith("passive.") || !term) continue;
+ for (const contributor of term.contributors) {
+ appliedEffects.push({
+ effectId: contributor.effectId,
+ bucket,
+ valuePct: contributor.valuePct,
+ source: contributor.source,
+ sourceSide: contributor.sourceSide,
+ stackingKey: contributor.stackingKey,
+ sameEffectStacking: contributor.sameEffectStacking
+ });
+ }
+ }
+}
+
+function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, detail: DamageDetail, staticProfile?: StaticDamageProfile): DamageExpressionResult {
+ if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, detail, staticProfile);
+ let numerator = 1;
+ for (const term of DEFAULT_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);
+ let denominator = 1;
+ for (const term of DEFAULT_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);
+ return {
+ rawDamage: numerator / denominator,
+ aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets) : EMPTY_AGGREGATION_GROUPS
+ };
+}
+
+function evaluateProfiledDamageExpression(
+ job: DamageJob,
+ buckets: NumericDamageBuckets,
+ detail: DamageDetail,
+ staticProfile: StaticDamageProfile
+): DamageExpressionResult {
+ validateProfiledStaticFactors(job, staticProfile);
+ let numerator =
+ valueForTerm(TROOPS_COUNT_TERM, job, buckets) *
+ valueForTerm(SOURCE_EXTRA_SKILL_TERM, job, buckets) *
+ staticProfile.offense[job.attackerSide][job.attackerUnit].factor *
+ staticProfile.defense[job.defenderSide][job.defenderUnit].factor;
+ for (const term of PROFILED_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);
+ let denominator = 100;
+ for (const term of PROFILED_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);
+ return {
+ rawDamage: numerator / denominator,
+ aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets, staticProfile) : EMPTY_AGGREGATION_GROUPS
+ };
+}
+
+function validateProfiledStaticFactors(job: DamageJob, staticProfile: StaticDamageProfile): void {
+ validateProfiledPctFactor(job, "player.attacker.attack", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.attack");
+ validateProfiledPctFactor(job, "player.attacker.lethality", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.lethality");
+ validateProfiledPctFactor(job, "player.defender.health", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.health");
+ validateProfiledPctFactor(job, "player.defender.defense", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.defense");
+}
+
+function validateProfiledPctFactor(job: DamageJob, groupId: string, entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): void {
+ const term = entry.buckets[bucket];
+ const totalPct = term?.totalPct ?? 0;
+ const factor = 1 + totalPct / 100;
+ if (factor > 0) return;
+ throw new DamageAggregationError({
+ groupId,
+ round: job.round,
+ jobId: job.id,
+ netPct: totalPct,
+ factor,
+ contributors: term?.contributors ?? []
+ });
+}
+
+function valueForTerm(term: DamageFactorTerm, job: DamageJob, buckets: NumericDamageBuckets): number {
+ if (term.appliesTo && term.appliesTo !== job.kind) return 1;
+ return buckets.factors[term.bucket];
+}
+
+function buildAggregationGroups(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): Record<string, DamageAggregationGroupTrace> {
+ const aggregationGroups: Record<string, DamageAggregationGroupTrace> = {};
+ if (staticProfile) addStaticAggregationGroups(aggregationGroups, job, staticProfile);
+ for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS]) {
+ if (term.appliesTo && term.appliesTo !== job.kind) continue;
+ const definition = BUCKET_DEFINITIONS[term.bucketName];
+ const factor = buckets.factors[term.bucket];
+ if (definition.valueType === "raw") {
+ aggregationGroups[term.id] = {
+ id: term.id,
+ mode: "raw",
+ placement: term.placement,
+ inputBuckets: [term.bucketName],
+ factor,
+ contributors: contributorsForTerm(term, buckets)
+ };
+ } else {
+ const totalPct = pctFromFactor(factor);
+ aggregationGroups[term.id] = {
+ id: term.id,
+ mode: "sum_pct",
+ placement: term.placement,
+ inputBuckets: [term.bucketName],
+ totalPct,
+ factor,
+ contributors: contributorsForTerm(term, buckets)
+ };
+ }
+ }
+ return aggregationGroups;
+}
+
+function addStaticAggregationGroups(
+ aggregationGroups: Record<string, DamageAggregationGroupTrace>,
+ job: DamageJob,
+ staticProfile: StaticDamageProfile
+): void {
+ const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];
+ const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];
+ addStaticRawGroup(aggregationGroups, "troops.baseAttack", "numerator", offense, "troops.baseAttack");
+ addStaticRawGroup(aggregationGroups, "troops.baseLethality", "numerator", offense, "troops.baseLethality");
+ addStaticPctGroup(aggregationGroups, "player.attacker.attack", "numerator", offense, "player.attack");
+ addStaticPctGroup(aggregationGroups, "player.attacker.lethality", "numerator", offense, "player.lethality");
+ addStaticPctGroup(aggregationGroups, "passive.attacker.attack.up", "numerator", offense, "passive.attack.up");
+ addStaticPctGroup(aggregationGroups, "passive.attacker.lethality.up", "numerator", offense, "passive.lethality.up");
+ addStaticPctGroup(aggregationGroups, "passive.defender.health.down", "numerator", defense, "passive.health.down");
+ addStaticPctGroup(aggregationGroups, "passive.defender.defense.down", "numerator", defense, "passive.defense.down");
+
+ addStaticRawGroup(aggregationGroups, "troops.baseHealth", "denominator", defense, "troops.baseHealth");
+ addStaticRawGroup(aggregationGroups, "troops.baseDefense", "denominator", defense, "troops.baseDefense");
+ addStaticPctGroup(aggregationGroups, "player.defender.health", "denominator", defense, "player.health");
+ addStaticPctGroup(aggregationGroups, "player.defender.defense", "denominator", defense, "player.defense");
+ addStaticPctGroup(aggregationGroups, "passive.attacker.attack.down", "denominator", offense, "passive.attack.down");
+ addStaticPctGroup(aggregationGroups, "passive.attacker.lethality.down", "denominator", offense, "passive.lethality.down");
+ addStaticPctGroup(aggregationGroups, "passive.defender.health.up", "denominator", defense, "passive.health.up");
+ addStaticPctGroup(aggregationGroups, "passive.defender.defense.up", "denominator", defense, "passive.defense.up");
+}
+
+function addStaticRawGroup(
+ aggregationGroups: Record<string, DamageAggregationGroupTrace>,
+ id: string,
+ placement: GroupPlacement,
+ entry: StaticDamageProfileEntry,
+ bucket: StaticDamageBucket
+): void {
+ const term = entry.buckets[bucket];
+ aggregationGroups[id] = {
+ id,
+ mode: "raw",
+ placement,
+ inputBuckets: [bucket],
+ factor: Math.max(0, term?.raw ?? 0),
+ contributors: term?.contributors ?? []
+ };
+}
+
+function addStaticPctGroup(
+ aggregationGroups: Record<string, DamageAggregationGroupTrace>,
+ id: string,
+ placement: GroupPlacement,
+ entry: StaticDamageProfileEntry,
+ bucket: StaticDamageBucket
+): void {
+ const term = entry.buckets[bucket];
+ const totalPct = term?.totalPct ?? 0;
+ aggregationGroups[id] = {
+ id,
+ mode: "sum_pct",
+ placement,
+ inputBuckets: [bucket],
+ totalPct,
+ factor: 1 + totalPct / 100,
+ contributors: term?.contributors ?? []
+ };
+}
+
+function contributorsForTerm(term: DamageFactorTerm, buckets: NumericDamageBuckets): DamageBucketTrace["contributors"] {
+ if (!buckets.contributors) return [];
+ return buckets.contributors[term.bucket] ?? [];
+}
+
+function toTraceBuckets(buckets: NumericDamageBuckets, staticEntries: StaticDamageProfileEntry[] = []): AtomicBuckets {
+ const traced = Object.fromEntries(
+ ATOMIC_BUCKETS.map((bucket, index) => {
+ const contributors = buckets.contributors?.[index] ?? [];
+ const definition = BUCKET_DEFINITIONS[bucket];
+ const factor = buckets.factors[index];
+ if (definition.valueType === "raw") {
+ return [bucket, { raw: factor, factor, contributors: [...contributors] }];
+ }
+ return [bucket, { totalPct: pctFromFactor(factor), factor, contributors: [...contributors] }];
+ })
+ ) as AtomicBuckets;
+ for (const entry of staticEntries) {
+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {
+ if (!term) continue;
+ traced[bucket] =
+ term.raw !== undefined
+ ? { raw: term.raw, factor: Math.max(0, term.raw), contributors: [...term.contributors] }
+ : { totalPct: term.totalPct ?? 0, factor: 1 + (term.totalPct ?? 0) / 100, contributors: [...term.contributors] };
+ }
+ }
+ return traced;
+}
+
+function totalTroops(troops: Record<UnitType, number>): number {
+ return UNIT_TYPES.reduce((sum, unit) => sum + (troops[unit] ?? 0), 0);
+}
+
+function pctFromFactor(factor: number): number {
+ return Number(((factor - 1) * 100).toFixed(12));
+}
+
+function sourceLabel(effect: ActiveEffect): string {
+ return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
+}
Index: simulator/src/damageBuckets.test.ts
===================================================================
--- simulator/src/damageBuckets.test.ts prev run
+++ simulator/src/damageBuckets.test.ts this run
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { createFastDamageScratch } from "./damage";
+import { ATOMIC_BUCKETS } from "./damageBuckets";
+
+const REMOVED_ACTIVE_BUCKET_PATTERN = new RegExp(`^active\\.(${["hero", "troop"].join("|")})\\.(${["dam", "age"].join("")}|${["dam", "age", "Taken"].join("")})\\.`);
+
+test("active hero and troop damage aliases are not supported buckets", () => {
+ assert.equal(ATOMIC_BUCKETS.some((bucket) => REMOVED_ACTIVE_BUCKET_PATTERN.test(bucket)), false);
+});
+
+test("damage scratch stores one factor value per atomic bucket", () => {
+ const scratch = createFastDamageScratch() as unknown as {
+ factors?: Float64Array;
+ raw?: unknown;
+ pct?: unknown;
+ rawSet?: unknown;
+ };
+
+ assert.ok(scratch.factors instanceof Float64Array);
+ const factors = scratch.factors as Float64Array;
+ assert.equal(factors.length, ATOMIC_BUCKETS.length);
+ assert.equal(factors.every((factor) => factor === 1), true);
+ assert.equal("raw" in scratch, false);
+ assert.equal("pct" in scratch, false);
+ assert.equal("rawSet" in scratch, false);
+});
Index: simulator/src/damageBuckets.ts
===================================================================
--- simulator/src/damageBuckets.ts prev run
+++ simulator/src/damageBuckets.ts this run
@@ -0,0 +1,119 @@
+import type { DamageKind } from "./types";
+
+export type BucketRole = "attacker" | "defender";
+export type BucketValueType = "raw" | "pct";
+export type BucketUpdate = "assign_factor" | "add_pct_factor";
+export type BucketPlacement = "numerator" | "denominator";
+
+export interface BucketLeaf {
+ role: BucketRole;
+ valueType: BucketValueType;
+ update: BucketUpdate;
+ placement: BucketPlacement;
+ appliesTo?: DamageKind;
+}
+
+type BucketTree = {
+ [key: string]: BucketLeaf | BucketTree;
+};
+
+export const BUCKETS = {
+ troops: {
+ count: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" }
+ },
+ active: {
+ hero: {
+ attack: {
+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
+ },
+ defense: {
+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
+ },
+ lethality: {
+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
+ },
+ health: {
+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
+ }
+ },
+ troop: {
+ attack: {
+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
+ },
+ defense: {
+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
+ },
+ lethality: {
+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },
+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }
+ },
+ health: {
+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },
+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }
+ }
+ }
+ },
+ type: {
+ normal: {
+ damage: {
+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },
+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" }
+ },
+ defense: {
+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },
+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" }
+ }
+ },
+ skill: {
+ damage: {
+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },
+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" }
+ },
+ defense: {
+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },
+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" }
+ }
+ }
+ },
+ source: {
+ extraSkill: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" }
+ }
+} as const satisfies BucketTree;
+
+export interface BucketDefinition extends BucketLeaf {
+ path: string;
+}
+
+export const BUCKET_DEFINITIONS = flattenBuckets(BUCKETS);
+export const ATOMIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS).sort();
+
+export type AtomicBucket = string;
+export type BucketName = AtomicBucket;
+
+export function bucketDefinition(path: string): BucketDefinition | undefined {
+ return BUCKET_DEFINITIONS[path];
+}
+
+export function isAtomicBucket(path: string): path is AtomicBucket {
+ return path in BUCKET_DEFINITIONS;
+}
+
+function flattenBuckets(tree: BucketTree, prefix = ""): Record<string, BucketDefinition> {
+ const buckets: Record<string, BucketDefinition> = {};
+ for (const [key, value] of Object.entries(tree)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (isBucketLeaf(value)) buckets[path] = { ...value, path };
+ else Object.assign(buckets, flattenBuckets(value, path));
+ }
+ return buckets;
+}
+
+function isBucketLeaf(value: BucketLeaf | BucketTree): value is BucketLeaf {
+ return "role" in value && "valueType" in value && "update" in value && "placement" in value;
+}
Index: simulator/src/effectIndex.test.ts
===================================================================
--- simulator/src/effectIndex.test.ts prev run
+++ simulator/src/effectIndex.test.ts this run
@@ -0,0 +1,93 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";
+import { unitMask } from "./types";
+import type { ActiveEffect, DamageJob } from "./types";
+
+test("effect index returns bucket-tagged candidates from a direct job-shape lookup", () => {
+ const index = createEffectIndex();
+ const effect: ActiveEffect = {
+ id: "effect-1",
+ source: { kind: "hero_skill", side: "attacker", effectId: "boost" },
+ intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },
+ ownerSide: "attacker",
+ kind: "modifier",
+ valuePct: 25,
+ appliesTo: { side: "attacker", units: unitMask("infantry") },
+ appliesVs: { side: "defender", units: unitMask("lancer") },
+ createdRound: 0,
+ startRound: 0,
+ duration: { type: "battle", value: 0 },
+ uses: 0,
+ stackingKey: "stack",
+ sameEffectStacking: "add"
+ };
+ indexEffect(index, effect);
+
+ const job: DamageJob = {
+ id: "job-1",
+ round: 1,
+ kind: "normal",
+ sourceIntentId: "intent-1",
+ roundStartTroops: {
+ attacker: { infantry: 100, lancer: 0, marksman: 0 },
+ defender: { infantry: 0, lancer: 100, marksman: 0 }
+ },
+ attackerSide: "attacker",
+ attackerUnit: "infantry",
+ defenderSide: "defender",
+ defenderUnit: "lancer"
+ };
+
+ assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);
+});
+
+test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {
+ const index = createEffectIndex();
+ const passive = effect("passive.attack.up");
+ const active = effect("active.hero.lethality.up");
+ indexEffect(index, passive);
+ indexEffect(index, active);
+
+ removeStaticProfileBucketEffects(index);
+
+ assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);
+ assert.deepEqual(index.all, [active]);
+});
+
+function effect(type: string): ActiveEffect {
+ return {
+ id: type,
+ source: { kind: "hero_skill", side: "attacker", effectId: type },
+ intent: { id: type, type, value: 25 },
+ ownerSide: "attacker",
+ kind: "modifier",
+ valuePct: 25,
+ appliesTo: { side: "attacker", units: unitMask("infantry") },
+ appliesVs: { side: "defender", units: unitMask("lancer") },
+ createdRound: 0,
+ startRound: 0,
+ duration: { type: "battle", value: 0 },
+ uses: 0,
+ stackingKey: "stack",
+ sameEffectStacking: "add"
+ };
+}
+
+function job(): DamageJob {
+ return {
+ id: "job-1",
+ round: 1,
+ kind: "normal",
+ sourceIntentId: "intent-1",
+ roundStartTroops: {
+ attacker: { infantry: 100, lancer: 0, marksman: 0 },
+ defender: { infantry: 0, lancer: 100, marksman: 0 }
+ },
+ attackerSide: "attacker",
+ attackerUnit: "infantry",
+ defenderSide: "defender",
+ defenderUnit: "lancer"
+ };
+}
Index: simulator/src/effectIndex.ts
===================================================================
--- simulator/src/effectIndex.ts prev run
+++ simulator/src/effectIndex.ts this run
@@ -0,0 +1,172 @@
+import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";
+import { unitsFromMask } from "./types";
+import { bucketDefinition, type AtomicBucket } from "./damageBuckets";
+import { isStaticProfileBucket } from "./staticDamageProfile";
+
+export type DamageEffectKey = `${DamageKind}:${SideId}:${UnitType}:${SideId}:${UnitType}:${AtomicBucket}`;
+export type DamageJobShapeKey = `${DamageKind}:${SideId}:${UnitType}:${SideId}:${UnitType}`;
+
+export interface IndexedBucketEffect {
+ effect: ActiveEffect;
+ bucket: AtomicBucket;
+}
+
+export interface EffectIndex {
+ all: ActiveEffect[];
+ damageByJobShape: Array<IndexedBucketEffect[] | undefined>;
+ controls: ActiveEffect[];
+ extraAttacks: ActiveEffect[];
+ battleOrder: ActiveEffect[];
+}
+
+const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;
+
+export function createEffectIndex(): EffectIndex {
+ return {
+ all: [],
+ damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),
+ controls: [],
+ extraAttacks: [],
+ battleOrder: []
+ };
+}
+
+export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {
+ index.all.push(effect);
+ if (effect.kind === "control") {
+ index.controls.push(effect);
+ return;
+ }
+ if (effect.kind === "extra_attack") {
+ index.extraAttacks.push(effect);
+ return;
+ }
+ if (effect.kind === "battle_order") {
+ index.battleOrder.push(effect);
+ return;
+ }
+
+ const definition = bucketDefinition(effect.intent.type);
+ if (!definition || definition.valueType !== "pct") return;
+
+ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];
+ const appliesToUnits = unitsFromMask(effect.appliesTo.units);
+ const appliesVsUnits = unitsFromMask(effect.appliesVs.units);
+ for (const jobKind of jobKinds) {
+ for (const appliesToUnit of appliesToUnits) {
+ for (const appliesVsUnit of appliesVsUnits) {
+ const slot =
+ definition.role === "attacker"
+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)
+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);
+ const effects = index.damageByJobShape[slot];
+ const candidate = { effect, bucket: definition.path };
+ if (effects) effects.push(candidate);
+ else index.damageByJobShape[slot] = [candidate];
+ }
+ }
+ }
+}
+
+export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {
+ compactEffects(index.all, isActive);
+ compactEffects(index.controls, isActive);
+ compactEffects(index.extraAttacks, isActive);
+ compactEffects(index.battleOrder, isActive);
+ for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
+ const candidates = index.damageByJobShape[slot];
+ if (!candidates) continue;
+ compactCandidates(candidates, isActive);
+ if (candidates.length === 0) index.damageByJobShape[slot] = undefined;
+ }
+}
+
+export function removeStaticProfileBucketEffects(index: EffectIndex): void {
+ compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));
+ for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {
+ const candidates = index.damageByJobShape[slot];
+ if (!candidates) continue;
+ compactCandidates(candidates, (effect, candidate) => !isStaticProfileBucket(candidate.bucket));
+ if (candidates.length === 0) index.damageByJobShape[slot] = undefined;
+ }
+}
+
+export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {
+ return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];
+}
+
+export function bucketEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {
+ return bucketCandidatesForJob(index, job).map((candidate) => candidate.effect);
+}
+
+export function damageJobShapeKey(
+ jobKind: DamageKind,
+ attackerSide: SideId,
+ attackerUnit: UnitType,
+ defenderSide: SideId,
+ defenderUnit: UnitType
+): DamageJobShapeKey {
+ return `${jobKind}:${attackerSide}:${attackerUnit}:${defenderSide}:${defenderUnit}`;
+}
+
+export function damageEffectKey(
+ jobKind: DamageKind,
+ attackerSide: SideId,
+ attackerUnit: UnitType,
+ defenderSide: SideId,
+ defenderUnit: UnitType,
+ bucket: AtomicBucket
+): DamageEffectKey {
+ return `${jobKind}:${attackerSide}:${attackerUnit}:${defenderSide}:${defenderUnit}:${bucket}`;
+}
+
+function damageJobShapeSlot(
+ jobKind: DamageKind,
+ attackerSide: SideId,
+ attackerUnit: UnitType,
+ defenderSide: SideId,
+ defenderUnit: UnitType
+): number {
+ return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);
+}
+
+function kindIndex(kind: DamageKind): number {
+ return kind === "normal" ? 0 : 1;
+}
+
+function sideIndex(side: SideId): number {
+ return side === "attacker" ? 0 : 1;
+}
+
+function unitIndex(unit: UnitType): number {
+ if (unit === "infantry") return 0;
+ if (unit === "lancer") return 1;
+ return 2;
+}
+
+function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {
+ let write = 0;
+ for (let read = 0; read < effects.length; read += 1) {
+ const effect = effects[read];
+ if (!isActive(effect)) continue;
+ effects[write] = effect;
+ write += 1;
+ }
+ effects.length = write;
+}
+
+function isStaticProfileEffect(effect: ActiveEffect): boolean {
+ const definition = bucketDefinition(effect.intent.type);
+ return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));
+}
+
+function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect, candidate: IndexedBucketEffect) => boolean): void {
+ let write = 0;
+ for (let read = 0; read < candidates.length; read += 1) {
+ const candidate = candidates[read];
+ if (!isActive(candidate.effect, candidate)) continue;
+ candidates[write] = candidate;
+ write += 1;
+ }
+ candidates.length = write;
+}
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -0,0 +1,250 @@
+import type {
+ ActiveEffect,
+ ActiveEffectKind,
+ AttackIntent,
+ EffectDuration,
+ EffectIntentDefinition,
+ ResolvedSkill,
+ ResolvedUnitScope,
+ SameEffectStacking,
+ SideId,
+ UnitType
+} from "./types";
+import { ALL_UNIT_MASK, UNIT_TYPES, unitMask } from "./types";
+import { normalizeUnitType } from "./normalize";
+
+export type Rng = () => number;
+
+interface CompiledTriggerSelectors {
+ source: ParsedTriggerSelector;
+ target: ParsedTriggerSelector;
+}
+
+const TRIGGER_SELECTOR_CACHE = new WeakMap<ResolvedSkill, CompiledTriggerSelectors>();
+
+export function oppositeSide(side: SideId): SideId {
+ return side === "attacker" ? "defender" : "attacker";
+}
+
+export function skillMatchesTrigger(
+ skill: ResolvedSkill,
+ triggerType: "battle_start" | "round_start" | "attack_declared",
+ round: number,
+ intent?: AttackIntent
+): boolean {
+ const trigger = skill.trigger;
+ if (triggerType === "battle_start" && trigger.type !== "battle_start") return false;
+ if (triggerType === "round_start" && trigger.type !== "turn") return false;
+ if (triggerType === "attack_declared" && trigger.type !== "attack") return false;
+ if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every)) return false;
+ if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every)) return false;
+ if (!intent) return true;
+ const selectors = compiledTriggerSelectors(skill);
+ return (
+ triggerSelectorMatches(selectors.source, skill.side, intent.attackerSide, intent.attackerUnit) &&
+ triggerSelectorMatches(selectors.target, skill.side, intent.defenderSide, intent.defenderUnit)
+ );
+}
+
+function compiledTriggerSelectors(skill: ResolvedSkill): CompiledTriggerSelectors {
+ const cached = TRIGGER_SELECTOR_CACHE.get(skill);
+ if (cached) return cached;
+ const compiled = {
+ source: parseTriggerSelector(skill.trigger.source, "self"),
+ target: parseTriggerSelector(skill.trigger.target, "enemy")
+ };
+ TRIGGER_SELECTOR_CACHE.set(skill, compiled);
+ return compiled;
+}
+
+export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {
+ const probability = skill.trigger.probability;
+ if (probability === undefined) return true;
+ const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);
+ if (!Number.isFinite(value) || value <= 0) return false;
+ if (value >= 100) return true;
+ const threshold = value / 100;
+ return rng() < threshold;
+}
+
+export function createSeededRng(seed: string | number = "simulator-default"): Rng {
+ let state = hashSeed(String(seed));
+ return () => {
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
+ return state / 0x100000000;
+ };
+}
+
+export function crossedFrequency(previous: number, current: number, frequency: number): boolean {
+ return Math.floor(previous / frequency) < Math.floor(current / frequency);
+}
+
+export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {
+ const units = intent.units ?? {};
+ const ownerSide = skill.side;
+ const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);
+ const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);
+ const duration = normalizeDuration(intent.duration);
+ const delay = duration.delay ?? 0;
+ const effectKind = kindForIntent(intent);
+ if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {
+ throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);
+ }
+ const sourceKey = skillActivationSourceKey(skill);
+ return {
+ id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,
+ source: {
+ kind: skill.sourceKind,
+ side: skill.side,
+ heroName: skill.heroName,
+ heroInstanceId: skill.heroInstanceId,
+ troopType: skill.troopType,
+ skillId: skill.id,
+ skillName: skill.name,
+ effectId: intent.id
+ },
+ intent,
+ ownerSide,
+ kind: effectKind,
+ valuePct: typeof intent.value === "number" ? intent.value : undefined,
+ appliesTo,
+ appliesVs,
+ triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,
+ createdRound: round,
+ startRound: round + delay,
+ duration,
+ uses: 0,
+ stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,
+ sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)
+ };
+}
+
+function skillActivationSourceKey(skill: ResolvedSkill): string {
+ return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";
+}
+
+function skillStackingSourceKey(skill: ResolvedSkill): string {
+ return skill.heroName ?? skill.troopType ?? "global";
+}
+
+export function isEffectActive(effect: ActiveEffect, round: number): boolean {
+ if (round < effect.startRound) return false;
+ if (effect.duration.type === "battle") return true;
+ if (effect.duration.type === "round") return round < effect.startRound + Math.max(1, effect.duration.value);
+ return effect.uses < Math.max(1, effect.duration.value);
+}
+
+export function currentEffectValuePct(effect: ActiveEffect, round: number): number {
+ const baseValue = Number(effect.valuePct ?? 0);
+ if (!Number.isFinite(baseValue)) return 0;
+ const evolution = effect.intent.value_evolution;
+ if (!evolution) return baseValue;
+ const firstActiveRound = Math.max(1, effect.startRound);
+ const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;
+ const amount = Number(evolution.value ?? 0);
+ if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;
+ if (evolution.type === "pct_decay") {
+ const factor = Math.max(0, 1 - amount / 100);
+ return baseValue * factor ** stepCount;
+ }
+ if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);
+ return baseValue;
+}
+
+export type TriggerSelectorRelation = "self" | "enemy";
+
+export interface ParsedTriggerSelector {
+ relation: TriggerSelectorRelation;
+ units?: UnitType[];
+}
+
+export function parseTriggerSelector(value: unknown, defaultRelation: TriggerSelectorRelation): ParsedTriggerSelector {
+ if (typeof value === "string") {
+ const [maybeRelation, maybeUnit, ...extra] = value.split(".");
+ if (extra.length === 0 && (maybeRelation === "self" || maybeRelation === "enemy")) {
+ if (maybeUnit === undefined || maybeUnit === "any" || maybeUnit === "all") return { relation: maybeRelation };
+ return { relation: maybeRelation, units: [normalizeUnitType(maybeUnit)] };
+ }
+ if (value === "any" || value === "all") return { relation: defaultRelation };
+ }
+ return { relation: defaultRelation, units: normalizeUnitList(value) };
+}
+
+export function sideForTriggerRelation(skillSide: SideId, relation: TriggerSelectorRelation): SideId {
+ return relation === "self" ? skillSide : oppositeSide(skillSide);
+}
+
+function triggerSelectorMatches(selector: ParsedTriggerSelector, skillSide: SideId, actualSide: SideId, actualUnit: UnitType): boolean {
+ if (sideForTriggerRelation(skillSide, selector.relation) !== actualSide) return false;
+ return selector.units === undefined || selector.units.includes(actualUnit);
+}
+
+export function normalizeEngagementType(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ const normalized = value.trim().toLowerCase();
+ return normalized ? normalized : undefined;
+}
+
+function resolveUnitScope(
+ value: unknown,
+ defaultSide: SideId,
+ role: "applies_to" | "applies_vs",
+ attackIntent?: AttackIntent,
+ ownerSide?: SideId
+): 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) {
+ return { side: attackIntent.attackerSide, units: unitMask(attackIntent.attackerUnit) };
+ }
+ if ((value === "trigger.target" || value === "target") && attackIntent) {
+ return { side: attackIntent.defenderSide, units: unitMask(attackIntent.defenderUnit) };
+ }
+ if (ownerSide && isRelationQualifiedSelector(value)) {
+ const selector = parseTriggerSelector(value, "self");
+ const side = sideForTriggerRelation(ownerSide, selector.relation);
+ return { side, units: selector.units ? unitMask(selector.units) : ALL_UNIT_MASK };
+ }
+ const list = normalizeUnitList(value);
+ return { side: defaultSide, units: list ? unitMask(list) : ALL_UNIT_MASK };
+}
+
+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)];
+ 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 === "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 { type: "battle", value: 0 };
+ const rawType = duration.type === "turn" ? "round" : duration.type;
+ if (rawType === "round" || rawType === "attack" || rawType === "battle") {
+ return { type: rawType, value: Number(duration.value ?? (rawType === "battle" ? 0 : 1)), delay: Number(duration.delay ?? 0) };
+ }
+ return { type: "battle", value: 0 };
+}
+
+function normalizeSameEffectStacking(value: EffectIntentDefinition["same_effect_stacking"]): SameEffectStacking {
+ return value === "max" ? "max" : "add";
+}
+
+function hashSeed(seed: string): number {
+ let hash = 2166136261;
+ for (let index = 0; index < seed.length; index += 1) {
+ hash ^= seed.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+ return hash >>> 0;
+}
Index: simulator/src/index.ts
===================================================================
--- simulator/src/index.ts prev run
+++ simulator/src/index.ts this run
@@ -0,0 +1,9 @@
+export { buildSimulatorConfig, loadSimulatorConfig } from "./config";
+export type { RawSimulatorConfig } from "./config";
+export { simulateBattle } from "./simulator";
+export { discoverTestcaseFiles, runTestcases, adaptTestcaseEntry } from "./testcases";
+export { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";
+export { classifyEffectForJob } from "./classifier";
+export { calculateDamageJob } from "./damage";
+export type { CalibrationCaseComparison, CalibrationComparison, CalibrationComparisonRow } from "./calibration";
+export type * from "./types";
Index: simulator/src/node-shims.d.ts
===================================================================
--- simulator/src/node-shims.d.ts prev run
+++ simulator/src/node-shims.d.ts this run
@@ -0,0 +1,42 @@
+declare const process: {
+ cwd(): string;
+};
+
+declare module "node:assert/strict" {
+ const assert: any;
+ export default assert;
+}
+
+declare module "node:fs" {
+ export function copyFileSync(source: string, destination: string): void;
+ export function existsSync(path: string): boolean;
+ export function mkdtempSync(prefix: string): string;
+ export function mkdirSync(path: string, options?: Record<string, unknown>): unknown;
+ export function readdirSync(path: string): string[];
+ export function readFileSync(path: string, encoding: string): string;
+ export function rmSync(path: string, options?: Record<string, unknown>): void;
+ export function statSync(path: string): { isDirectory(): boolean; isFile(): boolean };
+ export function writeFileSync(path: string, data: string): void;
+}
+
+declare module "node:os" {
+ export function cpus(): unknown[];
+ export function tmpdir(): string;
+}
+
+declare module "node:path" {
+ export function basename(path: string): string;
+ export function dirname(path: string): string;
+ export function join(...parts: string[]): string;
+ export function relative(from: string, to: string): string;
+ export function resolve(...parts: string[]): string;
+}
+
+declare module "node:test" {
+ export function test(name: string, fn: () => void): void;
+}
+
+declare module "node:url" {
+ export function fileURLToPath(url: string | URL): string;
+ export function pathToFileURL(path: string): URL;
+}
Index: simulator/src/normalize.test.ts
===================================================================
--- simulator/src/normalize.test.ts prev run
+++ simulator/src/normalize.test.ts this run
@@ -0,0 +1,13 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { normalizeStatBlock } from "./normalize";
+
+test("normalizeStatBlock accepts stat tuples in attack defense lethality health order", () => {
+ assert.deepEqual(normalizeStatBlock([1, 2, 3, 4]), {
+ attack: 1,
+ defense: 2,
+ lethality: 3,
+ health: 4
+ });
+});
Index: simulator/src/normalize.ts
===================================================================
--- simulator/src/normalize.ts prev run
+++ simulator/src/normalize.ts this run
@@ -0,0 +1,59 @@
+import type { StatBlock, UnitType } from "./types";
+
+export type StatBlockInput = Record<string, unknown> | readonly unknown[];
+
+export function normalizeUnitType(value: string): UnitType {
+ const normalized = value.toLowerCase().replace(/[\s_-]/g, "");
+ if (["inf", "infantry"].includes(normalized)) return "infantry";
+ if (["lanc", "lancer", "lancers"].includes(normalized)) return "lancer";
+ if (["mark", "marksman", "marksmen", "marks"].includes(normalized)) return "marksman";
+ throw new Error(`Unsupported unit type: ${value}`);
+}
+
+export function normalizeStatBlock(input: StatBlockInput | undefined): StatBlock {
+ return {
+ attack: numberField(input, "attack", "Attack", 0),
+ defense: numberField(input, "defense", "Defense", 1),
+ lethality: numberField(input, "lethality", "Lethality", 2),
+ health: numberField(input, "health", "Health", 3)
+ };
+}
+
+export function zeroStats(): StatBlock {
+ return { attack: 0, defense: 0, lethality: 0, health: 0 };
+}
+
+export function addStats(a: StatBlock, b: Partial<StatBlock>): StatBlock {
+ return {
+ attack: a.attack + (b.attack ?? 0),
+ defense: a.defense + (b.defense ?? 0),
+ lethality: a.lethality + (b.lethality ?? 0),
+ health: a.health + (b.health ?? 0)
+ };
+}
+
+export function numberField(input: StatBlockInput | undefined, ...keys: Array<string | number>): number {
+ if (!input) return 0;
+ for (const key of keys) {
+ const value = Array.isArray(input) ? (typeof key === "number" ? input[key] : undefined) : valueForObjectKey(input as Record<string, unknown>, key);
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ }
+ return 0;
+}
+
+function valueForObjectKey(input: Record<string, unknown>, key: string | number): unknown {
+ return typeof key === "string" ? input[key] : undefined;
+}
+
+export function valueAtLevel(value: unknown, level: number): number | string[] {
+ if (Array.isArray(value)) {
+ const index = Math.max(0, Math.min(value.length - 1, level - 1));
+ const selected = value[index];
+ if (typeof selected === "number") return selected;
+ if (Array.isArray(selected)) return selected.map(String);
+ return Number(selected) || 0;
+ }
+ if (typeof value === "number") return value;
+ if (Array.isArray(value)) return value.map(String);
+ return Number(value) || 0;
+}
Index: simulator/src/parityMetrics.ts
===================================================================
--- simulator/src/parityMetrics.ts prev run
+++ simulator/src/parityMetrics.ts this run
@@ -0,0 +1,117 @@
+export interface OutcomeDistribution {
+ n: number;
+ mu: number;
+ sigma: number;
+}
+
+export interface ParityThresholds {
+ z_threshold?: number;
+ min_bias_pct?: number;
+ max_diff_ratio?: number;
+ max_diff_ratio_deterministic?: number;
+}
+
+export interface ParityComparisonMetrics {
+ n_candidate: number;
+ mu_candidate: number;
+ sigma_candidate: number;
+ n_reference: number;
+ mu_reference: number;
+ sigma_reference: number;
+ bias_raw: number;
+ bias_pct: number;
+ sem: number;
+ stat_type: "deterministic" | "zero_var" | "single_obs" | "t";
+ stat: number | null;
+ p: number | null;
+ q: number | null;
+ passes: boolean;
+}
+
+export function compareOutcomeDistribution(options: {
+ candidate: OutcomeDistribution;
+ reference: OutcomeDistribution;
+ initialTroops: number;
+ deterministic: boolean;
+ thresholds?: ParityThresholds;
+}): ParityComparisonMetrics {
+ const thresholds = options.thresholds ?? {};
+ const initialTroops = options.initialTroops || 1;
+ const biasRaw = round(options.candidate.mu - options.reference.mu, 2);
+ const biasPct = round((biasRaw / initialTroops) * 100, 2);
+ const zThreshold = thresholds.z_threshold ?? 2;
+ const minBiasPct = thresholds.min_bias_pct ?? 0.5;
+ const deterministicLimit = (thresholds.max_diff_ratio_deterministic ?? thresholds.max_diff_ratio ?? 0.01) * 100;
+
+ let sem = 0;
+ let stat: number | null = null;
+ let p: number | null = null;
+ let statType: ParityComparisonMetrics["stat_type"];
+ let passes: boolean;
+
+ if (options.deterministic) {
+ statType = "deterministic";
+ passes = Math.abs(biasPct) <= deterministicLimit;
+ } else if (options.candidate.sigma === 0) {
+ statType = "zero_var";
+ passes = Math.abs(biasPct) <= deterministicLimit;
+ } else if (options.reference.n <= 1) {
+ statType = "single_obs";
+ sem = options.candidate.sigma;
+ stat = sem === 0 ? null : round(biasRaw / sem, 4);
+ p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
+ passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
+ } else {
+ statType = "t";
+ sem = options.candidate.sigma * Math.sqrt(1 / Math.max(options.candidate.n, 1) + 1 / options.reference.n);
+ stat = sem === 0 ? null : round(biasRaw / sem, 4);
+ p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);
+ passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;
+ }
+
+ return {
+ n_candidate: options.candidate.n,
+ mu_candidate: round(options.candidate.mu, 2),
+ sigma_candidate: round(options.candidate.sigma, 2),
+ n_reference: options.reference.n,
+ mu_reference: round(options.reference.mu, 2),
+ sigma_reference: round(options.reference.sigma, 2),
+ bias_raw: biasRaw,
+ bias_pct: biasPct,
+ sem: round(sem, 2),
+ stat_type: statType,
+ stat,
+ p,
+ q: null,
+ passes
+ };
+}
+
+export function applyBenjaminiHochberg(rows: Array<{ p: number | null; q: number | null }>): void {
+ const ranked = rows
+ .filter((row): row is { p: number; q: number | null } => row.p !== null)
+ .sort((a, b) => a.p - b.p);
+ const m = ranked.length;
+ let runningMin = 1;
+ for (let index = m - 1; index >= 0; index -= 1) {
+ const rawQ = (ranked[index].p * m) / (index + 1);
+ runningMin = Math.min(runningMin, rawQ);
+ ranked[index].q = round(Math.min(1, runningMin), 6);
+ }
+}
+
+function normalCdf(x: number): number {
+ return 0.5 * (1 + erf(x / Math.SQRT2));
+}
+
+function erf(x: number): number {
+ const sign = x < 0 ? -1 : 1;
+ const ax = Math.abs(x);
+ const t = 1 / (1 + 0.3275911 * ax);
+ const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
+ return sign * y;
+}
+
+function round(value: number, digits: number): number {
+ return Number(value.toFixed(digits));
+}
Index: simulator/src/resolve.ts
===================================================================
--- simulator/src/resolve.ts prev run
+++ simulator/src/resolve.ts this run
@@ -0,0 +1,348 @@
+import type {
+ EffectIntentDefinition,
+ FighterInput,
+ HeroInputCollection,
+ BattleInput,
+ ResolvedFighter,
+ ResolvedHero,
+ ResolvedSkill,
+ ResolvedTroopLine,
+ SideId,
+ SimulatorConfig,
+ SkillDefinition,
+ SkillFile,
+ SkillRequirement,
+ StatBlock,
+ UnitType
+} from "./types";
+import { UNIT_TYPES } from "./types";
+import { normalizeEngagementType } from "./effects";
+import { addStats, normalizeStatBlock, normalizeUnitType, valueAtLevel, zeroStats } from "./normalize";
+
+export function resolveFighter(input: FighterInput, side: SideId, config: SimulatorConfig, mechanics?: BattleInput["mechanics"]): ResolvedFighter {
+ const diagnostics: string[] = [];
+ const troops = emptyTroops();
+ const initialTroops = emptyTroops();
+ const troopDetails: Partial<Record<UnitType, ResolvedTroopLine>> = {};
+ const weightedStats: Partial<Record<UnitType, { count: number; stats: StatBlock; tier: number; fc: number; ids: string[] }>> = {};
+
+ for (const [troopId, rawCount] of Object.entries(input.troops ?? {})) {
+ const count = Number(rawCount) || 0;
+ if (count <= 0) continue;
+ const record = config.troopStats[troopId];
+ if (!record) {
+ diagnostics.push(`Unsupported troop id ${troopId}`);
+ continue;
+ }
+ let type: UnitType;
+ try {
+ type = normalizeUnitType(String(record.type));
+ } catch {
+ diagnostics.push(`Unsupported troop type ${record.type} for ${troopId}`);
+ continue;
+ }
+ const stats = normalizeStatBlock(record.stats);
+ troops[type] += count;
+ initialTroops[type] += count;
+ const existing = weightedStats[type] ?? { count: 0, stats: zeroStats(), tier: 0, fc: 0, ids: [] };
+ existing.count += count;
+ existing.stats = addStats(existing.stats, {
+ attack: stats.attack * count,
+ defense: stats.defense * count,
+ lethality: stats.lethality * count,
+ health: stats.health * count
+ });
+ existing.tier = Math.max(existing.tier, Number(record.tier) || 0);
+ existing.fc = Math.max(existing.fc, Number(record.fc) || 0);
+ existing.ids.push(troopId);
+ weightedStats[type] = existing;
+ }
+
+ for (const [type, aggregate] of Object.entries(weightedStats) as Array<[UnitType, NonNullable<typeof weightedStats[UnitType]>]>) {
+ troopDetails[type] = {
+ id: aggregate.ids.join("+"),
+ type,
+ tier: aggregate.tier,
+ fc: aggregate.fc,
+ count: aggregate.count,
+ stats: {
+ attack: aggregate.stats.attack / aggregate.count,
+ defense: aggregate.stats.defense / aggregate.count,
+ lethality: aggregate.stats.lethality / aggregate.count,
+ health: aggregate.stats.health / aggregate.count
+ }
+ };
+ }
+
+ const statBonuses = resolveInputStatBonuses(input.stats);
+ const heroes = resolveHeroes(input, side, config, statBonuses, diagnostics, mechanics);
+ const heroSkills = resolveHeroSkills(input, side, config, diagnostics, mechanics);
+ const troopSkills = resolveTroopSkills(side, troopDetails, config, mechanics);
+
+ return {
+ side,
+ name: input.name ?? side,
+ troops,
+ initialTroops,
+ troopDetails,
+ statBonuses,
+ heroes,
+ heroSkills,
+ troopSkills,
+ diagnostics
+ };
+}
+
+export function emptyTroops(): Record<UnitType, number> {
+ return { infantry: 0, lancer: 0, marksman: 0 };
+}
+
+function resolveInputStatBonuses(stats: FighterInput["stats"]): Record<UnitType, StatBlock> {
+ const byUnit: Record<UnitType, StatBlock> = {
+ infantry: zeroStats(),
+ lancer: zeroStats(),
+ marksman: zeroStats()
+ };
+ for (const [key, block] of Object.entries(stats ?? {})) {
+ try {
+ byUnit[normalizeUnitType(key)] = normalizeStatBlock(block as Record<string, unknown>);
+ } catch {
+ // Unknown testcase stat aliases are non-fatal because troop ids still determine active units.
+ }
+ }
+ return byUnit;
+}
+
+interface HeroInputInstance {
+ name: string;
+ levels: Record<string, number>;
+ role: "main" | "joiner";
+ instanceId: string;
+}
+
+function heroInputInstances(input: FighterInput): HeroInputInstance[] {
+ return [...heroCollectionInstances(input.heroes, "main"), ...heroCollectionInstances(input.joiner_heroes, "joiner")];
+}
+
+function heroCollectionInstances(collection: HeroInputCollection | undefined, role: "main" | "joiner"): HeroInputInstance[] {
+ if (!collection) return [];
+ const entries = Array.isArray(collection)
+ ? collection.map((entry) => ({ name: entry.name, levels: entry.levels ?? {} }))
+ : Object.entries(collection).map(([name, levels]) => ({ name, levels }));
+ const counts = new Map<string, number>();
+ return entries.map((entry) => {
+ const key = `${role}:${normalizeHeroName(entry.name)}`;
+ const index = counts.get(key) ?? 0;
+ counts.set(key, index + 1);
+ return {
+ name: entry.name,
+ levels: entry.levels,
+ role,
+ instanceId: `${role}:${normalizeHeroName(entry.name)}:${index}`
+ };
+ });
+}
+
+function resolveHeroes(
+ input: FighterInput,
+ side: SideId,
+ config: SimulatorConfig,
+ statBonuses: Record<UnitType, StatBlock>,
+ diagnostics: string[],
+ mechanics?: BattleInput["mechanics"]
+): ResolvedHero[] {
+ const heroes: ResolvedHero[] = [];
+ for (const instance of heroInputInstances(input)) {
+ const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
+ const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;
+ if (!definition) {
+ diagnostics.push(`Missing hero definition for ${instance.name}`);
+ heroes.push({
+ name: instance.name,
+ generationStats: zeroStats(),
+ skillIds: [],
+ instanceId: instance.instanceId,
+ role: instance.role,
+ missing: true
+ });
+ continue;
+ }
+ const generationStats = normalizeStatBlock(config.heroGenerationStats[definition.hero_generation ?? ""] as Record<string, unknown>);
+ if (instance.role === "main" && shouldApplyHeroGenerationStats(mechanics)) {
+ for (const unit of UNIT_TYPES) {
+ statBonuses[unit] = addStats(statBonuses[unit], generationStats);
+ }
+ }
+ heroes.push({
+ name: definition.name ?? instance.name,
+ heroGeneration: definition.hero_generation,
+ generationStats,
+ skillIds: resolveHeroSkillIds(definition, instance.levels, side, mechanics),
+ instanceId: instance.instanceId,
+ role: instance.role
+ });
+ }
+ void side;
+ return heroes;
+}
+
+function resolveHeroSkills(
+ input: FighterInput,
+ side: SideId,
+ config: SimulatorConfig,
+ diagnostics: string[],
+ mechanics?: BattleInput["mechanics"]
+): ResolvedSkill[] {
+ const skills: ResolvedSkill[] = [];
+ for (const instance of heroInputInstances(input)) {
+ const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);
+ const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;
+ if (!definition) continue;
+ let index = 0;
+ for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {
+ index += 1;
+ const level = Number(instance.levels[`skill_${index}`] ?? instance.levels[skillId] ?? 0);
+ if (level <= 0) continue;
+ if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, mechanics)) continue;
+ skills.push(hydrateSkill(skillId, rawSkill, side, level, "hero_skill", definition.name ?? resolvedHeroName, undefined, instance.instanceId, instance.role));
+ }
+ }
+ void diagnostics;
+ return skills;
+}
+
+function resolveHeroSkillIds(definition: SkillFile, levelMap: Record<string, number>, side: SideId, mechanics?: BattleInput["mechanics"]): string[] {
+ const ids: string[] = [];
+ let index = 0;
+ for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {
+ index += 1;
+ const level = Number(levelMap[`skill_${index}`] ?? levelMap[skillId] ?? 0);
+ if (level > 0 && heroRequirementsSatisfied(rawSkill.requirements, level, side, mechanics)) ids.push(skillId);
+ }
+ return ids;
+}
+
+function resolveTroopSkills(
+ side: SideId,
+ troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>,
+ config: SimulatorConfig,
+ mechanics?: BattleInput["mechanics"]
+): ResolvedSkill[] {
+ const skills: ResolvedSkill[] = [];
+ for (const [skillId, rawSkill] of Object.entries(config.troopSkills.skills ?? {})) {
+ let troopType: UnitType;
+ try {
+ troopType = normalizeUnitType(String((rawSkill as { troop_type?: unknown }).troop_type));
+ } catch {
+ continue;
+ }
+ const troop = troopDetails[troopType];
+ if (!troop) continue;
+ const requirements = (((rawSkill as { requirements?: unknown }).requirements ?? []) as SkillRequirement[]);
+ const troopRequirements = requirements.filter((req) => req.type === "tier" || req.type === "fc");
+ const satisfied = troopRequirements
+ .filter((req) => (req.type === "tier" ? troop.tier >= Number(req.value) : req.type === "fc" ? troop.fc >= Number(req.value) : false))
+ .sort((a, b) => b.level - a.level)[0];
+ if (!satisfied) continue;
+ if (!battleRequirementsSatisfied(requirements, satisfied.level, mechanics)) continue;
+ skills.push(hydrateSkill(skillId, rawSkill, side, satisfied.level, "troop_skill", undefined, troopType));
+ }
+ return skills;
+}
+
+function heroRequirementsSatisfied(requirements: SkillRequirement[] | undefined, level: number, side: SideId, mechanics?: BattleInput["mechanics"]): boolean {
+ return (requirements ?? []).every((requirement) => {
+ if (requirement.type !== "engagement_type") return battleRequirementsSatisfied([requirement], level, mechanics);
+ if (level < Number(requirement.level ?? 1)) return true;
+ return heroEngagementRequirementSatisfied(requirement, side, mechanics);
+ });
+}
+
+function battleRequirementsSatisfied(requirements: SkillRequirement[], level: number, mechanics?: BattleInput["mechanics"]): boolean {
+ return requirements.every((requirement) => {
+ if ((requirement.type === "tier" || requirement.type === "fc") && level < Number(requirement.level ?? 1)) return true;
+ if (requirement.type !== "engagement_type") return true;
+ if (level < Number(requirement.level ?? 1)) return true;
+ return normalizeEngagementType(requirement.value) === currentEngagementType(mechanics);
+ });
+}
+
+function heroEngagementRequirementSatisfied(requirement: SkillRequirement, side: SideId, mechanics?: BattleInput["mechanics"]): boolean {
+ const required = normalizeEngagementType(requirement.value);
+ const current = currentEngagementType(mechanics);
+ if (required === current) {
+ if (current !== "rally") return true;
+ return side === "attacker";
+ }
+ if (required === "garrison" && current === "rally") return side === "defender";
+ return false;
+}
+
+function currentEngagementType(mechanics?: BattleInput["mechanics"]): string | undefined {
+ return normalizeEngagementType(mechanics?.engagement_type ?? mechanics?.engagementType);
+}
+
+function shouldApplyHeroGenerationStats(mechanics?: BattleInput["mechanics"]): boolean {
+ return mechanics?.hero_generation_stats === true || mechanics?.heroGenerationStats === true;
+}
+
+function hydrateSkill(
+ skillId: string,
+ rawSkill: Omit<SkillDefinition, "id" | "name">,
+ side: SideId,
+ level: number,
+ sourceKind: "hero_skill" | "troop_skill",
+ heroName?: string,
+ troopType?: UnitType,
+ heroInstanceId?: string,
+ heroRole?: "main" | "joiner"
+): ResolvedSkill {
+ const effects: EffectIntentDefinition[] = [];
+ for (const [effectId, effect] of Object.entries(rawSkill.effects ?? {})) {
+ effects.push({
+ id: effectId,
+ ...effect,
+ value: levelSelectPreservingOrder(effect.value, level)
+ });
+ }
+ return {
+ id: skillId,
+ name: skillId,
+ sourceKind,
+ side,
+ heroName,
+ heroInstanceId,
+ heroRole,
+ troopType,
+ level,
+ trigger: rawSkill.trigger,
+ effects
+ };
+}
+
+function levelSelectPreservingOrder(value: unknown, level: number): unknown {
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value;
+ return valueAtLevel(value, level);
+}
+
+function resolveHeroDefinitionKey(heroName: string, config: SimulatorConfig): string | undefined {
+ if (config.heroDefinitions[heroName]) return heroName;
+ const normalized = normalizeHeroName(heroName);
+ const indexed = config.heroAliasIndex?.[normalized];
+ if (indexed && config.heroDefinitions[indexed]) return indexed;
+ const manualAliases: Record<string, string> = {
+ lingxue: "Ling",
+ lumakbokan: "Lumak",
+ wuming: "WuMing"
+ };
+ const manual = manualAliases[normalized];
+ if (manual && config.heroDefinitions[manual]) return manual;
+ for (const [key, definition] of Object.entries(config.heroDefinitions)) {
+ if (normalizeHeroName(key) === normalized || normalizeHeroName(definition.name ?? "") === normalized) return key;
+ }
+ return undefined;
+}
+
+function normalizeHeroName(name: string): string {
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
+}
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -0,0 +1,1803 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+import { loadSimulatorConfig } from "./config";
+import { createSeededRng, chancePasses } from "./effects";
+import { resolveFighter } from "./resolve";
+import { simulateBattle, simulateBattleScore } from "./simulator";
+import type { BattleInput, EffectIntentDefinition, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";
+
+test("simulateBattle returns structured result for a no-hero battle", () => {
+ const config = loadSimulatorConfig();
+ const result = simulateBattle(
+ {
+ maxRounds: 3,
+ trace: true,
+ attacker: {
+ name: "A",
+ troops: { infantry_t6: 8000 },
+ stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },
+ heroes: {}
+ },
+ defender: {
+ name: "D",
+ troops: { infantry_t6: 500 },
+ stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },
+ heroes: {}
+ }
+ },
+ config
+ );
+
+ assert.match(result.winner, /attacker|defender|draw/);
+ assert.ok(result.rounds >= 1);
+ assert.ok(result.attacks.some((attack) => attack.kind === "normal"));
+ assert.ok(result.trace?.resolved.attacker.troops.infantry);
+ assert.deepEqual(result.resolved.attacker.heroes, []);
+ assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));
+});
+
+test("simulateBattle calculates all same-round damage from the round-start troop snapshot", () => {
+ const config = loadSimulatorConfig();
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ name: "A",
+ troops: { infantry_t1: 1 },
+ stats: { inf: { attack: 100000, lethality: 100000 } },
+ heroes: {}
+ },
+ defender: {
+ name: "D",
+ troops: { infantry_t1: 1 },
+ stats: { inf: { attack: 100000, lethality: 100000 } },
+ heroes: {}
+ }
+ },
+ config
+ );
+
+ const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");
+ assert.equal(normalAttacks.length, 2);
+ assert.deepEqual(
+ normalAttacks.map((attack) => attack.kills),
+ [1, 1]
+ );
+ assert.equal(result.remaining.attacker.infantry, 0);
+ assert.equal(result.remaining.defender.infantry, 0);
+ assert.equal(result.trace?.rounds[0]?.roundStartTroops.attacker.infantry, 1);
+ assert.equal(result.trace?.rounds[0]?.roundStartTroops.defender.infantry, 1);
+ assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);
+});
+
+test("simulateBattle carries fractional casualties between rounds and ceils final survivors", () => {
+ const config = loadSimulatorConfig();
+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/simple_001_nc.json", import.meta.url));
+ const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;
+ const input = testcases.find((testcase) => testcase.test_id === "simple_001");
+ assert.notEqual(input, undefined);
+
+ const result = simulateBattle({ ...input!, trace: true }, config);
+
+ 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.003308");
+});
+
+test("simulateBattle defaults to a 1500 round cap when no explicit maxRounds is provided", () => {
+ const result = simulateBattle(
+ {
+ attacker: { troops: {}, heroes: {} },
+ defender: { troops: {}, heroes: {} }
+ },
+ minimalConfig()
+ );
+
+ 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", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 100, lancer_t1: 100 },
+ heroes: { PerUnit: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ PerUnit: {
+ name: "PerUnit",
+ skills: {
+ RoundFanout: {
+ trigger: { type: "turn", source: "self.any" },
+ effects: {
+ buff: {
+ type: "active.hero.lethality.up",
+ value: 100,
+ units: { applies_to: "trigger", applies_vs: "target" },
+ duration: { type: "turn", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const report = result.skillReport.attacker.find((entry) => entry.skillId === "RoundFanout");
+ assert.equal(report?.triggersSeen, 1);
+ assert.equal(report?.skillActivations, 1);
+ assert.equal(report?.effectActivations, 2);
+});
+
+test("attack trigger source and target selectors match relative to the skill owner", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 100, lancer_t1: 100 },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: { Shield: { skill_1: 1 } }
+ }
+ },
+ minimalConfig({
+ Shield: {
+ name: "Shield",
+ skills: {
+ EnemyHitsInfantry: {
+ trigger: { type: "attack", source: "enemy.any", target: "self.infantry" },
+ effects: {
+ guard: {
+ type: "active.hero.defense.up",
+ value: 100,
+ units: { applies_to: "target", applies_vs: "target" },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const report = result.skillReport.defender.find((entry) => entry.skillId === "EnemyHitsInfantry");
+ assert.equal(report?.triggersSeen, 2);
+ assert.equal(report?.skillActivations, 2);
+ assert.equal(report?.effectActivations, 2);
+});
+
+test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
+ const config = loadSimulatorConfig();
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ 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 } }
+ },
+ 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 } }
+ }
+ },
+ config
+ );
+
+ assert.ok(result.resolved.attacker.heroes.some((hero) => hero.name === "Alonso"));
+ assert.ok(result.resolved.attacker.troopSkillIds.length > 0);
+ 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 fighter = resolveFighter(
+ {
+ troops: { infantry_t1: 1 },
+ heroes: {
+ "Ling Xue": { skill_1: 1 },
+ "Lumak Bokan": { skill_1: 1 },
+ "Wu Ming": { skill_1: 1 }
+ }
+ },
+ "attacker",
+ config
+ );
+
+ assert.deepEqual(
+ fighter.heroes.map((hero) => hero.name),
+ ["Ling", "Lumak", "Wu Ming"]
+ );
+ assert.equal(fighter.heroes.some((hero) => hero.missing), false);
+ assert.equal(fighter.diagnostics.some((line) => line.includes("Missing hero definition")), false);
+});
+
+test("hero generation stats are opt-in because testcase stats are authoritative", () => {
+ const config = minimalConfig({
+ Example: {
+ name: "Example",
+ hero_generation: "S1",
+ skills: {}
+ }
+ });
+ config.heroGenerationStats.S1 = { attack: 50, defense: 40, lethality: 30, health: 20 };
+ const input = {
+ troops: { infantry_t1: 10 },
+ stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },
+ heroes: { Example: { skill_1: 1 } }
+ };
+
+ const defaultFighter = resolveFighter(input, "attacker", config);
+ const optInFighter = resolveFighter(input, "attacker", config, { hero_generation_stats: true });
+
+ assert.deepEqual(defaultFighter.statBonuses.infantry, { attack: 1, defense: 2, lethality: 3, health: 4 });
+ assert.deepEqual(optInFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });
+});
+
+test("array joiner heroes preserve duplicate skill instances", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 100 },
+ heroes: [],
+ joiner_heroes: [
+ { name: "Repeat", levels: { skill_1: 1 } },
+ { name: "Repeat", levels: { skill_1: 1 } }
+ ]
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Repeat: {
+ name: "Repeat",
+ skills: {
+ Buff: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },
+ duration: { type: "battle", value: 1 },
+ same_effect_stacking: "add"
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const reports = result.skillReport.attacker.filter((entry) => entry.heroName === "Repeat" && entry.skillId === "Buff");
+ assert.equal(reports.length, 2);
+ assert.deepEqual(
+ reports.map((entry) => entry.effectActivations),
+ [1, 1]
+ );
+});
+
+test("joiner hero generation stats are not applied when main hero stats are enabled", () => {
+ const config = minimalConfig({
+ Main: {
+ name: "Main",
+ hero_generation: "S1",
+ skills: {}
+ },
+ Joiner: {
+ name: "Joiner",
+ hero_generation: "S2",
+ skills: {
+ JoinerBuff: {
+ trigger: { type: "battle_start" },
+ effects: {}
+ }
+ }
+ }
+ });
+ config.heroGenerationStats.S1 = { attack: 10, defense: 20, lethality: 30, health: 40 };
+ config.heroGenerationStats.S2 = { attack: 100, defense: 200, lethality: 300, health: 400 };
+
+ const fighter = resolveFighter(
+ {
+ troops: { infantry_t1: 10 },
+ stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },
+ heroes: [{ name: "Main", levels: {} }],
+ joiner_heroes: [{ name: "Joiner", levels: { skill_1: 1 } }]
+ },
+ "attacker",
+ config,
+ { hero_generation_stats: true }
+ );
+
+ assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });
+});
+
+test("attack-duration effects are consumed even when a normal attack is cancelled", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { infantry_t1: 10 },
+ heroes: { Canceller: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Canceller: {
+ name: "Canceller",
+ skills: {
+ CancelAndDebuff: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ cancel: {
+ type: "no_attack",
+ value: 100,
+ units: { applies_to: "trigger", applies_vs: "target" },
+ duration: { type: "attack", value: 1 }
+ },
+ debuff: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "trigger", applies_vs: "target" },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
+ assert.notEqual(cancelled, undefined);
+ const cancelledAttack = cancelled!;
+ assert.equal(cancelledAttack.consumedEffectIds.length, 2);
+ assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":cancel:")));
+ assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":debuff:")));
+ assert.deepEqual(cancelledAttack.counterDeltas, [
+ { side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },
+ { side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }
+ ]);
+});
+
+test("cancelled normal attacks advance attack counters for later frequency checks", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 4,
+ attacker: {
+ troops: { infantry_t1: 100 },
+ heroes: { EveryOtherCancel: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ EveryOtherCancel: {
+ name: "EveryOtherCancel",
+ skills: {
+ Pause: {
+ trigger: { type: "attack", every: 2, source: "infantry" },
+ effects: {
+ cancel: {
+ type: "no_attack",
+ units: { applies_to: "trigger", applies_vs: "target" },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ assert.deepEqual(
+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),
+ ["r2:attacker:infantry:0:cancelled", "r4:attacker:infantry:0:cancelled"]
+ );
+});
+
+test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {
+ const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"));
+ const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"));
+
+ const maxRoundTwo = maxResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
+ const addRoundTwo = addResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");
+
+ assert.equal(maxRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 100);
+ assert.equal(maxRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
+ assert.equal(addRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 200);
+ assert.equal(addRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 2);
+});
+
+test("same_effect_stacking max caps duplicate hero instances of the same skill effect", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { infantry_t1: 100 },
+ heroes: [{ name: "Repeat", levels: { skill_1: 1 } }],
+ joiner_heroes: [{ name: "Repeat", levels: { skill_1: 1 } }]
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ },
+ sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up")
+ );
+
+ const attack = result.attacks.find((entry) => entry.jobId.startsWith("r1:attacker:infantry") && entry.kind === "normal");
+ assert.equal(result.skillReport.attacker.filter((entry) => entry.heroName === "Repeat" && entry.skillId === "Overlap").length, 2);
+ assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 100);
+ assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
+});
+
+test("same_effect_stacking max caps overlapping extra skill attacks while add keeps all activations", () => {
+ const maxResult = simulateBattle(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"));
+ const addResult = simulateBattle(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"));
+
+ const maxRoundTwoSkillJobs = maxResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
+ const addRoundTwoSkillJobs = addResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];
+
+ assert.equal(maxRoundTwoSkillJobs.length, 1);
+ assert.equal(addRoundTwoSkillJobs.length, 2);
+ assert.deepEqual(maxResult.extraSkillAttackJobsByEffect, { stacking: 2 });
+ assert.deepEqual(addResult.extraSkillAttackJobsByEffect, { stacking: 3 });
+});
+
+test("same_effect_stacking max consumes overlapping attack-duration extra skill effects as one group", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ trace: true,
+ attacker: {
+ troops: { infantry_t1: 10000 },
+ heroes: { MaxExtraAttackDuration: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ MaxExtraAttackDuration: {
+ name: "MaxExtraAttackDuration",
+ skills: {
+ Overlap: {
+ trigger: { type: "turn" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ same_effect_stacking: "max",
+ units: { applies_to: ["infantry"], applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 2 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));
+
+ assert.notEqual(roundTwoSkillOutcome, undefined);
+ assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 2);
+});
+
+test("requires_effect is ignored by native simulator effect activation", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 0,
+ attacker: {
+ troops: { infantry_t1: 10 },
+ heroes: { LegacyDependency: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ LegacyDependency: {
+ name: "LegacyDependency",
+ skills: {
+ BothEffectsActivate: {
+ trigger: { type: "battle_start" },
+ effects: {
+ base: {
+ type: "passive.attack.up",
+ value: 10
+ },
+ legacyDependent: {
+ ...({ requires_effect: "missing-effect" } as Record<string, unknown>),
+ type: "passive.defense.up",
+ value: 10
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const report = result.skillReport.attacker.find((entry) => entry.skillId === "BothEffectsActivate");
+ assert.equal(report?.effectActivations, 2);
+});
+
+test("fighter passive effects are added to the static profile after battle_start effects", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { infantry_t1: 100 },
+ stats: { infantry: { attack: 0, lethality: 0, defense: 0, health: 0 } },
+ passive: {
+ attack: { up: 20, down: 5 }
+ },
+ heroes: { BattleStart: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ stats: { infantry: { attack: 0, lethality: 0, defense: 0, health: 0 } },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ BattleStart: {
+ name: "BattleStart",
+ skills: {
+ StartsFirst: {
+ trigger: { type: "battle_start" },
+ effects: {
+ skillBuff: {
+ type: "passive.attack.up",
+ value: 10
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");
+ assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);
+ assert.equal(attack?.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);
+ assert.deepEqual(
+ attack?.trace?.atomicBuckets["passive.attack.up"].contributors.map((contributor) => contributor.effectId).sort(),
+ ["input:passive.attack.up", "skillBuff"]
+ );
+});
+
+test("extra skill attacks with array trigger damage targets hit those defender unit types", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { Router: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Router: {
+ name: "Router",
+ skills: {
+ SplitHit: {
+ trigger: { type: "attack", source: "marksman" },
+ effects: {
+ hitLancer: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: ["lancer"] }],
+ duration: { type: "attack", value: 1 }
+ },
+ hitMarksman: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: ["marksman"] }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
+ assert.deepEqual(
+ skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),
+ [
+ ["hitLancer", "lancer"],
+ ["hitMarksman", "marksman"]
+ ]
+ );
+});
+
+test('extra skill attacks with applies_vs "any" keep current-target compatibility', () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { Router: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Router: {
+ name: "Router",
+ skills: {
+ SingleHit: {
+ trigger: { type: "attack", source: "marksman" },
+ effects: {
+ hitAny: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
+ assert.deepEqual(
+ skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),
+ [["hitAny", "lancer"]]
+ );
+});
+
+test("skill report attributes kills only to the skill damage source", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { Shooter: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: { Guard: { skill_1: 1 } }
+ }
+ },
+ minimalConfig({
+ Shooter: {
+ name: "Shooter",
+ skills: {
+ PowerShot: {
+ trigger: { type: "attack", source: "marksman" },
+ effects: {
+ shot: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ },
+ Guard: {
+ name: "Guard",
+ skills: {
+ CrystalShield: {
+ trigger: { type: "attack", source: "enemy.any", target: "self.infantry" },
+ effects: {
+ shield: {
+ type: "active.hero.defense.up",
+ value: 50,
+ units: { applies_to: "trigger.target", applies_vs: "trigger.source" },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const powerShot = result.skillReport.attacker.find((entry) => entry.skillId === "PowerShot");
+ const crystalShield = result.skillReport.defender.find((entry) => entry.skillId === "CrystalShield");
+
+ assert.ok((powerShot?.skillKills ?? 0) > 0);
+ assert.equal(crystalShield?.skillKills, 0);
+});
+
+test("same-round outcomes are capped to available target troops before tracing skill kills", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 1000 },
+ heroes: { Blaster: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Blaster: {
+ name: "Blaster",
+ skills: {
+ DoubleBlast: {
+ trigger: { type: "attack", source: "marksman" },
+ effects: {
+ first: {
+ type: "extra_skill_attack",
+ value: 500,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ },
+ second: {
+ type: "extra_skill_attack",
+ value: 500,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const defenderLosses = result.attacks
+ .filter((attack) => attack.defenderSide === "defender" && attack.defenderUnit === "infantry")
+ .reduce((sum, attack) => sum + attack.kills, 0);
+ const doubleBlast = result.skillReport.attacker.find((entry) => entry.skillId === "DoubleBlast");
+
+ assert.equal(Number(defenderLosses.toFixed(6)), 10);
+ assert.ok((doubleBlast?.skillKills ?? 0) <= 10);
+ assert.equal(result.remaining.defender.infantry, 0);
+});
+
+test("same-round cap does not leave exhausted units targetable through floating point residue", () => {
+ const config = loadSimulatorConfig();
+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/sergey_solo_nc.json", import.meta.url));
+ const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;
+ const input = testcases.find((testcase) => testcase.test_id === "sergey_solo");
+ if (!input) throw new Error("missing sergey_solo fixture");
+
+ const result = simulateBattle(input, config);
+
+ assert.equal(result.winner, "attacker");
+ assert.equal(result.remaining.attacker.marksman, 1349);
+ assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });
+});
+
+test("extra skill trigger damage jobs reject missing runtime selectors", () => {
+ assert.throws(
+ () =>
+ simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { Malformed: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Malformed: {
+ name: "Malformed",
+ skills: {
+ MissingSelector: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source" } as never],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ ),
+ /target selector is required/i
+ );
+});
+
+test("extra skill attacks reject missing trigger damage jobs at activation", () => {
+ assert.throws(
+ () =>
+ simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { Malformed: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Malformed: {
+ name: "Malformed",
+ skills: {
+ MissingJobs: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ ),
+ /extra_skill_attack.*trigger_damage_jobs/i
+ );
+});
+
+test("attack-triggered extra skill attacks activate then resolve trigger damage jobs on normal attack use", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ FollowUpShot: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const jobs = result.trace?.rounds[0]?.jobs ?? [];
+ const normalJobs = jobs.filter((job) => job.kind === "normal");
+ const skillJobs = jobs.filter((job) => job.kind === "skill");
+
+ assert.equal(normalJobs.length, 2);
+ assert.equal(skillJobs.length, 1);
+ assert.equal(skillJobs[0]?.attackerSide, normalJobs[0]?.attackerSide);
+ assert.equal(skillJobs[0]?.attackerUnit, normalJobs[0]?.attackerUnit);
+ assert.equal(skillJobs[0]?.defenderSide, normalJobs[0]?.defenderSide);
+ assert.equal(skillJobs[0]?.defenderUnit, normalJobs[0]?.defenderUnit);
+});
+
+test("cancelled normal attacks do not consume extra skill attack uses", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: { Canceller: { skill_1: 1 } }
+ }
+ },
+ minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ OneUseFollowUp: {
+ trigger: { type: "battle_start" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: ["marksman"], applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ },
+ Canceller: {
+ name: "Canceller",
+ skills: {
+ CancelFirstMarksman: {
+ trigger: { type: "battle_start" },
+ effects: {
+ cancel: {
+ type: "no_attack",
+ value: 100,
+ units: { applies_to: "enemy.marksman", applies_vs: "any" },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
+ assert.notEqual(cancelled, undefined);
+ assert.equal(cancelled!.consumedEffectIds.some((id) => id.includes(":hitAgain:")), false);
+
+ const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
+ assert.deepEqual(skillJobsByRound, [0, 1]);
+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });
+});
+
+test("extra skill attack effects cannot be used by later enemy normal attacks", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ FollowUpShot: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 2 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
+ assert.deepEqual(
+ skillJobs.map((job) => `${job.attackerSide}.${job.attackerUnit}->${job.defenderSide}.${job.defenderUnit}`),
+ ["attacker.marksman->defender.infantry"]
+ );
+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });
+});
+
+test("extra skill trigger damage jobs can resolve to multiple living enemy targets without recursive attack triggers", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { MultiTarget: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ MultiTarget: {
+ name: "MultiTarget",
+ skills: {
+ MultiTargetShot: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ hitLiving: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ },
+ RecursiveGuard: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ recursive: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
+ assert.deepEqual(
+ skillJobs.map((job) => job.defenderUnit).sort(),
+ ["infantry", "infantry", "lancer", "marksman"]
+ );
+ assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "MultiTargetShot")?.triggersSeen, 1);
+ assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "RecursiveGuard")?.triggersSeen, 1);
+});
+
+test("extra skill attack consumes one use regardless of multiple generated target jobs", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { MultiTarget: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ MultiTarget: {
+ name: "MultiTarget",
+ skills: {
+ MultiTargetShot: {
+ trigger: { type: "battle_start" },
+ effects: {
+ hitLiving: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: ["marksman"], applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
+ duration: { type: "attack", value: 2 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
+ assert.deepEqual(skillJobsByRound, [3, 3]);
+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 6 });
+});
+
+test('direct simulator configs reject applies_vs "all" usage gates', () => {
+ assert.throws(
+ () =>
+ simulateBattle(
+ {
+ maxRounds: 1,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { MultiTarget: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ MultiTarget: {
+ name: "MultiTarget",
+ skills: {
+ MultiTargetShot: {
+ trigger: { type: "battle_start" },
+ effects: {
+ hitLiving: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: ["marksman"], applies_vs: "all" },
+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }]
+ }
+ }
+ }
+ }
+ }
+ })
+ ),
+ /applies_vs.*all/i
+ );
+});
+
+test("extra skill attack consumes one use when multiple same-round normal attacks match the effect", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { infantry_t1: 100, marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ OneUseFollowUp: {
+ trigger: { type: "battle_start" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: ["infantry", "marksman"], applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];
+ assert.equal(skillJobs.length, 1);
+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });
+});
+
+test("extra skill attack applies_vs must match the current normal attack target", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ LancerOnlyFollowUp: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ hitLancer: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: ["lancer"] },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const jobs = result.trace?.rounds[0]?.jobs ?? [];
+ assert.deepEqual(
+ jobs.map((job) => `${job.kind}:${job.attackerUnit}->${job.defenderUnit}`),
+ ["normal:marksman->infantry", "normal:infantry->marksman", "normal:lancer->marksman"]
+ );
+ assert.deepEqual(result.extraSkillAttackJobsByEffect, {});
+});
+
+test("extra skill de-dupe does not suppress unrelated attack-duration effect consumption", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 2,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ MultiTargetFollowUp: {
+ trigger: { type: "battle_start" },
+ effects: {
+ hitLiving: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: ["marksman"], applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
+ duration: { type: "attack", value: 2 }
+ }
+ }
+ },
+ SkillDamageBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "type.skill.damage.up",
+ value: 100,
+ units: { applies_to: ["marksman"], applies_vs: "any" },
+ duration: { type: "attack", value: 2 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
+ const skillBoosts = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => attack.trace?.atomicBuckets["type.skill.damage.up"].totalPct ?? 0);
+ assert.deepEqual(skillJobsByRound, [2, 2]);
+ assert.deepEqual(skillBoosts, [100, 100, 0, 0]);
+ assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });
+});
+
+test("attack-triggered source and target selectors resolve to concrete active scopes", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { infantry_t1: 100 },
+ heroes: { Debuffer: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Debuffer: {
+ name: "Debuffer",
+ skills: {
+ TargetedDebuff: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ down: {
+ type: "active.hero.lethality.down",
+ value: 50,
+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
+ duration: { type: "turn", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");
+ const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");
+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");
+
+ assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);
+ assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);
+ assert.equal(defenderMarksmanAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);
+});
+
+test('attack-triggered target selector with applies_vs "any" gates later opposing attacks', () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { infantry_t1: 100 },
+ heroes: { Debuffer: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ Debuffer: {
+ name: "Debuffer",
+ skills: {
+ TargetAnyDebuff: {
+ trigger: { type: "attack", source: "infantry" },
+ effects: {
+ down: {
+ type: "active.hero.lethality.down",
+ value: 50,
+ units: { applies_to: "trigger.target", applies_vs: "any" },
+ duration: { type: "turn", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ );
+
+ const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");
+ const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");
+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");
+
+ assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);
+ assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);
+ assert.equal(defenderMarksmanAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);
+});
+
+test("engagement_type requirements decide whether hero skills resolve", () => {
+ const config = minimalConfig({
+ Gated: {
+ name: "Gated",
+ skills: {
+ RallyOnly: {
+ requirements: [{ level: 1, type: "engagement_type", value: "rally" }],
+ trigger: { type: "battle_start" },
+ effects: { rally: { type: "passive.attack.up", value: 10 } }
+ },
+ GarrisonOnly: {
+ requirements: [{ level: 1, type: "engagement_type", value: "garrison" }],
+ trigger: { type: "battle_start" },
+ effects: { garrison: { type: "passive.defense.up", value: 10 } }
+ }
+ }
+ }
+ });
+ const input = {
+ maxRounds: 0,
+ attacker: {
+ troops: { infantry_t1: 10 },
+ heroes: { Gated: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10 },
+ heroes: {}
+ }
+ };
+
+ const defaultResult = simulateBattle(input, config);
+ const rallyResult = simulateBattle({ ...input, mechanics: { engagement_type: "rally" } }, config);
+ const garrisonResult = simulateBattle({ ...input, mechanics: { engagement_type: "garrison" } }, config);
+
+ assert.equal(skillActivations(defaultResult, "RallyOnly"), 0);
+ assert.equal(skillActivations(defaultResult, "GarrisonOnly"), 0);
+ assert.equal(defaultResult.skillReport.attacker.some((entry) => entry.skillId === "RallyOnly"), false);
+ assert.equal(defaultResult.skillReport.attacker.some((entry) => entry.skillId === "GarrisonOnly"), false);
+ assert.equal(skillActivations(rallyResult, "RallyOnly"), 1);
+ assert.equal(skillActivations(rallyResult, "GarrisonOnly"), 0);
+ assert.equal(rallyResult.skillReport.attacker.some((entry) => entry.skillId === "GarrisonOnly"), false);
+ assert.equal(skillActivations(garrisonResult, "RallyOnly"), 0);
+ assert.equal(skillActivations(garrisonResult, "GarrisonOnly"), 1);
+ assert.equal(garrisonResult.skillReport.attacker.some((entry) => entry.skillId === "RallyOnly"), false);
+});
+
+test("rally engagement resolves hero widget roles by owning side", () => {
+ const config = minimalConfig({
+ Gated: {
+ name: "Gated",
+ skills: {
+ RallyOnly: {
+ requirements: [{ level: 1, type: "engagement_type", value: "rally" }],
+ trigger: { type: "battle_start" },
+ effects: { rally: { type: "passive.attack.up", value: 10 } }
+ },
+ GarrisonOnly: {
+ requirements: [{ level: 1, type: "engagement_type", value: "garrison" }],
+ trigger: { type: "battle_start" },
+ effects: { garrison: { type: "passive.defense.up", value: 10 } }
+ }
+ }
+ }
+ });
+ const input = {
+ maxRounds: 0,
+ mechanics: { engagement_type: "rally" },
+ attacker: {
+ troops: { infantry_t1: 10 },
+ heroes: { Gated: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10 },
+ heroes: { Gated: { skill_1: 1, skill_2: 1 } }
+ }
+ };
+
+ const result = simulateBattle(input, config);
+
+ const attackerRally = result.skillReport.attacker.find((entry) => entry.skillId === "RallyOnly");
+ const defenderGarrison = result.skillReport.defender.find((entry) => entry.skillId === "GarrisonOnly");
+
+ assert.equal(attackerRally?.skillActivations, 1);
+ assert.equal(defenderGarrison?.skillActivations, 1);
+ assert.equal(Boolean(attackerRally), true);
+ assert.equal(result.skillReport.attacker.some((entry) => entry.skillId === "GarrisonOnly"), false);
+ assert.equal(result.skillReport.defender.some((entry) => entry.skillId === "RallyOnly"), false);
+ assert.equal(Boolean(defenderGarrison), true);
+});
+
+test("simulateBattle identifies stochastic battles from resolved chance triggers", () => {
+ const config = minimalConfig({
+ Chance: {
+ name: "Chance",
+ skills: {
+ CoinFlip: {
+ trigger: { type: "battle_start", probability: [50] },
+ effects: { buff: { type: "passive.attack.up", value: 10 } }
+ },
+ RallyCoinFlip: {
+ requirements: [{ level: 1, type: "engagement_type", value: "rally" }],
+ trigger: { type: "battle_start", probability: [50] },
+ effects: { rally: { type: "passive.attack.up", value: 10 } }
+ }
+ }
+ }
+ });
+
+ const input = {
+ maxRounds: 0,
+ attacker: {
+ troops: { infantry_t1: 10 },
+ heroes: { Chance: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10 },
+ heroes: {}
+ }
+ };
+
+ const chanceResult = simulateBattle(input, config);
+ const gatedOnlyResult = simulateBattle(
+ {
+ ...input,
+ attacker: { ...input.attacker, heroes: { Chance: { skill_2: 1 } } }
+ },
+ config
+ );
+
+ assert.equal(chanceResult.randomness.deterministic, false);
+ assert.deepEqual(chanceResult.randomness.chanceSkillIds.attacker, ["CoinFlip"]);
+ assert.equal(gatedOnlyResult.randomness.deterministic, true);
+ assert.deepEqual(gatedOnlyResult.randomness.chanceSkillIds.attacker, []);
+});
+
+test("fast simulation matches full semantic output without detailed attacks", () => {
+ const cases: Array<{ name: string; input: BattleInput; config: SimulatorConfig }> = [
+ {
+ name: "no-hero baseline",
+ input: {
+ maxRounds: 3,
+ seed: "fast-baseline",
+ attacker: {
+ troops: { infantry_t1: 250, lancer_t1: 150 },
+ stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },
+ heroes: {}
+ },
+ defender: {
+ troops: { infantry_t1: 300, marksman_t1: 80 },
+ stats: { inf: { attack: 8, defense: 12, lethality: 2, health: 3 } },
+ heroes: {}
+ }
+ },
+ config: minimalConfig()
+ },
+ {
+ name: "duplicate max-stacked hero instances",
+ input: {
+ maxRounds: 2,
+ seed: "fast-duplicate-max",
+ attacker: {
+ troops: { infantry_t1: 10000 },
+ heroes: [{ name: "Repeat", levels: { skill_1: 1 } }],
+ joiner_heroes: [{ name: "Repeat", levels: { skill_1: 1 } }]
+ },
+ defender: {
+ troops: { infantry_t1: 10000 },
+ heroes: {}
+ }
+ },
+ config: sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up")
+ },
+ {
+ name: "controls and extra skill attacks",
+ input: {
+ maxRounds: 3,
+ seed: "fast-controls-extra",
+ attacker: {
+ troops: { marksman_t1: 400, infantry_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 450, lancer_t1: 100 },
+ heroes: { Canceller: { skill_1: 1 } }
+ }
+ },
+ config: minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ FollowUpShot: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ hitAgain: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "attack", value: 2 }
+ }
+ }
+ }
+ }
+ },
+ Canceller: {
+ name: "Canceller",
+ skills: {
+ PauseFirstHit: {
+ trigger: { type: "battle_start" },
+ effects: {
+ pause: {
+ type: "no_attack",
+ units: { applies_to: "enemy.marksman", applies_vs: "any" },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ })
+ },
+ {
+ name: "stochastic real hero battle",
+ input: {
+ maxRounds: 8,
+ seed: "fast-real-heroes",
+ mechanics: { hero_generation_stats: true, engagement_type: "rally" },
+ attacker: {
+ troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },
+ heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } },
+ joiner_heroes: [{ name: "Jessie", levels: { skill_1: 5 } }]
+ },
+ defender: {
+ troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },
+ heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Bradley: { skill_1: 5, skill_2: 5, skill_3: 5 } },
+ joiner_heroes: [{ name: "Norah", levels: { skill_1: 5 } }]
+ }
+ },
+ config: loadSimulatorConfig()
+ }
+ ];
+
+ for (const { name, input, config } of cases) {
+ const full = simulateBattle({ ...input, trace: true }, config);
+ const fast = simulateBattle({ ...input, trace: true }, config, { detail: "fast" });
+
+ assert.deepEqual(semanticBattleSummary(fast), semanticBattleSummary(full), name);
+ assert.deepEqual(fast.attacks, [], name);
+ assert.equal(fast.trace, undefined, name);
+ }
+});
+
+test("simulateBattleScore returns signed remaining troops from the same battle semantics", () => {
+ const config = loadSimulatorConfig();
+ const input: BattleInput = {
+ maxRounds: 8,
+ seed: "score-real-heroes",
+ mechanics: { hero_generation_stats: true, engagement_type: "rally" },
+ attacker: {
+ troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },
+ heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } },
+ joiner_heroes: [{ name: "Jessie", levels: { skill_1: 5 } }]
+ },
+ defender: {
+ troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },
+ heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Bradley: { skill_1: 5, skill_2: 5, skill_3: 5 } },
+ joiner_heroes: [{ name: "Norah", levels: { skill_1: 5 } }]
+ }
+ };
+
+ const result = simulateBattle(input, config, { detail: "fast" });
+ const score = simulateBattleScore(input, config);
+
+ assert.equal(score, signedRemainingScore(result));
+});
+
+test("seeded probability rolls are deterministic and compare percentage thresholds", () => {
+ const skill: ResolvedSkill = {
+ id: "Chance",
+ name: "Chance",
+ sourceKind: "hero_skill",
+ side: "attacker",
+ heroName: "ChanceHero",
+ level: 1,
+ trigger: { type: "battle_start", probability: [50] },
+ effects: []
+ };
+ const first = createSeededRng("same-seed");
+ const second = createSeededRng("same-seed");
+ const different = createSeededRng("different-seed");
+
+ assert.deepEqual(
+ [chancePasses(skill, first), chancePasses(skill, first), chancePasses(skill, first)],
+ [chancePasses(skill, second), chancePasses(skill, second), chancePasses(skill, second)]
+ );
+ assert.deepEqual([createSeededRng("numeric")(), createSeededRng("numeric")()], [createSeededRng("numeric")(), createSeededRng("numeric")()]);
+ assert.ok(different() >= 0 && different() < 1);
+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [0] } }, createSeededRng("x")), false);
+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [1] } }, () => 0.009), true);
+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [1] } }, () => 0.01), false);
+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [0.5] } }, () => 0.004), true);
+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [0.5] } }, () => 0.005), false);
+ assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [100] } }, createSeededRng("x")), true);
+});
+
+function skillActivations(result: ReturnType<typeof simulateBattle>, skillId: string): number {
+ return result.skillReport.attacker.find((entry) => entry.skillId === skillId)?.skillActivations ?? 0;
+}
+
+function totalRemaining(troops: Record<UnitType, number>): number {
+ return Object.values(troops).reduce((sum, count) => sum + count, 0);
+}
+
+function signedRemainingScore(result: ReturnType<typeof simulateBattle>): number {
+ if (result.winner === "attacker") return totalRemaining(result.remaining.attacker);
+ if (result.winner === "defender") return -totalRemaining(result.remaining.defender);
+ return 0;
+}
+
+function semanticBattleSummary(result: ReturnType<typeof simulateBattle>): Pick<
+ ReturnType<typeof simulateBattle>,
+ "winner" | "rounds" | "remaining" | "effectActivationCounts" | "extraSkillAttackJobsByEffect" | "attackControlCounts"
+> {
+ return {
+ winner: result.winner,
+ rounds: result.rounds,
+ remaining: result.remaining,
+ effectActivationCounts: result.effectActivationCounts,
+ extraSkillAttackJobsByEffect: result.extraSkillAttackJobsByEffect,
+ attackControlCounts: result.attackControlCounts
+ };
+}
+
+function sameEffectStackingInput(heroName: string): BattleInput {
+ return {
+ maxRounds: 2,
+ trace: true,
+ attacker: {
+ troops: { infantry_t1: 10000 },
+ heroes: { [heroName]: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 10000 },
+ heroes: {}
+ }
+ };
+}
+
+function sameEffectStackingConfig(heroName: string, same_effect_stacking: "add" | "max", type: "active.hero.lethality.up" | "extra_skill_attack"): SimulatorConfig {
+ const effect: Omit<EffectIntentDefinition, "id"> =
+ type === "extra_skill_attack"
+ ? {
+ type,
+ value: 100,
+ same_effect_stacking,
+ units: { applies_to: ["infantry"], applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
+ duration: { type: "turn", value: 2 }
+ }
+ : {
+ type,
+ value: 100,
+ same_effect_stacking,
+ units: { applies_to: ["infantry"], applies_vs: "any" },
+ duration: { type: "turn", value: 2 }
+ };
+ return minimalConfig({
+ [heroName]: {
+ name: heroName,
+ skills: {
+ Overlap: {
+ trigger: { type: "turn" },
+ effects: { stacking: effect }
+ }
+ }
+ }
+ });
+}
+
+function minimalConfig(heroDefinitions: Record<string, SkillFile> = {}): SimulatorConfig {
+ return {
+ troopStats: {
+ infantry_t1: { id: "infantry_t1", type: "infantry", tier: 1, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } },
+ lancer_t1: { id: "lancer_t1", type: "lancer", tier: 1, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } },
+ marksman_t1: { id: "marksman_t1", type: "marksman", tier: 1, stats: { attack: 100, defense: 100, lethality: 100, health: 100 } }
+ },
+ heroGenerationStats: {},
+ heroDefinitions,
+ troopSkills: { name: "troop skills", skills: {} },
+ diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }
+ };
+}
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -0,0 +1,838 @@
+import type {
+ ActiveEffect,
+ AttackIntent,
+ AttackOutcome,
+ BattleInput,
+ BattleRandomness,
+ BattleResult,
+ BattleTrace,
+ DamageJob,
+ EffectSource,
+ FighterInput,
+ ResolvedFighter,
+ ResolvedSkill,
+ SideId,
+ SimulationOptions,
+ SimulatorConfig,
+ SkillReportEntry,
+ TriggerDamageJobSelector,
+ UnitType
+} from "./types";
+import { ALL_UNIT_MASK, UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";
+import { calculateDamageJob, createFastDamageScratch, type DamageScratch } from "./damage";
+import {
+ activateEffect,
+ chancePasses,
+ createSeededRng,
+ currentEffectValuePct,
+ isEffectActive,
+ oppositeSide,
+ parseTriggerSelector,
+ sideForTriggerRelation,
+ skillMatchesTrigger,
+ type Rng
+} from "./effects";
+import { classifyEffectForJob } from "./classifier";
+import { createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";
+import { normalizeUnitType } from "./normalize";
+import { emptyTroops, resolveFighter } from "./resolve";
+import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";
+
+const DEFAULT_MAX_ROUNDS = 1500;
+const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();
+
+interface Runtime {
+ activeEffects: ActiveEffect[];
+ effectIndex: EffectIndex;
+ staticDamageProfile?: StaticDamageProfile;
+ damageScratch: DamageScratch;
+ rng: Rng;
+ skills: RuntimeSkills;
+ skillReports: Record<SideId, Map<string, SkillReportEntry>>;
+ effectActivationCounts: Record<SideId, number>;
+ extraSkillAttackJobsByEffect: Record<string, number>;
+ attackControlCounts: { dodge: number; no_attack: number };
+ consumedEffectUseKeys: Set<string>;
+ counters: {
+ attacks: Record<SideId, Record<UnitType, number>>;
+ received: Record<SideId, Record<UnitType, number>>;
+ };
+}
+
+interface RuntimeSkills {
+ all: ResolvedSkill[];
+ battleStart: ResolvedSkill[];
+ roundStart: ResolvedSkill[];
+ roundStartGlobal: ResolvedSkill[];
+ roundStartPerUnit: ResolvedSkill[];
+ attackDeclared: ResolvedSkill[];
+}
+
+interface ExtraAttackEffectGroup {
+ selected: ActiveEffect;
+ effects: ActiveEffect[];
+}
+
+interface BattleRun {
+ fighters: Record<SideId, ResolvedFighter>;
+ runtime: Runtime;
+ winner: SideId | "draw";
+ rounds: number;
+ attacks: AttackOutcome[];
+ trace?: BattleTrace;
+}
+
+export function simulateBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}): BattleResult {
+ const run = runBattle(input, config, options);
+ const { fighters, runtime } = run;
+ return {
+ winner: run.winner,
+ rounds: run.rounds,
+ remaining: { attacker: ceilTroops(fighters.attacker.troops), defender: ceilTroops(fighters.defender.troops) },
+ attacks: run.attacks,
+ skillReport: {
+ attacker: [...runtime.skillReports.attacker.values()],
+ defender: [...runtime.skillReports.defender.values()]
+ },
+ resolved: buildResolved(fighters.attacker, fighters.defender),
+ effectActivationCounts: runtime.effectActivationCounts,
+ extraSkillAttackJobsByEffect: runtime.extraSkillAttackJobsByEffect,
+ attackControlCounts: runtime.attackControlCounts,
+ randomness: classifyRandomness(runtime.skills.all),
+ trace: run.trace
+ };
+}
+
+export function simulateBattleScore(input: BattleInput, config: SimulatorConfig): number {
+ const run = runBattle(input, config, { detail: "fast" });
+ return signedRemainingScore(run.winner, run.fighters);
+}
+
+function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions): BattleRun {
+ const attacker = resolveFighter(input.attacker, "attacker", config, input.mechanics);
+ const defender = resolveFighter(input.defender, "defender", config, input.mechanics);
+ const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };
+ const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"));
+ const detail = options.detail ?? "full";
+ const traceEnabled = detail === "full" && input.trace;
+ const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;
+ const attacks: AttackOutcome[] = [];
+ const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;
+
+ triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);
+ addInputPassiveEffects(runtime, input.attacker.passive, "attacker");
+ addInputPassiveEffects(runtime, input.defender.passive, "defender");
+ runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);
+ removeStaticProfileBucketEffects(runtime.effectIndex);
+
+ let rounds = 0;
+ for (let round = 1; round <= maxRounds; round += 1) {
+ if (winnerFor(fighters)) break;
+ rounds = round;
+ const roundStartTroops = snapshotTroops(fighters);
+ expireInactive(runtime, round);
+ triggerRoundStartSkills(round, fighters, runtime, roundStartTroops);
+
+ const intents = resolveAttackIntents(round, fighters, runtime, roundStartTroops);
+ const jobs: DamageJob[] = [];
+ const cancelled: AttackOutcome[] = [];
+
+ for (const intent of intents) {
+ triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);
+ const job = normalJob(intent, roundStartTroops);
+ const controls = applicableControls(job, round, runtime);
+ if (controls.no_attack || controls.dodge) {
+ const control = controls.no_attack ?? controls.dodge!;
+ runtime.attackControlCounts[control.reason] += 1;
+ const outcome = cancelledOutcome(intent, control.effect.id, control.reason, attackDurationEffectIdsForJob(job, round, runtime.activeEffects));
+ cancelled.push(outcome);
+ consumeEffects(runtime, outcome.consumedEffectIds);
+ } else {
+ jobs.push(job);
+ jobs.push(...extraSkillJobs(job, round, runtime, roundStartTroops));
+ }
+ }
+
+ const roundOutcomes: AttackOutcome[] = [];
+ for (const job of jobs) {
+ const outcome =
+ detail === "fast"
+ ? calculateDamageJob(job, fighters, runtime.activeEffects, {
+ detail: "fast",
+ effectIndex: runtime.effectIndex,
+ staticDamageProfile: runtime.staticDamageProfile,
+ scratch: runtime.damageScratch
+ })
+ : calculateDamageJob(job, fighters, runtime.activeEffects, {
+ trace: traceEnabled,
+ effectIndex: runtime.effectIndex,
+ staticDamageProfile: runtime.staticDamageProfile
+ });
+ roundOutcomes.push(outcome);
+ consumeEffects(runtime, outcome.consumedEffectIds, outcome.consumedEffectUseKey, outcome.consumedEffectUseId, outcome.consumedEffectUseIds);
+ }
+ finalizeRoundOutcomes(roundOutcomes, roundStartTroops, runtime);
+ if (detail === "full") attacks.push(...cancelled, ...roundOutcomes);
+ commitOutcomes(cancelled, fighters, runtime);
+ commitOutcomes(roundOutcomes, fighters, runtime);
+ trace?.rounds.push({ round, roundStartTroops, intents, jobs });
+ }
+
+ const winner = winnerFor(fighters) ?? "draw";
+ return {
+ fighters,
+ runtime,
+ winner,
+ rounds,
+ attacks,
+ trace
+ };
+}
+
+function triggerRoundStartSkills(
+ round: number,
+ fighters: Record<SideId, ResolvedFighter>,
+ runtime: Runtime,
+ roundStartTroops: DamageJob["roundStartTroops"]
+): ActiveEffect[] {
+ const activated: ActiveEffect[] = [];
+ activated.push(...triggerSkills("round_start", round, runtime.skills.roundStartGlobal, runtime));
+ for (const skill of runtime.skills.roundStartPerUnit) {
+ const side = roundTriggerSourceSide(skill);
+ const defenderSide = roundTriggerTargetSide(skill, side);
+ if (!skillMatchesTrigger(skill, "round_start", round)) continue;
+ const report = runtime.skillReports[skill.side].get(reportKey(skill));
+ if (report) report.triggersSeen += 1;
+ if (!chancePasses(skill, runtime.rng)) continue;
+ if (report) report.skillActivations += 1;
+ let orderIndex = 0;
+ for (const attackerUnit of roundTriggerUnits(skill, roundStartTroops)) {
+ const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);
+ if (!defenderUnit) continue;
+ const intent = syntheticRoundIntent(round, side, attackerUnit, defenderSide, defenderUnit, 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;
+ if (report) report.effectActivations += 1;
+ }
+ orderIndex += 1;
+ }
+ }
+ return activated;
+}
+
+function hasPerUnitRoundTrigger(skill: ResolvedSkill): boolean {
+ return skill.trigger.type === "turn" && skill.trigger.source !== undefined;
+}
+
+function roundTriggerUnits(skill: ResolvedSkill, roundStartTroops: DamageJob["roundStartTroops"]): UnitType[] {
+ const selector = parseTriggerSelector(skill.trigger.source, "self");
+ const side = sideForTriggerRelation(skill.side, selector.relation);
+ const living = UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0);
+ return selector.units === undefined ? living : living.filter((unit) => selector.units?.includes(unit));
+}
+
+function roundTriggerSourceSide(skill: ResolvedSkill): SideId {
+ const selector = parseTriggerSelector(skill.trigger.source, "self");
+ return sideForTriggerRelation(skill.side, selector.relation);
+}
+
+function roundTriggerTargetSide(skill: ResolvedSkill, sourceSide: SideId): SideId {
+ const selector = parseTriggerSelector(skill.trigger.target, "enemy");
+ const targetSide = sideForTriggerRelation(skill.side, selector.relation);
+ return targetSide === sourceSide ? oppositeSide(sourceSide) : targetSide;
+}
+
+function syntheticRoundIntent(
+ round: number,
+ attackerSide: SideId,
+ attackerUnit: UnitType,
+ defenderSide: SideId,
+ defenderUnit: UnitType,
+ orderIndex: number,
+ runtime: Runtime
+): AttackIntent {
+ return {
+ id: `r${round}:${attackerSide}:${attackerUnit}:turn:${orderIndex}`,
+ round,
+ source: "normal",
+ attackerSide,
+ attackerUnit,
+ defenderSide,
+ defenderUnit,
+ orderIndex,
+ previousAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],
+ projectedAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],
+ previousReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit],
+ projectedReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit]
+ };
+}
+
+function classifyRandomness(skills: ResolvedSkill[]): BattleRandomness {
+ const chanceSkillIds: Record<SideId, string[]> = { attacker: [], defender: [] };
+ for (const skill of skills) {
+ if (hasChanceTrigger(skill)) chanceSkillIds[skill.side].push(skill.id);
+ }
+ return {
+ deterministic: chanceSkillIds.attacker.length === 0 && chanceSkillIds.defender.length === 0,
+ chanceSkillIds
+ };
+}
+
+function hasChanceTrigger(skill: ResolvedSkill): boolean {
+ const probability = skill.trigger.probability;
+ if (probability === undefined) return false;
+ const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);
+ return Number.isFinite(value) && value > 0 && value < 100;
+}
+
+function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {
+ const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };
+ const skills = buildRuntimeSkills(fighters);
+ for (const fighter of fighters) {
+ for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {
+ reports[fighter.side].set(reportKey(skill), {
+ sourceKind: skill.sourceKind,
+ heroName: skill.heroName,
+ troopType: skill.troopType,
+ skillId: skill.id,
+ skillName: skill.name,
+ level: skill.level,
+ triggersSeen: 0,
+ skillActivations: 0,
+ effectActivations: 0,
+ skillKills: 0,
+ unsupportedEffects: []
+ });
+ }
+ }
+ return {
+ activeEffects: [],
+ effectIndex: createEffectIndex(),
+ staticDamageProfile: undefined,
+ damageScratch: createFastDamageScratch(),
+ rng,
+ skills,
+ skillReports: reports,
+ effectActivationCounts: { attacker: 0, defender: 0 },
+ extraSkillAttackJobsByEffect: {},
+ attackControlCounts: { dodge: 0, no_attack: 0 },
+ consumedEffectUseKeys: new Set(),
+ counters: {
+ attacks: { attacker: emptyTroops(), defender: emptyTroops() },
+ received: { attacker: emptyTroops(), defender: emptyTroops() }
+ }
+ };
+}
+
+function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {
+ const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);
+ const battleStart = all.filter((skill) => skill.trigger.type === "battle_start");
+ const roundStart = all.filter((skill) => skill.trigger.type === "turn");
+ const attackDeclared = all.filter((skill) => skill.trigger.type === "attack");
+ return {
+ all,
+ battleStart,
+ roundStart,
+ roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),
+ roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),
+ attackDeclared
+ };
+}
+
+function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
+ runtime.activeEffects.push(effect);
+ indexEffect(runtime.effectIndex, effect);
+}
+
+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {
+ if (!passive) return;
+ for (const stat of ["attack", "defense", "lethality", "health"] as const) {
+ for (const direction of ["up", "down"] as const) {
+ const valuePct = Number(passive[stat]?.[direction] ?? 0);
+ if (!Number.isFinite(valuePct) || valuePct <= 0) continue;
+ const bucket = `passive.${stat}.${direction}`;
+ addActiveEffect(runtime, {
+ id: `${side}:input_stat:${bucket}`,
+ source: {
+ kind: "input_stat",
+ side,
+ effectId: `input:${bucket}`
+ },
+ intent: {
+ id: `input:${bucket}`,
+ type: bucket,
+ value: valuePct
+ },
+ ownerSide: side,
+ kind: "modifier",
+ valuePct,
+ appliesTo: { side, units: ALL_UNIT_MASK },
+ appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },
+ createdRound: 0,
+ startRound: 0,
+ duration: { type: "battle", value: 0 },
+ uses: 0,
+ sameEffectStacking: "add"
+ });
+ }
+ }
+}
+
+function triggerSkills(
+ triggerType: "battle_start" | "round_start" | "attack_declared",
+ round: number,
+ skills: ResolvedSkill[],
+ runtime: Runtime,
+ intent?: AttackIntent
+): ActiveEffect[] {
+ const activated: ActiveEffect[] = [];
+ for (const skill of skills) {
+ if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;
+ const report = runtime.skillReports[skill.side].get(reportKey(skill));
+ if (report) report.triggersSeen += 1;
+ if (!chancePasses(skill, runtime.rng)) continue;
+ if (report) report.skillActivations += 1;
+ for (const effectIntent of skill.effects) {
+ const effect = activateEffect(skill, effectIntent, round, intent);
+ addActiveEffect(runtime, effect);
+ activated.push(effect);
+ runtime.effectActivationCounts[skill.side] += 1;
+ if (report) report.effectActivations += 1;
+ }
+ }
+ return activated;
+}
+
+function resolveAttackIntents(
+ round: number,
+ fighters: Record<SideId, ResolvedFighter>,
+ runtime: Runtime,
+ roundStartTroops: DamageJob["roundStartTroops"]
+): AttackIntent[] {
+ const intents: AttackIntent[] = [];
+ for (const side of ["attacker", "defender"] as SideId[]) {
+ const defenderSide = oppositeSide(side);
+ let orderIndex = 0;
+ for (const attackerUnit of UNIT_TYPES) {
+ if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;
+ const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);
+ if (!defenderUnit) continue;
+ const previousAttackCount = runtime.counters.attacks[side][attackerUnit];
+ const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];
+ intents.push({
+ id: `r${round}:${side}:${attackerUnit}:${orderIndex}`,
+ round,
+ source: "normal",
+ attackerSide: side,
+ attackerUnit,
+ defenderSide,
+ defenderUnit,
+ orderIndex,
+ previousAttackCount,
+ projectedAttackCount: previousAttackCount + 1,
+ previousReceivedAttackCount,
+ projectedReceivedAttackCount: previousReceivedAttackCount + 1
+ });
+ orderIndex += 1;
+ }
+ }
+ return intents;
+}
+
+function chooseDefenderUnit(
+ attackerUnit: UnitType,
+ attackerSide: SideId,
+ defenderSide: SideId,
+ roundStartTroops: DamageJob["roundStartTroops"],
+ effectIndex: EffectIndex,
+ round: number
+): UnitType | undefined {
+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round) ?? UNIT_TYPES;
+ return order.find((unit) => (roundStartTroops[defenderSide][unit] ?? 0) > 0);
+}
+
+function orderFromEffects(attackerUnit: UnitType, attackerSide: SideId, index: EffectIndex, round: number): UnitType[] | undefined {
+ for (const effect of index.battleOrder) {
+ if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;
+ if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;
+ if (Array.isArray(effect.intent.value)) {
+ try {
+ return effect.intent.value.map((value) => normalizeUnitType(String(value)));
+ } catch {
+ return undefined;
+ }
+ }
+ }
+ return undefined;
+}
+
+function applicableControls(
+ job: DamageJob,
+ round: number,
+ runtime: Runtime
+): { dodge?: Control; no_attack?: Control } {
+ const controls: { dodge?: Control; no_attack?: Control } = {};
+ for (const effect of runtime.effectIndex.controls) {
+ if (!isEffectActive(effect, round)) continue;
+ const classification = classifyEffectForJob(effect, job);
+ if (classification?.kind === "control" && classification.control) {
+ controls[classification.control] = { effect, reason: classification.control };
+ }
+ }
+ return controls;
+}
+
+interface Control {
+ effect: ActiveEffect;
+ reason: "dodge" | "no_attack";
+}
+
+function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {
+ return {
+ id: `${intent.id}:normal`,
+ round: intent.round,
+ kind: "normal",
+ sourceIntentId: intent.id,
+ roundStartTroops,
+ attackerSide: intent.attackerSide,
+ attackerUnit: intent.attackerUnit,
+ defenderSide: intent.defenderSide,
+ defenderUnit: intent.defenderUnit,
+ sourceMultiplier: 1
+ };
+}
+
+function extraSkillJobs(
+ normalAttack: DamageJob,
+ round: number,
+ runtime: Runtime,
+ roundStartTroops: DamageJob["roundStartTroops"]
+): DamageJob[] {
+ const jobs: DamageJob[] = [];
+ const effectGroups = selectStackedExtraAttackEffectGroups(
+ runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
+ round
+ );
+ for (const effectGroup of effectGroups) {
+ const effect = effectGroup.selected;
+ const definitions = effect.triggerDamageJobs ?? [];
+ const consumedEffectIds = effectGroup.effects.map((groupEffect) => groupEffect.id);
+ const consumedEffectUseKey = `${normalAttack.sourceIntentId}:${effect.stackingKey ?? effect.id}`;
+ const firstJobIndex = jobs.length;
+ let definitionIndex = 0;
+ for (const definition of definitions) {
+ const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);
+ const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);
+ const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);
+ if (multiplier <= 0) continue;
+ const sourceEffectId = effect.source.effectId ?? effect.intent.id;
+ const sourceSkillReportKey = reportKeyForEffectSource(effect.source);
+ for (const source of sources) {
+ if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;
+ for (const target of targets) {
+ if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;
+ jobs.push({
+ id: `${normalAttack.sourceIntentId}:skill:${sourceEffectId}:${definitionIndex}:${jobs.length}`,
+ round,
+ kind: "skill",
+ sourceIntentId: normalAttack.sourceIntentId,
+ roundStartTroops,
+ attackerSide: source.side,
+ attackerUnit: source.unit,
+ defenderSide: target.side,
+ defenderUnit: target.unit,
+ sourceEffectId,
+ sourceSkillReportKey,
+ sourceMultiplier: multiplier,
+ consumedEffectIds,
+ consumedEffectUseKey,
+ consumedEffectUseId: effect.id,
+ consumedEffectUseIds: consumedEffectIds
+ });
+ runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;
+ }
+ }
+ definitionIndex += 1;
+ }
+ if (jobs.length > firstJobIndex) {
+ for (const consumedEffectId of consumedEffectIds) reserveConsumedEffectUse(runtime, consumedEffectUseKey, consumedEffectId);
+ }
+ }
+ return jobs;
+}
+
+function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {
+ const selected: ExtraAttackEffectGroup[] = [];
+ const maxByKey = new Map<string, number>();
+ for (const effect of effects) {
+ if (effect.sameEffectStacking !== "max" || !effect.stackingKey) {
+ selected.push({ selected: effect, effects: [effect] });
+ continue;
+ }
+ const existingIndex = maxByKey.get(effect.stackingKey);
+ if (existingIndex === undefined) {
+ maxByKey.set(effect.stackingKey, selected.length);
+ selected.push({ selected: effect, effects: [effect] });
+ continue;
+ }
+ const existing = selected[existingIndex];
+ existing.effects.push(effect);
+ if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;
+ }
+ return selected;
+}
+
+function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {
+ return (
+ effect.appliesTo.side === normalAttack.attackerSide &&
+ unitMaskHas(effect.appliesTo.units, normalAttack.attackerUnit) &&
+ effect.appliesVs.side === normalAttack.defenderSide &&
+ unitMaskHas(effect.appliesVs.units, normalAttack.defenderUnit)
+ );
+}
+
+interface TriggerJobUnit {
+ side: SideId;
+ unit: UnitType;
+}
+
+function resolveTriggerJobSelector(
+ selector: TriggerDamageJobSelector,
+ role: "source" | "target",
+ effect: ActiveEffect,
+ normalAttack: DamageJob,
+ roundStartTroops: DamageJob["roundStartTroops"]
+): TriggerJobUnit[] {
+ if (selector === "use.source") return [{ side: normalAttack.attackerSide, unit: normalAttack.attackerUnit }];
+ if (selector === "use.target") return [{ side: normalAttack.defenderSide, unit: normalAttack.defenderUnit }];
+ 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.defenderSide, roundStartTroops);
+ if (selector === "self.living") return livingUnits(normalAttack.attackerSide, roundStartTroops);
+ const units = unitListFromSelector(selector);
+ if (!units) {
+ throw new Error(`trigger_damage_jobs ${role} selector is required and must be a supported selector, got ${JSON.stringify(selector)}`);
+ }
+ const fallbackSide = role === "source" ? normalAttack.attackerSide : normalAttack.defenderSide;
+ return units.map((unit) => ({ side: fallbackSide, unit }));
+}
+
+function livingUnits(side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): TriggerJobUnit[] {
+ return UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0).map((unit) => ({ side, unit }));
+}
+
+function unitListFromSelector(selector: TriggerDamageJobSelector): UnitType[] | undefined {
+ if (Array.isArray(selector)) {
+ try {
+ return selector.map((entry) => normalizeUnitType(String(entry)));
+ } catch {
+ return undefined;
+ }
+ }
+ if (typeof selector === "string") {
+ try {
+ return [normalizeUnitType(selector)];
+ } catch {
+ return undefined;
+ }
+ }
+ return undefined;
+}
+
+function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {
+ const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;
+ const pct = Number(raw ?? 0);
+ return Number.isFinite(pct) ? pct / 100 : 0;
+}
+
+function cancelledOutcome(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[] = [effectId]): AttackOutcome {
+ return {
+ jobId: `${intent.id}:cancelled`,
+ kind: "normal",
+ attackerSide: intent.attackerSide,
+ attackerUnit: intent.attackerUnit,
+ defenderSide: intent.defenderSide,
+ defenderUnit: intent.defenderUnit,
+ kills: 0,
+ counterDeltas: [
+ { side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },
+ { side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }
+ ],
+ appliedEffectIds: [],
+ appliedEffects: [],
+ consumedEffectIds,
+ cancelledBy: effectId,
+ cancelReason: reason
+ };
+}
+
+function finalizeRoundOutcomes(outcomes: AttackOutcome[], roundStartTroops: DamageJob["roundStartTroops"], runtime: Runtime): void {
+ capRoundOutcomeKills(outcomes, roundStartTroops);
+ attributeSkillKills(outcomes, runtime);
+}
+
+function capRoundOutcomeKills(outcomes: AttackOutcome[], roundStartTroops: DamageJob["roundStartTroops"]): void {
+ for (const side of ["attacker", "defender"] as SideId[]) {
+ for (const unit of UNIT_TYPES) {
+ const matching = outcomes.filter((outcome) => outcome.defenderSide === side && outcome.defenderUnit === unit && outcome.kills > 0);
+ if (matching.length === 0) continue;
+ const available = Math.max(0, roundStartTroops[side][unit] ?? 0);
+ const totalKills = matching.reduce((sum, outcome) => sum + outcome.kills, 0);
+ if (totalKills <= available) continue;
+ let remaining = available;
+ for (const outcome of matching) {
+ outcome.kills = Math.min(outcome.kills, remaining);
+ remaining -= outcome.kills;
+ if (outcome.trace) outcome.trace.finalKills = outcome.kills;
+ }
+ }
+ }
+}
+
+function attributeSkillKills(outcomes: AttackOutcome[], runtime: Runtime): void {
+ for (const outcome of outcomes) {
+ if (outcome.kind !== "skill" || !outcome.sourceSkillReportKey || outcome.kills <= 0) continue;
+ const report = runtime.skillReports[outcome.attackerSide].get(outcome.sourceSkillReportKey);
+ if (report) report.skillKills += outcome.kills;
+ }
+}
+
+function commitOutcomes(outcomes: AttackOutcome[], fighters: Record<SideId, ResolvedFighter>, runtime: Runtime): void {
+ const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };
+ for (const outcome of outcomes) {
+ losses[outcome.defenderSide][outcome.defenderUnit] += outcome.kills;
+ if (outcome.counterDeltas.length === 0) {
+ runtime.counters.attacks[outcome.attackerSide][outcome.attackerUnit] += 1;
+ runtime.counters.received[outcome.defenderSide][outcome.defenderUnit] += 1;
+ } else {
+ for (const delta of outcome.counterDeltas) {
+ if (delta.counter === "attacks") runtime.counters.attacks[delta.side][delta.unit] += delta.by;
+ else runtime.counters.received[delta.side][delta.unit] += delta.by;
+ }
+ }
+ }
+ for (const side of ["attacker", "defender"] as SideId[]) {
+ for (const unit of UNIT_TYPES) {
+ fighters[side].troops[unit] = Math.max(0, fighters[side].troops[unit] - losses[side][unit]);
+ }
+ }
+}
+
+function expireInactive(runtime: Runtime, round: number): void {
+ let write = 0;
+ for (let read = 0; read < runtime.activeEffects.length; read += 1) {
+ const effect = runtime.activeEffects[read];
+ if (isEffectActive(effect, round)) {
+ runtime.activeEffects[write] = effect;
+ write += 1;
+ }
+ }
+ runtime.activeEffects.length = write;
+ pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));
+}
+
+function attackDurationEffectIdsForJob(job: DamageJob, round: number, effects: ActiveEffect[]): string[] {
+ return effects
+ .filter((effect) => {
+ if (effect.kind === "extra_attack" || effect.duration.type !== "attack" || !isEffectActive(effect, round)) return false;
+ const classification = classifyEffectForJob(effect, job);
+ return classification?.kind === "bucket" || classification?.kind === "control";
+ })
+ .map((effect) => effect.id);
+}
+
+function consumeEffects(
+ runtime: Runtime,
+ consumedEffectIds: string[],
+ consumedEffectUseKey?: string,
+ consumedEffectUseId?: string,
+ consumedEffectUseIds?: string[]
+): void {
+ const dedupeIds = consumedEffectUseIds ?? (consumedEffectUseId ? [consumedEffectUseId] : undefined);
+ for (const consumed of consumedEffectIds) {
+ if (consumedEffectUseKey && dedupeIds?.includes(consumed)) {
+ const useKey = `${consumedEffectUseKey}:${consumed}`;
+ if (runtime.consumedEffectUseKeys.has(useKey)) continue;
+ runtime.consumedEffectUseKeys.add(useKey);
+ }
+ for (const effect of runtime.activeEffects) {
+ if (effect.id === consumed) effect.uses += 1;
+ }
+ }
+}
+
+function reserveConsumedEffectUse(runtime: Runtime, consumedEffectUseKey: string, consumedEffectId: string): void {
+ const useKey = `${consumedEffectUseKey}:${consumedEffectId}`;
+ if (runtime.consumedEffectUseKeys.has(useKey)) return;
+ runtime.consumedEffectUseKeys.add(useKey);
+ for (const effect of runtime.activeEffects) {
+ if (effect.id === consumedEffectId) effect.uses += 1;
+ }
+}
+
+function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {
+ const attackerAlive = total(fighters.attacker.troops) > 0;
+ const defenderAlive = total(fighters.defender.troops) > 0;
+ if (attackerAlive && !defenderAlive) return "attacker";
+ if (defenderAlive && !attackerAlive) return "defender";
+ return undefined;
+}
+
+function signedRemainingScore(winner: SideId | "draw", fighters: Record<SideId, ResolvedFighter>): number {
+ if (winner === "attacker") return total(ceilTroops(fighters.attacker.troops));
+ if (winner === "defender") return -total(ceilTroops(fighters.defender.troops));
+ return 0;
+}
+
+function total(troops: Record<UnitType, number>): number {
+ return UNIT_TYPES.reduce((sum, unit) => sum + troops[unit], 0);
+}
+
+function snapshotTroops(fighters: Record<SideId, ResolvedFighter>): DamageJob["roundStartTroops"] {
+ return {
+ attacker: { ...fighters.attacker.troops },
+ defender: { ...fighters.defender.troops }
+ };
+}
+
+function ceilTroops(troops: Record<UnitType, number>): Record<UnitType, number> {
+ return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, Math.ceil(troops[unit] ?? 0)])) as Record<UnitType, number>;
+}
+
+function buildResolved(attacker: ResolvedFighter, defender: ResolvedFighter): BattleResult["resolved"] {
+ return {
+ attacker: {
+ troops: { ...attacker.initialTroops },
+ heroes: attacker.heroes,
+ troopSkillIds: attacker.troopSkills.map((skill) => skill.id),
+ diagnostics: attacker.diagnostics
+ },
+ defender: {
+ troops: { ...defender.initialTroops },
+ heroes: defender.heroes,
+ troopSkillIds: defender.troopSkills.map((skill) => skill.id),
+ diagnostics: defender.diagnostics
+ }
+ };
+}
+
+function reportKey(skill: ResolvedSkill): string {
+ const cached = REPORT_KEY_CACHE.get(skill);
+ if (cached) return cached;
+ const key = reportKeyFromParts(skill.sourceKind, skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "", skill.id);
+ REPORT_KEY_CACHE.set(skill, key);
+ return key;
+}
+
+function reportKeyForEffectSource(source: EffectSource): string | undefined {
+ if ((source.kind !== "hero_skill" && source.kind !== "troop_skill") || !source.skillId) return undefined;
+ return reportKeyFromParts(source.kind, source.heroInstanceId ?? source.heroName ?? source.troopType ?? "", source.skillId);
+}
+
+function reportKeyFromParts(sourceKind: SkillReportEntry["sourceKind"], sourceKey: string, skillId: string): string {
+ return `${sourceKind}:${sourceKey}:${skillId}`;
+}
Index: simulator/src/staticDamageProfile.ts
===================================================================
--- simulator/src/staticDamageProfile.ts prev run
+++ simulator/src/staticDamageProfile.ts this run
@@ -0,0 +1,264 @@
+import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";
+import { UNIT_TYPES, unitMaskHas } from "./types";
+import type { BucketRole } from "./damageBuckets";
+import { currentEffectValuePct } from "./effects";
+
+export interface StaticDamageProfileTerm {
+ raw?: number;
+ totalPct?: number;
+ contributors: DamageBucketTrace["contributors"];
+}
+
+export interface StaticDamageProfileEntry {
+ factor: number;
+ buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;
+}
+
+export interface StaticDamageProfile {
+ offense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;
+ defense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;
+}
+
+interface PassiveCandidate {
+ effect: ActiveEffect;
+ bucket: StaticPassiveBucket;
+ valuePct: number;
+}
+
+interface PassiveCandidateGroup {
+ selected: PassiveCandidate;
+ candidates: PassiveCandidate[];
+ entry: StaticDamageProfileEntry;
+}
+
+export const STATIC_RAW_BUCKETS = ["troops.baseAttack", "troops.baseLethality", "troops.baseHealth", "troops.baseDefense"] as const;
+export const STATIC_PLAYER_BUCKETS = ["player.attack", "player.lethality", "player.health", "player.defense"] as const;
+export const STATIC_PASSIVE_BUCKETS = [
+ "passive.attack.up",
+ "passive.attack.down",
+ "passive.lethality.up",
+ "passive.lethality.down",
+ "passive.health.up",
+ "passive.health.down",
+ "passive.defense.up",
+ "passive.defense.down"
+] as const;
+
+export type StaticRawBucket = (typeof STATIC_RAW_BUCKETS)[number];
+export type StaticPlayerBucket = (typeof STATIC_PLAYER_BUCKETS)[number];
+export type StaticPassiveBucket = (typeof STATIC_PASSIVE_BUCKETS)[number];
+export type StaticDamageBucket = StaticRawBucket | StaticPlayerBucket | StaticPassiveBucket;
+
+const STATIC_PASSIVE_BUCKET_ROLES: Record<StaticPassiveBucket, BucketRole> = {
+ "passive.attack.up": "attacker",
+ "passive.attack.down": "attacker",
+ "passive.lethality.up": "attacker",
+ "passive.lethality.down": "attacker",
+ "passive.health.up": "defender",
+ "passive.health.down": "defender",
+ "passive.defense.up": "defender",
+ "passive.defense.down": "defender"
+};
+
+export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {
+ const profile: StaticDamageProfile = {
+ offense: {
+ attacker: buildSideEntries(fighters.attacker, "offense"),
+ defender: buildSideEntries(fighters.defender, "offense")
+ },
+ defense: {
+ attacker: buildSideEntries(fighters.attacker, "defense"),
+ defender: buildSideEntries(fighters.defender, "defense")
+ }
+ };
+
+ applyStaticPassives(profile, activeEffects);
+ recomputeFactors(profile);
+ return profile;
+}
+
+export function assertStaticPassiveEffectDefinition(
+ trigger: SkillFile["skills"][string]["trigger"],
+ effect: EffectIntentDefinition,
+ file: string,
+ skillId: string,
+ effectId: string
+): void {
+ if (!isPassiveBucket(effect.type)) return;
+
+ const path = `${file}:${skillId}.${effectId}`;
+ if (trigger.type !== "battle_start") {
+ throw new Error(`passive effect ${effect.type} must use battle_start trigger at ${path}`);
+ }
+ if (effect.value_evolution !== undefined) {
+ throw new Error(`passive effect ${effect.type} cannot define value_evolution at ${path}; passive effects must be static`);
+ }
+
+ const duration = effect.duration;
+ if (duration === undefined) return;
+ const durationType = duration.type ?? "battle";
+ if (durationType !== "battle") {
+ throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);
+ }
+ if (duration.delay !== undefined && duration.delay !== 0) {
+ throw new Error(`passive effect ${effect.type} must use battle duration with no delay at ${path}`);
+ }
+}
+
+export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {
+ return (STATIC_BUCKET_SET as Set<string>).has(bucket);
+}
+
+export function isPassiveBucket(bucket: string): bucket is StaticPassiveBucket {
+ return (STATIC_PASSIVE_BUCKET_SET as Set<string>).has(bucket);
+}
+
+export function staticPassiveBucketRole(bucket: string): BucketRole | undefined {
+ return isPassiveBucket(bucket) ? STATIC_PASSIVE_BUCKET_ROLES[bucket] : undefined;
+}
+
+function buildSideEntries(fighter: ResolvedFighter, role: "offense" | "defense"): Record<UnitType, StaticDamageProfileEntry> {
+ return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildEntry(fighter, unit, role)])) as Record<UnitType, StaticDamageProfileEntry>;
+}
+
+function buildEntry(fighter: ResolvedFighter, unit: UnitType, role: "offense" | "defense"): StaticDamageProfileEntry {
+ const stats = fighter.troopDetails[unit]?.stats ?? fallbackStats();
+ const bonuses = fighter.statBonuses[unit] ?? emptyStats();
+ const buckets: StaticDamageProfileEntry["buckets"] = {};
+ if (role === "offense") {
+ setRaw(buckets, "troops.baseAttack", stats.attack);
+ setRaw(buckets, "troops.baseLethality", stats.lethality);
+ setPct(buckets, "player.attack", bonuses.attack, [{ effectId: "input:attack", source: "input_stats", valuePct: bonuses.attack, bucket: "player.attack" }]);
+ setPct(buckets, "player.lethality", bonuses.lethality, [
+ { effectId: "input:lethality", source: "input_stats", valuePct: bonuses.lethality, bucket: "player.lethality" }
+ ]);
+ } else {
+ setRaw(buckets, "troops.baseHealth", stats.health);
+ setRaw(buckets, "troops.baseDefense", stats.defense);
+ setPct(buckets, "player.health", bonuses.health, [{ effectId: "input:health", source: "input_stats", valuePct: bonuses.health, bucket: "player.health" }]);
+ setPct(buckets, "player.defense", bonuses.defense, [
+ { effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }
+ ]);
+ }
+ return { factor: 1, buckets };
+}
+
+function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {
+ const groups = new Map<string, PassiveCandidateGroup>();
+ for (const effect of activeEffects) {
+ const bucket = effect.intent.type;
+ if (!isPassiveBucket(bucket)) continue;
+ const role = staticPassiveBucketRole(bucket)!;
+ const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];
+ for (const unit of UNIT_TYPES) {
+ if (!unitMaskHas(effect.appliesTo.units, unit)) continue;
+ const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };
+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {
+ const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;
+ const group = groups.get(key);
+ if (group) {
+ group.candidates.push(candidate);
+ if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;
+ } else {
+ groups.set(key, { selected: candidate, candidates: [candidate], entry: targetEntries[unit] });
+ }
+ } else {
+ addPassiveCandidate(targetEntries[unit], candidate);
+ }
+ }
+ }
+ for (const group of groups.values()) {
+ addPassiveCandidate(group.entry, group.selected);
+ }
+}
+
+function addPassiveCandidate(entry: StaticDamageProfileEntry, candidate: PassiveCandidate): void {
+ addPct(entry.buckets, candidate.bucket, candidate.valuePct, {
+ effectId: candidate.effect.source.effectId ?? candidate.effect.id,
+ source: sourceLabel(candidate.effect),
+ sourceSide: candidate.effect.ownerSide,
+ valuePct: candidate.valuePct,
+ bucket: candidate.bucket,
+ stackingKey: candidate.effect.stackingKey,
+ sameEffectStacking: candidate.effect.sameEffectStacking
+ });
+}
+
+function recomputeFactors(profile: StaticDamageProfile): void {
+ for (const side of ["attacker", "defender"] as SideId[]) {
+ for (const unit of UNIT_TYPES) {
+ profile.offense[side][unit].factor = offenseFactor(profile.offense[side][unit]);
+ profile.defense[side][unit].factor = defenseFactor(profile.defense[side][unit]);
+ }
+ }
+}
+
+function offenseFactor(entry: StaticDamageProfileEntry): number {
+ return (
+ raw(entry, "troops.baseAttack") *
+ raw(entry, "troops.baseLethality") *
+ pct(entry, "player.attack") *
+ pct(entry, "player.lethality") *
+ pct(entry, "passive.attack.up") *
+ pct(entry, "passive.lethality.up") /
+ (pct(entry, "passive.attack.down") * pct(entry, "passive.lethality.down"))
+ );
+}
+
+function defenseFactor(entry: StaticDamageProfileEntry): number {
+ return (
+ (pct(entry, "passive.health.down") * pct(entry, "passive.defense.down")) /
+ (raw(entry, "troops.baseHealth") *
+ raw(entry, "troops.baseDefense") *
+ pct(entry, "player.health") *
+ pct(entry, "player.defense") *
+ pct(entry, "passive.health.up") *
+ pct(entry, "passive.defense.up"))
+ );
+}
+
+function raw(entry: StaticDamageProfileEntry, bucket: StaticRawBucket): number {
+ return Math.max(0, entry.buckets[bucket]?.raw ?? 0);
+}
+
+function pct(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket | StaticPassiveBucket): number {
+ return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100;
+}
+
+function setRaw(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {
+ buckets[bucket] = { raw, contributors: [] };
+}
+
+function setPct(
+ buckets: StaticDamageProfileEntry["buckets"],
+ bucket: StaticPlayerBucket,
+ valuePct: number,
+ contributors: DamageBucketTrace["contributors"] = []
+): void {
+ buckets[bucket] = { totalPct: valuePct, contributors };
+}
+
+function addPct(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticPassiveBucket, valuePct: number, contributor: DamageBucketTrace["contributors"][number]): void {
+ const existing = buckets[bucket];
+ if (existing) {
+ existing.totalPct = (existing.totalPct ?? 0) + valuePct;
+ existing.contributors.push(contributor);
+ } else {
+ buckets[bucket] = { totalPct: valuePct, contributors: [contributor] };
+ }
+}
+
+function fallbackStats(): StatBlock {
+ return { attack: 1, defense: 1, lethality: 1, health: 1 };
+}
+
+function emptyStats(): StatBlock {
+ return { attack: 0, defense: 0, lethality: 0, health: 0 };
+}
+
+function sourceLabel(effect: ActiveEffect): string {
+ return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
+}
+
+const STATIC_BUCKET_SET = new Set<string>([...STATIC_RAW_BUCKETS, ...STATIC_PLAYER_BUCKETS, ...STATIC_PASSIVE_BUCKETS]);
+const STATIC_PASSIVE_BUCKET_SET = new Set<string>(STATIC_PASSIVE_BUCKETS);
Index: simulator/src/testcases.test.ts
===================================================================
--- simulator/src/testcases.test.ts prev run
+++ simulator/src/testcases.test.ts this run
@@ -0,0 +1,408 @@
+import assert from "node:assert/strict";
+import { mkdirSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+import { test } from "node:test";
+
+import { loadSimulatorConfig } from "./config";
+import { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";
+import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
+import { adaptTestcaseEntry, applyComparisonQValues, assignDetailArtifactPaths, battleScoreDelta, buildSummaryForOutput, discoverTestcaseFiles, runTestcases, type TestcaseSummaryEntry } from "./testcases";
+
+test("discoverTestcaseFiles follows simulator/testcases symlink and skips disabled or stale files by default", () => {
+ const files = discoverTestcaseFiles();
+
+ assert.ok(files.some((file) => file.endsWith("emulator_verified/simple_001_nc.json")));
+ assert.ok(!files.some((file) => file.endsWith(".disabled")));
+ assert.ok(!files.some((file) => file.endsWith(".stale_troops")));
+});
+
+test("discoverTestcaseFiles includes disabled and stale testcase files when requested", () => {
+ const files = discoverTestcaseFiles({ includeDisabled: true });
+
+ assert.ok(files.some((file) => file.endsWith("emulator_verified/jasser_solo.json.disabled")));
+ assert.ok(files.some((file) => file.endsWith("emulator_verified/reina_logan_combo_v2.json.stale_troops")));
+});
+
+test("runTestcases returns compact summary entries and full detail entries separately", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 5 }, config);
+ const key = Object.keys(report.testcases)[0];
+ const summary = report.testcases[key];
+ const detail = report.details[0];
+
+ assert.equal(report.reportKind, "simulator-parity-summary");
+ assert.equal(report.counts.filesFound, 1);
+ assert.equal(report.counts.testcasesFound, 1);
+ assert.equal(report.counts.executed, 1);
+ assert.equal(report.counts.errors, 0);
+ assert.equal(summary?.testcase_id, "simple_001");
+ assert.equal(summary?.deterministic, false);
+ assert.equal(summary?.sampleCount, 5);
+ assert.equal(typeof summary?.game?.mu_candidate, "number");
+ assert.equal(typeof summary?.baseline?.mu_candidate, "number");
+ assert.deepEqual(Object.keys(summary?.game ?? {}), Object.keys(summary?.baseline ?? {}));
+ assert.equal("result" in (summary as object), false);
+ assert.ok(detail?.result);
+ assert.equal(detail?.simulatorStats?.n, 5);
+ assert.ok(detail?.visibility.attacker.troops.lancer);
+});
+
+test("runTestcases defaults stochastic cases to 100 samples", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001" }, config);
+ const summary = Object.values(report.testcases)[0];
+ const detail = report.details[0];
+
+ assert.equal(report.options.repeat, 100);
+ assert.equal(summary?.deterministic, false);
+ assert.equal(summary?.sampleCount, 100);
+ assert.equal(detail?.simulatorStats?.n, 100);
+});
+
+test("assignDetailArtifactPaths assigns deterministic compact detail paths", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
+
+ assignDetailArtifactPaths(report, "simulator_parity_test");
+
+ assert.equal(report.artifactRoot, "simulator_parity_test");
+ const keys = Object.keys(report.testcases);
+ assert.deepEqual(keys, [...keys].sort());
+ assert.equal(report.testcases[keys[0]!]?.detailArtifact, "simulator_parity_test/cases/000001.json");
+});
+
+test("buildSummaryForOutput excludes full detail artifacts from compact output", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
+ assignDetailArtifactPaths(report, "simulator_parity_test");
+
+ const summary = buildSummaryForOutput(report);
+ const json = JSON.stringify(summary);
+
+ assert.equal("details" in summary, false);
+ assert.equal(json.includes("\"result\""), false);
+ assert.equal(json.includes("\"attacks\""), false);
+ assert.equal(Object.values(summary.testcases)[0]?.detailArtifact, "simulator_parity_test/cases/000001.json");
+});
+
+test("assignDetailArtifactPaths exposes failed testcase diagnostics through errors", () => {
+ const testcaseRoot = tempDir("simulator-invalid-testcases");
+ writeFileSync(
+ resolve(testcaseRoot, "invalid.json"),
+ JSON.stringify([{ test_id: "bad_case", attacker: { troops: { infantry_t1: 1 } } }]),
+ );
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ testcaseRoot, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
+
+ assignDetailArtifactPaths(report, "simulator_parity_failed");
+
+ assert.equal(report.counts.errors, 1);
+ assert.equal(report.details[0]?.detailArtifact, "simulator_parity_failed/cases/000001.json");
+ assert.equal(report.errors[0]?.detailArtifact, "simulator_parity_failed/cases/000001.json");
+ assert.equal(report.errors[0]?.stage, "adapt");
+ assert.equal(Object.keys(report.testcases).length, 0);
+});
+
+test("runTestcases logs structured damage aggregation errors and continues", () => {
+ const testcaseRoot = tempDir("simulator-aggregation-error-testcases");
+ writeFileSync(
+ resolve(testcaseRoot, "bad-aggregation.json"),
+ JSON.stringify([
+ {
+ test_id: "bad_aggregation",
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ stats: { infantry: { attack: 0, defense: 0, lethality: 0, health: 0 } }
+ },
+ defender: {
+ troops: { lancer_t1: 1000 },
+ stats: { lancer: { attack: 0, defense: 0, lethality: 0, health: -105 } }
+ }
+ },
+ {
+ test_id: "next_case_runs",
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ stats: { infantry: { attack: 0, defense: 0, lethality: 0, health: 0 } }
+ },
+ defender: {
+ troops: { lancer_t1: 1000 },
+ stats: { lancer: { attack: 0, defense: 0, lethality: 0, health: 0 } }
+ }
+ }
+ ])
+ );
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ testcaseRoot, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
+
+ assert.equal(report.counts.errors, 1);
+ assert.equal(report.counts.executed, 1);
+ assert.equal(report.errors[0]?.testcase_id, "bad_aggregation");
+ assert.equal(report.details[0]?.errorDetails?.type, "DamageAggregationError");
+ assert.equal(report.details[0]?.errorDetails?.groupId, "player.defender.health");
+ assert.equal(report.details[0]?.errorDetails?.factor, -0.050000000000000044);
+ assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");
+});
+
+test("runTestcases keeps executed testcase and warns when baseline snapshot row is missing", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1, calibrationReportPath: "/tmp/does-not-exist.json" }, config);
+ const summary = Object.values(report.testcases)[0];
+
+ assert.equal(report.counts.executed, 1);
+ assert.equal(summary?.game?.n_candidate, 1);
+ assert.equal(summary?.baseline, null);
+ assert.equal(report.warnings[0]?.stage, "baseline_comparison");
+});
+
+test("applyComparisonQValues keeps game and baseline correction families separate", () => {
+ const firstGame = comparisonMetric(0.01);
+ const secondGame = comparisonMetric(0.02);
+ const onlyBaseline = comparisonMetric(0.04);
+ const testcases: Record<string, TestcaseSummaryEntry> = {
+ "testcases/a.json#0": summaryEntry("a", 0, firstGame, onlyBaseline),
+ "testcases/b.json#0": summaryEntry("b", 0, secondGame, null)
+ };
+
+ applyComparisonQValues({ testcases });
+
+ assert.equal(firstGame.q, 0.02);
+ assert.equal(secondGame.q, 0.02);
+ assert.equal(onlyBaseline.q, 0.04);
+});
+
+test("calibration lookup supports simulator symlink and source testcase path variants", () => {
+ assert.deepEqual(testcaseFileLookupVariants("simulator/testcases/emulator_verified/simple_001_nc.json"), [
+ "simulator/testcases/emulator_verified/simple_001_nc.json",
+ "testcases/emulator_verified/simple_001_nc.json"
+ ]);
+
+ const comparison = loadCalibrationComparison();
+ const sourceRow = readCalibrationCase(comparison, "testcases/emulator_verified/simple_001_nc.json", "simple_001");
+ if (sourceRow) {
+ assert.deepEqual(readCalibrationCase(comparison, "simulator/testcases/emulator_verified/simple_001_nc.json", "simple_001"), sourceRow);
+ }
+});
+
+test("duplicate no-hero testcase ids align calibration rows by file and case index", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "greg_mia_nohero_control_current", repeat: 1 }, config);
+
+ assert.equal(report.counts.testcasesFound, 2);
+ assert.equal(report.counts.executed, 2);
+
+ const first = report.details.find((entry) => entry.index === 0);
+ const second = report.details.find((entry) => entry.index === 1);
+ assert.equal(first?.testcaseId, "greg_mia_nohero_control_current");
+ assert.equal(second?.testcaseId, "greg_mia_nohero_control_current");
+ assert.equal(first?.visibility.attacker.heroes.length, 0);
+ assert.equal(second?.visibility.attacker.heroes.length, 0);
+ assert.equal(first?.calibration?.idx, 0);
+ assert.equal(second?.calibration?.idx, 1);
+ assert.equal(first?.calibration?.muGame, 3752);
+ assert.equal(second?.calibration?.muGame, 3652);
+ assert.equal(battleScoreDelta(first?.gameResult), first?.calibration?.muGame);
+ assert.equal(battleScoreDelta(second?.gameResult), second?.calibration?.muGame);
+});
+
+test("no-hero simple testcase loads, runs, compares to calibration, and exposes aligned core fields", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
+ const entry = report.details[0];
+ const summary = Object.values(report.testcases)[0];
+
+ assert.equal(report.counts.testcasesFound, 1);
+ assert.equal(summary?.testcase_id, "simple_001");
+ assert.equal(summary?.game?.n_reference, 1);
+ assert.equal(summary?.baseline?.n_reference, 100);
+ assert.equal(entry?.visibility.attacker.heroes.length, 0);
+ assert.equal(entry?.visibility.defender.heroes.length, 0);
+ assert.equal(entry?.calibration?.muGame, -186);
+ assert.equal(battleScoreDelta(entry?.gameResult), -186);
+ assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);
+});
+
+test("runTestcases applies possible stat rounding correction to exact deterministic misses", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "norah_s2_inf_only_B" }, config);
+ const summary = Object.values(report.testcases)[0];
+
+ assert.equal(summary?.testcase_id, "norah_s2_inf_only_B");
+ assert.equal(summary?.deterministic, true);
+ assert.equal(summary?.game?.passes, true);
+ assert.equal(summary?.game?.bias_raw, 0);
+ assert.equal(summary?.gameStatAdjustment?.mode, "deterministic_exact");
+ assert.equal(summary?.gameStatAdjustment?.value, 0.05);
+ assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);
+});
+
+test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "1-testcases_no-heroes_t6_single-type_nc.json", repeat: 1 }, config);
+ const entry = report.details.find((item) => item.testcaseId === "daut_viper_9");
+
+ assert.equal(entry?.result?.winner, "attacker");
+ assert.ok((entry?.result?.rounds ?? 0) > 100);
+});
+
+test("runTestcases reports a parity summary from calibration JSON", () => {
+ const config = loadSimulatorConfig();
+ const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);
+ const row = Object.values(report.testcases)[0];
+ const detail = report.details[0];
+
+ assert.ok(report.calibrationReportPath?.endsWith("baseline_result_2026-05-21T04-46-47Z.json"));
+ assert.equal(row?.testcase_id, "simple_001");
+ assert.equal(row?.idx, 0);
+ assert.equal(row?.game?.mu_reference, -186);
+ assert.equal(row?.baseline?.mu_reference, -186);
+ assert.equal(row?.baseline?.bias_raw, 0);
+ assert.equal(row?.baseline?.sem, 0);
+ assert.equal(row?.baseline?.p, null);
+ assert.equal(row?.baseline?.q, null);
+ assert.equal(typeof detail?.simulatorScoreDelta, "number");
+ assert.equal(typeof row?.game?.bias_raw, "number");
+ assert.equal(row?.game?.n_candidate, 1);
+ assert.equal(typeof row?.game?.mu_candidate, "number");
+ assert.equal(typeof row?.baseline?.bias_raw, "number");
+ assert.equal(typeof row?.game?.bias_raw, "number");
+ assert.equal(typeof row?.baseline?.passes, "boolean");
+ assert.equal(typeof row?.game?.passes, "boolean");
+ assert.equal(report.counts.comparedToGame, 1);
+ assert.equal(report.counts.comparedToBaseline, 1);
+});
+
+test("adaptTestcaseEntry passes testcase mechanics and engagement aliases into BattleInput", () => {
+ const input = adaptTestcaseEntry({
+ test_id: "mechanics_case",
+ engagement_type: "rally",
+ mechanics: { weather: "clear" },
+ attacker: { troops: { infantry_t1: 1 } },
+ defender: { troops: { infantry_t1: 1 } }
+ });
+
+ assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });
+});
+
+test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {
+ const metrics = compareOutcomeDistribution({
+ candidate: { n: 1, mu: -186, sigma: 0 },
+ reference: { n: 1, mu: -186, sigma: 0 },
+ initialTroops: 1200,
+ deterministic: true,
+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ });
+
+ assert.deepEqual(metrics, {
+ n_candidate: 1,
+ mu_candidate: -186,
+ sigma_candidate: 0,
+ n_reference: 1,
+ mu_reference: -186,
+ sigma_reference: 0,
+ bias_raw: 0,
+ bias_pct: 0,
+ sem: 0,
+ stat_type: "deterministic",
+ stat: null,
+ p: null,
+ q: null,
+ passes: true
+ } satisfies ParityComparisonMetrics);
+});
+
+test("compareOutcomeDistribution uses candidate spread for single-observation stochastic references", () => {
+ const metrics = compareOutcomeDistribution({
+ candidate: { n: 5, mu: 10, sigma: 2 },
+ reference: { n: 1, mu: 8, sigma: 0 },
+ initialTroops: 100,
+ deterministic: false,
+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ });
+
+ assert.equal(metrics.stat_type, "single_obs");
+ assert.equal(metrics.passes, true);
+ assert.equal(metrics.bias_raw, 2);
+ assert.equal(metrics.bias_pct, 2);
+ assert.equal(metrics.sem, 2);
+ assert.equal(metrics.stat, 1);
+});
+
+test("compareOutcomeDistribution fails single-observation references outside candidate spread", () => {
+ const metrics = compareOutcomeDistribution({
+ candidate: { n: 100, mu: 862.15, sigma: 3.79 },
+ reference: { n: 1, mu: 879, sigma: 0 },
+ initialTroops: 1440,
+ deterministic: false,
+ thresholds: { max_diff_ratio_deterministic: 0.01, z_threshold: 2, min_bias_pct: 0.5 }
+ });
+
+ assert.equal(metrics.stat_type, "single_obs");
+ assert.equal(metrics.passes, false);
+ assert.equal(metrics.bias_raw, -16.85);
+ assert.equal(metrics.bias_pct, -1.17);
+ assert.equal(metrics.sem, 3.79);
+ assert.equal(metrics.stat, -4.4459);
+});
+
+test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {
+ const rows = [
+ { p: 0.01, q: null },
+ { p: 0.03, q: null },
+ { p: null, q: null }
+ ];
+
+ applyBenjaminiHochberg(rows);
+
+ assert.equal(rows[0].q, 0.02);
+ assert.equal(rows[1].q, 0.03);
+ assert.equal(rows[2].q, null);
+});
+
+test("calibration lookup exposes full baseline snapshot metrics", () => {
+ const comparison = loadCalibrationComparison();
+ const row = readCalibrationCase(comparison, "testcases/emulator_verified/simple_001_nc.json", "simple_001");
+
+ assert.equal(row?.biasRaw, 0);
+ assert.equal(row?.sem, 0);
+ assert.equal(row?.p, null);
+ assert.equal(row?.q, null);
+});
+
+function tempDir(prefix: string): string {
+ const dir = resolve(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
+ mkdirSync(dir, { recursive: true });
+ return dir;
+}
+
+function comparisonMetric(p: number): ParityComparisonMetrics {
+ return {
+ n_candidate: 2,
+ mu_candidate: 10,
+ sigma_candidate: 1,
+ n_reference: 2,
+ mu_reference: 8,
+ sigma_reference: 1,
+ bias_raw: 2,
+ bias_pct: 1,
+ sem: 1,
+ stat_type: "t",
+ stat: 2,
+ p,
+ q: null,
+ passes: true
+ };
+}
+
+function summaryEntry(testcaseId: string, idx: number, game: ParityComparisonMetrics | null, baseline: ParityComparisonMetrics | null): TestcaseSummaryEntry {
+ return {
+ file: `testcases/${testcaseId}.json`,
+ testcase_id: testcaseId,
+ idx,
+ deterministic: false,
+ sampleCount: 2,
+ game,
+ baseline
+ };
+}
Index: simulator/src/testcases.ts
===================================================================
--- simulator/src/testcases.ts prev run
+++ simulator/src/testcases.ts this run
@@ -0,0 +1,803 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ loadCalibrationComparison,
+ readCalibrationCase,
+ type CalibrationCaseComparison,
+ type SampleStats,
+ sampleStats
+} from "./calibration";
+import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";
+import { simulateBattle } from "./simulator";
+import { DamageAggregationError } from "./damage";
+import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "./types";
+
+const DEFAULT_STOCHASTIC_REPEAT = 100;
+const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;
+const STAT_ROUNDING_SCAN_STEPS = 50;
+
+export interface TestcaseRunOptions {
+ testcaseRoot?: string;
+ calibrationReportPath?: string;
+ matching?: string;
+ includeDisabled?: boolean;
+ repeat?: number;
+ seed?: string | number;
+ trace?: boolean;
+ workers?: number;
+}
+
+export interface TestcaseCaseReport {
+ file: string;
+ testcaseId: string;
+ index: number;
+ detailArtifact?: string;
+ diagnostics: string[];
+ gameResult?: unknown;
+ calibration?: CalibrationCaseComparison;
+ result?: BattleResult;
+ simulatorScoreDelta?: number;
+ simulatorStats?: SampleStats;
+ simulatorSampleOutcomes?: TestcaseSampleOutcome[];
+ simulatorSampleDeltas?: number[];
+ gameStatAdjustment?: TestcaseStatAdjustment;
+ deterministic?: boolean;
+ sampleCount?: number;
+ visibility: {
+ attacker: CaseVisibility;
+ defender: CaseVisibility;
+ };
+ error?: string;
+ errorDetails?: TestcaseErrorDetails;
+}
+
+export interface TestcaseRunWarning {
+ file: string;
+ testcase_id: string;
+ idx: number;
+ stage: "parse" | "adapt" | "execute" | "game_comparison" | "baseline_comparison" | "artifact";
+ reason: string;
+ detailArtifact?: string;
+}
+
+export interface TestcaseSummaryEntry {
+ file: string;
+ testcase_id: string;
+ idx: number;
+ detailArtifact?: string;
+ deterministic: boolean;
+ sampleCount: number;
+ game: ParityComparisonMetrics | null;
+ baseline: ParityComparisonMetrics | null;
+ gameStatAdjustment?: TestcaseStatAdjustment;
+}
+
+export interface TestcaseRunReport {
+ reportKind: "simulator-parity-summary";
+ schemaVersion: 1;
+ createdAt: string;
+ options: TestcaseRunOptions;
+ calibrationReportPath?: string;
+ artifactRoot?: string;
+ counts: {
+ filesFound: number;
+ testcasesFound: number;
+ executed: number;
+ warnings: number;
+ errors: number;
+ comparedToGame: number;
+ comparedToBaseline: number;
+ };
+ warnings: TestcaseRunWarning[];
+ errors: TestcaseRunWarning[];
+ testcases: Record<string, TestcaseSummaryEntry>;
+ details: TestcaseCaseReport[];
+}
+
+export type TestcaseSummaryOutput = Omit<TestcaseRunReport, "details">;
+
+interface CaseVisibility {
+ heroes: string[];
+ troopSkillIds: string[];
+ troops: Partial<Record<UnitType, number>>;
+ skillEffectActivations: number;
+}
+
+interface TestcaseErrorDetails {
+ type: string;
+ [key: string]: unknown;
+}
+
+export interface PreparedTestcaseCase {
+ file: string;
+ reportFile: string;
+ entry: unknown;
+ testcaseId: string;
+ index: number;
+ detail: TestcaseCaseReport;
+ input?: BattleInput;
+ key?: string;
+ adaptError?: TestcaseRunWarning;
+}
+
+export interface TestcaseExecutionJob {
+ file: string;
+ reportFile: string;
+ testcaseId: string;
+ index: number;
+ input: BattleInput;
+ repeat: number;
+ seed?: string | number;
+}
+
+export interface TestcaseExecutionResult {
+ testcaseId: string;
+ index: number;
+ result?: BattleResult;
+ deterministic?: boolean;
+ sampleCount?: number;
+ simulatorStats?: SampleStats;
+ simulatorScoreDelta?: number;
+ simulatorSampleOutcomes?: TestcaseSampleOutcome[];
+ simulatorSampleDeltas?: number[];
+ diagnostics: string[];
+ error?: string;
+ errorDetails?: TestcaseErrorDetails;
+}
+
+export interface TestcaseSampleOutcome {
+ run: number;
+ attackerHeroes: string[];
+ defenderHeroes: string[];
+ attackerTroops: Partial<Record<UnitType, number>>;
+ defenderTroops: Partial<Record<UnitType, number>>;
+ attackerRemainingByType: Partial<Record<UnitType, number>>;
+ defenderRemainingByType: Partial<Record<UnitType, number>>;
+ attackerRemaining: number;
+ defenderRemaining: number;
+ scoreDelta: number;
+}
+
+export interface TestcaseStatAdjustment {
+ value: number;
+ mode: "deterministic_exact" | "deterministic_within_one" | "stochastic_tolerance" | "best_effort";
+ unadjusted: ParityComparisonMetrics;
+}
+
+export function defaultTestcaseRoot(): string {
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "testcases");
+}
+
+export function discoverTestcaseFiles(options: Pick<TestcaseRunOptions, "testcaseRoot" | "matching" | "includeDisabled"> = {}): string[] {
+ const root = resolve(options.testcaseRoot ?? defaultTestcaseRoot());
+ const files: string[] = [];
+ walk(root, files);
+ return files
+ .filter((file) => isDiscoverableTestcaseFile(file, options.includeDisabled))
+ .filter((file) => options.includeDisabled || (!file.endsWith(".disabled") && !file.endsWith(".stale_troops")))
+ .filter((file) => !options.matching || file.includes(options.matching))
+ .sort();
+}
+
+export function runTestcases(options: TestcaseRunOptions, config: SimulatorConfig): TestcaseRunReport {
+ const prepared = prepareTestcaseCases(options);
+ return runPreparedTestcases(options, config, prepared, (job) => executeTestcaseCase(job, config));
+}
+
+export function prepareTestcaseCases(options: TestcaseRunOptions): { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] } {
+ const files = discoverTestcaseFiles(options);
+ const cases: PreparedTestcaseCase[] = [];
+ const parseErrors: TestcaseRunWarning[] = [];
+ for (const file of files) {
+ const reportFile = normalizeReportPath(relative(process.cwd(), file));
+ let entries: unknown[];
+ try {
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
+ entries = Array.isArray(parsed) ? parsed : [parsed];
+ } catch (error) {
+ parseErrors.push({ file: reportFile, testcase_id: "(parse_error)", idx: 0, stage: "parse", reason: `Failed to parse JSON: ${errorMessage(error)}` });
+ continue;
+ }
+
+ entries.forEach((entry, index) => {
+ const testcaseId = testcaseIdFor(entry, index);
+ const diagnostics: string[] = [];
+ const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);
+ const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };
+ try {
+ preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace }, diagnostics);
+ preparedCase.key = snapshotKey(reportFile, index);
+ } catch (error) {
+ detail.error = errorMessage(error);
+ diagnostics.push(detail.error);
+ preparedCase.adaptError = { file: reportFile, testcase_id: testcaseId, idx: index, stage: "adapt", reason: detail.error };
+ }
+ cases.push(preparedCase);
+ });
+ }
+ return { filesFound: files.length, cases, parseErrors };
+}
+
+export function runPreparedTestcases(
+ options: TestcaseRunOptions,
+ config: SimulatorConfig,
+ prepared: { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] },
+ execute: (job: TestcaseExecutionJob, config: SimulatorConfig) => TestcaseExecutionResult
+): TestcaseRunReport {
+ const comparison = loadCalibrationComparison(options.calibrationReportPath);
+ const repeat = normalizeRepeat(options.repeat);
+ const report: TestcaseRunReport = {
+ reportKind: "simulator-parity-summary",
+ schemaVersion: 1,
+ createdAt: new Date().toISOString(),
+ options: { ...options, repeat },
+ calibrationReportPath: comparison.reportPath,
+ counts: { filesFound: prepared.filesFound, testcasesFound: prepared.cases.length, executed: 0, warnings: 0, errors: 0, comparedToGame: 0, comparedToBaseline: 0 },
+ warnings: [],
+ errors: [...prepared.parseErrors],
+ testcases: {},
+ details: []
+ };
+
+ for (const preparedCase of prepared.cases) {
+ const { file, reportFile, entry, testcaseId, index, detail } = preparedCase;
+ if (!preparedCase.input || !preparedCase.key) {
+ if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);
+ report.details.push(detail);
+ continue;
+ }
+ try {
+ const execution = execute({ file, reportFile, testcaseId, index, input: preparedCase.input, repeat, seed: options.seed }, config);
+ applyExecutionResult(report, comparison, preparedCase, execution, config);
+ } catch (error) {
+ detail.error = errorMessage(error);
+ detail.errorDetails = errorDetails(error);
+ detail.diagnostics.push(detail.error);
+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: detail.error });
+ }
+ report.details.push(detail);
+ }
+
+ finalizeReport(report);
+ return report;
+}
+
+export async function runPreparedTestcasesAsync(
+ options: TestcaseRunOptions,
+ config: SimulatorConfig,
+ prepared: { filesFound: number; cases: PreparedTestcaseCase[]; parseErrors: TestcaseRunWarning[] },
+ execute: (job: TestcaseExecutionJob) => Promise<TestcaseExecutionResult>
+): Promise<TestcaseRunReport> {
+ const comparison = loadCalibrationComparison(options.calibrationReportPath);
+ const repeat = normalizeRepeat(options.repeat);
+ const report: TestcaseRunReport = {
+ reportKind: "simulator-parity-summary",
+ schemaVersion: 1,
+ createdAt: new Date().toISOString(),
+ options: { ...options, repeat },
+ calibrationReportPath: comparison.reportPath,
+ counts: { filesFound: prepared.filesFound, testcasesFound: prepared.cases.length, executed: 0, warnings: 0, errors: 0, comparedToGame: 0, comparedToBaseline: 0 },
+ warnings: [],
+ errors: [...prepared.parseErrors],
+ testcases: {},
+ details: []
+ };
+
+ const jobs = prepared.cases.map(async (preparedCase) => {
+ if (!preparedCase.input || !preparedCase.key) return { preparedCase };
+ try {
+ const execution = await execute({
+ file: preparedCase.file,
+ reportFile: preparedCase.reportFile,
+ testcaseId: preparedCase.testcaseId,
+ index: preparedCase.index,
+ input: preparedCase.input,
+ repeat,
+ seed: options.seed
+ });
+ return { preparedCase, execution };
+ } catch (error) {
+ return { preparedCase, error };
+ }
+ });
+
+ for (const { preparedCase, execution, error } of await Promise.all(jobs)) {
+ const { reportFile, testcaseId, index, detail } = preparedCase;
+ if (!preparedCase.input || !preparedCase.key) {
+ if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);
+ report.details.push(detail);
+ continue;
+ }
+ if (error !== undefined) {
+ detail.error = errorMessage(error);
+ detail.errorDetails = errorDetails(error);
+ detail.diagnostics.push(detail.error);
+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: detail.error });
+ } else {
+ applyExecutionResult(report, comparison, preparedCase, execution!, config);
+ }
+ report.details.push(detail);
+ }
+
+ finalizeReport(report);
+ return report;
+}
+
+function applyExecutionResult(
+ report: TestcaseRunReport,
+ comparison: ReturnType<typeof loadCalibrationComparison>,
+ preparedCase: PreparedTestcaseCase,
+ execution: TestcaseExecutionResult,
+ config: SimulatorConfig
+): void {
+ const { reportFile, entry, testcaseId, index, detail } = preparedCase;
+ if (execution.error) {
+ detail.error = execution.error;
+ detail.errorDetails = execution.errorDetails;
+ detail.diagnostics.push(execution.error);
+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason: execution.error });
+ return;
+ }
+ const result = execution.result;
+ if (!result || !execution.simulatorStats || execution.deterministic === undefined || execution.sampleCount === undefined) {
+ const reason = "Worker returned incomplete testcase execution result";
+ detail.error = reason;
+ detail.errorDetails = { type: "IncompleteExecutionResult" };
+ detail.diagnostics.push(reason);
+ report.errors.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "execute", reason });
+ return;
+ }
+ const stats = execution.simulatorStats;
+ const gameResult = (entry as { game_report_result?: unknown }).game_report_result;
+ const calibration = readCalibrationCase(comparison, reportFile, testcaseId, { index });
+ const initialTroops = totalInputTroops(preparedCase.input!.attacker) + totalInputTroops(preparedCase.input!.defender);
+ const simulatorDistribution = { n: stats.n, mu: stats.mu, sigma: stats.sigma };
+ const gameDistribution = distributionFromGameResult(gameResult);
+ let game = gameDistribution
+ ? compareOutcomeDistribution({
+ candidate: simulatorDistribution,
+ reference: gameDistribution,
+ initialTroops,
+ deterministic: result.randomness.deterministic,
+ thresholds: comparison.thresholds
+ })
+ : null;
+ const gameStatAdjustment = game && preparedCase.input
+ ? findGameStatAdjustment({
+ game,
+ input: preparedCase.input,
+ config,
+ job: { file: preparedCase.file, reportFile, testcaseId, index, input: preparedCase.input, repeat: execution.sampleCount, seed: undefined },
+ reference: gameDistribution!,
+ initialTroops,
+ deterministic: result.randomness.deterministic,
+ thresholds: comparison.thresholds
+ })
+ : undefined;
+ if (gameStatAdjustment) game = gameStatAdjustment.adjusted;
+ const baseline = calibration?.nSim !== undefined && calibration.muSim !== undefined && calibration.sigmaSim !== undefined
+ ? compareOutcomeDistribution({
+ candidate: simulatorDistribution,
+ reference: { n: calibration.nSim, mu: calibration.muSim, sigma: calibration.sigmaSim },
+ initialTroops,
+ deterministic: result.randomness.deterministic,
+ thresholds: comparison.thresholds
+ })
+ : null;
+
+ detail.result = result;
+ detail.deterministic = execution.deterministic;
+ detail.sampleCount = execution.sampleCount;
+ detail.simulatorStats = stats;
+ detail.simulatorScoreDelta = execution.simulatorScoreDelta;
+ detail.simulatorSampleOutcomes = execution.simulatorSampleOutcomes;
+ detail.simulatorSampleDeltas = execution.simulatorSampleDeltas;
+ detail.gameStatAdjustment = gameStatAdjustment ? statAdjustmentForReport(gameStatAdjustment) : undefined;
+ detail.gameResult = gameResult;
+ detail.calibration = calibration;
+ detail.visibility = visibilityFromResult(result);
+ detail.diagnostics.push(...execution.diagnostics);
+ if (!game) {
+ report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "game_comparison", reason: "Missing game_report_result" });
+ }
+ if (!baseline) {
+ report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "baseline_comparison", reason: "No matching baseline snapshot row" });
+ }
+ report.counts.executed += 1;
+ report.testcases[preparedCase.key!] = {
+ file: reportFile,
+ testcase_id: testcaseId,
+ idx: index,
+ deterministic: execution.deterministic,
+ sampleCount: execution.sampleCount,
+ game,
+ baseline,
+ ...(gameStatAdjustment ? { gameStatAdjustment: statAdjustmentForReport(gameStatAdjustment) } : {})
+ };
+}
+
+interface InternalStatAdjustment {
+ value: number;
+ mode: TestcaseStatAdjustment["mode"];
+ unadjusted: ParityComparisonMetrics;
+ adjusted: ParityComparisonMetrics;
+}
+
+function findGameStatAdjustment(options: {
+ game: ParityComparisonMetrics;
+ input: BattleInput;
+ config: SimulatorConfig;
+ job: TestcaseExecutionJob;
+ reference: { n: number; mu: number; sigma: number };
+ initialTroops: number;
+ deterministic: boolean;
+ thresholds?: Record<string, number>;
+}): InternalStatAdjustment | undefined {
+ const shouldCorrect = options.deterministic ? options.game.bias_raw !== 0 : !options.game.passes;
+ if (!shouldCorrect || options.game.bias_raw === 0) return undefined;
+ const direction = -Math.sign(options.game.bias_raw);
+ const candidates = statAdjustmentCandidates(direction);
+ let best: InternalStatAdjustment | undefined;
+ for (const value of candidates) {
+ const adjustedInput = inputWithStatAdjustment(options.input, value);
+ const candidateStats = simulateAdjustedDistribution(adjustedInput, options.job, options.config);
+ const adjusted = compareOutcomeDistribution({
+ candidate: { n: candidateStats.n, mu: candidateStats.mu, sigma: candidateStats.sigma },
+ reference: options.reference,
+ initialTroops: options.initialTroops,
+ deterministic: options.deterministic,
+ thresholds: options.thresholds
+ });
+ const candidate = {
+ value,
+ mode: adjustmentMode(options.game, adjusted, options.deterministic),
+ unadjusted: options.game,
+ adjusted: adjustedForRoundingRules(adjusted, options.deterministic)
+ };
+ if (!best || correctionScore(candidate.adjusted, options.deterministic) < correctionScore(best.adjusted, options.deterministic)) best = candidate;
+ if (options.deterministic && candidate.mode === "deterministic_exact") return candidate;
+ }
+ if (best && !options.deterministic && best.adjusted.passes) return { ...best, mode: "stochastic_tolerance" };
+ return best;
+}
+
+function statAdjustmentCandidates(direction: number): number[] {
+ const values: number[] = [];
+ for (let step = STAT_ROUNDING_SCAN_STEPS; step >= 1; step -= 1) {
+ values.push(roundStatAdjustment(direction * (STAT_ROUNDING_MAX_ADJUSTMENT * step) / STAT_ROUNDING_SCAN_STEPS));
+ }
+ return values;
+}
+
+function simulateAdjustedDistribution(input: BattleInput, job: TestcaseExecutionJob, config: SimulatorConfig): SampleStats {
+ const samples: number[] = [];
+ for (let iteration = 0; iteration < job.repeat; iteration += 1) {
+ const result = simulateBattle(sampleInput(input, job.seed, job.file, job.testcaseId, job.index, iteration), config, { detail: "fast" });
+ const score = battleScoreDelta(result);
+ if (score !== undefined) samples.push(score);
+ }
+ return sampleStats(samples);
+}
+
+function inputWithStatAdjustment(input: BattleInput, value: number): BattleInput {
+ const adjusted = structuredClone(input);
+ adjustFighterStats(adjusted.attacker, value);
+ adjustFighterStats(adjusted.defender, -value);
+ return adjusted;
+}
+
+function adjustFighterStats(fighter: FighterInput, value: number): void {
+ for (const stats of Object.values(fighter.stats ?? {}) as Array<Partial<StatBlock>>) {
+ for (const key of ["attack", "defense", "lethality", "health"] as Array<keyof StatBlock>) {
+ if (stats[key] === undefined) continue;
+ stats[key] = roundStatAdjustment(Number(stats[key]) + value);
+ }
+ }
+}
+
+function adjustmentMode(original: ParityComparisonMetrics, adjusted: ParityComparisonMetrics, deterministic: boolean): TestcaseStatAdjustment["mode"] {
+ if (deterministic) {
+ if (adjusted.bias_raw === 0) return "deterministic_exact";
+ if (Math.abs(adjusted.bias_raw) <= 1) return "deterministic_within_one";
+ return "best_effort";
+ }
+ return adjusted.passes ? "stochastic_tolerance" : "best_effort";
+}
+
+function adjustedForRoundingRules(metric: ParityComparisonMetrics, deterministic: boolean): ParityComparisonMetrics {
+ if (!deterministic) return metric;
+ return { ...metric, passes: Math.abs(metric.bias_raw) <= 1 };
+}
+
+function correctionScore(metric: ParityComparisonMetrics, deterministic: boolean): number {
+ if (deterministic) return Math.abs(metric.bias_raw);
+ if (metric.stat !== null && Number.isFinite(metric.stat)) return Math.abs(metric.stat);
+ return Math.abs(metric.bias_raw);
+}
+
+function statAdjustmentForReport(adjustment: InternalStatAdjustment): TestcaseStatAdjustment {
+ return {
+ value: adjustment.value,
+ mode: adjustment.mode,
+ unadjusted: adjustment.unadjusted
+ };
+}
+
+function roundStatAdjustment(value: number): number {
+ return Number(value.toFixed(3));
+}
+
+function finalizeReport(report: TestcaseRunReport): void {
+ applyComparisonQValues(report);
+ report.counts.warnings = report.warnings.length;
+ report.counts.errors = report.errors.length;
+ report.counts.comparedToGame = Object.values(report.testcases).filter((entry) => entry.game).length;
+ report.counts.comparedToBaseline = Object.values(report.testcases).filter((entry) => entry.baseline).length;
+}
+
+function normalizeRepeat(repeat: number | undefined): number {
+ return Math.max(1, repeat ?? DEFAULT_STOCHASTIC_REPEAT);
+}
+
+export function executeTestcaseCase(job: TestcaseExecutionJob, config: SimulatorConfig): TestcaseExecutionResult {
+ try {
+ const samples: number[] = [];
+ const sampleOutcomes: TestcaseSampleOutcome[] = [];
+ const sampleDeltas: number[] = [];
+ let result = simulateBattle(sampleInput(job.input, job.seed, job.file, job.testcaseId, job.index, 0), config);
+ const firstScore = battleScoreDelta(result);
+ if (firstScore !== undefined) {
+ samples.push(firstScore);
+ sampleOutcomes.push(sampleOutcome(1, result, firstScore));
+ sampleDeltas.push(firstScore);
+ }
+ const sampleCount = result.randomness.deterministic ? 1 : job.repeat;
+ for (let iteration = 1; iteration < sampleCount; iteration += 1) {
+ result = simulateBattle(sampleInput(job.input, job.seed, job.file, job.testcaseId, job.index, iteration), config);
+ const score = battleScoreDelta(result);
+ if (score !== undefined) {
+ samples.push(score);
+ if (sampleDeltas.length < 10) {
+ sampleOutcomes.push(sampleOutcome(iteration + 1, result, score));
+ sampleDeltas.push(score);
+ }
+ }
+ }
+ return {
+ testcaseId: job.testcaseId,
+ index: job.index,
+ result,
+ deterministic: result.randomness.deterministic,
+ sampleCount,
+ simulatorStats: sampleStats(samples),
+ simulatorScoreDelta: battleScoreDelta(result),
+ simulatorSampleOutcomes: sampleOutcomes,
+ simulatorSampleDeltas: sampleDeltas,
+ diagnostics: [...result.resolved.attacker.diagnostics, ...result.resolved.defender.diagnostics]
+ };
+ } catch (error) {
+ return {
+ testcaseId: job.testcaseId,
+ index: job.index,
+ diagnostics: [errorMessage(error)],
+ error: errorMessage(error),
+ errorDetails: errorDetails(error)
+ };
+ }
+}
+
+export function applyComparisonQValues(report: Pick<TestcaseRunReport, "testcases">): void {
+ applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.game).filter((value): value is ParityComparisonMetrics => !!value));
+ applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.baseline).filter((value): value is ParityComparisonMetrics => !!value));
+}
+
+export function assignDetailArtifactPaths(report: TestcaseRunReport, artifactRoot: string): void {
+ report.artifactRoot = artifactRoot;
+ report.details.forEach((detail, index) => {
+ const detailArtifact = `${artifactRoot}/cases/${String(index + 1).padStart(6, "0")}.json`;
+ detail.detailArtifact = detailArtifact;
+ const testcase = report.testcases[snapshotKey(detail.file, detail.index)];
+ if (testcase) testcase.detailArtifact = detailArtifact;
+ for (const issue of [...report.warnings, ...report.errors]) {
+ if (issue.file === detail.file && issue.idx === detail.index && issue.testcase_id === detail.testcaseId) {
+ issue.detailArtifact = detailArtifact;
+ }
+ }
+ });
+}
+
+export function buildSummaryForOutput(report: TestcaseRunReport): TestcaseSummaryOutput {
+ const { details: _details, ...summary } = report;
+ return summary;
+}
+
+export function adaptTestcaseEntry(entry: unknown, options: { seed?: string | number; trace?: boolean } = {}, diagnostics: string[] = []): BattleInput {
+ const object = entry as {
+ attacker?: FighterInput;
+ defender?: FighterInput;
+ test_id?: string;
+ mechanics?: Record<string, unknown>;
+ engagement_type?: unknown;
+ engagementType?: unknown;
+ maxRounds?: unknown;
+ max_rounds?: unknown;
+ };
+ if (!object.attacker || !object.defender) throw new Error(`Testcase ${object.test_id ?? "(unknown)"} is missing attacker or defender`);
+ diagnostics.push(...diagnoseFighterShape("attacker", object.attacker), ...diagnoseFighterShape("defender", object.defender));
+ const mechanics = testcaseMechanics(object);
+ const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);
+ return {
+ attacker: object.attacker,
+ defender: object.defender,
+ seed: options.seed,
+ trace: options.trace,
+ ...(maxRounds !== undefined ? { maxRounds } : {}),
+ ...(mechanics ? { mechanics } : {})
+ };
+}
+
+export function battleScoreDelta(value: unknown): number | undefined {
+ if (isBattleResult(value)) return totalSide(value.remaining.attacker) - totalSide(value.remaining.defender);
+ const gameResult = Array.isArray(value) ? value[0] : value;
+ if (!gameResult || typeof gameResult !== "object") return undefined;
+ const attacker = Number((gameResult as { attacker?: unknown }).attacker);
+ const defender = Number((gameResult as { defender?: unknown }).defender);
+ if (!Number.isFinite(attacker) || !Number.isFinite(defender)) return undefined;
+ return attacker - defender;
+}
+
+function sampleOutcome(run: number, result: BattleResult, scoreDelta: number): TestcaseSampleOutcome {
+ return {
+ run,
+ attackerHeroes: result.resolved.attacker.heroes.map((hero) => hero.name),
+ defenderHeroes: result.resolved.defender.heroes.map((hero) => hero.name),
+ attackerTroops: result.resolved.attacker.troops,
+ defenderTroops: result.resolved.defender.troops,
+ attackerRemainingByType: result.remaining.attacker,
+ defenderRemainingByType: result.remaining.defender,
+ attackerRemaining: totalSide(result.remaining.attacker),
+ defenderRemaining: totalSide(result.remaining.defender),
+ scoreDelta
+ };
+}
+
+function isDiscoverableTestcaseFile(file: string, includeDisabled?: boolean): boolean {
+ if (file.endsWith(".json")) return true;
+ return !!includeDisabled && (file.endsWith(".json.disabled") || file.endsWith(".json.stale_troops"));
+}
+
+function walk(path: string, files: string[]): void {
+ const stat = statSync(path);
+ if (stat.isDirectory()) {
+ for (const child of readdirSync(path)) walk(resolve(path, child), files);
+ } else if (stat.isFile()) {
+ files.push(path);
+ }
+}
+
+function emptyCaseReport(file: string, testcaseId: string, index: number, diagnostics: string[]): TestcaseCaseReport {
+ return {
+ file,
+ testcaseId,
+ index,
+ diagnostics,
+ visibility: {
+ attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },
+ defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }
+ }
+ };
+}
+
+function testcaseIdFor(entry: unknown, index: number): string {
+ const id = (entry as { test_id?: unknown; id?: unknown }).test_id ?? (entry as { id?: unknown }).id;
+ return id === undefined ? `case_${index}` : String(id);
+}
+
+function diagnoseFighterShape(side: string, fighter: FighterInput): string[] {
+ const diagnostics: string[] = [];
+ if (!fighter.troops || Object.keys(fighter.troops).length === 0) diagnostics.push(`${side} has no troops`);
+ if (!fighter.stats) diagnostics.push(`${side} has no stats block`);
+ return diagnostics;
+}
+
+function testcaseMechanics(entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown }): Record<string, unknown> | undefined {
+ const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};
+ if (entry.engagement_type !== undefined) mechanics.engagement_type = entry.engagement_type;
+ if (entry.engagementType !== undefined) mechanics.engagementType = entry.engagementType;
+ return Object.keys(mechanics).length > 0 ? mechanics : undefined;
+}
+
+function visibilityFromResult(result: BattleResult | undefined): TestcaseCaseReport["visibility"] {
+ if (!result) {
+ return {
+ attacker: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 },
+ defender: { heroes: [], troopSkillIds: [], troops: {}, skillEffectActivations: 0 }
+ };
+ }
+ return {
+ attacker: {
+ heroes: result.resolved.attacker.heroes.map((hero) => hero.name),
+ troopSkillIds: result.resolved.attacker.troopSkillIds,
+ troops: result.resolved.attacker.troops,
+ skillEffectActivations: result.effectActivationCounts.attacker
+ },
+ defender: {
+ heroes: result.resolved.defender.heroes.map((hero) => hero.name),
+ troopSkillIds: result.resolved.defender.troopSkillIds,
+ troops: result.resolved.defender.troops,
+ skillEffectActivations: result.effectActivationCounts.defender
+ }
+ };
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function errorDetails(error: unknown): TestcaseErrorDetails | undefined {
+ if (error instanceof DamageAggregationError) {
+ return {
+ type: error.name,
+ groupId: error.groupId,
+ round: error.round,
+ jobId: error.jobId,
+ netPct: error.netPct,
+ factor: error.factor,
+ contributors: error.contributors
+ };
+ }
+ if (error instanceof Error) return { type: error.name };
+ return undefined;
+}
+
+function optionalNumber(value: unknown): number | undefined {
+ if (value === undefined) return undefined;
+ const number = Number(value);
+ return Number.isFinite(number) ? number : undefined;
+}
+
+function isBattleResult(value: unknown): value is BattleResult {
+ return !!value && typeof value === "object" && "remaining" in value;
+}
+
+function totalSide(troops: Record<UnitType, number>): number {
+ return (troops.infantry ?? 0) + (troops.lancer ?? 0) + (troops.marksman ?? 0);
+}
+
+function sampleSeed(baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): string {
+ return `${baseSeed ?? "simulator-default"}:${relative(process.cwd(), file)}:${testcaseId}:${index}:${iteration}`;
+}
+
+function sampleInput(input: BattleInput, baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): BattleInput {
+ return { ...input, seed: sampleSeed(baseSeed ?? input.seed, file, testcaseId, index, iteration) };
+}
+
+function snapshotKey(filePath: string, index: number): string {
+ return `${normalizeReportPath(filePath)}#${index}`;
+}
+
+function normalizeReportPath(filePath: string): string {
+ const normalized = filePath.replaceAll("\\", "/");
+ const testcaseIndex = normalized.indexOf("testcases/");
+ return testcaseIndex >= 0 ? normalized.slice(testcaseIndex) : normalized;
+}
+
+function distributionFromGameResult(value: unknown): { n: number; mu: number; sigma: number } | undefined {
+ const outcomes = extractOutcomeScores(value);
+ if (outcomes.length === 0) return undefined;
+ const stats = sampleStats(outcomes);
+ return { n: stats.n, mu: stats.mu, sigma: stats.sigma };
+}
+
+function extractOutcomeScores(value: unknown): number[] {
+ const rows = Array.isArray(value) ? value : value ? [value] : [];
+ return rows
+ .map((row) => battleScoreDelta(row))
+ .filter((score): score is number => score !== undefined);
+}
+
+function totalInputTroops(fighter: FighterInput): number {
+ return Object.values(fighter.troops ?? {}).reduce((sum, count) => sum + Number(count || 0), 0);
+}
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -0,0 +1,403 @@
+export type SideId = "attacker" | "defender";
+export type UnitType = "infantry" | "lancer" | "marksman";
+export type DamageKind = "normal" | "skill";
+export type UnitMask = number;
+export type ActiveEffectKind = "modifier" | "extra_attack" | "control" | "battle_order";
+export type SameEffectStacking = "add" | "max";
+
+export const UNIT_TYPES: UnitType[] = ["infantry", "lancer", "marksman"];
+export const UNIT_BITS: Record<UnitType, UnitMask> = {
+ infantry: 1 << 0,
+ lancer: 1 << 1,
+ marksman: 1 << 2
+};
+export const ALL_UNIT_MASK: UnitMask = UNIT_TYPES.reduce((mask, unit) => mask | UNIT_BITS[unit], 0);
+
+export interface ResolvedUnitScope {
+ side: SideId;
+ units: UnitMask;
+}
+
+export type SupportedTriggerDamageJobSelector =
+ | "use.source"
+ | "use.target"
+ | "effect.applies_to"
+ | "effect.applies_vs"
+ | "enemy.living"
+ | "self.living";
+export type TriggerDamageJobSelector = SupportedTriggerDamageJobSelector | UnitType | UnitType[];
+export type TriggerDamageJobMultiplier = number;
+
+export interface TriggerDamageJobDefinition {
+ source: TriggerDamageJobSelector;
+ target: TriggerDamageJobSelector;
+ multiplier?: TriggerDamageJobMultiplier;
+}
+
+export function unitMask(units: UnitType | UnitType[]): UnitMask {
+ const list = Array.isArray(units) ? units : [units];
+ return list.reduce((mask, unit) => mask | UNIT_BITS[unit], 0);
+}
+
+export function unitMaskHas(mask: UnitMask, unit: UnitType): boolean {
+ return (mask & UNIT_BITS[unit]) !== 0;
+}
+
+export function unitsFromMask(mask: UnitMask): UnitType[] {
+ return UNIT_TYPES.filter((unit) => unitMaskHas(mask, unit));
+}
+
+export interface StatBlock {
+ attack: number;
+ defense: number;
+ lethality: number;
+ health: number;
+}
+
+export type MainStat = keyof StatBlock;
+
+export interface PassiveEffectBucket {
+ up?: number;
+ down?: number;
+}
+
+export type PassiveEffects = Partial<Record<MainStat, PassiveEffectBucket>>;
+
+export interface TroopStatsRecord {
+ id: string;
+ type: UnitType | string;
+ tier: number;
+ fc?: number;
+ stats: Record<string, number>;
+}
+
+export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;
+export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;
+
+export interface EffectDuration {
+ type: "battle" | "round" | "attack";
+ value: number;
+ delay?: number;
+}
+
+export interface EffectIntentDefinition {
+ id: string;
+ type: string;
+ value?: unknown;
+ value_evolution?: { type?: string; step?: string; value?: number };
+ units?: Record<string, unknown>;
+ trigger_damage_jobs?: TriggerDamageJobDefinition[];
+ duration?: { type?: string; value?: number; delay?: number };
+ same_effect_stacking?: SameEffectStacking;
+ reason?: string;
+}
+
+export interface TriggerDefinition {
+ type: string;
+ probability?: unknown;
+ every?: number;
+ source?: unknown;
+ target?: unknown;
+}
+
+export interface SkillDefinition {
+ id: string;
+ name: string;
+ description?: string;
+ troop_type?: UnitType | string;
+ requirements?: SkillRequirement[];
+ trigger: TriggerDefinition;
+ effects: Record<string, Omit<EffectIntentDefinition, "id">>;
+}
+
+export interface SkillRequirement {
+ level: number;
+ type: "tier" | "fc" | "engagement_type" | string;
+ value: number | string;
+}
+
+export interface SkillFile {
+ name: string;
+ hero_generation?: string;
+ 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;
+ heroGenerationStats: HeroGenerationStatsCatalogue;
+ heroDefinitions: Record<string, SkillFile>;
+ heroAliasIndex?: Record<string, string>;
+ troopSkills: SkillFile;
+ diagnostics: ConfigDiagnostics;
+}
+
+export type HeroSkillLevels = Record<string, number>;
+
+export interface HeroInputEntry {
+ name: string;
+ levels?: HeroSkillLevels;
+}
+
+export type HeroInputCollection = Record<string, HeroSkillLevels> | HeroInputEntry[];
+
+export interface FighterInput {
+ name?: string;
+ troops: Record<string, number>;
+ stats?: Record<string, Partial<StatBlock>>;
+ passive?: PassiveEffects;
+ heroes?: HeroInputCollection;
+ joiner_heroes?: HeroInputCollection;
+}
+
+export interface BattleInput {
+ attacker: FighterInput;
+ defender: FighterInput;
+ seed?: string | number;
+ maxRounds?: number;
+ trace?: boolean;
+ mechanics?: Record<string, unknown>;
+}
+
+export type SimulationDetail = "full" | "fast";
+
+export interface SimulationOptions {
+ detail?: SimulationDetail;
+}
+
+export interface ResolvedTroopLine {
+ id: string;
+ type: UnitType;
+ tier: number;
+ fc: number;
+ count: number;
+ stats: StatBlock;
+}
+
+export interface ResolvedSkill {
+ id: string;
+ name: string;
+ sourceKind: "hero_skill" | "troop_skill";
+ side: SideId;
+ heroName?: string;
+ heroInstanceId?: string;
+ heroRole?: "main" | "joiner";
+ troopType?: UnitType;
+ level: number;
+ trigger: TriggerDefinition;
+ effects: EffectIntentDefinition[];
+}
+
+export interface ResolvedHero {
+ name: string;
+ heroGeneration?: string;
+ generationStats: StatBlock;
+ skillIds: string[];
+ instanceId?: string;
+ role?: "main" | "joiner";
+ missing?: boolean;
+}
+
+export interface ResolvedFighter {
+ side: SideId;
+ name: string;
+ troops: Record<UnitType, number>;
+ initialTroops: Record<UnitType, number>;
+ troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>;
+ statBonuses: Record<UnitType, StatBlock>;
+ heroes: ResolvedHero[];
+ troopSkills: ResolvedSkill[];
+ heroSkills?: ResolvedSkill[];
+ diagnostics: string[];
+}
+
+export interface EffectSource {
+ kind: "hero_skill" | "troop_skill" | "input_stat" | "unknown";
+ side: SideId;
+ heroName?: string;
+ heroInstanceId?: string;
+ troopType?: UnitType;
+ skillId?: string;
+ skillName?: string;
+ effectId?: string;
+}
+
+export interface ActiveEffect {
+ id: string;
+ source: EffectSource;
+ intent: EffectIntentDefinition;
+ ownerSide: SideId;
+ kind: ActiveEffectKind;
+ valuePct?: number;
+ // Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",
+ // trigger-relative selectors, or concrete unit selectors; it does not accept "all".
+ appliesTo: ResolvedUnitScope;
+ appliesVs: ResolvedUnitScope;
+ triggerDamageJobs?: TriggerDamageJobDefinition[];
+ createdRound: number;
+ startRound: number;
+ duration: EffectDuration;
+ uses: number;
+ stackingKey?: string;
+ sameEffectStacking: SameEffectStacking;
+}
+
+export interface AttackIntent {
+ id: string;
+ round: number;
+ source: "normal";
+ attackerSide: SideId;
+ attackerUnit: UnitType;
+ defenderSide: SideId;
+ defenderUnit: UnitType;
+ orderIndex: number;
+ previousAttackCount: number;
+ projectedAttackCount: number;
+ previousReceivedAttackCount: number;
+ projectedReceivedAttackCount: number;
+}
+
+export interface DamageJob {
+ id: string;
+ round: number;
+ kind: DamageKind;
+ sourceIntentId: string;
+ roundStartTroops: Record<SideId, Record<UnitType, number>>;
+ attackerSide: SideId;
+ attackerUnit: UnitType;
+ defenderSide: SideId;
+ defenderUnit: UnitType;
+ sourceEffectId?: string;
+ sourceSkillReportKey?: string;
+ sourceMultiplier?: number;
+ consumedEffectIds?: string[];
+ consumedEffectUseKey?: string;
+ consumedEffectUseId?: string;
+ consumedEffectUseIds?: string[];
+}
+
+export interface CounterDelta {
+ side: SideId;
+ unit: UnitType;
+ counter: "attacks" | "received_attacks";
+ by: number;
+ cause: "normal_attack" | "extra_skill_attack";
+}
+
+export interface DamageBucketTrace {
+ totalPct?: number;
+ factor: number;
+ raw?: number;
+ contributors: Array<{ effectId: string; source: string; sourceSide?: SideId; valuePct: number; bucket: string; stackingKey?: string; sameEffectStacking?: SameEffectStacking }>;
+}
+
+export interface DamageAggregationGroupTrace {
+ id: string;
+ mode: string;
+ placement: "numerator" | "denominator";
+ inputBuckets: string[];
+ totalPct?: number;
+ factor: number;
+ contributors: DamageBucketTrace["contributors"];
+}
+
+export interface DamageEquationTrace {
+ roundStartTroops: Record<SideId, Record<UnitType, number>>;
+ armyTerm: number;
+ atomicBuckets: Record<string, DamageBucketTrace>;
+ aggregationGroups: Record<string, DamageAggregationGroupTrace>;
+ appliedEffects: AppliedEffectTrace[];
+ rejectedEffects: Array<{ effectId: string; reason: string }>;
+ rawDamage: number;
+ finalKills: number;
+}
+
+export interface AppliedEffectTrace {
+ effectId: string;
+ bucket: string;
+ valuePct: number;
+ source: string;
+ sourceSide?: SideId;
+ stackingKey?: string;
+ sameEffectStacking?: SameEffectStacking;
+}
+
+export interface AttackOutcome {
+ jobId: string;
+ kind: DamageKind;
+ sourceEffectId?: string;
+ sourceSkillReportKey?: string;
+ attackerSide: SideId;
+ attackerUnit: UnitType;
+ defenderSide: SideId;
+ defenderUnit: UnitType;
+ kills: number;
+ counterDeltas: CounterDelta[];
+ appliedEffectIds: string[];
+ appliedEffects: AppliedEffectTrace[];
+ consumedEffectIds: string[];
+ consumedEffectUseKey?: string;
+ consumedEffectUseId?: string;
+ consumedEffectUseIds?: string[];
+ cancelledBy?: string;
+ cancelReason?: "dodge" | "no_attack";
+ trace?: DamageEquationTrace;
+}
+
+export interface SkillReportEntry {
+ sourceKind: "hero_skill" | "troop_skill";
+ heroName?: string;
+ troopType?: UnitType;
+ skillId: string;
+ skillName: string;
+ level: number;
+ triggersSeen: number;
+ skillActivations: number;
+ effectActivations: number;
+ skillKills: number;
+ unsupportedEffects: string[];
+}
+
+export interface BattleResult {
+ winner: SideId | "draw";
+ rounds: number;
+ remaining: Record<SideId, Record<UnitType, number>>;
+ attacks: AttackOutcome[];
+ skillReport: Record<SideId, SkillReportEntry[]>;
+ resolved: {
+ attacker: {
+ troops: Record<UnitType, number>;
+ heroes: ResolvedHero[];
+ troopSkillIds: string[];
+ diagnostics: string[];
+ };
+ defender: {
+ troops: Record<UnitType, number>;
+ heroes: ResolvedHero[];
+ troopSkillIds: string[];
+ diagnostics: string[];
+ };
+ };
+ effectActivationCounts: Record<SideId, number>;
+ extraSkillAttackJobsByEffect: Record<string, number>;
+ attackControlCounts: { dodge: number; no_attack: number };
+ randomness: BattleRandomness;
+ trace?: BattleTrace;
+}
+
+export interface BattleRandomness {
+ deterministic: boolean;
+ chanceSkillIds: Record<SideId, string[]>;
+}
+
+export interface BattleTrace {
+ resolved: BattleResult["resolved"];
+ rounds: Array<{ round: number; roundStartTroops: Record<SideId, Record<UnitType, number>>; intents: AttackIntent[]; jobs: DamageJob[] }>;
+}
Index: simulator/tsconfig.json
===================================================================
--- simulator/tsconfig.json prev run
+++ simulator/tsconfig.json this run
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
Index: testcases/emulator_verified/reina_logan_combo_simulator.json
===================================================================
--- testcases/emulator_verified/reina_logan_combo_simulator.json prev run
+++ testcases/emulator_verified/reina_logan_combo_simulator.json this run
@@ -0,0 +1,83 @@
+[
+ {
+ "test_id": "reina_logan_combo_simulator",
+ "description": "Reina+Logan combo simulator with conservative WIP troop counts. WIP attacking no heroes, minxxx defending with Reina(2/2)+Logan(3/3/3).",
+ "attacker": {
+ "name": "[BBQ]XxWIPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 600,
+ "lancer_t6": 600,
+ "marksman_t6": 600
+ },
+ "stats": {
+ "inf": {
+ "attack": 66.1,
+ "defense": 63.9,
+ "lethality": 23.3,
+ "health": 26.4
+ },
+ "lanc": {
+ "attack": 68.6,
+ "defense": 65.1,
+ "lethality": 26.3,
+ "health": 21.1
+ },
+ "mark": {
+ "attack": 69.1,
+ "defense": 61.4,
+ "lethality": 25.8,
+ "health": 21.5
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[ARK]Piddlyminxxx",
+ "heroes": {
+ "Logan": {
+ "skill_1": 3,
+ "skill_2": 3,
+ "skill_3": 3
+ },
+ "Reina": {
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "infantry_t6": 200,
+ "lancer_t6": 200,
+ "marksman_t6": 200
+ },
+ "stats": {
+ "inf": {
+ "attack": 306.9,
+ "defense": 288.5,
+ "lethality": 118.0,
+ "health": 131.1
+ },
+ "lanc": {
+ "attack": 265.0,
+ "defense": 257.6,
+ "lethality": 120.2,
+ "health": 121.0
+ },
+ "mark": {
+ "attack": 205.7,
+ "defense": 197.3,
+ "lethality": 122.9,
+ "health": 123.9
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 0,
+ "defender": 553
+ }
+ ]
+ }
+]
Index: testcases/emulator_verified/reina_logan_combo_v3.json
===================================================================
--- testcases/emulator_verified/reina_logan_combo_v3.json prev run
+++ testcases/emulator_verified/reina_logan_combo_v3.json this run
@@ -1,83 +0,0 @@
-[
- {
- "test_id": "reina_logan_combo_v3",
- "description": "Reina+Logan combo v3 with conservative WIP troop counts. WIP attacking no heroes, minxxx defending with Reina(2/2)+Logan(3/3/3).",
- "attacker": {
- "name": "[BBQ]XxWIPxX",
- "heroes": {},
- "troops": {
- "infantry_t6": 600,
- "lancer_t6": 600,
- "marksman_t6": 600
- },
- "stats": {
- "inf": {
- "attack": 66.1,
- "defense": 63.9,
- "lethality": 23.3,
- "health": 26.4
- },
- "lanc": {
- "attack": 68.6,
- "defense": 65.1,
- "lethality": 26.3,
- "health": 21.1
- },
- "mark": {
- "attack": 69.1,
- "defense": 61.4,
- "lethality": 25.8,
- "health": 21.5
- }
- },
- "joiner_heroes": {}
- },
- "defender": {
- "name": "[ARK]Piddlyminxxx",
- "heroes": {
- "Logan": {
- "skill_1": 3,
- "skill_2": 3,
- "skill_3": 3
- },
- "Reina": {
- "skill_1": 2,
- "skill_2": 2,
- "skill_3": 0
- }
- },
- "troops": {
- "infantry_t6": 200,
- "lancer_t6": 200,
- "marksman_t6": 200
- },
- "stats": {
- "inf": {
- "attack": 306.9,
- "defense": 288.5,
- "lethality": 118.0,
- "health": 131.1
- },
- "lanc": {
- "attack": 265.0,
- "defense": 257.6,
- "lethality": 120.2,
- "health": 121.0
- },
- "mark": {
- "attack": 205.7,
- "defense": 197.3,
- "lethality": 122.9,
- "health": 123.9
- }
- },
- "joiner_heroes": {}
- },
- "game_report_result": [
- {
- "attacker": 0,
- "defender": 553
- }
- ]
- }
-]
\ No newline at end of file
Index: testcases/emulator_verified/wos425_wip_nohero_control_nc.json
===================================================================
--- testcases/emulator_verified/wos425_wip_nohero_control_nc.json prev run
+++ testcases/emulator_verified/wos425_wip_nohero_control_nc.json this run
@@ -0,0 +1,68 @@
+[
+ {
+ "test_id": "wos425_wip_nohero_control_nc",
+ "description": "WOS-425 control. minxxx attacks WIP, no heroes, Heroic troops only. Deterministic baseline for damage-taken-down stacking checks.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 260.9,
+ "defense": 256.0,
+ "lethality": 198.9,
+ "health": 204.3
+ },
+ "lanc": {
+ "attack": 261.9,
+ "defense": 253.0,
+ "lethality": 183.5,
+ "health": 181.0
+ },
+ "mark": {
+ "attack": 269.4,
+ "defense": 264.5,
+ "lethality": 217.2,
+ "health": 208.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWPxX",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 200
+ },
+ "stats": {
+ "inf": {
+ "attack": 176.7,
+ "defense": 178.0,
+ "lethality": 137.2,
+ "health": 149.6
+ },
+ "lanc": {
+ "attack": 161.2,
+ "defense": 162.0,
+ "lethality": 136.2,
+ "health": 124.8
+ },
+ "mark": {
+ "attack": 173.7,
+ "defense": 169.5,
+ "lethality": 142.5,
+ "health": 139.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 774,
+ "defender": 0
+ }
+ ]
+ }
+]
\ No newline at end of file
Index: testcases/emulator_verified/wos425_wip_patrick_health_nc.json
===================================================================
--- testcases/emulator_verified/wos425_wip_patrick_health_nc.json prev run
+++ testcases/emulator_verified/wos425_wip_patrick_health_nc.json this run
@@ -0,0 +1,73 @@
+[
+ {
+ "test_id": "wos425_wip_patrick_health_nc",
+ "description": "WOS-425 health-up fixture. minxxx attacks WIP; WIP defends with Patrick. Heroic troops only; deterministic current WIP kit is S1 health-up plus S2 attack-up.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 260.9,
+ "defense": 256.0,
+ "lethality": 198.9,
+ "health": 204.3
+ },
+ "lanc": {
+ "attack": 261.9,
+ "defense": 253.0,
+ "lethality": 183.5,
+ "health": 181.0
+ },
+ "mark": {
+ "attack": 269.4,
+ "defense": 264.5,
+ "lethality": 217.2,
+ "health": 208.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWPxX",
+ "heroes": {
+ "Patrick": {
+ "skill_1": 4,
+ "skill_2": 4
+ }
+ },
+ "troops": {
+ "infantry_t6": 200
+ },
+ "stats": {
+ "inf": {
+ "attack": 176.7,
+ "defense": 178.0,
+ "lethality": 137.2,
+ "health": 149.6
+ },
+ "lanc": {
+ "attack": 236.7,
+ "defense": 237.5,
+ "lethality": 136.2,
+ "health": 124.8
+ },
+ "mark": {
+ "attack": 173.7,
+ "defense": 169.5,
+ "lethality": 142.5,
+ "health": 139.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 762,
+ "defender": 0
+ }
+ ]
+ }
+]
\ No newline at end of file
Index: testcases/emulator_verified/wos425_wip_philly_defense.json
===================================================================
--- testcases/emulator_verified/wos425_wip_philly_defense.json prev run
+++ testcases/emulator_verified/wos425_wip_philly_defense.json this run
@@ -0,0 +1,90 @@
+[
+ {
+ "test_id": "wos425_wip_philly_defense",
+ "description": "WOS-425 defense-up fixture with relaxed RNG constraint. minxxx attacks WIP; WIP defends with Philly. Heroic infantry only. Current WIP Philly has deterministic S1 attack-up plus defense-up and active chance S2 damage-up, so collect repeated observations.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 260.9,
+ "defense": 256.0,
+ "lethality": 198.9,
+ "health": 204.3
+ },
+ "lanc": {
+ "attack": 261.9,
+ "defense": 253.0,
+ "lethality": 183.5,
+ "health": 181.0
+ },
+ "mark": {
+ "attack": 269.4,
+ "defense": 264.5,
+ "lethality": 217.2,
+ "health": 208.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWPxX",
+ "heroes": {
+ "Philly": {
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "infantry_t6": 200
+ },
+ "stats": {
+ "inf": {
+ "attack": 176.7,
+ "defense": 178.0,
+ "lethality": 137.2,
+ "health": 149.6
+ },
+ "lanc": {
+ "attack": 230.4,
+ "defense": 231.2,
+ "lethality": 136.2,
+ "health": 124.8
+ },
+ "mark": {
+ "attack": 173.7,
+ "defense": 169.5,
+ "lethality": 142.5,
+ "health": 139.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 769,
+ "defender": 0
+ },
+ {
+ "attacker": 769,
+ "defender": 0
+ },
+ {
+ "attacker": 769,
+ "defender": 0
+ },
+ {
+ "attacker": 768,
+ "defender": 0
+ },
+ {
+ "attacker": 768,
+ "defender": 0
+ }
+ ]
+ }
+]
\ No newline at end of file
Index: testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.json
===================================================================
--- testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.json prev run
+++ testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.json this run
@@ -0,0 +1,73 @@
+[
+ {
+ "test_id": "wos425_wip_sergey_damage_taken_nc",
+ "description": "WOS-425 damage-taken-down fixture. minxxx attacks WIP; WIP defends with Sergey. Heroic infantry only. Sergey is infantry class and current WIP kit is deterministic.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 260.9,
+ "defense": 256.0,
+ "lethality": 198.9,
+ "health": 204.3
+ },
+ "lanc": {
+ "attack": 261.9,
+ "defense": 253.0,
+ "lethality": 183.5,
+ "health": 181.0
+ },
+ "mark": {
+ "attack": 269.4,
+ "defense": 264.5,
+ "lethality": 217.2,
+ "health": 208.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWPxX",
+ "heroes": {
+ "Sergey": {
+ "skill_1": 2,
+ "skill_2": 2
+ }
+ },
+ "troops": {
+ "infantry_t6": 200
+ },
+ "stats": {
+ "inf": {
+ "attack": 256.7,
+ "defense": 258.0,
+ "lethality": 137.2,
+ "health": 149.6
+ },
+ "lanc": {
+ "attack": 161.2,
+ "defense": 162.0,
+ "lethality": 136.2,
+ "health": 124.8
+ },
+ "mark": {
+ "attack": 173.7,
+ "defense": 169.5,
+ "lethality": 142.5,
+ "health": 139.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 749,
+ "defender": 0
+ }
+ ]
+ }
+]
\ No newline at end of file
Index: testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.json
===================================================================
--- testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.json prev run
+++ testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.json this run
@@ -0,0 +1,77 @@
+[
+ {
+ "test_id": "wos425_wip_sergey_patrick_combo_nc",
+ "description": "WOS-425 damage-taken-down plus health-up fixture. minxxx attacks WIP; WIP defends with Sergey+Patrick. Heroic infantry only. Sergey is infantry class and Patrick is lancer class; current WIP kits are deterministic.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 260.9,
+ "defense": 256.0,
+ "lethality": 198.9,
+ "health": 204.3
+ },
+ "lanc": {
+ "attack": 261.9,
+ "defense": 253.0,
+ "lethality": 183.5,
+ "health": 181.0
+ },
+ "mark": {
+ "attack": 269.4,
+ "defense": 264.5,
+ "lethality": 217.2,
+ "health": 208.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWPxX",
+ "heroes": {
+ "Sergey": {
+ "skill_1": 2,
+ "skill_2": 2
+ },
+ "Patrick": {
+ "skill_1": 4,
+ "skill_2": 4
+ }
+ },
+ "troops": {
+ "infantry_t6": 200
+ },
+ "stats": {
+ "inf": {
+ "attack": 256.7,
+ "defense": 258.0,
+ "lethality": 137.2,
+ "health": 149.6
+ },
+ "lanc": {
+ "attack": 236.7,
+ "defense": 237.5,
+ "lethality": 136.2,
+ "health": 124.8
+ },
+ "mark": {
+ "attack": 173.7,
+ "defense": 169.5,
+ "lethality": 142.5,
+ "health": 139.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 726,
+ "defender": 0
+ }
+ ]
+ }
+]
\ No newline at end of file
Index: testcases/emulator_verified/wos425_wip_sergey_philly_combo.json
===================================================================
--- testcases/emulator_verified/wos425_wip_sergey_philly_combo.json prev run
+++ testcases/emulator_verified/wos425_wip_sergey_philly_combo.json this run
@@ -0,0 +1,94 @@
+[
+ {
+ "test_id": "wos425_wip_sergey_philly_combo",
+ "description": "WOS-425 damage-taken-down plus defense-up fixture with relaxed RNG constraint. minxxx attacks WIP; WIP defends with Sergey+Philly. Heroic infantry only. Sergey is infantry class; Philly is lancer class and has active chance S2 damage-up, so collect repeated observations.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 260.9,
+ "defense": 256.0,
+ "lethality": 198.9,
+ "health": 204.3
+ },
+ "lanc": {
+ "attack": 261.9,
+ "defense": 253.0,
+ "lethality": 183.5,
+ "health": 181.0
+ },
+ "mark": {
+ "attack": 269.4,
+ "defense": 264.5,
+ "lethality": 217.2,
+ "health": 208.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWPxX",
+ "heroes": {
+ "Sergey": {
+ "skill_1": 2,
+ "skill_2": 2
+ },
+ "Philly": {
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "infantry_t6": 200
+ },
+ "stats": {
+ "inf": {
+ "attack": 256.7,
+ "defense": 258.0,
+ "lethality": 137.2,
+ "health": 149.6
+ },
+ "lanc": {
+ "attack": 230.4,
+ "defense": 231.2,
+ "lethality": 136.2,
+ "health": 124.8
+ },
+ "mark": {
+ "attack": 173.7,
+ "defense": 169.5,
+ "lethality": 142.5,
+ "health": 139.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 738,
+ "defender": 0
+ },
+ {
+ "attacker": 738,
+ "defender": 0
+ },
+ {
+ "attacker": 738,
+ "defender": 0
+ },
+ {
+ "attacker": 738,
+ "defender": 0
+ },
+ {
+ "attacker": 739,
+ "defender": 0
+ }
+ ]
+ }
+]
\ No newline at end of file
Index: testcases/emulator_verified/wos427_wip_sergey_philly_840.json
===================================================================
--- testcases/emulator_verified/wos427_wip_sergey_philly_840.json prev run
+++ testcases/emulator_verified/wos427_wip_sergey_philly_840.json this run
@@ -0,0 +1,90 @@
+[
+ {
+ "test_id": "wos427_wip_sergey_philly_840",
+ "description": "WOS-427 high-signal damage-taken-down plus defense-up fixture. minxxx attacks WIP; WIP defends with Sergey+Philly and 840 heroic infantry to separate additive defense stacking from multiplicative defense stacking in simulator.",
+ "attacker": {
+ "name": "[ARK]Piddlyminxx",
+ "heroes": {},
+ "troops": {
+ "infantry_t6": 800
+ },
+ "stats": {
+ "inf": {
+ "attack": 255.9,
+ "defense": 256.0,
+ "lethality": 198.9,
+ "health": 204.3
+ },
+ "lanc": {
+ "attack": 256.9,
+ "defense": 253.0,
+ "lethality": 183.5,
+ "health": 181.0
+ },
+ "mark": {
+ "attack": 264.4,
+ "defense": 264.5,
+ "lethality": 217.2,
+ "health": 208.0
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "defender": {
+ "name": "[BBQ]XxWPxX",
+ "heroes": {
+ "Sergey": {
+ "skill_1": 2,
+ "skill_2": 2
+ },
+ "Philly": {
+ "skill_1": 2,
+ "skill_2": 2,
+ "skill_3": 0
+ }
+ },
+ "troops": {
+ "infantry_t6": 840
+ },
+ "stats": {
+ "inf": {
+ "attack": 256.7,
+ "defense": 258.0,
+ "lethality": 137.2,
+ "health": 149.6
+ },
+ "lanc": {
+ "attack": 230.4,
+ "defense": 231.2,
+ "lethality": 136.2,
+ "health": 124.8
+ },
+ "mark": {
+ "attack": 173.7,
+ "defense": 169.5,
+ "lethality": 142.5,
+ "health": 139.4
+ }
+ },
+ "joiner_heroes": {}
+ },
+ "game_report_result": [
+ {
+ "attacker": 24,
+ "defender": 0
+ },
+ {
+ "attacker": 7,
+ "defender": 0
+ },
+ {
+ "attacker": 32,
+ "defender": 0
+ },
+ {
+ "attacker": 27,
+ "defender": 0
+ }
+ ]
+ }
+]
\ No newline at end of file
Show raw per-run patches
Run A dirty state patch
diff --git a/testcases/emulator_verified/gwen_solo_nc.json b/testcases/emulator_verified/gwen_solo_nc.json
--- a/testcases/emulator_verified/gwen_solo_nc.json
+++ b/testcases/emulator_verified/gwen_solo_nc.json
@@ -1,7 +1,7 @@
[
{
"test_id": "gwen_solo",
- "description": "Gwen solo defender test. WIP attacking with no heroes, minxxx defending with Gwen. Gwen has per-attack triggers (S2: every 5 attacks, S3: every 4 attacks for marksmen). S2 and S3 have effect_op=101.",
+ "description": "Gwen solo defender test. WIP attacking with no heroes, minxxx defending with Gwen.",
"attacker": {
"name": "[BBQ]XxWIPxX",
"heroes": {},