WOS Sim
← Back to Runs

Compare Runs

A (baseline)
89edb7ef-ccc40ddd33611/06/2026, 04:34:47
B (current)
97bcaa8d-96840ddd33611/06/2026, 05:27:58
Avg Error A0.20%
Avg Error B0.21%
Δ Avg Error+0.00%
Improved0
Regressed1
Added0
Retired0

Testcase Delta

178 / 178
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/alonso_solo.jsonalonso_solo02.55%2.97%+0.42%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21+0.05%-0.97%-0.77%-0.20%regressed
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo00.15%0.32%+0.17%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1-2.16%-2.01%-0.15%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.05%0.41%0.56%+0.15%unchanged
testcases/emulator_verified/greg_solo.jsongreg_solo0-0.15%-0.04%-0.11%unchanged
testcases/emulator_verified/sergey_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc0-1.54%-1.44%-0.10%unchanged
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0+0.005%-1.18%-1.10%-0.08%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo0-0.15%-0.23%+0.08%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21-1.66%-1.59%-0.07%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo00.17%0.24%+0.07%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_21+0.05%0.00%-0.06%+0.06%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_10+0.05%-0.13%0.19%+0.06%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo1-0.05%0.91%0.97%+0.06%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10+0.05%0.00%-0.06%+0.06%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65+0.05%0.00%-0.06%+0.06%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_10+0.05%0.00%-0.06%+0.06%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10+0.05%0.00%-0.06%+0.06%unchanged
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0-0.25%-0.20%-0.05%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo10.29%0.24%-0.05%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.05%0.00%-0.05%+0.05%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo0+0.05%-2.51%-2.47%-0.04%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0-0.11%-0.07%-0.04%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1-0.25%-0.21%-0.04%unchanged
testcases/emulator_verified/reina_attacker_wip.jsonreina_attacker_wip00.05%0.01%-0.04%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.29%-0.32%+0.03%unchanged
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip0-0.05%-0.02%-0.03%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo00.00%-0.02%+0.02%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2-0.16%-0.18%+0.02%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201.56%1.58%+0.02%unchanged
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc0-2.97%-2.99%+0.02%unchanged
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo0-0.08%-0.10%+0.02%unchanged
testcases/emulator_verified/hector_solo.jsonhector_solo00.14%0.12%-0.02%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo40.19%0.21%+0.02%unchanged
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo00.02%0.00%-0.02%unchanged
testcases/emulator_verified/patrick_solo_nc.jsonpatrick_solo0+0.011%0.00%-0.02%+0.02%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo0-0.11%-0.13%+0.02%unchanged
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0-0.39%-0.37%-0.02%unchanged
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo00.16%0.18%+0.02%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_43-0.15%-0.13%-0.02%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_32-0.41%-0.42%+0.01%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer10.03%0.02%-0.01%unchanged
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.16%-0.17%+0.01%unchanged
testcases/emulator_verified/ambusher_no_marksmen.jsonambusher_no_marksmen00.65%0.64%-0.01%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo00.09%0.08%-0.01%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo10.02%0.03%+0.01%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo2-0.01%0.00%-0.01%unchanged
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0-0.49%-0.50%+0.01%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo10.21%0.22%+0.01%unchanged
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0-0.09%-0.10%+0.01%unchanged
testcases/emulator_verified/norah_solo.jsonnorah_solo0-0.29%-0.28%-0.01%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo10.02%0.03%+0.01%unchanged
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc01.13%1.12%-0.01%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_54-0.01%-0.02%+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_156+0.015%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_167+0.009%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_178-0.003%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_189+0.024%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_19100.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2011-0.01%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2112+0.016%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_22130.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_23140.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2415-0.016%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_25160.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2617-0.002%0.00%0.00%0.00%unchanged
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_900.00%0.00%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_10+0.05%-0.32%-0.32%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_32+0.05%-0.38%-0.38%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_43+0.05%-0.45%-0.45%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_54+0.047%0.00%0.00%0.00%unchanged
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_65+0.05%-0.07%-0.07%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_430.02%0.02%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_540.64%0.64%0.00%unchanged
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_750.16%0.16%0.00%unchanged
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_10-0.22%-0.22%0.00%unchanged
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo0-0.05%0.06%0.06%0.00%unchanged
testcases/emulator_verified/alonso_attacker_600_all.jsonalonso_attacker_600_all0-0.14%-0.14%0.00%unchanged
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer00.04%0.04%0.00%unchanged
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender00.14%0.14%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo1-0.26%-0.26%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer0-0.04%-0.04%0.00%unchanged
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker0-0.03%-0.03%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_minxxx_attacks_nc.jsonattdef_jasser_patrick_minxxx_attacks0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_nc.jsonattdef_jasser_patrick00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_swap_nc.jsonattdef_jasser_patrick_swap00.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks1-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/attdef_test_minxxx_attacks_nc.jsonattdef_test_minxxx_attacks_nc0-0.05%0.08%0.08%0.00%unchanged
testcases/emulator_verified/attdef_test_wip_attacks_nc.jsonattdef_test_wip_attacks_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc00.04%0.04%0.00%unchanged
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal00.00%0.00%0.00%unchanged
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped00.00%0.00%0.00%unchanged
testcases/emulator_verified/flint_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/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current1-0.06%-0.06%0.00%unchanged
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2-0.08%-0.08%0.00%unchanged
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc00.65%0.65%0.00%unchanged
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0-0.05%0.24%0.24%0.00%unchanged
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo0-0.05%-0.05%0.00%unchanged
testcases/emulator_verified/hector_renee_wayne.jsondeterminism_test_normal0-0.05%0.54%0.54%0.00%unchanged
testcases/emulator_verified/jasser_solo_nc.jsonjasser_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/jessie_solo_nc.jsonjessie_solo0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo1+0.05%-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/logan_bahiti_combo_nc.jsonlogan_bahiti_combo_nc00.55%0.55%0.00%unchanged
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc0-0.05%0.13%0.13%0.00%unchanged
testcases/emulator_verified/logan_solo_minxxx_nc.jsonlogan_solo_minxxx00.00%0.00%0.00%unchanged
testcases/emulator_verified/logan_solo_v2_nc.jsonlogan_solo_v200.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo1+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/lynn_solo.jsonlynn_solo0-0.08%-0.08%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.27%-0.27%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo30.04%0.04%0.00%unchanged
testcases/emulator_verified/molly_solo.jsonmolly_solo0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo00.08%0.08%0.00%unchanged
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0+0.05%-0.35%-0.35%0.00%unchanged
testcases/emulator_verified/norah_s2_inf_only_B_nc.jsonnorah_s2_inf_only_B0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_A.jsonnorah_s2_splash_A00.23%0.23%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_B_nc.jsonnorah_s2_splash_B00.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_C_nc.jsonnorah_s2_splash_C00.00%0.00%0.00%unchanged
testcases/emulator_verified/norah_s2_splash_D.jsonnorah_s2_splash_D00.12%0.12%0.00%unchanged
testcases/emulator_verified/norah_zinman_combo_nc.jsonnorah_zinman_combo_nc00.39%0.39%0.00%unchanged
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator00.14%0.14%0.00%unchanged
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc0+0.033%-0.43%-0.43%0.00%unchanged
testcases/emulator_verified/seo_yoon_solo_nc.jsonseo_yoon_solo0-0.05%0.10%0.10%0.00%unchanged
testcases/emulator_verified/sergey_reina_combo_nc.jsonsergey_reina_combo_nc0-0.35%-0.35%0.00%unchanged
testcases/emulator_verified/sergey_solo_nc.jsonsergey_solo0-0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/simple_001_nc.jsonsimple_00100.00%0.00%0.00%unchanged
testcases/emulator_verified/troop_check_nc.jsontroop_check00.00%0.00%0.00%unchanged
testcases/emulator_verified/type_triangle_A_nc.jsontype_triangle_A_nc0+0.05%0.00%0.00%0.00%unchanged
testcases/emulator_verified/type_triangle_B_nc.jsontype_triangle_B_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo00.36%0.36%0.00%unchanged
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo00.43%0.43%0.00%unchanged
testcases/emulator_verified/wos425_wip_nohero_control_nc.jsonwos425_wip_nohero_control_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_patrick_health_nc.jsonwos425_wip_patrick_health_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_philly_defense.jsonwos425_wip_philly_defense0-0.02%-0.02%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.jsonwos425_wip_sergey_damage_taken_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.jsonwos425_wip_sergey_patrick_combo_nc00.00%0.00%0.00%unchanged
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo0-0.01%-0.01%0.00%unchanged
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_8400-0.28%-0.28%0.00%unchanged
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo00.00%0.00%0.00%unchanged
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10+0.05%-0.01%-0.01%0.00%unchanged
testcases/heroes_unittests/Hector_tc.jsondaut_viper_100.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32-0.039%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43-0.024%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_540.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_870.00%0.00%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_100.17%0.17%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_32-0.10%-0.10%0.00%unchanged
testcases/heroes_unittests/Mia_tc.jsondaut_viper_54-0.11%-0.11%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_21+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_32-0.039%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Molly_tc.jsondaut_viper_430.33%0.33%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_10+0.05%-0.18%-0.18%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_210.00%0.00%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_42-0.46%-0.46%0.00%unchanged
testcases/heroes_unittests/Reina_tc.jsondaut_viper_530.01%0.01%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32-0.039%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43+0.05%-0.04%-0.04%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54+0.05%-0.06%-0.06%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61+0.05%-0.07%-0.07%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65-0.03%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76-0.034%0.02%0.02%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10-0.05%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21-0.032%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_32-0.05%0.00%0.00%0.00%unchanged

Code / Config Changes

Code Changes (Run A → Run B)

Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -822,8 +822,38 @@
assert.equal(result.remaining.attacker.marksman, 1349);
assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });
});
+test("committed losses clamp exhausted floating point residue before next-round target selection", () => {
+ const config = loadSimulatorConfig();
+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/renee_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 === "renee_solo_nc");
+ if (!input) throw new Error("missing renee_solo_nc fixture");
+
+ const adjusted = structuredClone(input);
+ for (const [fighter, adjustment] of [
+ [adjusted.attacker, 0.05],
+ [adjusted.defender, -0.05]
+ ] as const) {
+ for (const stats of Object.values(fighter.stats ?? {})) {
+ for (const key of ["attack", "defense", "lethality", "health"] as const) {
+ if (stats[key] !== undefined) stats[key] = Number((Number(stats[key]) + adjustment).toFixed(3));
+ }
+ }
+ }
+ adjusted.trace = true;
+
+ const result = simulateBattle(adjusted, config);
+ const roundSix = result.trace?.rounds.find((round) => round.round === 6);
+ const defenderTargets = roundSix?.intents
+ .filter((intent) => intent.attackerSide === "defender")
+ .map((intent) => intent.defenderUnit);
+
+ assert.equal(roundSix?.roundStartTroops.attacker.infantry, 0);
+ assert.deepEqual(defenderTargets, ["lancer", "lancer", "lancer"]);
+});
+
test("extra skill trigger damage jobs reject missing runtime selectors", () => {
assert.throws(
() =>
simulateBattle(
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -688,12 +688,16 @@
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;
+ let appliedKills = 0;
+ let rawRemaining = available;
for (const outcome of matching) {
- outcome.kills = Math.min(outcome.kills, remaining);
- remaining -= outcome.kills;
+ const rawKills = outcome.kills;
+ outcome.kills = Math.min(rawKills, Math.max(0, available - appliedKills));
+ appliedKills += outcome.kills;
+ rawRemaining = Math.max(0, rawRemaining - rawKills);
+ if (rawRemaining === 0) appliedKills = available;
if (outcome.trace) outcome.trace.finalKills = outcome.kills;
}
}
}
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -38,6 +38,7 @@
effect: ActiveEffect;
bucket: AtomicBucket;
valuePct: number;
+ carriedInactive?: boolean;
}
interface MaxBucketCandidateGroup {
@@ -123,13 +124,17 @@
const consumedEffectIds = new Set<string>();
const candidates: BucketCandidate[] = [];
const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
+ const carriedAttackDurationEffectIds = job.carriedAttackDurationEffectIds ? new Set(job.carriedAttackDurationEffectIds) : undefined;
for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
- if (!isEffectActive(candidate.effect, job.round)) continue;
+ const active = isEffectActive(candidate.effect, job.round);
+ const carriedInactive = !active && carriedAttackDurationEffectIds?.has(candidate.effect.id) === true;
+ if (!active && !carriedInactive) continue;
handledCandidateEffectIds?.add(candidate.effect.id);
candidates.push({
effect: candidate.effect,
bucket: candidate.bucket,
- valuePct: currentEffectValuePct(candidate.effect, job.round)
+ valuePct: currentEffectValuePct(candidate.effect, job.round),
+ carriedInactive
});
}
@@ -267,7 +272,7 @@
appliedEffects.push(appliedEffect);
}
for (const candidate of candidates) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ if (candidate.effect.duration.type === "attack" && !candidate.carriedInactive) 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" });
}
diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts
--- a/simulator/src/simulator.test.ts
+++ b/simulator/src/simulator.test.ts
@@ -1323,6 +1323,87 @@
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });
});
+test("attack-duration normal damage effects do not carry to triggered extra skill damage by default", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig()
+ );
+
+ const normalBoosts = result.attacks
+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ const skillBoosts = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ assert.deepEqual(normalBoosts, [100]);
+ assert.deepEqual(skillBoosts, [0]);
+});
+
+test("mechanics flag carries consumed attack-duration effects to matching triggered extra skill damage only", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig({ buffAppliesVs: "infantry", extraTargets: "enemy.living" })
+ );
+
+ const normalBoosts = result.attacks
+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ const skillBoostsByTarget = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => `${attack.defenderUnit}:${attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0}`)
+ .sort();
+ assert.deepEqual(normalBoosts, [100]);
+ assert.deepEqual(skillBoostsByTarget, ["infantry:100", "lancer:0", "marksman:0"]);
+});
+
+test("mechanics flag carries consumed attack-duration effects with any target scope to all matching triggered extra skill damage", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig({ buffAppliesVs: "any", extraTargets: "enemy.living" })
+ );
+
+ const skillBoosts = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0)
+ .sort((left, right) => left - right);
+ assert.deepEqual(skillBoosts, [100, 100, 100]);
+});
+
test("attack-triggered source and target selectors resolve to concrete active scopes", () => {
const result = simulateBattle(
{
@@ -1801,3 +1882,40 @@
diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }
};
}
+
+function attackDurationCarryConfig(
+ options: { buffAppliesVs?: "any" | "infantry"; extraTargets?: "use.target" | "enemy.living" } = {}
+): SimulatorConfig {
+ const buffAppliesVs = options.buffAppliesVs ?? "any";
+ const extraTargets = options.extraTargets ?? "use.target";
+ return minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ OneUseNormalBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "marksman", applies_vs: buffAppliesVs },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ },
+ TriggeredExtraDamage: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ extra: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: extraTargets }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ });
+}
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -53,6 +53,7 @@
extraSkillAttackJobsByEffect: Record<string, number>;
attackControlCounts: { dodge: number; no_attack: number };
consumedEffectUseKeys: Set<string>;
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: boolean;
counters: {
attacks: Record<SideId, Record<UnitType, number>>;
received: Record<SideId, Record<UnitType, number>>;
@@ -112,7 +113,7 @@
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 runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"), input.mechanics);
const detail = options.detail ?? "full";
const traceEnabled = detail === "full" && input.trace;
const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;
@@ -289,7 +290,7 @@
return Number.isFinite(value) && value > 0 && value < 100;
}
-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {
+function createRuntime(fighters: ResolvedFighter[], rng: Rng, mechanics?: BattleInput["mechanics"]): Runtime {
const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };
const skills = buildRuntimeSkills(fighters);
for (const fighter of fighters) {
@@ -321,6 +322,7 @@
extraSkillAttackJobsByEffect: {},
attackControlCounts: { dodge: 0, no_attack: 0 },
consumedEffectUseKeys: new Set(),
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: mechanics?.carryAttackDurationEffectsToTriggeredExtraSkillDamage === true,
counters: {
attacks: { attacker: emptyTroops(), defender: emptyTroops() },
received: { attacker: emptyTroops(), defender: emptyTroops() }
@@ -513,6 +515,9 @@
roundStartTroops: DamageJob["roundStartTroops"]
): DamageJob[] {
const jobs: DamageJob[] = [];
+ const carriedAttackDurationEffectIds = runtime.carryAttackDurationEffectsToTriggeredExtraSkillDamage
+ ? attackDurationBucketEffectIdsForJob(normalAttack, round, runtime.activeEffects)
+ : undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
round
@@ -548,6 +553,7 @@
sourceEffectId,
sourceSkillReportKey,
sourceMultiplier: multiplier,
+ carriedAttackDurationEffectIds,
consumedEffectIds,
consumedEffectUseKey,
consumedEffectUseId: effect.id,
@@ -745,6 +751,16 @@
.map((effect) => effect.id);
}
+function attackDurationBucketEffectIdsForJob(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";
+ })
+ .map((effect) => effect.id);
+}
+
function consumeEffects(
runtime: Runtime,
consumedEffectIds: string[],
diff --git a/simulator/src/testcases.test.ts b/simulator/src/testcases.test.ts
--- a/simulator/src/testcases.test.ts
+++ b/simulator/src/testcases.test.ts
@@ -285,6 +285,25 @@
assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });
});
+test("adaptTestcaseEntry merges option mechanics without replacing testcase mechanics", () => {
+ const input = adaptTestcaseEntry(
+ {
+ test_id: "mechanics_case",
+ engagement_type: "rally",
+ mechanics: { weather: "clear" },
+ attacker: { troops: { infantry_t1: 1 } },
+ defender: { troops: { infantry_t1: 1 } }
+ },
+ { mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true } }
+ );
+
+ assert.deepEqual(input.mechanics, {
+ weather: "clear",
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: true,
+ engagement_type: "rally"
+ });
+});
+
test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {
const metrics = compareOutcomeDistribution({
candidate: { n: 1, mu: -186, sigma: 0 },
diff --git a/simulator/src/testcases.ts b/simulator/src/testcases.ts
--- a/simulator/src/testcases.ts
+++ b/simulator/src/testcases.ts
@@ -27,6 +27,7 @@
seed?: string | number;
trace?: boolean;
workers?: number;
+ mechanics?: Record<string, unknown>;
}
export interface TestcaseCaseReport {
@@ -207,7 +208,7 @@
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.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace, mechanics: options.mechanics }, diagnostics);
preparedCase.key = snapshotKey(reportFile, index);
} catch (error) {
detail.error = errorMessage(error);
@@ -613,7 +614,11 @@
return summary;
}
-export function adaptTestcaseEntry(entry: unknown, options: { seed?: string | number; trace?: boolean } = {}, diagnostics: string[] = []): BattleInput {
+export function adaptTestcaseEntry(
+ entry: unknown,
+ options: { seed?: string | number; trace?: boolean; mechanics?: Record<string, unknown> } = {},
+ diagnostics: string[] = []
+): BattleInput {
const object = entry as {
attacker?: FighterInput;
defender?: FighterInput;
@@ -626,7 +631,7 @@
};
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 mechanics = testcaseMechanics(object, options.mechanics);
const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);
return {
attacker: object.attacker,
@@ -702,8 +707,12 @@
return diagnostics;
}
-function testcaseMechanics(entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown }): Record<string, unknown> | undefined {
+function testcaseMechanics(
+ entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown },
+ optionMechanics?: Record<string, unknown>
+): Record<string, unknown> | undefined {
const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};
+ if (optionMechanics && typeof optionMechanics === "object") Object.assign(mechanics, optionMechanics);
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;
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -277,6 +277,7 @@
sourceEffectId?: string;
sourceSkillReportKey?: string;
sourceMultiplier?: number;
+ carriedAttackDurationEffectIds?: string[];
consumedEffectIds?: string[];
consumedEffectUseKey?: string;
consumedEffectUseId?: string;
Run B dirty state patch
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -38,6 +38,7 @@
effect: ActiveEffect;
bucket: AtomicBucket;
valuePct: number;
+ carriedInactive?: boolean;
}
interface MaxBucketCandidateGroup {
@@ -123,13 +124,17 @@
const consumedEffectIds = new Set<string>();
const candidates: BucketCandidate[] = [];
const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
+ const carriedAttackDurationEffectIds = job.carriedAttackDurationEffectIds ? new Set(job.carriedAttackDurationEffectIds) : undefined;
for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
- if (!isEffectActive(candidate.effect, job.round)) continue;
+ const active = isEffectActive(candidate.effect, job.round);
+ const carriedInactive = !active && carriedAttackDurationEffectIds?.has(candidate.effect.id) === true;
+ if (!active && !carriedInactive) continue;
handledCandidateEffectIds?.add(candidate.effect.id);
candidates.push({
effect: candidate.effect,
bucket: candidate.bucket,
- valuePct: currentEffectValuePct(candidate.effect, job.round)
+ valuePct: currentEffectValuePct(candidate.effect, job.round),
+ carriedInactive
});
}
@@ -267,7 +272,7 @@
appliedEffects.push(appliedEffect);
}
for (const candidate of candidates) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ if (candidate.effect.duration.type === "attack" && !candidate.carriedInactive) 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" });
}
diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts
--- a/simulator/src/simulator.test.ts
+++ b/simulator/src/simulator.test.ts
@@ -823,6 +823,36 @@
assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });
});
+test("committed losses clamp exhausted floating point residue before next-round target selection", () => {
+ const config = loadSimulatorConfig();
+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/renee_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 === "renee_solo_nc");
+ if (!input) throw new Error("missing renee_solo_nc fixture");
+
+ const adjusted = structuredClone(input);
+ for (const [fighter, adjustment] of [
+ [adjusted.attacker, 0.05],
+ [adjusted.defender, -0.05]
+ ] as const) {
+ for (const stats of Object.values(fighter.stats ?? {})) {
+ for (const key of ["attack", "defense", "lethality", "health"] as const) {
+ if (stats[key] !== undefined) stats[key] = Number((Number(stats[key]) + adjustment).toFixed(3));
+ }
+ }
+ }
+ adjusted.trace = true;
+
+ const result = simulateBattle(adjusted, config);
+ const roundSix = result.trace?.rounds.find((round) => round.round === 6);
+ const defenderTargets = roundSix?.intents
+ .filter((intent) => intent.attackerSide === "defender")
+ .map((intent) => intent.defenderUnit);
+
+ assert.equal(roundSix?.roundStartTroops.attacker.infantry, 0);
+ assert.deepEqual(defenderTargets, ["lancer", "lancer", "lancer"]);
+});
+
test("extra skill trigger damage jobs reject missing runtime selectors", () => {
assert.throws(
() =>
@@ -1323,6 +1353,87 @@
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });
});
+test("attack-duration normal damage effects do not carry to triggered extra skill damage by default", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig()
+ );
+
+ const normalBoosts = result.attacks
+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ const skillBoosts = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ assert.deepEqual(normalBoosts, [100]);
+ assert.deepEqual(skillBoosts, [0]);
+});
+
+test("mechanics flag carries consumed attack-duration effects to matching triggered extra skill damage only", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig({ buffAppliesVs: "infantry", extraTargets: "enemy.living" })
+ );
+
+ const normalBoosts = result.attacks
+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);
+ const skillBoostsByTarget = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => `${attack.defenderUnit}:${attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0}`)
+ .sort();
+ assert.deepEqual(normalBoosts, [100]);
+ assert.deepEqual(skillBoostsByTarget, ["infantry:100", "lancer:0", "marksman:0"]);
+});
+
+test("mechanics flag carries consumed attack-duration effects with any target scope to all matching triggered extra skill damage", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 1,
+ trace: true,
+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },
+ attacker: {
+ troops: { marksman_t1: 100 },
+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },
+ heroes: {}
+ }
+ },
+ attackDurationCarryConfig({ buffAppliesVs: "any", extraTargets: "enemy.living" })
+ );
+
+ const skillBoosts = result.attacks
+ .filter((attack) => attack.kind === "skill")
+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0)
+ .sort((left, right) => left - right);
+ assert.deepEqual(skillBoosts, [100, 100, 100]);
+});
+
test("attack-triggered source and target selectors resolve to concrete active scopes", () => {
const result = simulateBattle(
{
@@ -1801,3 +1912,40 @@
diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }
};
}
+
+function attackDurationCarryConfig(
+ options: { buffAppliesVs?: "any" | "infantry"; extraTargets?: "use.target" | "enemy.living" } = {}
+): SimulatorConfig {
+ const buffAppliesVs = options.buffAppliesVs ?? "any";
+ const extraTargets = options.extraTargets ?? "use.target";
+ return minimalConfig({
+ FollowUp: {
+ name: "FollowUp",
+ skills: {
+ OneUseNormalBoost: {
+ trigger: { type: "battle_start" },
+ effects: {
+ boost: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "marksman", applies_vs: buffAppliesVs },
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ },
+ TriggeredExtraDamage: {
+ trigger: { type: "attack", probability: 100, source: "marksman" },
+ effects: {
+ extra: {
+ type: "extra_skill_attack",
+ value: 100,
+ units: { applies_to: "trigger.source", applies_vs: "any" },
+ trigger_damage_jobs: [{ source: "use.source", target: extraTargets }],
+ duration: { type: "attack", value: 1 }
+ }
+ }
+ }
+ }
+ }
+ });
+}
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -53,6 +53,7 @@
extraSkillAttackJobsByEffect: Record<string, number>;
attackControlCounts: { dodge: number; no_attack: number };
consumedEffectUseKeys: Set<string>;
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: boolean;
counters: {
attacks: Record<SideId, Record<UnitType, number>>;
received: Record<SideId, Record<UnitType, number>>;
@@ -112,7 +113,7 @@
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 runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"), input.mechanics);
const detail = options.detail ?? "full";
const traceEnabled = detail === "full" && input.trace;
const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;
@@ -289,7 +290,7 @@
return Number.isFinite(value) && value > 0 && value < 100;
}
-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {
+function createRuntime(fighters: ResolvedFighter[], rng: Rng, mechanics?: BattleInput["mechanics"]): Runtime {
const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };
const skills = buildRuntimeSkills(fighters);
for (const fighter of fighters) {
@@ -321,6 +322,7 @@
extraSkillAttackJobsByEffect: {},
attackControlCounts: { dodge: 0, no_attack: 0 },
consumedEffectUseKeys: new Set(),
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: mechanics?.carryAttackDurationEffectsToTriggeredExtraSkillDamage === true,
counters: {
attacks: { attacker: emptyTroops(), defender: emptyTroops() },
received: { attacker: emptyTroops(), defender: emptyTroops() }
@@ -513,6 +515,9 @@
roundStartTroops: DamageJob["roundStartTroops"]
): DamageJob[] {
const jobs: DamageJob[] = [];
+ const carriedAttackDurationEffectIds = runtime.carryAttackDurationEffectsToTriggeredExtraSkillDamage
+ ? attackDurationBucketEffectIdsForJob(normalAttack, round, runtime.activeEffects)
+ : undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
round
@@ -548,6 +553,7 @@
sourceEffectId,
sourceSkillReportKey,
sourceMultiplier: multiplier,
+ carriedAttackDurationEffectIds,
consumedEffectIds,
consumedEffectUseKey,
consumedEffectUseId: effect.id,
@@ -683,10 +689,14 @@
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;
+ let appliedKills = 0;
+ let rawRemaining = available;
for (const outcome of matching) {
- outcome.kills = Math.min(outcome.kills, remaining);
- remaining -= outcome.kills;
+ const rawKills = outcome.kills;
+ outcome.kills = Math.min(rawKills, Math.max(0, available - appliedKills));
+ appliedKills += outcome.kills;
+ rawRemaining = Math.max(0, rawRemaining - rawKills);
+ if (rawRemaining === 0) appliedKills = available;
if (outcome.trace) outcome.trace.finalKills = outcome.kills;
}
}
@@ -745,6 +755,16 @@
.map((effect) => effect.id);
}
+function attackDurationBucketEffectIdsForJob(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";
+ })
+ .map((effect) => effect.id);
+}
+
function consumeEffects(
runtime: Runtime,
consumedEffectIds: string[],
diff --git a/simulator/src/testcases.test.ts b/simulator/src/testcases.test.ts
--- a/simulator/src/testcases.test.ts
+++ b/simulator/src/testcases.test.ts
@@ -285,6 +285,25 @@
assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });
});
+test("adaptTestcaseEntry merges option mechanics without replacing testcase mechanics", () => {
+ const input = adaptTestcaseEntry(
+ {
+ test_id: "mechanics_case",
+ engagement_type: "rally",
+ mechanics: { weather: "clear" },
+ attacker: { troops: { infantry_t1: 1 } },
+ defender: { troops: { infantry_t1: 1 } }
+ },
+ { mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true } }
+ );
+
+ assert.deepEqual(input.mechanics, {
+ weather: "clear",
+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: true,
+ engagement_type: "rally"
+ });
+});
+
test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {
const metrics = compareOutcomeDistribution({
candidate: { n: 1, mu: -186, sigma: 0 },
diff --git a/simulator/src/testcases.ts b/simulator/src/testcases.ts
--- a/simulator/src/testcases.ts
+++ b/simulator/src/testcases.ts
@@ -27,6 +27,7 @@
seed?: string | number;
trace?: boolean;
workers?: number;
+ mechanics?: Record<string, unknown>;
}
export interface TestcaseCaseReport {
@@ -207,7 +208,7 @@
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.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace, mechanics: options.mechanics }, diagnostics);
preparedCase.key = snapshotKey(reportFile, index);
} catch (error) {
detail.error = errorMessage(error);
@@ -613,7 +614,11 @@
return summary;
}
-export function adaptTestcaseEntry(entry: unknown, options: { seed?: string | number; trace?: boolean } = {}, diagnostics: string[] = []): BattleInput {
+export function adaptTestcaseEntry(
+ entry: unknown,
+ options: { seed?: string | number; trace?: boolean; mechanics?: Record<string, unknown> } = {},
+ diagnostics: string[] = []
+): BattleInput {
const object = entry as {
attacker?: FighterInput;
defender?: FighterInput;
@@ -626,7 +631,7 @@
};
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 mechanics = testcaseMechanics(object, options.mechanics);
const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);
return {
attacker: object.attacker,
@@ -702,8 +707,12 @@
return diagnostics;
}
-function testcaseMechanics(entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown }): Record<string, unknown> | undefined {
+function testcaseMechanics(
+ entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown },
+ optionMechanics?: Record<string, unknown>
+): Record<string, unknown> | undefined {
const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};
+ if (optionMechanics && typeof optionMechanics === "object") Object.assign(mechanics, optionMechanics);
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;
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -277,6 +277,7 @@
sourceEffectId?: string;
sourceSkillReportKey?: string;
sourceMultiplier?: number;
+ carriedAttackDurationEffectIds?: string[];
consumedEffectIds?: string[];
consumedEffectUseKey?: string;
consumedEffectUseId?: string;