WOS Sim
← Back to Runs

Compare Runs

A (baseline)
e35f1f79-2cc40ddd33611/06/2026, 04:24:16
B (current)
5212cdc6-ce340ddd33611/06/2026, 04:33:39
Avg Error A0.20%
Avg Error B0.20%
Δ Avg Error+0.00%
Improved0
Regressed0
Added0
Retired0

Testcase Delta

178 / 178
FileCase#AdjA%B%Δ%Status
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc00.45%0.65%+0.20%unchanged
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_100.06%-0.22%+0.16%unchanged
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0-0.05%0.13%0.24%+0.11%unchanged
testcases/emulator_verified/ambusher_no_marksmen.jsonambusher_no_marksmen00.63%0.65%+0.02%unchanged
testcases/emulator_verified/hector_renee_wayne.jsondeterminism_test_normal0-0.05%0.55%0.54%-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_21+0.044%0.00%0.00%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_10+0.043%-0.13%-0.13%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_32-0.41%-0.41%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/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_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer10.03%0.03%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_combo00.00%0.00%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo1-0.26%-0.26%0.00%unchanged
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo2-0.16%-0.16%0.00%unchanged
testcases/emulator_verified/alonso_solo.jsonalonso_solo02.55%2.55%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201.56%1.56%0.00%unchanged
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v21-1.66%-1.66%0.00%unchanged
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso0-0.16%-0.16%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_norah_combo_nc.jsonbahiti_norah_combo_nc0-2.97%-2.97%0.00%unchanged
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo00.15%0.15%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_reina_zinman_combo.jsonflint_reina_zinman_combo0-0.08%-0.08%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_combo.jsongreg_mia_combo0+0.049%-2.51%-2.51%0.00%unchanged
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo1-0.04%0.91%0.91%0.00%unchanged
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0-0.25%-0.25%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/greg_solo.jsongreg_solo0-0.15%-0.15%0.00%unchanged
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo0-0.05%-0.05%0.00%unchanged
testcases/emulator_verified/hector_solo.jsonhector_solo00.14%0.14%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_lynn_combo.jsonlogan_lynn_combo00.09%0.09%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo10.02%0.02%0.00%unchanged
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo2-0.01%-0.01%0.00%unchanged
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc0-0.49%-0.49%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_only_defender_current.jsonmia_only_defender_current0-0.11%-0.11%0.00%unchanged
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1-0.25%-0.25%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo0-0.27%-0.27%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo10.21%0.21%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo2-0.29%-0.29%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo30.04%0.04%0.00%unchanged
testcases/emulator_verified/mia_solo.jsonmia_solo40.19%0.19%0.00%unchanged
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo00.02%0.02%0.00%unchanged
testcases/emulator_verified/molly_solo.jsonmolly_solo0-0.07%-0.07%0.00%unchanged
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0+0.015%-1.18%-1.18%0.00%unchanged
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo0-0.09%-0.09%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_solo.jsonnorah_solo0-0.29%-0.29%0.00%unchanged
testcases/emulator_verified/norah_zinman_combo_nc.jsonnorah_zinman_combo_nc00.39%0.39%0.00%unchanged
testcases/emulator_verified/patrick_solo_nc.jsonpatrick_solo0+0.044%0.00%0.00%0.00%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo0-0.11%-0.11%0.00%unchanged
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo10.29%0.29%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo00.17%0.17%0.00%unchanged
testcases/emulator_verified/philly_solo.jsonphilly_solo10.02%0.02%0.00%unchanged
testcases/emulator_verified/reina_attacker_wip.jsonreina_attacker_wip00.05%0.05%0.00%unchanged
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc01.13%1.13%0.00%unchanged
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator00.14%0.14%0.00%unchanged
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip0-0.05%-0.05%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_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc0-1.54%-1.54%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_mixed_solo.jsonwayne_mixed_solo0-0.15%-0.15%0.00%unchanged
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo1-2.16%-2.16%0.00%unchanged
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo0-0.39%-0.39%0.00%unchanged
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo00.16%0.16%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/Flint_tc.jsondaut_viper_21-0.03%0.41%0.41%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_10+0.044%0.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_65+0.027%0.00%0.00%0.00%unchanged
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.038%0.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_21+0.015%-0.97%-0.97%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_43-0.15%-0.15%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_10+0.044%0.00%0.00%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/Molly_tc.jsondaut_viper_54-0.01%-0.01%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_10+0.044%0.00%0.00%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)

No code changes between Run A and Run B.

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
@@ -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;