From 35dfdaed618d43991103cd1038771e33fa347eab Mon Sep 17 00:00:00 2001 From: phenom Date: Mon, 28 Sep 2026 22:46:45 -0400 Subject: [PATCH] Update Healer Man server --- PLAYTEST_NOTES.md | 19 + docs/ACTIVE_CLASS_AUDIT.md | 140 + docs/BITE_SIZED_EDITOR.md | 12 +- scripts/playtest-bite-sized-editor.cjs | 43 + src/GameRuntime.tsx | 4 + src/game/abilityCatalog.ts | 57 +- src/game/biteSizedEditorInput.ts | 8 + src/game/combatActorAuthority.ts | 4 +- src/game/combatActors.ts | 1 + src/game/combatAuras.test.ts | 68 + src/game/combatAuras.ts | 43 +- src/game/combatResources.ts | 46 +- src/game/combatRotation.ts | 5 +- src/game/combatStore.test.ts | 185 +- src/game/combatStore.ts | 4227 ++++++++++++++++- src/game/cooldownReset.ts | 7 +- src/game/deathKnightEmpowerRuneWeapon.test.ts | 34 + src/game/deathKnightSourceRuntime.test.ts | 379 ++ src/game/healingAbilityRules.ts | 5 +- src/game/inputManager.test.ts | 40 + src/game/inputManager.ts | 10 +- src/game/mageBlinkRuntime.test.ts | 52 + src/game/partyRuntime.ts | 28 +- src/game/partyStore.ts | 9 +- src/game/romAbilityCatalog.ts | 435 +- src/game/romAbilityCatalogSemantics.test.ts | 279 ++ src/game/romAbilitySemantics.ts | 1 + src/game/romChampionOverrides.ts | 117 + src/game/romChampionRuntime.test.ts | 194 + src/game/romCombatMechanics.test.ts | 116 +- src/game/romCombatMechanics.ts | 210 +- src/game/romCombatRuntime.test.ts | 803 ++++ src/game/romDruidNaturePower.test.ts | 133 + src/game/romDruidOverrides.ts | 273 ++ src/game/romDruidRuntime.test.ts | 188 + src/game/romKnightOverrides.ts | 81 + src/game/romKnightPeriodicRuntime.test.ts | 323 ++ src/game/romMageMechanics.test.ts | 365 ++ src/game/romMageRuntime.ts | 24 + src/game/romMageWarlockOverrides.ts | 68 + src/game/romPlayerCatalog.test.ts | 34 +- src/game/romPriestChampionOverrides.ts | 13 + src/game/romPriestKnightOverrides.ts | 111 + .../romPriestKnightRuntimeEffects.test.ts | 164 + src/game/romPriestLightningRod.test.ts | 76 + src/game/romPriestSaintsBlessing.test.ts | 85 + src/game/romPriestTooltips.ts | 5 + src/game/romPriestWarlockOverrides.ts | 33 + src/game/romPriestWarlockRuntime.test.ts | 110 + src/game/romRogueMechanics.test.ts | 307 ++ src/game/romRogueMechanics.ts | 65 + src/game/romScoutMechanics.test.ts | 107 + src/game/romScoutRogueOverrides.ts | 86 +- src/game/romScoutRoguePairValidation.test.ts | 72 +- src/game/romSmashRuntime.test.ts | 94 + src/game/romWardenOverrides.ts | 142 + src/game/romWardenRuntime.test.ts | 300 ++ src/game/romWarlockOverrides.ts | 195 + src/game/romWarlockRuntime.test.ts | 324 ++ src/game/romWarriorOverrides.test.ts | 114 + src/game/romWarriorOverrides.ts | 23 + src/game/talentModifiers.test.ts | 78 + src/game/talentModifiers.ts | 125 +- src/game/wow335AbilityCatalog.test.ts | 309 +- src/game/wow335AbilityCatalog.ts | 883 +++- src/game/wow335DruidProcRuntime.test.ts | 249 + src/game/wow335DruidRuntime.test.ts | 105 + src/game/wow335HunterMechanics.test.ts | 376 ++ src/game/wow335MageImpactRuntime.test.ts | 125 + src/game/wow335MageRuntime.test.ts | 490 ++ src/game/wow335MageTalentRuntime.ts | 65 + src/game/wow335PaladinRuntime.test.ts | 552 +++ src/game/wow335PriestMechanics.test.ts | 672 +++ ...wow335PriestPrayerOfMendingRuntime.test.ts | 163 + src/game/wow335RogueMechanics.test.ts | 212 + ...ow335RogueVanishPreparationRuntime.test.ts | 59 + ...335ShamanConditionalTalentsRuntime.test.ts | 119 + .../wow335ShamanHealingStreamRuntime.test.ts | 84 + src/game/wow335ShamanProcRuntime.test.ts | 152 + src/game/wow335ShamanisticRageRuntime.test.ts | 77 + src/game/wow335WarlockMechanics.test.ts | 238 + .../wow335WarlockSoulshatterRuntime.test.ts | 64 + src/game/wow335WarlockTalentProcs.test.ts | 185 + src/game/wow335WarriorRuntime.test.ts | 269 ++ .../wowDruidNatureSwiftnessRuntime.test.ts | 53 + src/game/wowNatureSwiftnessRuntime.test.ts | 73 + src/game/wowTidalForceRuntime.test.ts | 74 + src/scene/CombatSummonPopulation.tsx | 29 + src/scene/GameScene.tsx | 3 + src/scene/OriginalCombatEffect.tsx | 2 +- src/scene/RunewakerParticleEffect.tsx | 26 +- src/scene/combatEffectRuntime.test.ts | 28 + src/scene/combatEffectRuntime.ts | 15 +- src/ui/BiteSizedEditor.tsx | 2 + 94 files changed, 17034 insertions(+), 418 deletions(-) create mode 100644 docs/ACTIVE_CLASS_AUDIT.md create mode 100644 src/game/biteSizedEditorInput.ts create mode 100644 src/game/deathKnightEmpowerRuneWeapon.test.ts create mode 100644 src/game/deathKnightSourceRuntime.test.ts create mode 100644 src/game/mageBlinkRuntime.test.ts create mode 100644 src/game/romChampionOverrides.ts create mode 100644 src/game/romChampionRuntime.test.ts create mode 100644 src/game/romDruidNaturePower.test.ts create mode 100644 src/game/romDruidOverrides.ts create mode 100644 src/game/romDruidRuntime.test.ts create mode 100644 src/game/romKnightOverrides.ts create mode 100644 src/game/romKnightPeriodicRuntime.test.ts create mode 100644 src/game/romMageMechanics.test.ts create mode 100644 src/game/romMageRuntime.ts create mode 100644 src/game/romPriestChampionOverrides.ts create mode 100644 src/game/romPriestKnightRuntimeEffects.test.ts create mode 100644 src/game/romPriestLightningRod.test.ts create mode 100644 src/game/romPriestSaintsBlessing.test.ts create mode 100644 src/game/romPriestWarlockOverrides.ts create mode 100644 src/game/romPriestWarlockRuntime.test.ts create mode 100644 src/game/romRogueMechanics.test.ts create mode 100644 src/game/romRogueMechanics.ts create mode 100644 src/game/romScoutMechanics.test.ts create mode 100644 src/game/romSmashRuntime.test.ts create mode 100644 src/game/romWardenOverrides.ts create mode 100644 src/game/romWardenRuntime.test.ts create mode 100644 src/game/romWarlockOverrides.ts create mode 100644 src/game/romWarlockRuntime.test.ts create mode 100644 src/game/romWarriorOverrides.test.ts create mode 100644 src/game/romWarriorOverrides.ts create mode 100644 src/game/wow335DruidProcRuntime.test.ts create mode 100644 src/game/wow335DruidRuntime.test.ts create mode 100644 src/game/wow335HunterMechanics.test.ts create mode 100644 src/game/wow335MageImpactRuntime.test.ts create mode 100644 src/game/wow335MageRuntime.test.ts create mode 100644 src/game/wow335MageTalentRuntime.ts create mode 100644 src/game/wow335PaladinRuntime.test.ts create mode 100644 src/game/wow335PriestMechanics.test.ts create mode 100644 src/game/wow335PriestPrayerOfMendingRuntime.test.ts create mode 100644 src/game/wow335RogueMechanics.test.ts create mode 100644 src/game/wow335RogueVanishPreparationRuntime.test.ts create mode 100644 src/game/wow335ShamanConditionalTalentsRuntime.test.ts create mode 100644 src/game/wow335ShamanHealingStreamRuntime.test.ts create mode 100644 src/game/wow335ShamanProcRuntime.test.ts create mode 100644 src/game/wow335ShamanisticRageRuntime.test.ts create mode 100644 src/game/wow335WarlockMechanics.test.ts create mode 100644 src/game/wow335WarlockSoulshatterRuntime.test.ts create mode 100644 src/game/wow335WarlockTalentProcs.test.ts create mode 100644 src/game/wow335WarriorRuntime.test.ts create mode 100644 src/game/wowDruidNatureSwiftnessRuntime.test.ts create mode 100644 src/game/wowNatureSwiftnessRuntime.test.ts create mode 100644 src/game/wowTidalForceRuntime.test.ts diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 9ec40454..155c750e 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -4,6 +4,25 @@ This file is the durable record of runtime behavior, regressions, and the evidence required before calling a visual bug fixed. Update it whenever scene, camera, input, physics, mob-rendering, or asset-loading code changes. +## 2026-09-27 Bite-Sized editor movement regression + +- The original edit-mode action blocker also suppressed continuous movement and + input snapshots, so WASD did nothing even after returning focus to the world. + Movement/jump gating is now separate from combat-action gating. Panel controls + and overlays still pause movement; Walk in scene or clicking the world returns + keyboard focus while keeping the panel open. +- A focused headless Edge check with `BITE_SIZED_EDITOR_INPUT_ONLY=1` moved the + player in all four WASD directions on the Stockades entrance stairs. Focusing + a coordinate field stopped W movement, and clicking the world restored it. + The character remained grounded after walking. An ability key started no cast + in edit mode. Right drag changed yaw, pointer lock stayed null, and release + stopped camera changes; no page errors occurred. +- Evidence: `playtest-artifacts/bite-sized-editor/editor-movement.png` and + `movement-observations.json`. All 33 focused input tests, the production build, + and release candidate check passed. The earlier authoring playtest verified + input suppression and camera drag but missed actual walking; its script now + includes this movement regression check. + ## 2026-09-27 Bite-Sized spot editor - `scripts/playtest-bite-sized-editor.cjs` used the real app in headless Edge diff --git a/docs/ACTIVE_CLASS_AUDIT.md b/docs/ACTIVE_CLASS_AUDIT.md new file mode 100644 index 00000000..bdb41741 --- /dev/null +++ b/docs/ACTIVE_CLASS_AUDIT.md @@ -0,0 +1,140 @@ +# Active class behavior and spell visual audit + +Updated 2026-09-28. This is a source/runtime audit of the shipped WoW 3.3.5a and RuneWaker catalogs in Healer Man. It records confirmed gaps and the fixes made in this pass. It is not a claim that every ability has passed a live-game playtest or a screenshot-based visual review. + +## Reviewed scope + +- WoW: the ten playable class catalogs, including ability ranks, talent-driven modifiers, and combat-effect coverage. +- RoM: the generated catalog inventory covers all ten primary class catalogs and both sides of each paired-elite row. The generated catalog currently contains 929 unique rows (587 active rows and 342 passive rows). Counting each paired row in both its primary and secondary class view gives 1,589 class-linked row associations and 587 distinct active source spell IDs. Primary and secondary role associations have been source-reviewed; executable runtime coverage is selective, as listed below. + +WoW inventory by generated catalog class: + +| Class | Ability rows | Rank rows | Rank rows with manifest mapping | Rank rows with original effect graph | +|---|---:|---:|---:|---:| +| Warrior | 51 | 153 | 123 / 153 | 123 / 153 | +| Paladin | 75 | 221 | 169 / 221 | 168 / 221 | +| Hunter | 68 | 194 | 157 / 194 | 125 / 194 | +| Rogue | 42 | 136 | 114 / 136 | 108 / 136 | +| Priest | 56 | 264 | 218 / 264 | 218 / 264 | +| Death Knight | 45 | 77 | 47 / 77 | 47 / 77 | +| Shaman | 62 | 280 | 251 / 280 | 149 / 280 | +| Mage | 78 | 319 | 258 / 319 | 258 / 319 | +| Warlock | 66 | 267 | 224 / 267 | 224 / 267 | +| Druid | 74 | 318 | 283 / 318 | 280 / 318 | +- RoM rows and active-effect coverage by class (primary + secondary roles): + +| Class | Rows | Active / passive | Active rank IDs with manifest entry | Active rank IDs with original effect graph | +|---|---:|---:|---:|---:| +| Warrior | 204 | 120 / 84 | 118 / 120 | 10 / 120 | +| Scout | 166 | 89 / 77 | 89 / 89 | 28 / 89 | +| Rogue | 207 | 113 / 94 | 111 / 113 | 32 / 113 | +| Mage | 205 | 121 / 84 | 122 / 124 | 28 / 124 | +| Priest | 167 | 103 / 64 | 101 / 103 | 35 / 103 | +| Knight | 123 | 68 / 55 | 68 / 68 | 23 / 68 | +| Warden | 132 | 84 / 48 | 84 / 84 | 1 / 84 | +| Druid | 130 | 80 / 50 | 81 / 81 | 5 / 81 | +| Warlock | 130 | 85 / 45 | 81 / 85 | 9 / 85 | +| Champion | 125 | 87 / 38 | 87 / 88 | 5 / 88 | + +Manifest coverage means an effect mapping exists; a graph means an original effect graph can render in the browser. Neither proves the effect is the right size or brightness. Most RoM entries have only source-model metadata and use a generated fallback in the browser. + +## Fixes made in this pass + +- Priest Flash Heal impact is softened for both WoW catalog IDs: scale `0.38`, opacity `0.22`. Cast-start remains unchanged. The original model was inspected and showed a broad, bright impact. +- WoW Mage Arcane Explosion cast-start sphere is tuned to scale `0.65`, opacity `0.5`; the impact cue keeps default tuning after the original purple sphere appeared visually dominant. +- WoW Priest Improved Flash Heal no longer grants its crit talent globally; Inner Focus cooldown reduction now happens when the relevant cast occurs. +- WoW Shaman and Druid Nature's Swiftness now instant-casts the next successful eligible cast-time Nature spell and stays armed until used; Shaman shares its three-minute cooldown with Elemental Mastery. The effect does not grant a haste bonus. +- WoW Shaman Tidal Force now starts with three 20% healing-critical charges; Healing Wave, Lesser Healing Wave, and Chain Heal receive the bonus, and each critical heal spends one charge. +- WoW Shaman Healing Stream Totem ranks 5394–58757 now summon a five-health, nonattacking totem for five minutes. It heals injured party members in 10 yards every two seconds, with each rank's extracted child-spell amount (6–25). +- WoW Death Knight Empower Rune Weapon (47568) now activates all six runes with a single source-aligned effect. Black Ice now treats Frost and Shadow as alternative schools rather than requiring both on one spell. +- WoW Rogue Vanish now enters stealth, clears active root/slow control, and leaves the player in place; Preparation resets only Evasion, Sprint, Vanish, Cold Blood, and Shadowstep. +- WoW Warlock Soulshatter (29858) now cuts each nearby enemy's threat by the source-defined 50% in a caster-centered 50-yard radius; targets outside it remain unchanged, including when the area is empty. +- WoW Mage Blink now removes root/stun control and can be cast while stunned; the false movement-away behavior is removed. The intended 20-yard forward teleport remains unsupported until player facing is represented. +- WoW Hunter Readiness now preserves Bestial Wrath's cooldown exception. Bestial Wrath requires an active pet and places its source-row 19574 effects on that pet; The Beast Within (34471) now procs only on Bestial Wrath at its talent rank's 50%/100% chance and applies the source-row payload to the hunter. Extracted 34471 data specifies 18 seconds, +10% damage, and -20% mana cost, while the generated talent tooltip says 6 seconds, +15%, and -50%; this client/source discrepancy remains unresolved. +- WoW Priest Prayer of Mending ranks 33076/48112/48113 now heal for the extracted base values (800/905/1043), spend one of five damage-taken charges per activation, and transfer to the most injured eligible party member within 40 yards and line of sight. This models five total heals, with up to four transfers; unrelated outgoing damage does not spend a charge. +- WoW Holy Shock retains the player button and both source heal/damage payloads; Death Grip pulls and taunts; Pestilence spreads the caster's two diseases; Chains of Ice keeps its slow without a bogus root; Life Tap and Create Healthstone no longer produce false heals; Swiftmend prefers a caster-owned eligible Rejuvenation/Regrowth or otherwise the shortest remaining one, heals exactly 4/6 ticks, and consumes that HoT. Glyph of Swiftmend state is not represented, so glyph preservation is unsupported. +- WoW Warrior Bloodthirst now keeps player cast/damage fields from 23881 and uses child spell 23880 for three 1%-max-health heals on landed melee hits. Sweeping Strikes selects player self-buff 12328, arms five charges, and applies one hit to the nearest eligible secondary enemy for each landed melee hit. +- RoM Warrior Survival Instinct now checks its health threshold; Moon Cleave runs three hits and checks its two-handed weapon requirement; Shout applies the current [CoA Shout entry](https://chroniclesofarcadia.com/en/wiki/skills/list/1-8)'s 15-Rage, 40-second self-centered area stun without the stale five-target cap (the local RuneWaker snapshot has older values); The Final Battle applies its second hit only below 30% target health; Probing Attack applies a six-second Vulnerable state that Keen Attack requires. +- RoM Knight Shackles of Light resolves its all-enemy root; Knight Smash requires a recent landed player critical hit; RoM Mage Fire Ward applies to the party; RoM Priest support targets are friendly, paired Priest buffs apply to their intended party members, and Priest/Scout Curing Shot heals and applies its HoT to up to five allies. +- RoM Priest/Warlock Prayer of Tribulation (498770) now targets a friendly ally, channels four source-authored heals, and applies its local defense buff using the tooltip's 40% health branches. The local snapshot omits timing, so the matching current [CoA skill entry](https://chroniclesofarcadia.com/en/wiki/skills/list/5-9) supplied only the 1.5-second channel and 5-second cooldown; its newer area-damage and defense-buff payload was not imported. +- RoM Scout/Mage Aggro Transfer now applies its mana regeneration to the selected ally; Scout/Priest Mana Arrows shields up to six nearby allies; Scout/Knight Healing Shot fires its hostile shot and heals the party member with the lowest maximum health. +- RoM Scout, Knight, and Druid ranged skills now enforce their ranged-weapon requirement, including Thorn Arrow, Hidden Peril, Gryphon Bash, Arrow of Vengeance, Healing Arrows, and Unbinding Magic; Warrior/Scout Stun Shot and Scout Neck Strike also enforce it. +- RoM Druid/Mage Spring of Blessings heals nearby party members over time; Druid/Mage Earth Chain chains to three enemies with 70% falloff; Shield of Light applies to up to three nearby allies; Druid/Scout Group Exorcism cleanses nearby allies and consumes its required Nature Power. Knight/Priest Free Will grants its selected ally 10 seconds of Root and Slow immunity. +- RoM Scout/Warden Thorn Arrow now requires the Briar Shield buff before it can be cast. +- RoM Warden Crystalline Protection and Acceleration now fan out within the source radius; Coat of Arms applies its source armor/defense buffs to the caster and selected ally. +- RoM Druid/Scout Healing Arrows (494021) now selects its Nature-Power-enhanced HoT when the caster has a point, drains that point, and uses the baseline HoT at zero points. Player and companion casts follow the same branch. +- RoM Plasma Arrow (491168) impact tuning is scale `0.45` and opacity `0.32`; Electric Bolt (491169) is scale `0.6` and opacity `0.45`. These impact adjustments leave cast cues unchanged. +- RoM Warlock/Priest Ring of Comfort heals up to six nearby allies instead of damaging the selected hostile target. +- Self-target Champion Rune Pulse's Rage regeneration is put on the caster. +- RoM Champion Kinetic Burn, Vacuum Wave, and Light Pulse resolve their self-centered nearby areas; Shock Strike uses a forward cone; Overrule clears control and grants the stated short control immunity. + +### Second-pass class follow-ups + +- WoW Priest Test of Faith now applies its rank-scaled healing and Smite/Holy Fire bonus below 50% target health, plus the source-described 10% magic-damage reduction while casting an eligible Priest heal, Smite, or Holy Fire. Divine Aegis, Surge of Light, Serendipity, Shadow Weaving, and Holy Concentration also have event-backed hooks. The focused Priest runtime suite passed 29 tests across three files. +- WoW Priest Psychic Scream now applies its fear status once per cast, avoiding duplicate boss diminishing-return consumption, and Pain Suppression now remains eligible for low-health mitigation even though it also reduces threat. The Priest AI/control regression suites passed 45 tests across three files. +- WoW Paladin Eye for an Eye reflects the talent rank's 5/10/15% of incoming critical damage, capped at 50% of maximum health. Incoming critical-chance modifiers now increase the enemy attack's crit chance instead of reducing a clamped crit-avoidance value. Divine Sacrifice tracks its source-defined 150%-of-caster-max-health cumulative redirect cap on the caster aura. Party hits redirect only the remaining allowance; self-targeted hits do not enter that redirect path. Redemption also enforces its source-described out-of-combat gate. Aura Mastery grants the active 10-second silence immunity to eligible party recipients; interrupt immunity and the aura-amplification rider remain unmodeled. The focused Paladin runtime suite passed 14 tests. +- WoW Mage now wires Improved Scorch, Fingers of Frost, and Hot Streak from rank-specific source data. Its focused Mage, Blink, and effect-tuning suites passed 26 tests across three files. Ignite and several proc payloads remain source-conflicted or incomplete. +- WoW Shaman now wires Ancestral Awakening, Ancestral Healing, Shamanism, and rank-scaled Reverberation Shock cooldown reduction. The focused Shaman proc runtime file passed five tests, and the shared Shock cooldown regression passed one test. +- RoM Champion rank 1 Forge installs a five-minute trigger; landed normal melee hits add a capped 8-second, 2%-per-stack attack-power buff and 10 Rage. Its runtime test file passed five tests. Shield Form and rank-2 proc chance data remain unavailable. +- WoW Hunter's Intimidation proc is attributed to the pet and consumes on its successful melee hit. Kill Command's special-attack rider remains unsupported because the simulator emits no distinct pet-special event; The Beast Within source row and generated tooltip still conflict. The focused Hunter suite passed 10 tests. +- RoM Scout now keeps bow/crossbow accuracy out of melee hit chance, gates Poisonous Spit on Poison Powder, and implements Accurate Targeting's 15-second +50 physical-attack buff with its 30-second cooldown. The ranged-hit consumer and Scout-focused suites passed 49 tests across two files. +- RoM Priest/Mage Lightning Rod (492638) now requires both its passive state and Bone Chill on the target before Lightning applies Electric Shock (20 seconds; −50 Water and Wind resistance). Its focused test file passed three tests. +- RoM Priest/Scout Saint's Blessing (493039) now triggers on Heal when a friendly target is below 30% health and applies its extracted +30 healing and +1% healing for 10 seconds before the triggering heal. The focused Priest/Scout runtime suites passed 12 tests across three files. +- RoM Mage rank-2 Catalysis's +50% spell haste now shortens casts/GCDs only for the RoM Mage class; rank 1 and other classes are unchanged. The focused RoM Mage runtime suite passed 10 tests. +- RoM Druid now selects source-backed Nature Power branches for Fountain, Seeds, Wrath, Sandstorm, Earth Chain, Healing Wind, Poisonous Widow, Heart Piercing, and Cross of Thorns, with exact resource drain/refund behavior. Holy Fury, Extended Recovery, and Knee Ulcer also use their sourced duration/root values and passive gate. Five focused Druid/RoM suites passed 115 tests. +- RoM Warden's Will Attack passive now gates Frantic Briar's 10-second, 10% enemy damage reduction and routes it to every affected mob; all modeled incoming melee recipient paths consume the debuff. Its runtime suite passed nine tests. +- RoM Knight Divine Punishment now stores its passive as a marker and applies the extracted 1% bonus only to Punishment damage/DoT components; Holy Seal behavior is unchanged. Knight periodic/mechanics suites passed 23 tests across two files. The separate Light-vulnerability component remains unresolved because its extracted resistance direction conflicts with the tooltip. +- WoW Warlock Fel Synergy heals its active pet for 15% of effective spell damage at 50%/100% proc ranks; Soul Leech heals on eligible direct single-target Destruction hits with rank-scaled chance, a 15%-of-damage heal capped at 15% max health, and a 3-second cooldown; Eradication procs from Corruption at 15% and grants 7/14/20% cast haste for 10 seconds; Improved Shadow Bolt requires a critical Shadow Bolt and applies a 5% spell-crit vulnerability for 30 seconds. The focused Warlock suite passed 19 tests across four files. +- WoW Death Knight Butchery now grants 10/20 Runic Power for eligible player killing blows at ranks 1/2, clamps to the resource cap, and ignores ineligible targets and party/pet kills. Its focused runtime test passed 15 cases. +- WoW Mage Impact now uses the active rank-3 row's 4% landed direct-magic-spell proc to arm the next Fire Blast, which stuns for 3 seconds and consumes the charge on a landed cast; a miss preserves it. The focused Mage/Impact suites passed 13 tests across two files. The local legacy DBC differs on proc chance and stun duration. +- RoM Rogue/Priest Shadow Walker now lets Sneak Attack work in combat, reduces its Energy cost by 10, and adds 10 seconds to its cooldown. The Rogue mechanics and paired-class regression suites passed 60 tests across two files. +- RoM Warrior/Priest Defender's Roar now applies both source-backed maximum-health auras (+300 HP and +1% max HP for 120 seconds) to the caster and party; the separate timeout/proc aura remains caster-only. Its focused runtime test passed 2/2. +- RoM Champion/Warrior Determination Rune (498584) now scales its source-backed shield to 30% of caster max health; other classes and abilities are unchanged. The focused Champion runtime suite passed 7/7. +- WoW Warrior's Improved Whirlwind now reduces Whirlwind's cooldown by 1/2 seconds at ranks 1/2 without affecting other attacks. Battle Stance's active 10% armor-penetration aura now applies to physical attacks. Battle Shout and Commanding Shout reach party members within their source 30-yard radius. Improved Heroic Strike and Improved Execute reduce Rage costs by 2/4/6 per rank; Improved Execute damage and Unrelenting Assault damage/cooldown are scoped to the named abilities. Execute keeps its below-20% gate and rank-scaled damage instead of mapping to an instant-kill effect. The focused Warrior runtime, catalog, and talent suites passed 57 tests across three files. +- WoW Druid Improved Rejuvenation now parses its rank-scaled +7/14/20% bonus and applies it only to Rejuvenation healing. The focused talent and Druid runtime suites passed 12 tests across two files. +- Companion resource restores now update only the restored resource pool, preserving the WoW Druid Cat's Energy regeneration when its companion casts Enrage. The party damage audit suite passed 15 tests. +- RoM Warlock Defense Net now retains its 5-second 50% magic mitigation and grants fear, stun, root, and slow immunity to its friendly target; the runtime maps the source's Helpless category to fear because it has no distinct Helpless state. Its focused runtime suite passed seven tests. +- RoM Warlock/Priest Mind Shield now shortens Defense Net to four seconds for that class pair, as its description specifies; the normal spell remains five seconds elsewhere. The Warlock runtime suite passed eight tests. + +## Confirmed remaining behavior gaps + +These are specific, source-backed gaps that still need implementation or a gameplay test. The catalog contains many more conditional and proc skills than the current focused tests exercise. + +### WoW + +- **Hunter:** Intimidation now stores and consumes its next-melee proc on the pet; Freezing Arrow and Explosive Trap have armed-trap behavior. Kill Command's 1%-per-special-attack rider cannot be represented correctly because the pet runtime emits only generic melee hits, not pet-special events. The Beast Within's extracted spell row and generated talent tooltip still disagree on duration and amounts. +- **Priest:** Desperate Prayer, allied Dispel Magic, party buffs, Pain Suppression threat reduction, Prayer of Mending, Divine Aegis, Surge of Light, Serendipity, Shadow Weaving, Holy Concentration, and Test of Faith have focused runtime behavior. Lightwell's exact click-heal amount remains undocumented in the available child-spell payload; its visual remains unreviewed in live gameplay. +- **Death Knight:** Death Coil's friendly Undead heal, Death Strike disease-count self-heal, Anti-Magic Shell and Zone absorbs, Bone Shield charges, Blood Tap rune restriction, Deathchill, Improved Rune Tap, and Butchery's eligible-kill Runic Power have focused fixes/tests. Anti-Magic Shell's runic-power gain remains uncertain; spell visuals have not received a rendered class pass. +- **Rogue:** stealth/dagger gates, five-assault Killing Spree (both weapons per assault), Shadowstep, Pick Pocket feedback, Riposte readiness, and future-only Tricks threat routing are covered. Initiative, Ruthlessness, and rank-3 Honor Among Thieves now have proc hooks; Honor Among Thieves uses the Rogue's own landed direct-damage casts with a three-second limit. Shadow Walker also enables combat Sneak Attack and applies its sourced cost/cooldown changes only to the Rogue/Priest pair. Combat Potency remains unsupported without off-hand hit identity; Pick Pocket still has no loot/currency result model. +- **Shaman:** Nature's Swiftness, Tidal Force, Healing Stream Totem, Shamanistic Rage, Ancestral Awakening, Ancestral Healing, Shamanism, and rank-scaled Reverberation are wired. Elemental Focus now grants rank-scaled Clearcasting with two discounted casts even though its child spell is absent; Reverberation's extra-Shock child 965977 remains missing. Maelstrom Weapon's tooltip describes its eligible spells and 20%-per-stack cast-time reduction, but proc chance, stack duration, and child payloads are absent; PvP damage reduction has no PvP runtime path. Other passives still need event-model coverage; no exhaustive class runtime or rendered VFX pass is claimed. +- **Paladin:** Redemption, Reckoning's rank-based proc and three-hit charge window, Eye for an Eye reflection/cap, Divine Storm healing, Beacon transfer, Greater Blessing fanout, Hammer of Wrath health gate, Holy Shield shield gate, Divine Sacrifice redirect cap, aura parsing, Aura Mastery's source-row 10-second silence immunity, and Avenger's Shield's three-target/no-falloff chain have targeted fixes or runtime coverage. Remaining gaps include Vindication, Holy Shield block-charge consumption, Aura Master's interrupt immunity and aura amplification, and the active/UI Aura Mastery duration/scope discrepancy. Holy Shield's exact charge count is absent from available source payloads. +- **Mage:** Blink root/stun clearing, Improved Scorch, Fingers of Frost, Hot Streak, and active-row rank-3 Impact's next-Fire-Blast stun have focused coverage. Impact's active generated row (4% proc, 3-second stun) conflicts with the local legacy DBC (10% proc, 2-second child); runtime follows the active row. Ignite and Focus Magic retain source/UI conflicts; Brain Freeze, Arcane Concentration, and Missile Barrage lack complete proc-expiry data. The forward Blink teleport is unsupported while player facing is absent. +- **Warlock:** Conflagrate consumption, Shadowflame periodic damage, Seed of Corruption threshold/death explosion, Curse of Agony ramp, Soulshatter, Fel Synergy, Soul Leech healing, Eradication, crit-gated Improved Shadow Bolt, and Backlash's rank-based proc/cast rider have focused coverage. Soul Link's DBC value conflicts with the talent UI; Nightfall's next-cast/expiry payload, Soul Leech's missing child 900067 AP debuff and capstone crit-after-hit, and Metamorphosis remain unsupported or unresolved. +- **Druid:** Eclipse, Omen of Clarity (including successful overheal events), Nature's Grace, and the rank-scaled Improved Rejuvenation healing bonus have source-backed behavior. Glyph of Swiftmend state is not represented, so its HoT-preservation behavior remains unsupported. +- **Warrior:** Battle Stance armor penetration, Battle/Commanding Shout party radius, Improved Heroic Strike/Execute Rage discounts, Improved Execute's below-20% gate, rank-scaled damage and conditional crit window, Unrelenting Assault's named ability scope, Bloodthirst/Sweeping Strikes, and Improved Whirlwind's 1/2-second cooldown reduction have focused behavior tests. Unbridled Wrath, Blood Craze, and Second Wind still reference hidden child payloads. Other passives/procs remain incomplete. + +### RoM + +- **Warrior:** Survival thresholds, weapon requirements, three-hit Moon Cleave, several Slash/state riders, and Warrior/Priest Defender's Roar party max-health effects have focused fixes; numerical source gaps remain for Heroic Surprise Attack, Resist, Recovery of Life's additional rider, Exhaustion's drain chance, Speed Slash's reset chance, and missing source rows 499825/499826. +- **Scout:** Ranged Weapon Mastery's bow/crossbow accuracy now affects ranged hit chance only; Poisonous Spit requires Poison Powder; Accurate Targeting grants its source-backed buff and cooldown. Natural Poison Powder acquisition for Scout/Rogue remains unavailable in the loadout model. Pet crit/dodge/damage passives remain unsupported because summons expose no pet-specific stats or event path; other paired passives still need review. +- **Rogue:** Dagger Mastery, Treasure Hunter, Weakened Soul Stab, rear-position gates, Sneak Attack's out-of-combat gate, resource gates, and the Rogue/Priest Shadow Walker override have targeted behavior. Off Hand Mastery remains unsupported without an off-hand damage event; Instant Shadow's dodge trigger and Day of Rain's hit-rate value need further source/runtime validation. +- **Priest:** Knight/Priest Holy Smite now heals and consumes Holy Seals correctly; Holy Protection applies its alternate threat payload as intended; Lightning Rod requires the sourced Bone Chill target state; Priest/Scout Saint's Blessing uses its friendly-target health threshold; and Priest/Warlock Prayer of Tribulation has focused runtime coverage. Infused Light Chain's trigger chance and Rage values are absent from local source rows. Current CoA records conflict with local RuneWaker IDs/names or payloads for Priest/Champion 498610 and 498612 and Priest 490274–490276; those records were not used to remap the local catalog. Other conditional/passive mechanics still need review. +- **Knight:** Divine Punishment's 1% damage modifier is now limited to Punishment instead of applying globally. Some periodic/triggered spell references do not resolve. Multiple passives are tooltip-only or inert. Throw Shield and Shield Bash now require an equipped shield, and Arrow of Vengeance enforces its ranged-weapon requirement. Light-vulnerability remains ambiguous between the tooltip and extracted resistance component. +- **Warden:** Nature Crystal immunity, Heart of Oak/Crystalline absorbs, Awakened immunity/next-crit behavior, Nature Power scaling for Healing Ripple/Gift Pulse, Spirit of Life party HoT, Mysterious Grace party scope, Will Attack's Frantic Briar debuff, and Damage Transfer's 30% split to a live nearby pet have focused fixes. Hidden child 493416, Revival's trigger row 499544, and Weak Point Strike's extra-hit proc chance are missing; other reactive passives remain incomplete. +- **Mage:** rank-1 Catalysis mana ticks, rank-2 Catalysis cast haste, Phoenix's 12-target cap, Eruption's 10% double-damage proc, Control Flame, and Flame Follower's Fireball cooldown rider are covered. Rank 2's source payload has no mana tick; Flame Follower's crit amount is absent from source components. +- **Druid:** Spirit of Life party HoT, Unity's guaranteed spell crit/healing and 15-second movement lock, Spirit Guidance's Nature Power gain, Savage Blessing's friendly target, Healing Arrows, source-backed Nature Power selection/spending for Fountain, Seeds, Wrath, Sandstorm, Earth Chain, Healing Wind, Poisonous Widow, Heart Piercing, and Cross of Thorns, plus Brilliance of Nature's 10-to-15 dynamic cap for the player and companion, have focused fixes. Charm faction conversion and Rebirth's debt reductions have no corresponding simulator state; several source-incomplete refund/proc values remain; Seed modifier ranks and Necrotic Wound's player-only healing reduction are not distinct in current source/runtime data. +- **Warlock:** Mind Transference's Rage-to-psi conversion, Heart Fence's full Rage consumption and duration scaling, Heart Strike gate, Crime and Punishment refresh/scaling, Liquidation Suffering target scopes, named cooldown resets, Defense Net's magic mitigation/control immunity, and Mind Shield's pair-scoped duration reduction have focused behavior. Helpless currently maps to fear in the runtime. Breathing Control and several Priest-pair passives lack proc probabilities or complete event details; hidden child 499869 and End of Thought's tick interval are absent. Current [CoA Warlock/Warrior](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1) and [Warlock/Priest](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) records conflict with RuneWaker values and IDs for Mind Transference, Deep Breathing Control, Divine Assistance, and related triggers; those were preserved. +- **Champion:** Fearless Blow's health/Rage gate, Rune Draw pull, Indomitable Spirit threshold, cooldown-reset scopes, rank-1 Forge trigger, Champion/Warrior Determination Rune's 30%-max-HP shield, and Rune Overload's local 15-Rage-per-second drain and zero-Rage termination have focused fixes. Shield Form has no player-learnable source skill; Forge rank-2 and other proc chances are absent or incomplete. The current [CoA Champion/Rogue skills page](https://chroniclesofarcadia.com/en/wiki/skills/list/10-3) reuses Rune Overload's identity with newer values/behavior, so local RuneWaker values were retained. + +## Visual review and evidence limits + +- Spell effect manifest counts above identify mapped source assets and browser-renderable graphs. They do not certify perceived brightness, screen coverage, or combat readability. WoW phase graph coverage is strong for mapped ranks; Shaman has the largest graph gap (149/251 mapped rank rows), followed by Hunter (125/157). +- The original WoW Flash Heal impact and Arcane Explosion cast-start sphere, plus RoM Plasma Arrow and Electric Bolt impact models, were inspected in the development preview. Their selected phases are tuned; other WoW spell visuals still need a per-spell rendered review. +- Hunter graph metadata shows Arcane Shot/Aimed Shot using a five-emitter/two-ribbon ArcaneShot_Missile release (particle max scale about 0.26) and the shared Magic_Impact_Chest impact (max scale about 1.76, peak alpha 1); Volley uses a 16-emitter/four-ribbon release (max scale about 0.26) and Magic_Impact_Chest/ArcaneShot_Area impacts (ArcaneShot_Area max scale about 0.83). The Beast Within cast graphs report Magic_Cast_Hand max scale about 0.45 and BeastRageCaster about 0.96. These are extracted graph values only: the Hunter review's CUA browser inventory exposed no browser, so no rendered size/brightness verdict was recorded. +- Warrior evidence gap: Bloodthirst and Sweeping Strikes have focused catalog/runtime regression coverage, but the remaining Warrior abilities/talents have not received a complete runtime pass, and Warrior spell size/brightness has not received per-spell rendered review. +- RoM visual review remains limited: only a subset of active source effects have browser-renderable graphs. A class-by-class size/brightness verdict cannot be derived from source file names or default `1x` tuning. Most RoM spells still need per-spell visual review. +- Verification is against the local simulator/runtime and extracted catalogs, not an official WoW or RoM server. The focused tests validate selected fixes; they do not prove every class ability or talent works. + +## Focused test coverage + +The consolidated class-related suite matched `^(wow335|rom|deathKnight|mageBlinkRuntime|wowDruid|wowNature|wowTidal|talentModifiers|combatAuras|combatStore)` plus `src/scene/combatEffectRuntime.test.ts`: **55 files and 711 tests passed**. This covers focused behavior changes, not every ability/talent combination. The earlier full `npm test` run had 15 failing files and 21 failed tests; class regressions from that run have since passed in focused suites, while several dungeon-count and balance expectation failures remain. The full suite was not rerun after the latest class fixes. The final `npm run build` passed, including TypeScript, the KTX2 audit (zero fallback textures), the environment-performance audit (zero regressions), bundle generation, and release verification; Vite reported existing chunk-size/dynamic-import warnings. `npm run release:check` passed, including untracked files. diff --git a/docs/BITE_SIZED_EDITOR.md b/docs/BITE_SIZED_EDITOR.md index bbde6a96..0d021594 100644 --- a/docs/BITE_SIZED_EDITOR.md +++ b/docs/BITE_SIZED_EDITOR.md @@ -5,9 +5,11 @@ GM administration also provides the same entry. A selected character and an onli GM account are required. Leave an online activity or Dungeon Race before editing. The scene uses the normal renderer, movement, and hold-right-drag camera. The right -panel scrolls and collapses to leave the world visible. Focusing a panel control -suspends movement and camera input. Click a world marker or collapse the panel to -return to placement. +panel scrolls and collapses to leave the world visible. Click the world or press +**Walk in scene**, then use **WASD** to move, **Space** to jump, and hold the +**right mouse button** to look. Focusing a panel control suspends movement and +camera input until you return focus to the scene. The panel can stay open while +you walk and place points. - Select Party start, Random boss spawn, or an individual mob point. Walk to a grounded position and press **Set here**, edit coordinates/facing, or use @@ -88,3 +90,7 @@ using another dev port. It records WoW/RoM authoring, imported previews, both bo settings, completion and unranked safeguards, panel input gating, right-drag release without pointer lock, and randomized entry into distinct parent maps under `playtest-artifacts/bite-sized-editor/`. + +Set `BITE_SIZED_EDITOR_INPUT_ONLY=1` for a focused WASD, panel-focus, combat-gating +and right-drag check. It writes `editor-movement.png` and +`movement-observations.json` in the same evidence directory. diff --git a/scripts/playtest-bite-sized-editor.cjs b/scripts/playtest-bite-sized-editor.cjs index 8bbfceb6..c7e5e416 100644 --- a/scripts/playtest-bite-sized-editor.cjs +++ b/scripts/playtest-bite-sized-editor.cjs @@ -28,6 +28,42 @@ async function ready(page) { await page.waitForTimeout(1200); console.log(`Ready: ${(await state(page)).mode || 'run'}`); } +async function verifyEditorMovement(page, panel) { + await panel.getByRole('button', { name: 'Walk in scene', exact: true }).click(); + for (const key of ['w', 's', 'a', 'd']) { + const before = await state(page); + await page.keyboard.down(key); await page.waitForTimeout(600); await page.keyboard.up(key); + await page.waitForTimeout(150); + const after = await state(page); + assert.ok(Math.hypot(after.position[0] - before.position[0], after.position[2] - before.position[2]) > .15, `${key.toUpperCase()} must move the player in edit mode`); + } + await panel.getByLabel('Spawn X', { exact: true }).focus(); + const before = await state(page); + assert.equal(before.focused, true); + await page.keyboard.down('w'); await page.waitForTimeout(350); await page.keyboard.up('w'); + const paused = await state(page); + assert.ok(Math.hypot(paused.position[0] - before.position[0], paused.position[2] - before.position[2]) < .1, 'Panel focus must pause movement'); + await page.mouse.click(550, 380); + assert.equal((await state(page)).focused, false, 'Clicking the world must return keyboard control'); + await page.keyboard.down('w'); await page.waitForTimeout(500); await page.keyboard.up('w'); + const walking = await state(page); + assert.ok(Math.hypot(walking.position[0] - paused.position[0], walking.position[2] - paused.position[2]) > .15, 'W must work again after clicking the world'); + await page.keyboard.press('Digit1'); + assert.equal(await page.evaluate(async () => { + const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts'); + return useCombatStore.getState().activeCast; + }), null, 'Editing must still block combat actions'); + await page.mouse.move(550, 380); await page.mouse.down({ button: 'right' }); + const yaw = (await state(page)).yaw; + await page.mouse.move(650, 395, { steps: 8 }); await page.waitForTimeout(300); + assert.notEqual((await state(page)).yaw, yaw); assert.equal((await state(page)).pointerLock, false); + await page.mouse.up({ button: 'right' }); await page.waitForTimeout(100); + const released = (await state(page)).yaw; + await page.mouse.move(700, 420, { steps: 4 }); await page.waitForTimeout(300); + assert.equal((await state(page)).yaw, released, 'Camera motion must stop after right-button release'); + observations.push({ name: 'editor WASD, panel focus return, combat gating, and right drag without pointer lock', state: await state(page) }); + await page.screenshot({ path: path.join(out, 'editor-movement.png') }); +} (async () => { const { createGameServer } = await import('../server/server.mjs'); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-editor-playtest-')); @@ -74,6 +110,13 @@ async function ready(page) { assert.equal((await state(page)).mode, 'edit'); assert.equal((await state(page)).grounded, true); await page.screenshot({ path: path.join(out, 'wow-editor.png') }); + await verifyEditorMovement(page, panel); + if (process.env.BITE_SIZED_EDITOR_INPUT_ONLY === '1') { + assert.deepEqual(errors, []); + fs.writeFileSync(path.join(out, 'movement-observations.json'), `${JSON.stringify({ observations, errors }, null, 2)}\n`); + console.log(`Passed editor movement and camera checks. Screenshots: ${out}`); + return; + } await panel.getByRole('button', { name: 'New spot', exact: true }).click(); await ready(page); await panel.getByLabel('Spot name', { exact: true }).fill('Playtest new spot'); diff --git a/src/GameRuntime.tsx b/src/GameRuntime.tsx index 898e124d..b1c3791f 100644 --- a/src/GameRuntime.tsx +++ b/src/GameRuntime.tsx @@ -98,6 +98,10 @@ function InputBridge() { const game = useGameStore.getState(); return game.overlay !== null || game.companionOpen || useBiteSizedLayoutsStore.getState().editorMode === "edit"; }, + movementBlocked: () => { + const game = useGameStore.getState(); + return game.overlay !== null || game.companionOpen; + }, setCameraLookActive: (active) => useGameStore.getState().setCameraLookActive(active), setInputMode: (mode) => useGameStore.getState().setInputMode(mode), setActionLayer: (layer) => useGameStore.getState().setActionLayer(layer), diff --git a/src/game/abilityCatalog.ts b/src/game/abilityCatalog.ts index 82915231..f1e8459d 100644 --- a/src/game/abilityCatalog.ts +++ b/src/game/abilityCatalog.ts @@ -149,7 +149,9 @@ export interface AbilityHealingRecipients { readonly center?: "caster" | "ability-target"; readonly radius?: number; readonly maximumTargets?: number; - readonly selection?: "random"; + readonly falloffPerJump?: number; + readonly selection?: "random" | "lowest-max-health"; + readonly excludeCaster?: boolean; } export interface AbilityDamageResource { @@ -166,12 +168,21 @@ export type AbilityEffect = /** Optional stack-based payload used by poison-consuming finishers. */ readonly poison?: import("./roguePoison").RoguePoisonFinisherScaling; } - | ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients) + | ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number; readonly percentOfDamage?: number } & AbilityAmountScaling & AbilityHealingRecipients) | ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients) | ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource) - | ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling & AbilityHealingRecipients) - | { readonly kind: "resurrection"; readonly percentMaxHealth: number } - | { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number } + | ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients) + | { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number } + | { + readonly kind: "summon"; + readonly creatureId: number; + readonly coefficient: number; + readonly durationMs: number; + /** Source-defined pulse for stationary healing totems. */ + readonly healingPulse?: { readonly amount: number; readonly intervalMs: number; readonly radius: number }; + /** Source-defined party absorb area carried by a stationary Death Knight zone. */ + readonly antiMagicZone?: { readonly radius: number; readonly capacityBase: number; readonly capacityPerAttackPower: number; readonly absorbFraction: number; readonly intervalMs: number }; + } | { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean } | { readonly kind: "combo"; readonly amount: number } | { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number } @@ -180,7 +191,7 @@ export type AbilityEffect = readonly kind: "apply-aura"; readonly aura: AuraDefinition; /** Some hostile spells apply their beneficial rider to the caster. */ - readonly recipient?: "ability-target" | "caster"; + readonly recipient?: "ability-target" | "caster" | "pet"; /** Party/raid area auras are applied to the player and every active party member. */ readonly scope?: "target" | "party"; /** Some party effects benefit allies but exclude the caster. */ @@ -218,11 +229,18 @@ export type AbilityEffect = | { readonly kind: "dispel-mechanic"; readonly mechanic: number; readonly maxCount: number } | { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number } | { readonly kind: "totem"; readonly action: "destroy-all" } - | { readonly kind: "rune"; readonly action: "activate"; readonly count: number } + | { + readonly kind: "rune"; + readonly action: "activate"; + readonly count: number; + readonly runeType?: "blood" | "frost" | "unholy"; + readonly convertToDeathMs?: number; + } | { readonly kind: "cooldown-reset"; readonly mode: "reset" | "reduce"; readonly abilityName?: string; + readonly excludeAbilityNames?: readonly string[]; readonly amountMs?: number; /** Fraction of the target ability's remaining cooldown removed at execution time. */ readonly amountPercent?: number; @@ -907,7 +925,7 @@ const abilityCatalog: Record = {}; let romEliteAbilities: readonly AbilityDefinition[] = []; let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null; const coaTriggeredAbilities = new Map(); -const classicChannelTriggeredAbilities = new Map(); +const classicTriggeredAbilities = new Map(); let classicLoad: Promise | null = null; let romLoad: Promise | null = null; const coaLoads = new Map>(); @@ -950,7 +968,7 @@ async function preloadClassicAbilityCatalog(): Promise { if (classicLoad) return classicLoad; classicLoad = import("./wow335AbilityCatalog").then(({ WOW335_ABILITIES_BY_CLASS, - WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID, + WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID, }) => { for (const classId of BASE_CLASS_IDS) { registerClassAbilities( @@ -958,8 +976,8 @@ async function preloadClassicAbilityCatalog(): Promise { mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]), ); } - for (const [spellId, ability] of Object.entries(WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID)) { - classicChannelTriggeredAbilities.set(Number(spellId), ability); + for (const [spellId, ability] of Object.entries(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID)) { + classicTriggeredAbilities.set(Number(spellId), ability); } }).catch((error: unknown) => { classicLoad = null; @@ -1026,7 +1044,7 @@ export function triggeredAbilityBySpellId( includeCoa = true, ): AbilityDefinition | null { return romTriggeredAbilityResolver?.(spellId) - ?? classicChannelTriggeredAbilities.get(spellId) + ?? classicTriggeredAbilities.get(spellId) ?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined) ?? null; } @@ -1048,6 +1066,21 @@ export function abilitiesForCharacter( return [...primary, ...secondaryGeneral, ...elites]; } +/** Return the RuneWaker Nature Power cap granted by the learned Brilliance passive. */ +export function naturePowerMaximumForCharacter( + classId: ClassId, + secondaryClassId: ClassId | null | undefined, + level: number, +): number { + const hasBrillianceOfNature = abilitiesForCharacter(classId, secondaryClassId).some((ability) => ( + ability.source === "runewaker" + && ability.dbcSpellId === 494931 + && ability.passive + && level >= ability.unlockLevel + )); + return hasBrillianceOfNature ? 15 : 10; +} + export function abilityRankForLevel( ability: AbilityDefinition, playerLevel: number, diff --git a/src/game/biteSizedEditorInput.ts b/src/game/biteSizedEditorInput.ts new file mode 100644 index 00000000..05b08b88 --- /dev/null +++ b/src/game/biteSizedEditorInput.ts @@ -0,0 +1,8 @@ +import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore"; + +/** Give placement controls keyboard focus without collapsing the editor panel. */ +export function focusBiteSizedEditorScene(): void { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + document.querySelector(".scene-shell")?.focus({ preventScroll: true }); + useBiteSizedLayoutsStore.setState({ panelFocused: false }); +} diff --git a/src/game/combatActorAuthority.ts b/src/game/combatActorAuthority.ts index 8b84b705..6a002590 100644 --- a/src/game/combatActorAuthority.ts +++ b/src/game/combatActorAuthority.ts @@ -1,4 +1,4 @@ -import { resourceProfileForClass, type ResourceType } from "./abilityCatalog"; +import { naturePowerMaximumForCharacter, resourceProfileForClass, type ResourceType } from "./abilityCatalog"; import { normalizeActorResourcePool, type CombatActorState, @@ -10,7 +10,7 @@ import { deriveCharacterStats } from "./wotlkStats"; export function companionResourceMaximum(member: PartyMember, id: string): number { return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId) ? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana - : id === "nature-power" ? 10 + : id === "nature-power" ? naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level) : id === "psi" ? 6 : 100; } diff --git a/src/game/combatActors.ts b/src/game/combatActors.ts index 4a35c853..778236a1 100644 --- a/src/game/combatActors.ts +++ b/src/game/combatActors.ts @@ -20,6 +20,7 @@ export interface DeathKnightRuneState { readonly baseType: Exclude; readonly type: DeathKnightRuneType; readonly readyAt: number; + readonly deathRuneExpiresAt?: number; } export interface CombatActorCastState { diff --git a/src/game/combatAuras.test.ts b/src/game/combatAuras.test.ts index 13d3723c..f821e01a 100644 --- a/src/game/combatAuras.test.ts +++ b/src/game/combatAuras.test.ts @@ -124,6 +124,43 @@ describe("combat auras", () => { expect(result.auras[0].absorbs.map((absorb) => absorb.remaining)).toEqual([0, 0]); }); + it("supports fractional, multi-school absorbs while keeping the capacity shared", () => { + const auras = apply(buff({ + id: "anti-magic-shell", + absorbs: [{ + id: "shell", + amount: 120, + schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], + fraction: 0.75, + }], + })); + const magical = absorbDamage(auras, { + targetId: "player", + amount: 100, + school: "fire", + now: 100, + }); + expect(magical).toMatchObject({ absorbed: 75, remainingDamage: 25 }); + expect(magical.auras[0].absorbs[0].remaining).toBe(45); + + const physical = absorbDamage(magical.auras, { + targetId: "player", + amount: 40, + school: "physical", + now: 200, + }); + expect(physical).toMatchObject({ absorbed: 0, remainingDamage: 40 }); + expect(physical.auras[0].absorbs[0].remaining).toBe(45); + + const capped = absorbDamage(magical.auras, { + targetId: "player", + amount: 100, + school: "shadow", + now: 300, + }); + expect(capped).toMatchObject({ absorbed: 45, remainingDamage: 55 }); + }); + it("supports guaranteed and failed deterministic proc rolls", () => { const definition = buff({ procs: [{ @@ -149,6 +186,37 @@ describe("combat auras", () => { expect(failed.triggered).toHaveLength(0); }); + it("spends melee-only proc charges only on landed melee attack events", () => { + const auras = apply(buff({ + procs: [{ + id: "melee-charge", + triggers: ["hit", "crit"], + attackKinds: ["melee"], + chance: 1, + charges: 5, + action: { kind: "custom", id: "melee-only" }, + }], + })); + + const rangedHit = evaluateAuraProcs(auras, { + trigger: "hit", + subjectId: "player", + otherId: "mob", + attackKind: "ranged", + }, 100, () => 0); + expect(rangedHit.triggered).toHaveLength(0); + expect(rangedHit.auras[0].procStates[0].remainingCharges).toBe(5); + + const meleeHit = evaluateAuraProcs(rangedHit.auras, { + trigger: "hit", + subjectId: "player", + otherId: "mob", + attackKind: "melee", + }, 200, () => 0); + expect(meleeHit.triggered).toHaveLength(1); + expect(meleeHit.auras[0].procStates[0].remainingCharges).toBe(4); + }); + it("enforces proc internal cooldowns and consumes charges only on success", () => { const roll = vi.fn(() => 0); const definition = buff({ diff --git a/src/game/combatAuras.ts b/src/game/combatAuras.ts index 8c0ff330..e6c33d53 100644 --- a/src/game/combatAuras.ts +++ b/src/game/combatAuras.ts @@ -69,8 +69,14 @@ export type AuraModifier = export interface AuraAbsorbDefinition { readonly id: string; readonly amount: number; + /** Number of matching attacks this absorb fully negates instead of consuming a point pool. */ + readonly hitCharges?: number; /** Omit to absorb all damage schools. */ readonly school?: DamageSchool; + /** Optional school set for absorbs such as Anti-Magic Shell. */ + readonly schools?: readonly DamageSchool[]; + /** Fraction of each incoming packet this absorb can consume; defaults to 1. */ + readonly fraction?: number; /** Defaults to true, granting `amount` of capacity for each aura stack. */ readonly perStack?: boolean; } @@ -78,7 +84,10 @@ export interface AuraAbsorbDefinition { export interface AuraAbsorbState { readonly id: string; readonly school?: DamageSchool; + readonly schools?: readonly DamageSchool[]; + readonly fraction?: number; readonly remaining: number; + readonly remainingHits?: number; } export type AuraProcAction = @@ -114,6 +123,8 @@ export type AuraProcAction = export interface AuraProcDefinition { readonly id: string; readonly triggers: ProcTrigger | readonly ProcTrigger[]; + /** Limit attack-triggered procs to the listed landed attack types. */ + readonly attackKinds?: readonly AttackKind[]; /** Probability from 0 through 1. */ readonly chance: number; readonly internalCooldownMs?: number; @@ -157,6 +168,8 @@ export interface AuraDefinition { readonly reflectSpellChance?: number; /** Portion of incoming target damage transferred to the aura source. */ readonly redirectDamagePercent?: number; + /** Shared source-max-health cap for Paladin Divine Sacrifice redirection. */ + readonly redirectDamageCapPercent?: number; readonly dispelCategory?: DispelCategory; readonly dispelPriority?: number; readonly modifiers?: readonly AuraModifier[]; @@ -174,6 +187,8 @@ export interface ActiveAura { readonly stacks: number; readonly absorbs: readonly AuraAbsorbState[]; readonly procStates: readonly AuraProcState[]; + /** Cumulative redirected damage for an aura that owns a shared redirect cap. */ + readonly redirectedDamage?: number; /** Number of Fire critical hits landed during the current Combustion buff. */ readonly combustionCriticalHits?: number; } @@ -214,6 +229,7 @@ export interface AbsorbDamageRequest { readonly amount: number; readonly school: DamageSchool; readonly now: number; + readonly countsAsAttack?: boolean; } export interface AbsorbConsumption { @@ -288,7 +304,12 @@ function newAbsorbStates(definition: AuraDefinition, stacks: number): readonly A return (definition.absorbs ?? []).map((absorb) => ({ id: absorb.id, school: absorb.school, + schools: absorb.schools, + fraction: absorb.fraction, remaining: nonNegative(absorb.amount) * (absorb.perStack === false ? 1 : stacks), + ...(absorb.hitCharges === undefined + ? {} + : { remainingHits: Math.max(0, Math.floor(nonNegative(absorb.hitCharges))) }), })); } @@ -313,6 +334,7 @@ function makeAura(request: ApplyAuraRequest, stacks: number): ActiveAura { stacks, absorbs: newAbsorbStates(definition, stacks), procStates: newProcStates(definition), + ...(definition.redirectDamageCapPercent === undefined ? {} : { redirectedDamage: 0 }), }; } @@ -498,15 +520,28 @@ export function absorbDamage( if (index < 0) continue; const aura = next[index]; const absorbs = aura.absorbs.map((absorb) => { - if (remainingDamage <= 0 || (absorb.school !== undefined && absorb.school !== request.school)) { + if (remainingDamage <= 0 + || (absorb.school !== undefined && absorb.school !== request.school) + || (absorb.schools !== undefined && !absorb.schools.includes(request.school))) { return absorb; } - const amount = Math.min(absorb.remaining, remainingDamage); + const packetFraction = Math.max(0, Math.min(1, absorb.fraction ?? 1)); + const isChargedAttackAbsorb = absorb.remainingHits !== undefined + && request.countsAsAttack === true + && absorb.remainingHits > 0; + if (absorb.remainingHits !== undefined && !isChargedAttackAbsorb) return absorb; + const amount = isChargedAttackAbsorb + ? remainingDamage * packetFraction + : Math.min(absorb.remaining, remainingDamage * packetFraction); remainingDamage -= amount; if (amount > 0) { consumptions.push({ auraInstanceId: aura.instanceId, absorbId: absorb.id, amount }); } - return { ...absorb, remaining: absorb.remaining - amount }; + return { + ...absorb, + remaining: absorb.remaining - amount, + ...(isChargedAttackAbsorb ? { remainingHits: absorb.remainingHits! - 1 } : {}), + }; }); next[index] = { ...aura, absorbs }; } @@ -545,6 +580,8 @@ export function evaluateAuraProcs( if ( proc === undefined || !procHasTrigger(proc, event.trigger) || + (proc.attackKinds !== undefined + && (event.attackKind === undefined || !proc.attackKinds.includes(event.attackKind))) || state.readyAt > now || state.remainingCharges === 0 ) { diff --git a/src/game/combatResources.ts b/src/game/combatResources.ts index 263aa57b..ca745996 100644 --- a/src/game/combatResources.ts +++ b/src/game/combatResources.ts @@ -47,7 +47,47 @@ export function availableDeathKnightRunes( runes: readonly DeathKnightRuneState[], now: number, ): readonly DeathKnightRuneState[] { - return runes.filter((rune) => rune.readyAt <= now); + return normalizeDeathKnightRunes(runes, now).filter((rune) => rune.readyAt <= now); +} + +export function normalizeDeathKnightRunes( + runes: readonly DeathKnightRuneState[], + now: number, +): readonly DeathKnightRuneState[] { + return Object.freeze(runes.map((rune) => { + if (rune.deathRuneExpiresAt === undefined || rune.deathRuneExpiresAt > now) return rune; + const { deathRuneExpiresAt: _expiredAt, ...baseRune } = rune; + void _expiredAt; + return Object.freeze({ ...baseRune, type: rune.baseType }); + })); +} + +export function canActivateDeathKnightBloodTapRune( + runes: readonly DeathKnightRuneState[], + now: number, +): boolean { + return normalizeDeathKnightRunes(runes, now).some((rune) => rune.baseType === "blood" && rune.type === "blood"); +} + +export function activateDeathKnightBloodTapRune( + runes: readonly DeathKnightRuneState[], + now: number, + durationMs: number, +): readonly DeathKnightRuneState[] { + const current = [...normalizeDeathKnightRunes(runes, now)]; + const candidate = current + .filter((rune) => rune.baseType === "blood" && rune.type === "blood") + .sort((left, right) => Number(right.readyAt > now) - Number(left.readyAt > now) + || right.readyAt - left.readyAt || left.slot - right.slot)[0]; + if (!candidate) return Object.freeze(current); + return Object.freeze(current.map((rune) => rune.slot === candidate.slot + ? Object.freeze({ + ...rune, + type: "death" as const, + readyAt: Math.min(rune.readyAt, now), + deathRuneExpiresAt: now + Math.max(1, durationMs), + }) + : rune)); } function runeMatches(rune: DeathKnightRuneState, type: Exclude): boolean { @@ -60,7 +100,9 @@ export function spendDeathKnightRunes( now: number, rechargeMs = DEATH_KNIGHT_RUNE_RECHARGE_MS, ): readonly DeathKnightRuneState[] | null { - const current = runes?.length === 6 ? [...runes] : [...createDeathKnightRunes(now)]; + const current = [...normalizeDeathKnightRunes( + runes?.length === 6 ? runes : createDeathKnightRunes(now), now, + )]; const spent = new Set(); for (const type of ["blood", "frost", "unholy"] as const) { const count = Math.max(0, Math.trunc(cost[type] ?? 0)); diff --git a/src/game/combatRotation.ts b/src/game/combatRotation.ts index e6e106bc..bd965485 100644 --- a/src/game/combatRotation.ts +++ b/src/game/combatRotation.ts @@ -476,7 +476,10 @@ function scoreAbility( targetId: context.selfId ?? null, } : null; } - if (categories.has("threat") && !damaging && !maintenanceBuff) { + // Some defensive spells also reduce the target's threat (for example + // Pain Suppression). Keep them eligible for mitigation scoring even when + // the healer does not currently hold enemy attention. + if (categories.has("threat") && !categories.has("mitigation") && !damaging && !maintenanceBuff) { if (THREAT_REDIRECT_NAMES.has(ability.name) && context.role !== "tank" && durableTarget) { return { ability, diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index 9ed7befc..a7ad5745 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -522,7 +522,7 @@ describe("combat store", () => { expect(useCombatStore.getState().shield).toBe(0); }); - it("keeps Inner Focus as an on-use, one-heal effect", () => { + it("keeps Inner Focus as an on-use, next-spell effect", () => { useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!; const ranks = { [talent.id]: 1 }; @@ -543,7 +543,7 @@ describe("combat store", () => { ))).toBe(true); expect(useCombatStore.getState().globalCooldownEndsAt).toBe(0); - const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 1_000); + const heal = useCombatStore.getState().castAbility("wow335-priest-greater-heal", undefined, 1_000); expect(heal.ok).toBe(true); expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0); const completion = useCombatStore.getState().activeCast!.completesAt; @@ -576,6 +576,40 @@ describe("combat store", () => { .toMatchObject({ ok: false, reason: "global-cooldown" }); }); + it("reduces Inner Focus's active cooldown when Improved Flash Heal is used", () => { + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + const talent = TALENT_NODES.find((node) => node.name === "Improved Flash Heal")!; + const ranks = { [talent.id]: talent.maxRank }; + const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!; + useCombatStore.setState((state) => ({ + talentRanks: ranks, + talentModifiers: talentModifiersForRanks(ranks, "priest"), + cooldowns: { ...state.cooldowns, [innerFocus.id]: 10_000 }, + })); + + const cast = useCombatStore.getState().castAbility("priest-flash-heal", { + targetActorId: null, + now: 1_000, + }); + expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); + const completion = useCombatStore.getState().activeCast!.completesAt; + useCombatStore.getState().tick(0.1, completion); + + expect(useCombatStore.getState().cooldowns[innerFocus.id]).toBe(8_000); + }); + + it("converts health into mana with Life Tap", () => { + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 }); + useCombatStore.setState((state) => ({ health: 5_000, resource: 0, + resourcePools: { ...state.resourcePools, mana: 0 } })); + const lifeTap = useCombatStore.getState().abilities.find((ability) => ability.name === "Life Tap")!; + + const result = useCombatStore.getState().castAbility(lifeTap.id, undefined, 1_000); + + expect(result).toMatchObject({ ok: true, healing: 0 }); + expect(useCombatStore.getState()).toMatchObject({ health: 3_510, resource: 1_490 }); + }); + it("ticks damage over time and awards kill experience exactly once", () => { useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 }); registerTarget("ooze", [10, 0, 0], 5, 100); @@ -839,6 +873,34 @@ describe("combat store", () => { }); }); + it("spreads only active Death Knight diseases with Pestilence and taunts with Death Grip", () => { + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); + registerTarget("disease-primary", [2, 0, 0], 5_000); + registerTarget("disease-neighbor", [5, 0, 0], 5_000); + useCombatStore.getState().selectMob("disease-primary"); + const abilities = useCombatStore.getState().abilities; + const spellId = (name: string) => { + const ability = abilities.find((entry) => entry.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; + }; + + expect(useCombatStore.getState().castAbility(spellId("Icy Touch"), undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().castAbility(spellId("Plague Strike"), undefined, 3_000).ok).toBe(true); + const neighborHealth = useCombatStore.getState().mobs["disease-neighbor"].health; + expect(useCombatStore.getState().castAbility(spellId("Pestilence"), undefined, 5_000).ok).toBe(true); + + expect(useCombatStore.getState().mobs["disease-neighbor"].health).toBe(neighborHealth); + expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === "disease-neighbor") + .map((effect) => effect.tag).sort()).toEqual(["blood-plague", "frost-fever"]); + + useCombatStore.getState().selectMob("disease-neighbor"); + expect(useCombatStore.getState().castAbility(spellId("Death Grip"), undefined, 7_000).ok).toBe(true); + expect(useCombatStore.getState().mobs["disease-neighbor"]).toMatchObject({ + forcedTarget: { actorId: PLAYER_AGGRO_ID, endsAt: 10_000 }, + }); + }); + it("swings at the resolved companion without redirecting player damage", () => { const target = usePartyStore.getState().members.find((member) => member.role === "damage")!; usePartyStore.setState((state) => ({ @@ -1425,6 +1487,97 @@ describe("combat store", () => { expect(seals[0].definition.name).toContain("Seal of Wisdom"); }); + it("applies Free Will Root and Slow immunity to the selected Knight/Priest ally", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", + secondaryClassId: "rom-priest", + level: 80, + xp: 0, + }); + usePartyStore.getState().initializeParty("free-will-test", 80, useCombatStore.getState().health); + const memberId = preparePartyMember(0, [2, 0, 0], Number.MAX_SAFE_INTEGER, true); + const freeWill = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491351); + expect(freeWill).toBeDefined(); + + const result = useCombatStore.getState().castAbility(freeWill!.id, { + targetActorId: memberId, + now: 1_000, + }); + + expect(result).toMatchObject({ ok: true, abilityId: freeWill!.id }); + const aura = useCombatStore.getState().auras.find((entry) => ( + entry.targetId === memberId && entry.definition.id === "rom-free-will-immunity" + )); + expect(aura).toMatchObject({ + targetId: memberId, + definition: { + durationMs: 10_000, + controlImmunities: ["root", "slow"], + }, + }); + expect(useCombatStore.getState().auras.some((entry) => ( + entry.targetId === PLAYER_AURA_ENTITY_ID && entry.definition.id === "rom-free-will-immunity" + ))).toBe(false); + }); + + it("fans out Crystalline Protection and Acceleration only to party members in range", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-warden", level: 80, xp: 0 }); + usePartyStore.getState().initializeParty("warden-crystalline-test", 80, useCombatStore.getState().health); + const inRangeId = preparePartyMember(0, [6, 0, 0], Number.MAX_SAFE_INTEGER); + const outOfRangeId = preparePartyMember(1, [8, 0, 0], Number.MAX_SAFE_INTEGER); + const abilities = useCombatStore.getState().abilities; + const protection = abilities.find((ability) => ability.dbcSpellId === 498201)!; + const acceleration = abilities.find((ability) => ability.dbcSpellId === 498202)!; + + expect(useCombatStore.getState().castAbility(protection.id, { now: 1_000 }).ok).toBe(true); + let auras = useCombatStore.getState().auras; + expect(auras.some((entry) => entry.targetId === inRangeId && entry.definition.name === "Crystalline Protection" + && (entry.definition.absorbs?.length ?? 0) > 0)).toBe(true); + expect(auras.some((entry) => entry.targetId === outOfRangeId && entry.definition.name === "Crystalline Protection")) + .toBe(false); + + expect(useCombatStore.getState().castAbility(acceleration.id, { now: 3_000 }).ok).toBe(true); + auras = useCombatStore.getState().auras; + const accelerationAuraFor = (targetId: string) => auras.filter((entry) => ( + entry.targetId === targetId && entry.definition.name.startsWith("Crystalline Acceleration") + )); + expect(accelerationAuraFor(inRangeId).flatMap((entry) => entry.definition.modifiers ?? [])) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "stat", stat: "attack-speed" }), + expect.objectContaining({ kind: "stat", stat: "spell-haste" }), + ])); + expect(accelerationAuraFor(outOfRangeId)).toHaveLength(0); + }); + + it("applies Coat of Arms to its caster and the selected ally only", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warden", + secondaryClassId: "rom-warrior", + level: 80, + xp: 0, + }); + usePartyStore.getState().initializeParty("warden-coat-of-arms-test", 80, useCombatStore.getState().health); + const selectedId = preparePartyMember(0, [2, 0, 0], Number.MAX_SAFE_INTEGER, true); + const otherId = preparePartyMember(1, [3, 0, 0], Number.MAX_SAFE_INTEGER); + const coatOfArms = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 494611)!; + + const result = useCombatStore.getState().castAbility(coatOfArms.id, { + targetActorId: selectedId, + now: 1_000, + }); + + expect(result).toMatchObject({ ok: true, abilityId: coatOfArms.id }); + const auras = useCombatStore.getState().auras.filter((entry) => entry.definition.name === "Coat of Arms"); + const hasBothDefenses = (targetId: string) => auras.some((entry) => ( + entry.targetId === targetId + && entry.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "armor") + && entry.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "magical-defense") + )); + expect(hasBothDefenses(PLAYER_AURA_ENTITY_ID)).toBe(true); + expect(hasBothDefenses(selectedId)).toBe(true); + expect(hasBothDefenses(otherId)).toBe(false); + }); + it("redirects Hand of Sacrifice damage to the paladin", () => { useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 }); usePartyStore.getState().initializeParty("hand-of-sacrifice-test", 80, useCombatStore.getState().health); @@ -1619,15 +1772,39 @@ describe("combat store", () => { expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === secondId)).toHaveLength(1); }); + it("requires and consumes a Rejuvenation or Regrowth HoT for Swiftmend", () => { + useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 }); + useCombatStore.setState({ health: 1_000 }); + const swiftmend = useCombatStore.getState().abilities.find((ability) => ability.name === "Swiftmend")!; + unlockReadySpell(swiftmend.id); + expect(useCombatStore.getState().castAbility(swiftmend.id, { targetActorId: null, now: 1_000 })) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + + expect(useCombatStore.getState().castAbility("druid-rejuvenation", { + targetActorId: null, + now: 1_000, + }).ok).toBe(true); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" + && effect.sourceAbilityId === "druid-rejuvenation")).toBe(true); + + const result = useCombatStore.getState().castAbility(swiftmend.id, { targetActorId: null, now: 3_000 }); + expect(result.ok).toBe(true); + expect(result.healing).toBeGreaterThan(0); + expect(useCombatStore.getState().health).toBeGreaterThan(1_000); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" + && effect.sourceAbilityId === "druid-rejuvenation")).toBe(false); + }); + it("keeps healing components of hostile abilities on the player", () => { useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 2 }); useCombatStore.setState({ resource: 100 }); - registerTarget("death-strike-target", [2, 0, 0], 100, 0); + registerTarget("death-strike-target", [2, 0, 0], 1_000, 0); const memberId = preparePartyMember(0, [2, 0, 0], 1, true); useCombatStore.getState().damagePlayer(40); const playerHealthBefore = useCombatStore.getState().health; + expect(useCombatStore.getState().castAbility("death-knight-icy-touch", [0, 0, 0], 1_000).ok).toBe(true); - const result = useCombatStore.getState().castAbility("death-knight-death-strike", [0, 0, 0], 1_000); + const result = useCombatStore.getState().castAbility("death-knight-death-strike", [0, 0, 0], 3_000); expect(result.ok).toBe(true); expect(result.damage).toBeGreaterThan(0); expect(result.healing).toBeGreaterThan(0); diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 7890048d..fb1de579 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -23,6 +23,7 @@ import { isAbilityUnlocked, learnedAbilityForBuild, learnedAbilityRank, + naturePowerMaximumForCharacter, resourceProfileForClass, triggeredAbilityBySpellId, type AbilityAmountScaling, @@ -63,8 +64,10 @@ import { memberFromCompanionActor, } from "./combatActorAuthority"; import { + activateDeathKnightBloodTapRune, activateDeathKnightRunes, advanceCombatResourcePool, + canActivateDeathKnightBloodTapRune, spendDeathKnightRunes, } from "./combatResources"; import { cooldownResetCandidates } from "./cooldownReset"; @@ -158,9 +161,41 @@ import { resolvedEquipmentStats, type HeirloomContext, } from "./heirloomEquipment"; -import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount } from "./romCombatMechanics"; -import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset, romMageWarlockCriticalEffects } from "./romMageWarlockOverrides"; +import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorPhysicalWeaponTooltip, romWarriorThreatMultiplier } from "./romCombatMechanics"; +import { + ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, + ROM_MAGE_WARLOCK_PASSIVE_TAGS, + romFireLightningBurstReset, + romMageWarlockCriticalEffects, +} from "./romMageWarlockOverrides"; +import { romMageCooldownReductionMs } from "./romMageRuntime"; import { romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides"; +import { + ROM_ROGUE_DAGGER_MASTERY_TAG, + ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID, + ROM_ROGUE_TREASURE_HUNTER_TAG, + romRogueDaggerDamageMultiplier, + romRogueShadowWalkerAdjustment, + romRogueTreasureHunterLootChance, +} from "./romRogueMechanics"; +import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides"; +import { romPriestKnightConditionalCastEffects } from "./romPriestKnightOverrides"; +import { + ROM_KNIGHT_GODS_VENGEANCE_TAG, + ROM_KNIGHT_SHIELD_OF_VALOR_TAG, +} from "./romKnightOverrides"; +import { romDruidPeriodicHealAmount } from "./romDruidOverrides"; +import { + ROM_CHAMPION_FORGE_STACK_AURA, + ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND, + ROM_CHAMPION_RUNE_OVERLOAD_TAG, +} from "./romChampionOverrides"; +import { + ROM_WARDEN_DAMAGE_TRANSFER_FRACTION, + ROM_WARDEN_DAMAGE_TRANSFER_TAG, + ROM_WARDEN_WILL_ATTACK_TAG, +} from "./romWardenOverrides"; +import { romWarlockStateRefreshTags } from "./romWarlockOverrides"; import { romCreatureFamilyForServerEntry, romCreatureFamilyTargetTags, type CombatCreatureFamily } from "./romCreatureFamilies"; import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles"; import { specializationById } from "./specializationCatalog"; @@ -195,6 +230,18 @@ import { rogueInstantPoisonDamage, roguePoisonApplicationChances, } from "./roguePoison"; +import { hunterExplosiveTrapDirectScaling } from "./wow335AbilityCatalog"; +import { + WOW335_MAGE_FINGERS_OF_FROST_AURA, + WOW335_MAGE_FINGERS_OF_FROST_TAG, + WOW335_MAGE_FOCUS_MAGIC_PROC_AURA, + WOW335_MAGE_HOT_STREAK_AURA, + WOW335_MAGE_HOT_STREAK_TAG, + WOW335_MAGE_IMPROVED_SCORCH_AURA, + WOW335_MAGE_IMPROVED_SCORCH_TAG, + WOW335_MAGE_IMPACT_AURA, + WOW335_MAGE_IMPACT_TAG, +} from "./wow335MageTalentRuntime"; import { baseMissChancePercent, combatAttackerFromCharacterStats, @@ -485,6 +532,7 @@ export interface TimedCombatEffect extends AbilityDamageResource { readonly coefficient: number; /** Pre-scaled amount for non-player actors whose equipment is not the player loadout. */ readonly fixedAmount?: number; + readonly percentMaxHealth?: number; readonly basePoints?: number; readonly dieSides?: number; readonly pointsPerLevel?: number; @@ -497,9 +545,17 @@ export interface TimedCombatEffect extends AbilityDamageResource { readonly remainingTicks: number; readonly intervalMs: number; readonly nextTickAt: number; + /** Extra Renew ticks granted by Holy Concentration, capped at two. */ + readonly extensionTicks?: number; readonly damageSchool?: DamageSchool; readonly spellPenetration?: number; readonly tag?: string; + readonly warlockSeed?: { + readonly threshold: number; + readonly explosionSpellId: number; + readonly radius: number; + readonly accumulatedDamage: number; + }; } export type CombatFeedbackKind = "success" | "error" | "damage" | "heal" | "level-up" | "target" | "talent" | "loot"; @@ -659,6 +715,9 @@ export interface CombatSummon { readonly nextActionAt: number; readonly pulseIntervalMs: number; readonly attackDamage: number; + readonly clickCharges?: number; + readonly healingPulse?: { readonly amount: number; readonly intervalMs: number; readonly radius: number }; + readonly antiMagicZone?: { readonly radius: number; readonly capacityBase: number; readonly capacityPerAttackPower: number; readonly absorbFraction: number; readonly intervalMs: number }; readonly abilityIds: readonly string[]; readonly resources: CombatActorState["resources"]; readonly cooldowns: CombatActorState["cooldowns"]; @@ -681,6 +740,12 @@ export interface CombatAreaEffect { readonly nextTickAt: number; readonly fixedDamagePerTick?: number; readonly fixedHealingPerTick?: number; + readonly hunterTrap?: { + readonly kind: "freezing" | "explosive"; + readonly triggerRadius: number; + readonly freezeDurationMs?: number; + readonly directDamageScaling?: AbilityAmountScaling & { readonly coefficient: number }; + }; } export interface PendingGroundTarget { @@ -818,8 +883,9 @@ export interface CombatState { applyActorAura: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract, now?: number) => boolean; applyTimedActorEffect: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract, now?: number) => boolean; summonActor: (ownerActorId: string, ability: AbilityDefinition, effect: Extract, position: CombatPosition, targetId?: string | null, now?: number) => string | null; + clickLightwell: (summonId: string, targetActorId?: string | null, now?: number) => number; createActorAreaEffect: (sourceActorId: string, ability: AbilityDefinition, position: CombatPosition, now?: number) => string | null; - damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number; + damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number, now?: number) => number; damageSummon: (id: string, amount: number) => number; healPlayer: (amount: number) => number; revivePlayer: (percentMaxHealth?: number) => number; @@ -952,6 +1018,15 @@ function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput) work.combatEvents = [...recordCombatEvent(work.combatEvents, input)]; } +function hasRecentPlayerCriticalHit(events: readonly StructuredCombatEvent[], playerActorId: string): boolean { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]!; + if (event.kind !== "damage" || event.sourceActorId !== playerActorId) continue; + return event.critical && event.effectiveAmount > 0; + } + return false; +} + function nextFeedback( kind: CombatFeedbackKind, message: string, @@ -1194,6 +1269,21 @@ function threatSourceForActor( }; } +const TRICKS_THREAT_REDIRECT_GROUP_PREFIX = "wow335:rogue:tricks-target:"; + +function activeTricksThreatRecipient( + auras: readonly ActiveAura[], + sourceActorId: AggroActorId, + now: number, +): AggroActorId | null { + const redirect = auras.find((aura) => ( + aura.targetId === sourceActorId + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.exclusiveGroup?.startsWith(TRICKS_THREAT_REDIRECT_GROUP_PREFIX) + )); + return redirect?.definition.exclusiveGroup?.slice(TRICKS_THREAT_REDIRECT_GROUP_PREFIX.length) || null; +} + function actorPosition(state: Pick, actorId: AggroActorId): CombatPosition | null { if (actorId === PLAYER_AGGRO_ID) return state.playerPosition; return getPartyRuntimePosition(actorId) @@ -1229,6 +1319,7 @@ function addHealingThreatInWork( work: MutableCastWork, source: ThreatSource, effectiveHealing: number, + now = combatNow(), ): void { const engagedIds = Object.values(work.mobs) .filter((mob) => mob.engaged && !mob.dead) @@ -1237,11 +1328,12 @@ function addHealingThreatInWork( * (source.threatScale ?? 1) * resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat"); if (perMob <= 0) return; + const threatActorId = activeTricksThreatRecipient(work.auras, source.actorId, now) ?? source.actorId; for (const id of engagedIds) { const mob = work.mobs[id]; work.mobs[id] = { ...mob, - threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob), + threatByActor: addActorThreat(mob.threatByActor, threatActorId, perMob), }; } } @@ -1263,6 +1355,48 @@ function talentAdjustedAmount( return Math.max(0, Math.round(scalar)); } +function priestTestOfFaithAmount( + amount: number, + classId: ClassId, + talentRanks: TalentRanks, + ability: AbilityDefinition, + kind: "damage" | "healing", + targetHealth: number, + targetMaxHealth: number, +): number { + if (classId !== "priest" || targetMaxHealth <= 0 || targetHealth > targetMaxHealth / 2) return amount; + if (kind === "damage" && !["Smite", "Holy Fire"].includes(ability.name)) return amount; + const talentRank = Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === "Test of Faith" ? Math.max(rank, points) : rank + ), 0); + return talentRank > 0 ? Math.round(amount * (1 + Math.min(3, talentRank) * 0.04)) : amount; +} + +function priestTestOfFaithIncomingDamage( + amount: number, + classId: ClassId, + talentRanks: TalentRanks, + activeCast: ActiveCast | null, + school: DamageSchool, + now: number, +): number { + if (amount <= 0 || classId !== "priest" || school === "physical" || !activeCast + || activeCast.startedAt > now || activeCast.completesAt <= now) return amount; + const ability = abilityById(activeCast.abilityId); + const isEligibleCast = Boolean(ability && ( + ability.name === "Smite" + || ability.name === "Holy Fire" + || ability.effects.some((effect) => ( + effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield" || effect.kind === "resurrection" + )) + )); + if (!isEligibleCast) return amount; + const talentRank = Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === "Test of Faith" ? Math.max(rank, points) : rank + ), 0); + return talentRank > 0 ? Math.round(amount * 0.9) : amount; +} + function talentAdjustedMaxHealth( classId: ClassId, level: number, @@ -1286,6 +1420,7 @@ function talentAdjustedMaxHealth( } function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind { + if (romWarriorPhysicalWeaponTooltip(ability)) return "melee"; if (ability.source === "runewaker") { const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot"); if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") { @@ -1308,6 +1443,8 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA } export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool { + if (romWarriorPhysicalWeaponTooltip(ability)) return "physical"; + if (classId === "hunter" && ability.name === "Explosive Trap") return "fire"; if (ability.source === "runewaker") { const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot"); if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school) return sourceHit.school; @@ -1458,8 +1595,12 @@ function resolvePlayerDamageResult( ); const stats = applyAuraStats(baseStats, aurasForEntity(work.auras, sourceActorId)); const baseAttacker = combatAttackerFromCharacterStats(stats, attackKind); + const deathchillAbilities = ["Icy Touch", "Howling Blast", "Frost Strike", "Obliterate"]; + const deathchillEligible = classId === "death-knight" && Boolean(ability) + && deathchillAbilities.includes(ability!.name); const nextCritical = !periodic && Boolean(ability && aurasForEntity(work.auras, sourceActorId).some((aura) => ( aura.definition.utilityTags?.includes("next-offensive-critical") + && (!aura.definition.utilityTags.includes("wow335-deathchill-next-critical") || deathchillEligible) ))); const premeditation = nextCritical && aurasForEntity(work.auras, sourceActorId).some((aura) => ( aura.definition.utilityTags?.includes("rom-premeditation") @@ -1523,6 +1664,22 @@ function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind { return "spell"; } +function romWardenWillAttackDamage( + amount: number, + auras: readonly ActiveAura[], + mobId: string, + attack: EnemyAttackDefinition, +): number { + const willAttack = aurasForEntity(auras, mobId) + .filter((aura) => aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG)); + if (!willAttack.length || enemyAttackKind(attack) !== "melee") return amount; + return Math.round(resolveAuraDamageValue(amount, willAttack, { + direction: "dealt", + school: attack.school, + attackKind: enemyAttackKind(attack), + })); +} + function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean { return attack.id === DEFAULT_ENEMY_ATTACK.id && attack.delivery === "melee" @@ -1625,11 +1782,11 @@ function resolveEnemyDamageResult( damageSchool: attack.school, attacker: { level: mob.level, - criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent), + criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent) + incomingCrit, }, defender: { ...defenderStats, - criticalAvoidancePercent: (defenderStats.criticalAvoidancePercent ?? 0) - incomingCrit, + criticalAvoidancePercent: defenderStats.criticalAvoidancePercent, }, rules: { isAutoAttack: attack.animation === "attack" && (attack.castTimeMs ?? 0) === 0, @@ -1755,6 +1912,7 @@ function initialResourcePools( abilities: readonly AbilityDefinition[], activeMaximum: number, manaMaximum = 100, + naturePowerMaximum = defaultResourceMaximum("nature-power"), ): { pools: Partial>; maximums: Partial> } { const activeProfile = resourceProfileForClass(classId); const resourceTypes = new Set([activeProfile.type]); @@ -1763,7 +1921,8 @@ function initialResourcePools( const pools: Partial> = {}; for (const type of resourceTypes) { if (type === "health") continue; - const maximum = type === activeProfile.type ? activeMaximum : type === "mana" ? manaMaximum : defaultResourceMaximum(type); + const maximum = type === activeProfile.type ? activeMaximum : type === "mana" ? manaMaximum + : type === "nature-power" ? naturePowerMaximum : defaultResourceMaximum(type); maximums[type] = maximum; pools[type] = type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi" ? 0 @@ -1795,18 +1954,45 @@ export function resolvedAbilityCosts( abilityTalentContext(ability), ))); const playerAuras = aurasForEntity(state.auras ?? [], PLAYER_AURA_ENTITY_ID); + // Arcane Blast's DBC child applies its +200% cost modifier only to the + // next Arcane Blast, even though its aura is stored on the caster. + const resourceCostAuras = ability.name === "Arcane Blast" + ? playerAuras + : playerAuras.filter((aura) => !aura.definition.utilityTags?.includes("wow335-arcane-blast-stack")); + const druidClearcastingAuras = state.classId === "druid" && druidClearcastingEligible(ability, ability.target) + ? resourceCostAuras + : resourceCostAuras.filter((aura) => !aura.definition.utilityTags?.includes("wow335-druid-clearcasting")); + const clearcastingAuras = state.classId === "shaman" && shamanClearcastingEligible(ability) + ? druidClearcastingAuras + : druidClearcastingAuras.filter((aura) => !aura.definition.utilityTags?.includes(SHAMAN_ELEMENTAL_FOCUS_AURA_TAG)); const resourceAdjusted = resolveAuraResourceValue( - resolveAuraResourceValue(rawAmount, playerAuras, cost.resource, "cost"), - playerAuras, + resolveAuraResourceValue(rawAmount, clearcastingAuras, cost.resource, "cost"), + clearcastingAuras, "power", "cost", ); - const amount = Math.max(0, Math.round(resourceAdjusted)); + const hotStreakCostMultiplier = state.classId === "mage" && ability.name === "Pyroblast" + && playerAuras.some((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)) + ? 0.5 + : 1; + const amount = Math.max(0, Math.round(resourceAdjusted * hotStreakCostMultiplier)); aggregated.set(cost.resource, (aggregated.get(cost.resource) ?? 0) + amount); } return [...aggregated].map(([resource, amount]) => ({ resource, amount })); } +function druidClearcastingEligible(ability: AbilityDefinition, effectiveTarget: string): boolean { + if (ability.passive || ability.name === "Omen of Clarity") return false; + const hasDamageOrHeal = ability.effects.some((effect) => [ + "damage", "finisher-damage", "dot", "heal", "hot", "execute", "resource-drain", + ].includes(effect.kind)); + const hasOffensiveEffect = effectiveTarget === "hostile" && ability.effects.some((effect) => ( + ["taunt", "status", "interrupt", "pull", "execute", "resource-drain", "threat"].includes(effect.kind) + || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff") + )); + return hasDamageOrHeal || hasOffensiveEffect; +} + function talentAdjustedResourceCost( ability: AbilityDefinition, modifiers: readonly TalentModifier[], @@ -2165,17 +2351,108 @@ function hasControlImmunity( )); } +const MAGIC_DAMAGE_SCHOOLS: readonly DamageSchool[] = ["holy", "fire", "nature", "frost", "shadow", "arcane"]; + +function deathKnightAntiMagicShellBlocksMagicEffect( + auras: readonly ActiveAura[], + targetId: string, + school: DamageSchool, +): boolean { + return school !== "physical" && auras.some((aura) => ( + aura.targetId === targetId + && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell") + )); +} + +function applyDeathKnightAntiMagicZoneInWork( + work: MutableCastWork, + center: CombatPosition, + radius: number, + durationMs: number, + capacityBase: number, + capacityPerAttackPower: number, + absorbFraction: number, + now: number, + playerPosition: CombatPosition, +): void { + const sourceStats = playerCharacterStats( + work.classId, + work.progression.level, + work.gearStats, + work.raceId, + work.secondaryClassId, + work.talentModifiers, + ); + const capacity = Math.max(0, Math.round(capacityBase + sourceStats.attackPower * capacityPerAttackPower)); + const candidates = [ + { id: PLAYER_AURA_ENTITY_ID, health: work.health, position: playerPosition }, + ...usePartyStore.getState().members.map((member) => { + const actor = ensureCompanionActorInWork(work, member.id); + return { id: member.id, health: actor?.health ?? member.health, position: getPartyRuntimePosition(member.id) }; + }), + ]; + const inRange = candidates.filter((candidate) => candidate.health > 0 + && candidate.position + && Math.sqrt(distanceSquared(center, candidate.position)) <= radius + && combatHasLineOfSight(center, candidate.position)); + const inRangeIds = new Set(inRange.map((candidate) => candidate.id)); + work.auras = work.auras.filter((aura) => !( + aura.sourceId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone") + && !inRangeIds.has(aura.targetId) + )); + for (const candidate of inRange) { + if (work.auras.some((aura) => aura.targetId === candidate.id + && aura.sourceId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone"))) continue; + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:death-knight:anti-magic-zone-absorb", + name: "Anti-Magic Zone", + description: "Absorbs 75% of incoming magic damage until its capacity is spent.", + disposition: "buff", + durationMs, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: ["wow335-death-knight-anti-magic-zone"], + absorbs: [{ + id: "wow335:death-knight:anti-magic-zone-pool", + amount: capacity, + schools: MAGIC_DAMAGE_SCHOOLS, + fraction: absorbFraction, + }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: candidate.id, + now, + }).auras]; + } +} + function auraDefinitionForAbility( definition: AuraDefinition, ability: AbilityDefinition, durationMs = definition.durationMs, ): AuraDefinition { const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()); + // Focus Magic's ally aura reacts to that ally's spell criticals, then grants + // the Mage its separate 10-second critical-strike buff. The generated spell + // row (54646) supplies +3% to the ally; the talent-node prose says +1%. + const focusMagicProc = ability.name === "Focus Magic" + ? [{ + id: "wow335-mage-focus-magic-proc", + triggers: "crit" as const, + attackKinds: ["spell"] as const, + chance: 1, + action: { kind: "custom" as const, id: "wow335-mage-focus-magic-proc" }, + }] + : []; return { ...definition, description: definition.description ?? ability.description, icon: definition.icon ?? ability.icon, durationMs, + ...(focusMagicProc.length ? { procs: [...(definition.procs ?? []), ...focusMagicProc] } : {}), ...(defaultPartyBuffFamily && !definition.exclusiveGroup ? { uniqueBySource: false, exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`, @@ -2227,7 +2504,7 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha "spell-critical-strike", "magical-critical-strike", ), meleeHitPercent: stat(base.meleeHitPercent, "hit", "physical-hit"), - rangedHitPercent: stat(base.rangedHitPercent, "hit", "physical-hit"), + rangedHitPercent: stat(base.rangedHitPercent, "hit", "physical-hit", "ranged-hit"), spellHitPercent: stat(base.spellHitPercent, "spell-hit", "magical-hit"), meleeHastePercent: stat(base.meleeHastePercent, "haste", "melee-haste", "attack-speed"), rangedHastePercent: stat(base.rangedHastePercent, "haste", "ranged-haste", "attack-speed"), @@ -2236,6 +2513,7 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha parryPercent: stat(base.parryPercent, "parry"), blockPercent: stat(base.blockPercent, "block", "block-chance"), expertise: stat(base.expertise, "expertise"), + armorPenetrationPercent: stat(base.armorPenetrationPercent, "armor-penetration"), arcaneResistance: resistance(base.arcaneResistance, "arcane"), fireResistance: resistance(base.fireResistance, "fire"), frostResistance: resistance(base.frostResistance, "frost"), @@ -2301,6 +2579,38 @@ function defaultPartyBuffPotency( ), 0); } +function activeHunterPetId(summons: readonly CombatSummon[], now: number): string | null { + return summons.find((summon) => ( + summon.kind === "pet" + && summon.ownerActorId === PLAYER_AGGRO_ID + && summon.health > 0 + && summon.expiresAt > now + ))?.id ?? null; +} + +function activeWardenNatureCrystal(summons: readonly CombatSummon[], now: number): boolean { + return summons.some((summon) => summon.creatureId === 102325 + && summon.ownerActorId === PLAYER_AGGRO_ID + && summon.health > 0 + && summon.expiresAt > now); +} + +function activeWardenDamageTransferPet( + summons: readonly CombatSummon[], + now: number, + ownerPosition: CombatPosition, +): CombatSummon | null { + return summons.find((summon) => [102297, 102324, 102325].includes(summon.creatureId) + && summon.ownerActorId === PLAYER_AGGRO_ID + && summon.health > 0 + && summon.expiresAt > now + && Math.hypot( + summon.position[0] - ownerPosition[0], + summon.position[1] - ownerPosition[1], + summon.position[2] - ownerPosition[2], + ) <= 300) ?? null; +} + function syncPassiveAuras( current: readonly ActiveAura[], abilities: readonly AbilityDefinition[], @@ -2309,7 +2619,7 @@ function syncPassiveAuras( now: number, ): ActiveAura[] { const definitions = abilities.flatMap((catalogAbility) => { - if (!catalogAbility.passive) return []; + if (!catalogAbility.passive || catalogAbility.name === "The Beast Within") return []; const ability = learnedAbilityForBuild(catalogAbility, level, talentRanks); if (!ability) return []; return ability.effects @@ -2334,6 +2644,24 @@ function syncPassiveAuras( return next; } +function spendDeathKnightBoneShieldCharge( + auras: readonly ActiveAura[], + triggered: readonly TriggeredAuraProc[], +): ActiveAura[] { + let next = [...auras]; + for (const proc of triggered) { + if (proc.action.kind !== "custom" || proc.action.id !== "wow335-death-knight-bone-shield-charge" + || (proc.event.amount ?? 0) <= 0) continue; + const index = next.findIndex((aura) => aura.instanceId === proc.auraInstanceId + && aura.definition.utilityTags?.includes("wow335-death-knight-bone-shield")); + if (index < 0) continue; + const aura = next[index]; + if (aura.stacks <= 1) next = next.filter((entry) => entry.instanceId !== aura.instanceId); + else next[index] = { ...aura, stacks: aura.stacks - 1 }; + } + return next; +} + function executeTriggeredAuraProcsInWork( work: MutableCastWork, triggeredProcs: readonly TriggeredAuraProc[], @@ -2341,11 +2669,244 @@ function executeTriggeredAuraProcsInWork( ): void { for (const triggered of triggeredProcs) { const action = triggered.action; + if (work.classId === "mage" + && action.kind === "custom" + && action.id === "wow335-mage-focus-magic-proc") { + // Focus Magic only reacts to a spell critical dealt by its recipient. + // The aura proc is attached to the recipient, so `sourceId` is the Mage. + if (triggered.sourceId === PLAYER_AURA_ENTITY_ID + && triggered.event.trigger === "crit" + && triggered.event.attackKind === "spell" + && triggered.event.otherId + && work.mobs[triggered.event.otherId]) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_FOCUS_MAGIC_PROC_AURA, + sourceId: triggered.sourceId, + targetId: triggered.sourceId, + now, + }).auras]; + } + continue; + } + if (work.classId === "hunter" + && action.kind === "custom" + && action.id === "wow335-hunter-intimidation-hit") { + const targetId = triggered.event.otherId; + const mob = targetId ? work.mobs[targetId] : null; + const intimidation = work.abilities.find((ability) => ability.dbcSpellId === 19577); + if (mob && targetId && intimidation) { + const controlled = applyStatus(mob, { + kind: "status", + status: "stun", + durationMs: 3_000, + }, intimidation.id, now, triggered.targetId); + const threatByActor = { + ...controlled.threatByActor, + [triggered.targetId]: (controlled.threatByActor[triggered.targetId] ?? 0) + 580, + }; + work.mobs[targetId] = { ...controlled, threatByActor }; + } + work.auras = work.auras.filter((aura) => aura.instanceId !== triggered.auraInstanceId); + continue; + } + if (work.classId === "shaman" + && action.kind === "custom" + && action.id === "wow335-shamanistic-rage-mana") { + if (triggered.targetId !== PLAYER_AURA_ENTITY_ID || (triggered.event.amount ?? 0) <= 0) continue; + const percent = typeof action.data?.attackPowerPercent === "number" + ? Math.max(0, action.data.attackPowerPercent) / 100 + : 0.15; + const shamanStats = applyAuraStats( + playerCharacterStats( + work.classId, + work.progression.level, + work.gearStats, + work.raceId, + work.secondaryClassId, + work.talentModifiers, + ), + aurasForEntity(work.auras, triggered.targetId), + ); + const requested = Math.max(0, Math.round(shamanStats.attackPower * percent)); + const maximum = work.maxResourcePools.mana ?? defaultResourceMaximum("mana"); + const current = work.resourcePools.mana ?? 0; + const restored = Math.max(0, Math.min(requested, maximum - current)); + if (restored <= 0) continue; + work.resourcePools.mana = current + restored; + if (resourceProfileForClass(work.classId).type === "mana") work.resource = work.resourcePools.mana; + const rageAbilityId = work.abilities.find((ability) => ability.name === "Shamanistic Rage")?.id + ?? triggered.procId; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "resource", + sourceActorId: triggered.sourceId, + targetActorId: triggered.targetId, + abilityId: rageAbilityId, + rawAmount: requested, + effectiveAmount: restored, + overheal: requested - restored, + absorbed: 0, + critical: false, + }); + continue; + } + if (action.kind === "custom" && action.id === "wow335-death-knight-bone-shield-charge") { + work.auras = spendDeathKnightBoneShieldCharge(work.auras, [triggered]); + continue; + } + if (action.kind === "custom" && action.id === "wow335-prayer-of-mending") { + const activeAura = work.auras.find((aura) => aura.instanceId === triggered.auraInstanceId); + if (!activeAura) continue; + const healAmount = typeof action.data?.healAmount === "number" + ? Math.max(1, Math.round(action.data.healAmount)) : 0; + const healTargetId = triggered.targetId; + const healTarget = healTargetId === PLAYER_AURA_ENTITY_ID + ? { health: work.health, maxHealth: work.maxHealth } + : ensureCompanionActorInWork(work, healTargetId); + const healed = healAmount > 0 && healTarget && healTarget.health > 0 + ? healTargetId === PLAYER_AURA_ENTITY_ID + ? healPlayerInWork(work, healAmount) + : healCompanionActorInWork(work, healTargetId, healAmount) + : 0; + work.totalHealing += healed; + if (healed > 0) recordCombatEventInWork(work, { + occurredAt: now, + kind: "healing", + sourceActorId: triggered.sourceId, + targetActorId: healTargetId, + abilityId: "wow335-priest-prayer-of-mending", + school: "holy", + rawAmount: healAmount, + effectiveAmount: healed, + overheal: Math.max(0, healAmount - healed), + absorbed: 0, + critical: false, + }); + + const remainingCharges = activeAura.procStates.find((state) => state.procId === triggered.procId) + ?.remainingCharges ?? 0; + work.auras = work.auras.filter((aura) => aura.sourceId !== triggered.sourceId || !isPrayerOfMendingAura(aura)); + if (remainingCharges <= 0) continue; + + const origin = healTargetId === PLAYER_AURA_ENTITY_ID + ? useCombatStore.getState().playerPosition + : getPartyRuntimePosition(healTargetId); + if (!origin) continue; + const maxRange = typeof action.data?.maxRange === "number" + ? Math.max(0, action.data.maxRange) : 40; + const candidates = [ + ...(work.health > 0 && work.health < work.maxHealth + ? [{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, + position: useCombatStore.getState().playerPosition }] + : []), + ...usePartyStore.getState().members.flatMap((member) => { + const actor = work.actors[member.id] ?? ensureCompanionActorInWork(work, member.id); + const position = getPartyRuntimePosition(member.id); + return actor && actor.health > 0 && actor.health < actor.maxHealth && position + ? [{ id: member.id, health: actor.health, maxHealth: actor.maxHealth, position }] + : []; + }), + ].filter((candidate) => candidate.id !== healTargetId + && distanceSquared(origin, candidate.position) <= maxRange * maxRange + && combatHasLineOfSight(origin, candidate.position)) + .sort((left, right) => ( + left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth) + || distanceSquared(origin, left.position) - distanceSquared(origin, right.position) + || left.id.localeCompare(right.id) + )); + const nextTarget = candidates[0]; + if (!nextTarget) continue; + const definition: AuraDefinition = { + ...activeAura.definition, + procs: activeAura.definition.procs?.map((proc) => ( + proc.action.kind === "custom" && proc.action.id === "wow335-prayer-of-mending" + ? { ...proc, charges: remainingCharges } + : proc + )), + }; + work.auras = [...applyCombatAura(work.auras, { + definition, + sourceId: triggered.sourceId, + targetId: nextTarget.id, + now, + }).auras]; + continue; + } + if (action.kind === "custom" && action.id === "wow335-sweeping-strikes-secondary-hit") { + const primaryTargetId = triggered.event.otherId; + const primaryPosition = primaryTargetId ? getMobPosition(primaryTargetId) : null; + if (triggered.event.attackKind !== "melee" + || !primaryTargetId + || !primaryPosition + || !work.mobs[primaryTargetId] + || work.mobs[primaryTargetId].dead + || (triggered.event.amount ?? 0) <= 0) continue; + const secondaryTargetId = Object.values(work.mobs) + .filter((mob) => mob.id !== primaryTargetId && !mob.dead) + .map((mob) => ({ + id: mob.id, + distanceSquared: mobRuntimeDistanceSquared(mob.id, primaryPosition), + })) + .filter((candidate) => candidate.distanceSquared <= 25) + .sort((left, right) => left.distanceSquared - right.distanceSquared || left.id.localeCompare(right.id))[0]?.id; + if (secondaryTargetId) { + damageMobInWork( + work.classId, + work, + secondaryTargetId, + Math.max(0, triggered.event.amount ?? 0), + now, + playerThreatSource({ talentModifiers: work.talentModifiers }, triggered.procId, true), + "physical", + work.gearStats.spellPenetration, + ); + } + continue; + } if (action.kind === "custom" && action.id.startsWith("rom-")) { const triggeringAbility = triggered.event.abilityId ? work.abilities.find((ability) => ability.id === triggered.event.abilityId) ?? abilityById(triggered.event.abilityId) : null; + if (action.id === "rom-champion-forge-hit") { + if (work.classId === "rom-champion" + && triggered.sourceId === PLAYER_AGGRO_ID + && triggered.event.attackKind === "melee" + && triggered.event.abilityId === undefined) { + work.auras = [...applyCombatAura(work.auras, { + definition: ROM_CHAMPION_FORGE_STACK_AURA, + sourceId: PLAYER_AGGRO_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + const rage = gainRomRage( + work.classId, + work.secondaryClassId, + 10, + work.resource, + work.maxResource, + work.resourcePools, + work.maxResourcePools, + ); + work.resource = rage.resource; + work.resourcePools = rage.resourcePools; + } + continue; + } + if (action.id === "rom-cripple-joint") { + const targetId = triggered.event.otherId ?? triggered.targetId; + const target = work.mobs[targetId]; + if (triggeringAbility?.dbcSpellId === 490420 && target) { + work.mobs[targetId] = applyStatus(target, { + kind: "status", + status: "root", + durationMs: 5_000, + maxTargets: 1, + breakOnDamage: true, + }, triggeringAbility.id, now, triggered.sourceId); + } + continue; + } const poisonous = work.auras.some((aura) => aura.targetId === triggered.sourceId && aura.definition.utilityTags?.includes("rom-poisonous")); const spec = romScoutRogueProcActionSpec(action.id, triggeringAbility?.dbcSpellId ?? 0, poisonous); @@ -2535,6 +3096,32 @@ function evaluateProcsInWork( } } +function isPrayerOfMendingAura(aura: ActiveAura): boolean { + return aura.definition.procs?.some((proc) => ( + proc.action.kind === "custom" && proc.action.id === "wow335-prayer-of-mending" + )) ?? false; +} + +/** Companion damage only needs this spell's proc; keep unrelated companion procs unchanged. */ +function evaluatePrayerOfMendingInWork(work: MutableCastWork, event: ProcEvent, now: number): void { + const candidates = work.auras.filter((aura) => isPrayerOfMendingAura(aura)); + if (!candidates.length) return; + const candidateIds = new Set(candidates.map((aura) => aura.instanceId)); + const result = evaluateAuraProcs( + candidates, + event, + now, + ({ aura, proc }) => stableCombatUnit( + `${aura.instanceId}:${proc.id}:${event.trigger}:${event.abilityId ?? ""}:${event.otherId ?? ""}:${now}`, + ), + ); + work.auras = [ + ...work.auras.filter((aura) => !candidateIds.has(aura.instanceId)), + ...result.auras, + ]; + executeTriggeredAuraProcsInWork(work, result.triggered, now); +} + function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] { return nearbyMobRuntimeIds(origin, maxRange).filter((id) => { const mob = state.mobs[id]; @@ -2633,6 +3220,12 @@ function grantMobExperience( work.raceId, work.secondaryClassId, ); + if (work.maxResourcePools["nature-power"] !== undefined) { + work.maxResourcePools = { + ...work.maxResourcePools, + "nature-power": naturePowerMaximumForCharacter(classId, work.secondaryClassId, result.level), + }; + } } return scaledAward; } @@ -2692,7 +3285,12 @@ function engageMobGroupInWork( now: number, sourceActorId: AggroActorId, ): void { - engageMobGroup(work.mobs, sourceId, now, sourceActorId); + engageMobGroup( + work.mobs, + sourceId, + now, + activeTricksThreatRecipient(work.auras, sourceActorId, now) ?? sourceActorId, + ); } function nearestAggroActorId( @@ -2767,6 +3365,335 @@ function damageAfterMobResistance( return amount * (1 - Math.min(0.75, resisted)); } +const DRUID_CLEARCASTING_AURA: AuraDefinition = { + id: "wow335:druid:clearcasting", + name: "Clearcasting", + disposition: "buff", + durationMs: 15_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-druid-clearcasting"], + modifiers: [{ + kind: "resource", + resource: "power", + property: "cost", + operation: "percent", + value: -1, + }], +}; + +const DRUID_NATURES_GRACE_AURA: AuraDefinition = { + id: "wow335:druid:natures-grace", + name: "Nature's Grace", + disposition: "buff", + durationMs: 4_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-druid-natures-grace"], + modifiers: [{ kind: "stat", stat: "haste", operation: "flat", value: 20 }], +}; + +function druidTalentRank(work: MutableCastWork, talentId: string, maxRank: number): number { + return Math.min(maxRank, Math.max(0, Math.trunc(work.talentRanks[talentId] ?? 0))); +} + +const PRIEST_SURGE_OF_LIGHT_AURA: AuraDefinition = { + id: "wow335:priest:surge-of-light", + name: "Surge of Light", + description: "Your next Smite or Flash Heal is instant and costs no mana.", + disposition: "buff", + durationMs: 10_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-priest-surge-of-light"], +}; + +const PRIEST_SERENDIPITY_AURA: AuraDefinition = { + id: "wow335:priest:serendipity", + name: "Serendipity", + description: "Each stack reduces the cast time and mana cost of your next Greater Heal or Prayer of Healing.", + disposition: "buff", + durationMs: 20_000, + maxStacks: 3, + stackBehavior: "add", + utilityTags: ["wow335-priest-serendipity"], +}; + +const PRIEST_SHADOW_WEAVING_AURA: AuraDefinition = { + id: "wow335:priest:shadow-weaving", + name: "Shadow Weaving", + disposition: "buff", + durationMs: 15_000, + maxStacks: 5, + stackBehavior: "add", + utilityTags: ["wow335-priest-shadow-weaving"], + modifiers: [{ + kind: "damage", + direction: "dealt", + school: "shadow", + operation: "percent", + value: 0.02, + perStack: true, + }], +}; + +const PRIEST_HOLY_CONCENTRATION_BASE: Omit = { + id: "wow335:priest:holy-concentration", + name: "Holy Concentration", + disposition: "buff", + durationMs: 12_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-priest-holy-concentration"], +}; + +const INNER_FOCUS_SERENDIPITY_BLOCKED_SPELLS = new Set([ + "Holy Light", + "Flash of Light", + "Binding Heal", + "Renew", + "Greater Heal", + "Flash Heal", + "Smite", +]); + +function priestTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { + return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank + ), 0))); +} + +function paladinTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { + return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank + ), 0))); +} + +const PALADIN_RECKONING_TAG = "wow335-paladin-reckoning"; +const PALADIN_RECKONING_AURA: AuraDefinition = { + id: "wow335:paladin:reckoning", + name: "Reckoning", + description: "Your next three auto attacks each generate an additional attack within 8 seconds.", + disposition: "buff", + durationMs: 8_000, + maxStacks: 3, + stackBehavior: "replace", + utilityTags: [PALADIN_RECKONING_TAG], +}; + +function maybeProcPaladinReckoningInWork( + work: MutableCastWork, + attackKind: WotlkAttackKind, + wasHitOrAvoided: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "paladin" || attackKind !== "melee" || !wasHitOrAvoided) return; + const rank = paladinTalentRank(work.talentRanks, "Reckoning", 5); + const chance = [0, 0.03, 0.06, 0.09, 0.12, 0.15][rank] ?? 0; + if (chance <= 0 || stableCombatUnit(`${seed}:wow335-paladin-reckoning`) >= chance) return; + work.auras = [...applyCombatAura(work.auras, { + definition: PALADIN_RECKONING_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + stacks: 3, + }).auras]; +} + +function consumePaladinReckoningCharge(auras: readonly ActiveAura[], now: number): readonly ActiveAura[] { + const index = auras.findIndex((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(PALADIN_RECKONING_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.stacks > 0); + if (index < 0) return auras; + const current = auras[index]; + if (current.stacks <= 1) return auras.filter((aura) => aura.instanceId !== current.instanceId); + return auras.map((aura, auraIndex) => auraIndex === index + ? { ...aura, stacks: aura.stacks - 1 } + : aura); +} + +function maybeProcPaladinEyeForAnEyeInWork( + work: MutableCastWork, + attackerId: string, + damageTaken: number, + critical: boolean, + now: number, +): void { + if (work.classId !== "paladin" || !critical || damageTaken <= 0) return; + const rank = paladinTalentRank(work.talentRanks, "Eye for an Eye", 3); + const reflectPercent = [0, 5, 10, 15][rank] ?? 0; + if (reflectPercent <= 0) return; + const reflected = Math.min( + Math.round(work.maxHealth * 0.5), + Math.round(damageTaken * reflectPercent / 100), + ); + if (reflected <= 0) return; + damageMobInWork( + work.classId, + work, + attackerId, + reflected, + now, + playerThreatSource({ talentModifiers: work.talentModifiers }, "talent-1632"), + "holy", + ); +} + +function applyPriestSelfAuraInWork( + work: MutableCastWork, + definition: AuraDefinition, + now: number, +): void { + work.auras = [...applyCombatAura(work.auras, { + definition, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + +function maybeProcPriestDamageTalentsInWork( + work: MutableCastWork, + ability: AbilityDefinition | undefined, + sourceActorId: string, + school: DamageSchool, + critical: boolean, + landed: boolean, + periodic: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "priest" || sourceActorId !== PLAYER_AGGRO_ID || !ability || !landed) return; + + const shadowWeavingRank = priestTalentRank(work.talentRanks, "Shadow Weaving", 3); + const shadowWeavingChance = [0, 0.33, 0.66, 1][shadowWeavingRank] ?? 0; + if (school === "shadow" && shadowWeavingRank > 0 + && stableCombatUnit(`${seed}:wow335-priest-shadow-weaving`) < shadowWeavingChance) { + applyPriestSelfAuraInWork(work, PRIEST_SHADOW_WEAVING_AURA, now); + } + + if (periodic || !critical) return; + const surgeRank = priestTalentRank(work.talentRanks, "Surge of Light", 2); + const surgeChance = [0, 0.25, 0.5][surgeRank] ?? 0; + if (surgeRank > 0 && stableCombatUnit(`${seed}:wow335-priest-surge-of-light`) < surgeChance) { + applyPriestSelfAuraInWork(work, PRIEST_SURGE_OF_LIGHT_AURA, now); + } + + if (["Smite", "Holy Fire"].includes(ability.name)) { + const holyConcentrationRank = priestTalentRank(work.talentRanks, "Holy Concentration", 3); + if (holyConcentrationRank > 0) { + applyPriestSelfAuraInWork(work, { + ...PRIEST_HOLY_CONCENTRATION_BASE, + modifiers: [{ + kind: "stat", + stat: "spirit", + operation: "percent", + value: ([0, 0.1, 0.2, 0.3][holyConcentrationRank] ?? 0), + }], + }, now); + } + } +} + +function maybeExtendPriestRenewInWork( + work: MutableCastWork, + rank: number, + playerPosition: CombatPosition, + seed: string, + now: number, +): void { + const chance = [0, 0.33, 0.66, 1][Math.min(3, Math.max(0, rank))] ?? 0; + if (work.classId !== "priest" || chance <= 0 + || stableCombatUnit(`${seed}:wow335-priest-holy-concentration-renew`) >= chance) return; + + const eligibleTargets = new Set([PLAYER_AURA_ENTITY_ID]); + const partyMembers = usePartyStore.getState().members; + const nearby = (id: string): boolean => { + const position = id === PLAYER_AURA_ENTITY_ID ? playerPosition : getPartyRuntimePosition(id); + return Boolean(position + && Math.sqrt(distanceSquared(playerPosition, position)) <= 40 + && combatHasLineOfSight(playerPosition, position)); + }; + for (const member of partyMembers) { + if (member.health > 0 && nearby(member.id)) eligibleTargets.add(member.id); + } + for (const actor of onlinePlayerRuntimeActors()) { + if (actor.health > 0 && nearby(actor.id)) eligibleTargets.add(actor.id); + } + + work.effects = work.effects.map((effect) => { + if (effect.kind !== "hot" || effect.sourceActorId !== PLAYER_AGGRO_ID + || abilityById(effect.sourceAbilityId)?.name !== "Renew" + || !eligibleTargets.has(effect.targetId ?? PLAYER_AURA_ENTITY_ID) + || (effect.extensionTicks ?? 0) >= 2 || effect.remainingTicks <= 0) return effect; + const expiresAt = effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs; + if (expiresAt < now) return effect; + return { ...effect, remainingTicks: effect.remainingTicks + 1, extensionTicks: (effect.extensionTicks ?? 0) + 1 }; + }); +} + +function applyDruidSelfAura(work: MutableCastWork, definition: AuraDefinition, now: number): void { + work.auras = [...applyCombatAura(work.auras, { + definition, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + +function maybeProcDruidNatureGrace( + work: MutableCastWork, + ability: AbilityDefinition, + critical: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "druid" || !critical || ability.source === "runewaker") return; + const rank = druidTalentRank(work, "talent-789", 3); + if (rank <= 0 || stableCombatUnit(seed) >= rank / 3) return; + applyDruidSelfAura(work, DRUID_NATURES_GRACE_AURA, now); +} + +function maybeProcDruidOmenOfClarity(work: MutableCastWork, seed: string, now: number): void { + if (work.classId !== "druid") return; + const rank = druidTalentRank(work, "talent-827", 3); + const cooldownKey = "wow335-druid-omen-of-clarity"; + if (rank <= 0 || (work.cooldowns[cooldownKey] ?? 0) > now + || stableCombatUnit(seed) >= rank * 0.02) return; + work.cooldowns[cooldownKey] = now + 2_000; + applyDruidSelfAura(work, DRUID_CLEARCASTING_AURA, now); +} + +function maybeProcDruidEclipse(work: MutableCastWork, ability: AbilityDefinition, seed: string, now: number): void { + if (work.classId !== "druid" || (ability.name !== "Wrath" && ability.name !== "Starfire")) return; + const rank = druidTalentRank(work, "talent-1924", 3); + const solar = ability.name === "Starfire"; + const auraTag = solar ? "wow335-druid-eclipse-solar" : "wow335-druid-eclipse-lunar"; + const otherAuraTag = solar ? "wow335-druid-eclipse-lunar" : "wow335-druid-eclipse-solar"; + const cooldownKey = solar ? "wow335-druid-eclipse-solar" : "wow335-druid-eclipse-lunar"; + const eclipseActive = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.definition.utilityTags?.includes("wow335-druid-eclipse-solar") + || aura.definition.utilityTags?.includes("wow335-druid-eclipse-lunar")) + && (aura.expiresAt === null || aura.expiresAt > now)); + const procChance = rank / 3 * (solar ? 1 : 0.6); + if (rank <= 0 || eclipseActive || (work.cooldowns[cooldownKey] ?? 0) > now + || stableCombatUnit(seed) >= procChance) return; + const definition: AuraDefinition = { + id: `wow335:druid:eclipse:${solar ? "solar" : "lunar"}`, + name: solar ? "Solar Eclipse" : "Lunar Eclipse", + disposition: "buff", + durationMs: 15_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [auraTag], + }; + work.cooldowns[cooldownKey] = now + 30_000; + work.auras = work.auras.filter((aura) => !aura.definition.utilityTags?.includes(otherAuraTag)); + applyDruidSelfAura(work, definition, now); +} + function damageMobInWork( classId: ClassId, work: MutableCastWork, @@ -2837,10 +3764,15 @@ function damageMobInWork( const testing = useGameStore.getState().gmTestMode; const canAward = killed && !mob.experienceGranted && !testing; const beforeLevel = work.progression.level; + const hasTreasureHunter = work.auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(ROM_ROGUE_TREASURE_HUNTER_TAG) + )); const pendingLoot = testing ? [] : killed && mob.lootSourceId ? rollBossLoot(mob.lootSourceId, beforeLevel, { now, - bonusItemChance: mob.bonusLootChance, + bonusItemChance: romRogueTreasureHunterLootChance(mob.bonusLootChance, hasTreasureHunter), }) : mob.pendingLoot; const card = killed && mob.boss && !testing @@ -2854,13 +3786,25 @@ function damageMobInWork( } satisfies BossCardDrop : mob.bossCardDrop; const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot; const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0; + if (classId === "death-knight" && source.actorId === PLAYER_AGGRO_ID && canAward && mob.xpReward > 0) { + const butcheryRank = Math.min(2, Math.max(0, Object.entries(work.talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === "Butchery" ? Math.max(rank, points) : rank + ), 0))); + const runicPowerGain = [0, 10, 20][butcheryRank] ?? 0; + const runicPower = work.resourcePools["runic-power"] ?? work.resource; + const maximumRunicPower = work.maxResourcePools["runic-power"] ?? work.maxResource; + if (runicPowerGain > 0) { + work.resource = clamp(runicPower + runicPowerGain, 0, maximumRunicPower); + work.resourcePools["runic-power"] = work.resource; + } + } engageMobGroupInWork(work, id, now, source.actorId); const engagedMob = work.mobs[id] ?? mob; const threatByActor = killed ? {} : addActorThreat( engagedMob.threatByActor, - source.actorId, + activeTricksThreatRecipient(work.auras, source.actorId, now) ?? source.actorId, threatFromDamage(applied, source.role, source.threatScale) * resolveAuraStatValue(1, aurasForEntity(work.auras, source.actorId), "threat"), ); @@ -2900,10 +3844,56 @@ function damageMobInWork( : mob.nextWoundAnimationAt, deathRevision: killed ? mob.deathRevision + 1 : mob.deathRevision, }; + const seedsToDetonate: TimedCombatEffect[] = []; + if (classId === "warlock" && (applied > 0 || killed)) { + work.effects = work.effects.flatMap((effect) => { + if (effect.kind !== "dot" || effect.targetId !== id || !effect.warlockSeed) return [effect]; + const accumulatedDamage = effect.warlockSeed.accumulatedDamage + applied; + if (killed || accumulatedDamage >= effect.warlockSeed.threshold) { + seedsToDetonate.push(effect); + return []; + } + return [{ ...effect, warlockSeed: { ...effect.warlockSeed, accumulatedDamage } }]; + }); + } // Pulling a member links the whole authored pack even when the opening hit // kills that member outright. The dead source remains inert because the // group helper deliberately skips dead mobs. work.totalDamage += applied; + if (classId === "warlock" && source.actorId === PLAYER_AGGRO_ID && applied > 0) { + const sourceAbility = source.abilityId ? abilityById(source.abilityId) : null; + const felSynergyRank = warlockTalentRank(work.talentRanks, "Fel Synergy", 2); + const pet = work.summons.find((summon) => summon.kind === "pet" + && summon.ownerActorId === PLAYER_AGGRO_ID + && summon.health > 0 + && summon.expiresAt > now); + if (sourceAbility && abilityAttackKind(classId, sourceAbility) === "spell" + && felSynergyRank > 0 && pet + && stableCombatUnit(`${sourceAbility.id}:${id}:${now}:wow335-warlock-fel-synergy`) < felSynergyRank / 2) { + const requested = Math.max(0, Math.round(applied * 0.15)); + const healed = Math.min(requested, pet.maxHealth - pet.health); + if (healed > 0) { + work.summons = work.summons.map((summon) => summon.id === pet.id + ? { ...summon, health: summon.health + healed } + : summon); + work.totalHealing += healed; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "healing", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: pet.id, + abilityId: sourceAbility.id, + school, + rawAmount: requested, + effectiveAmount: healed, + overheal: Math.max(0, requested - healed), + absorbed: 0, + critical: false, + }); + } + } + } + for (const seed of seedsToDetonate) detonateWarlockSeedInWork(work, seed, now); if (applied > 0) { evaluateProcsInWork(work, { trigger: "damage-taken", @@ -2923,6 +3913,567 @@ function damageMobInWork( }; } +function detonateWarlockSeedInWork( + work: MutableCastWork, + seed: TimedCombatEffect, + now: number, +): void { + const profile = seed.warlockSeed; + if (seed.kind !== "dot" || !seed.targetId || !profile) return; + const center = getMobPosition(seed.targetId); + const ability = triggeredAbilityBySpellId(profile.explosionSpellId, false); + const damageEffect = ability?.effects.find((effect) => effect.kind === "damage"); + if (!center || !ability || damageEffect?.kind !== "damage") return; + const sourceMember = usePartyStore.getState().members.find((member) => member.id === seed.sourceActorId); + const sourceLevel = sourceMember?.level ?? seed.sourceLevel ?? work.progression.level; + const sourceClassId = sourceMember?.classId ?? work.classId; + const sourceGearStats = sourceMember?.gearStats ?? work.gearStats; + const amount = talentAdjustedAmount( + equipmentAdjustedCombatAmount(damageEffect, sourceLevel, sourceGearStats, sourceClassId, ability), + work.talentModifiers, + "damage-percent", + ability, + ); + const source = threatSourceForActor( + { talentModifiers: work.talentModifiers }, + seed.sourceActorId, + seed.sourceAbilityId, + ); + for (const id of nearbyMobRuntimeIds(center, profile.radius)) { + if (id === seed.targetId || work.mobs[id]?.dead) continue; + const position = getMobPosition(id); + if (!position || Math.sqrt(distanceSquared(center, position)) > profile.radius + || !combatHasLineOfSight(center, position)) continue; + resolveAndDamageMobInWork( + work.classId, + work, + id, + amount, + now, + source, + "spell", + "shadow", + `${seed.id}:${id}:${now}:seed-of-corruption`, + ability, + false, + { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + ); + } + const sourceAbility = abilityById(seed.sourceAbilityId); + if (!sourceAbility) return; + emitCombatPresentation({ + occurredAt: now, + phase: "impact", + actorGroup: seed.sourceActorId === PLAYER_AGGRO_ID ? "player" : "party", + sourceActorId: seed.sourceActorId, + targetActorId: seed.targetId, + abilityId: sourceAbility.id, + sourceSpellId: ability.dbcSpellId, + name: sourceAbility.name, + source: presentationSourceForAbility(sourceAbility), + school: "shadow", + delivery: "area", + origin: center, + targetPosition: center, + }); +} + +const MAGE_HOT_STREAK_CRITICAL_SPELLS = new Set([ + "Fireball", + "Fire Blast", + "Scorch", + "Living Bomb", + "Frostfire Bolt", +]); + +function mageTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { + return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank + ), 0))); +} + +const WARLOCK_SOUL_LEECH_DIRECT_SPELLS = new Set([ + "Chaos Bolt", + "Conflagrate", + "Immolate", + "Incinerate", + "Searing Pain", + "Shadow Bolt", + "Shadowburn", + "Soul Fire", +]); + +const WARLOCK_ERADICATION_TAG = "wow335-warlock-eradication"; +const WARLOCK_IMPROVED_SHADOW_BOLT_TAG = "wow335-warlock-improved-shadow-bolt"; +const WARLOCK_SOUL_LEECH_COOLDOWN_TAG = "wow335-warlock-soul-leech-cooldown"; +const WARLOCK_BACKLASH_READY_TAG = "wow335-warlock-backlash-ready"; +const WARLOCK_BACKLASH_COOLDOWN_TAG = "wow335-warlock-backlash-cooldown"; +const WARLOCK_BACKLASH_SPELLS = new Set(["Shadow Bolt", "Incinerate"]); + +function warlockTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { + return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank + ), 0))); +} + +const WARLOCK_BACKLASH_READY_AURA: AuraDefinition = { + id: "wow335:warlock:backlash-ready", + name: "Backlash", + description: "Your next Shadow Bolt or Incinerate is instant, costs less mana, and deals increased damage.", + disposition: "buff", + durationMs: 12_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [WARLOCK_BACKLASH_READY_TAG], +}; + +const WARLOCK_BACKLASH_COOLDOWN_AURA: AuraDefinition = { + id: "wow335:warlock:backlash-cooldown", + name: "Backlash Internal Cooldown", + disposition: "buff", + durationMs: 8_000, + hideFromAuraStrip: true, + utilityTags: [WARLOCK_BACKLASH_COOLDOWN_TAG], +}; + +function maybeApplyWarlockBacklash( + auras: readonly ActiveAura[], + classId: ClassId, + talentRanks: TalentRanks, + attackKind: WotlkAttackKind, + school: DamageSchool, + landed: boolean, + seed: string, + now: number, +): ActiveAura[] { + if (classId !== "warlock" || school !== "physical" || attackKind === "spell" || !landed) return [...auras]; + const rank = warlockTalentRank(talentRanks, "Backlash", 3); + if (rank <= 0) return [...auras]; + const coolingDown = auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WARLOCK_BACKLASH_COOLDOWN_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)); + const chance = [0, 0.08, 0.16, 0.25][rank] ?? 0; + if (coolingDown || stableCombatUnit(`${seed}:wow335-warlock-backlash`) >= chance) return [...auras]; + const readyAuras = applyCombatAura(auras, { + definition: WARLOCK_BACKLASH_READY_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras; + return [...applyCombatAura(readyAuras, { + definition: WARLOCK_BACKLASH_COOLDOWN_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + +const WARLOCK_SOUL_LEECH_COOLDOWN_AURA: AuraDefinition = { + id: "wow335:warlock:soul-leech-cooldown", + name: "Soul Leech Cooldown", + disposition: "buff", + durationMs: 3_000, + hideFromAuraStrip: true, + utilityTags: [WARLOCK_SOUL_LEECH_COOLDOWN_TAG], +}; + +function maybeProcWarlockDamageTalentsInWork( + work: MutableCastWork, + ability: AbilityDefinition | undefined, + sourceActorId: string, + targetId: string, + result: DamageResult, + attackKind: WotlkAttackKind, + periodic: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "warlock" || sourceActorId !== PLAYER_AGGRO_ID + || !ability || attackKind !== "spell" || !result.combatResult?.landed || result.amount <= 0) return; + + if (ability.name === "Corruption") { + const eradicationRank = warlockTalentRank(work.talentRanks, "Eradication", 3); + if (eradicationRank > 0 && stableCombatUnit(`${seed}:wow335-warlock-eradication`) < 0.15) { + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:warlock:eradication", + name: "Eradication", + disposition: "buff", + durationMs: 10_000, + stackBehavior: "refresh", + utilityTags: [WARLOCK_ERADICATION_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + + if (!periodic && ability.name === "Shadow Bolt" && result.combatResult.critical) { + const improvedShadowBoltRank = warlockTalentRank(work.talentRanks, "Improved Shadow Bolt", 5); + if (improvedShadowBoltRank > 0 + && stableCombatUnit(`${seed}:wow335-warlock-improved-shadow-bolt`) < improvedShadowBoltRank * 0.2) { + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:warlock:improved-shadow-bolt-vulnerability", + name: "Improved Shadow Bolt", + disposition: "debuff", + durationMs: 30_000, + stackBehavior: "replace", + utilityTags: [WARLOCK_IMPROVED_SHADOW_BOLT_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId, + now, + }).auras]; + } + } + + if (periodic || !WARLOCK_SOUL_LEECH_DIRECT_SPELLS.has(ability.name)) return; + const soulLeechRank = warlockTalentRank(work.talentRanks, "Soul Leech", 3); + const soulLeechCoolingDown = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WARLOCK_SOUL_LEECH_COOLDOWN_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)); + const soulLeechChance = [0, 0.33, 0.66, 1][soulLeechRank] ?? 0; + if (soulLeechRank <= 0 || soulLeechCoolingDown + || stableCombatUnit(`${seed}:wow335-warlock-soul-leech`) >= soulLeechChance) return; + + const requestedHealing = Math.min( + Math.round(result.amount * 0.15), + Math.round(work.maxHealth * 0.15), + ); + const healed = healPlayerInWork(work, requestedHealing); + work.totalHealing += healed; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "healing", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: ability.id, + rawAmount: requestedHealing, + effectiveAmount: healed, + overheal: Math.max(0, requestedHealing - healed), + absorbed: 0, + critical: false, + }); + work.auras = [...applyCombatAura(work.auras, { + definition: WARLOCK_SOUL_LEECH_COOLDOWN_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + +function shamanTalentRankFromRanks(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { + return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank + ), 0))); +} + +function shamanTalentRank(work: MutableCastWork, talentName: string, maximumRank: number): number { + return work.classId === "shaman" + ? shamanTalentRankFromRanks(work.talentRanks, talentName, maximumRank) + : 0; +} + +const SHAMAN_SHOCK_NAMES = new Set(["Earth Shock", "Flame Shock", "Frost Shock"]); +const SHAMAN_REVERBERATION_AURA_TAG = "wow335-shaman-reverberation"; +const SHAMAN_ELEMENTAL_FOCUS_AURA_TAG = "wow335-shaman-elemental-focus-clearcasting"; + +function shamanClearcastingEligible(ability: AbilityDefinition): boolean { + return !ability.passive + && (ability.costs?.length ? ability.costs : [ability.cost]).some((cost) => cost.resource === "mana") + && abilityAttackKind("shaman", ability) === "spell" + && ["fire", "frost", "nature"].includes(abilityDamageSchool("shaman", ability)); +} + +function shamanReverberationSourceTag(ability: AbilityDefinition): string { + return `${SHAMAN_REVERBERATION_AURA_TAG}:from:${ability.id}`; +} + +function maybeProcShamanElementalFocusInWork( + work: MutableCastWork, + ability: AbilityDefinition | undefined, + school: DamageSchool, + critical: boolean, + periodic: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "shaman" || !ability || ability.source === "runewaker" + || !critical || periodic || !["fire", "frost", "nature"].includes(school)) return; + const rank = shamanTalentRank(work, "Elemental Focus", 2); + const chance = rank >= 2 ? 1 : rank === 1 ? 0.5 : 0; + if (chance <= 0 || stableCombatUnit(`${seed}:wow335-shaman-elemental-focus`) >= chance) return; + + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:shaman:elemental-focus-clearcasting", + name: "Clearcasting", + description: "Reduces the mana cost of your next two Fire, Frost, or Nature spells and abilities by 30%.", + disposition: "buff", + durationMs: null, + maxStacks: 2, + initialStacks: 2, + stackBehavior: "replace", + utilityTags: [SHAMAN_ELEMENTAL_FOCUS_AURA_TAG], + modifiers: [{ + kind: "resource", + resource: "mana", + property: "cost", + operation: "percent", + value: -0.3, + perStack: false, + }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + +function maybeProcShamanReverberationInWork( + work: MutableCastWork, + ability: AbilityDefinition | undefined, + landedDamage: number, + periodic: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "shaman" || !ability || ability.source === "runewaker" + || !SHAMAN_SHOCK_NAMES.has(ability.name) || landedDamage <= 0 || periodic) return; + const rank = shamanTalentRank(work, "Reverberation", 3); + const chance = [0, 1 / 3, 2 / 3, 1][rank] ?? 0; + if (chance <= 0 || stableCombatUnit(`${seed}:wow335-shaman-reverberation`) >= chance) return; + + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:shaman:reverberation", + name: "Reverberation", + description: "Your next different Shaman Shock or Elemental Blast deals 20% more direct damage within 8 seconds.", + disposition: "buff", + durationMs: 8_000, + stackBehavior: "replace", + utilityTags: [SHAMAN_REVERBERATION_AURA_TAG, shamanReverberationSourceTag(ability)], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + +function maybeProcShamanismAfterDamage( + work: MutableCastWork, + ability: AbilityDefinition | undefined, + seed: string, + now: number, +): void { + if (work.classId !== "shaman" || !ability + || !["Lightning Bolt", "Chain Lightning"].includes(ability.name)) return; + const rank = shamanTalentRank(work, "Shamanism", 3); + if (rank <= 0 || stableCombatUnit(`${seed}:wow335-shaman-shamanism`) >= rank / 3) return; + for (const cooldownAbility of work.abilities.filter((candidate) => ( + ["Elemental Mastery", "Thunderstorm"].includes(candidate.name) + ))) { + const readyAt = work.cooldowns[cooldownAbility.id] ?? 0; + if (readyAt > now) work.cooldowns[cooldownAbility.id] = Math.max(now, readyAt - 1_000); + } +} + +function maybeProcShamanHealingTalentsInWork( + work: MutableCastWork, + ability: AbilityDefinition, + targetId: string, + playerPosition: CombatPosition, + rawHealingAmount: number, + school: DamageSchool, + critical: boolean, + now: number, +): void { + if (work.classId !== "shaman" || !critical || ability.source === "runewaker") return; + + const ancestralHealingRank = shamanTalentRank(work, "Ancestral Healing", 3); + if (ancestralHealingRank > 0 && school === "nature") { + const reduction = [0, 0.03, 0.07, 0.1][ancestralHealingRank] ?? 0; + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:shaman:ancestral-healing", + name: "Ancestral Healing", + disposition: "buff", + durationMs: 15_000, + stackBehavior: "replace", + utilityTags: ["wow335-shaman-ancestral-healing"], + modifiers: [{ + kind: "damage", + direction: "taken", + school: "physical", + operation: "percent", + value: -reduction, + }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId, + now, + }).auras]; + } + + if (!["Healing Wave", "Lesser Healing Wave", "Riptide"].includes(ability.name)) return; + const awakeningRank = shamanTalentRank(work, "Ancestral Awakening", 3); + if (awakeningRank <= 0 || rawHealingAmount <= 0) return; + + const partyMembers = usePartyStore.getState().members; + const candidates = [ + { id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: playerPosition }, + ...partyMembers.map((member) => ({ + id: member.id, + health: member.health, + maxHealth: member.maxHealth, + position: getPartyRuntimePosition(member.id), + })), + ].filter((candidate) => candidate.health > 0 + && candidate.health < candidate.maxHealth + && candidate.position !== null + && Math.sqrt(distanceSquared(playerPosition, candidate.position)) <= 40 + && combatHasLineOfSight(playerPosition, candidate.position)) + .sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth + || left.id.localeCompare(right.id)); + const target = candidates[0]; + if (!target) return; + + const requested = Math.max(1, Math.round(rawHealingAmount * ([0, 0.1, 0.2, 0.3][awakeningRank] ?? 0))); + const healed = target.id === PLAYER_AURA_ENTITY_ID + ? healPlayerInWork(work, requested) + : healCompanionActorInWork(work, target.id, requested); + if (healed <= 0) return; + work.totalHealing += healed; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "healing", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: target.id, + abilityId: "wow335-shaman-ancestral-awakening", + school: "nature", + rawAmount: requested, + effectiveAmount: healed, + overheal: requested - healed, + absorbed: 0, + critical: false, + }); +} + +const ROGUE_RUTHLESSNESS_COOLDOWN_AURA: AuraDefinition = { + id: "wow335:rogue:ruthlessness-cooldown", + name: "Ruthlessness Cooldown", + disposition: "buff", + durationMs: 9_000, + hideFromAuraStrip: true, + utilityTags: ["wow335-rogue-ruthlessness-cooldown"], +}; + +const ROGUE_HONOR_AMONG_THIEVES_COOLDOWN_AURA: AuraDefinition = { + id: "wow335:rogue:honor-among-thieves-cooldown", + name: "Honor Among Thieves Cooldown", + disposition: "buff", + durationMs: 3_000, + hideFromAuraStrip: true, + utilityTags: ["wow335-rogue-honor-among-thieves-cooldown"], +}; + +function rogueTalentRank(talentRanks: TalentRanks, talentName: string, maximumRank: number): number { + return Math.min(maximumRank, Math.max(0, Object.entries(talentRanks).reduce((rank, [nodeId, points]) => ( + talentNodeById(nodeId)?.name === talentName ? Math.max(rank, points) : rank + ), 0))); +} + +function grantRogueComboPointInWork(work: MutableCastWork, targetId: string): void { + work.comboPoints = clamp((work.comboTargetId === targetId ? work.comboPoints : 0) + 1, 0, 5); + work.comboTargetId = targetId; +} + +function maybeProcRogueCastTalentsInWork( + work: MutableCastWork, + ability: AbilityDefinition, + targetId: string | null, + comboPointsBeforeCast: number, + comboTargetBeforeCast: string | null, + now: number, +): void { + if (work.classId !== "rogue" || !targetId || !work.mobs[targetId] || work.mobs[targetId]!.dead) return; + + const initiativeRank = ["Ambush", "Garrote", "Cheap Shot"].includes(ability.name) + ? rogueTalentRank(work.talentRanks, "Initiative", 3) + : 0; + if (initiativeRank > 0 + && stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:rogue-initiative`) < initiativeRank / 3) { + grantRogueComboPointInWork(work, targetId); + } + + // The generated rank-3 tooltip describes this capstone as a combo point + // from the Rogue's own direct-damage abilities, with a three-second limit. + // The combat log's cast events are the right boundary: periodic-only spells + // must not proc it on later ticks, and an area cast can grant at most once. + const honorAmongThievesRank = rogueTalentRank(work.talentRanks, "Honor Among Thieves", 3); + const directDamageAbility = ability.effects.some((effect) => ( + effect.kind === "damage" || effect.kind === "finisher-damage" + )); + const honorAmongThievesOnCooldown = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-rogue-honor-among-thieves-cooldown") + && (aura.expiresAt === null || aura.expiresAt > now)); + if (honorAmongThievesRank >= 3 + && directDamageAbility + && work.totalDamage > 0 + && work.comboPoints < 5 + && !honorAmongThievesOnCooldown) { + grantRogueComboPointInWork(work, targetId); + work.auras = [...applyCombatAura(work.auras, { + definition: ROGUE_HONOR_AMONG_THIEVES_COOLDOWN_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + + const meleeFinisher = abilityAttackKind(work.classId, ability) === "melee" + && ability.effects.some((effect) => effect.kind === "finisher-damage" + || (effect.kind === "combo" && effect.amount < 0)); + if (!meleeFinisher || comboPointsBeforeCast <= 0 || comboTargetBeforeCast !== targetId) return; + + const ruthlessnessRank = rogueTalentRank(work.talentRanks, "Ruthlessness", 3); + const onCooldown = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-rogue-ruthlessness-cooldown") + && (aura.expiresAt === null || aura.expiresAt > now)); + if (ruthlessnessRank <= 0 || onCooldown + || stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:rogue-ruthlessness`) >= ruthlessnessRank / 3) return; + + grantRogueComboPointInWork(work, targetId); + work.auras = [...applyCombatAura(work.auras, { + definition: ROGUE_RUTHLESSNESS_COOLDOWN_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + +function previousEligibleHotStreakCritical( + events: readonly StructuredCombatEvent[], + sourceActorId: string, + currentAbility: AbilityDefinition, + now: number, +): boolean { + for (let index = events.length - 2; index >= 0; index -= 1) { + const event = events[index]!; + if (event.kind !== "damage" || event.sourceActorId !== sourceActorId) continue; + // Several targets can receive the same area cast at the same timestamp; + // those hits count as one cast for Hot Streak's consecutive-critical rule. + if (event.occurredAt === now && event.abilityId === currentAbility.id) continue; + const previousAbility = event.abilityId ? abilityById(event.abilityId) : null; + return Boolean(event.critical && event.effectiveAmount > 0 + && previousAbility && MAGE_HOT_STREAK_CRITICAL_SPELLS.has(previousAbility.name)); + } + return false; +} + function resolveAndDamageMobInWork( classId: ClassId, work: MutableCastWork, @@ -2943,14 +4494,79 @@ function resolveAndDamageMobInWork( return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 }; } const sourceAuras = aurasForEntity(work.auras, source.actorId); + const activeSourceAuras = sourceAuras.filter((aura) => aura.expiresAt === null || aura.expiresAt > now); + const reverberationAura = classId === "shaman" && source.actorId === PLAYER_AGGRO_ID + && !periodic && ability && (SHAMAN_SHOCK_NAMES.has(ability.name) || ability.name === "Elemental Blast") + ? activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes(SHAMAN_REVERBERATION_AURA_TAG) + && !aura.definition.utilityTags.includes(shamanReverberationSourceTag(ability))) + : undefined; + const fingersOfFrost = classId === "mage" && source.actorId === PLAYER_AGGRO_ID + ? activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG)) + : undefined; + const hotStreak = classId === "mage" && source.actorId === PLAYER_AGGRO_ID + ? activeSourceAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)) + : undefined; + const targetFrozen = mob.statuses.some((status) => status.kind === "freeze" && status.endsAt > now); + const treatedAsFrozen = targetFrozen || Boolean(fingersOfFrost); + const mageProcDamageMultiplier = classId === "mage" && source.actorId === PLAYER_AGGRO_ID + ? (ability?.name === "Ice Lance" && treatedAsFrozen ? 3 : 1) + * (ability?.name === "Pyroblast" && hotStreak ? 1.4 : 1) + : 1; + const improvedScorchVulnerability = attackKind === "spell" + && aurasForEntity(work.auras, id).some((aura) => ( + aura.definition.utilityTags?.includes(WOW335_MAGE_IMPROVED_SCORCH_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + )); + const improvedShadowBoltVulnerability = attackKind === "spell" + && aurasForEntity(work.auras, id).some((aura) => ( + aura.definition.utilityTags?.includes(WARLOCK_IMPROVED_SHADOW_BOLT_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + )); const combustion = !periodic && attackKind === "spell" && school === "fire" ? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("fire-crit-stacking")) : undefined; + const druidSolarEclipse = classId === "druid" && source.actorId === PLAYER_AGGRO_ID + && (ability?.name === "Wrath" || ability?.name === "Starsurge") + ? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-druid-eclipse-solar") + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const druidLunarEclipse = classId === "druid" && source.actorId === PLAYER_AGGRO_ID + && (ability?.name === "Starfire" || ability?.name === "Starsurge") + ? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-druid-eclipse-lunar") + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const daggerMasteryTags = sourceAuras + .filter((aura) => aura.expiresAt === null || aura.expiresAt > now) + .flatMap((aura) => aura.definition.utilityTags ?? []); + const mainHandDagger = (() => { + if (classId !== "rom-rogue" || source.actorId !== PLAYER_AURA_ENTITY_ID + || !daggerMasteryTags.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)) return false; + const currentState = useCombatStore.getState(); + return resolvedEquipmentLoadout(currentState.equipment, currentState.inventory, { + ownerId: PLAYER_EQUIPMENT_OWNER_ID, + ownerKind: "player", + classId: "rom-rogue", + secondaryClassId: currentState.secondaryClassId, + specializationId: currentState.specializationId, + role: currentState.gearRole, + level: currentState.level, + })["main-hand"]?.weaponType === "dagger"; + })(); + const rogueDaggerMultiplier = romRogueDaggerDamageMultiplier({ + classId, + sourceActorId: source.actorId, + playerActorId: PLAYER_AURA_ENTITY_ID, + attackKind, + school, + mainHandDagger, + passiveTags: daggerMasteryTags, + }); const modifiedAmount = resolveAuraDamageValue(amount, sourceAuras, { direction: "dealt", school, attackKind, - }); + }) * (druidSolarEclipse ? 1.3 : 1) * rogueDaggerMultiplier * mageProcDamageMultiplier + * (reverberationAura ? 1.2 : 1); const combatResult = resolvePlayerDamageResult( classId, work, @@ -2963,7 +4579,11 @@ function resolveAndDamageMobInWork( ability, periodic, rules, - criticalChanceBonusPercent + (combustion ? Math.max(0, combustion.stacks - 1) : 0), + criticalChanceBonusPercent + + (combustion ? Math.max(0, combustion.stacks - 1) : 0) + + (druidLunarEclipse ? 30 : 0) + + (improvedScorchVulnerability ? 5 : 0) + + (improvedShadowBoltVulnerability ? 5 : 0), ); const result = damageMobInWork( classId, @@ -2976,6 +4596,125 @@ function resolveAndDamageMobInWork( work.gearStats.spellPenetration, combatResult, ); + if (reverberationAura && combatResult.landed && result.amount > 0) { + work.auras = work.auras.filter((aura) => aura.instanceId !== reverberationAura.instanceId); + } + const impactAura = classId === "mage" && source.actorId === PLAYER_AGGRO_ID + && !periodic && attackKind === "spell" && ability?.name === "Fire Blast" + ? work.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_IMPACT_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + if (impactAura && combatResult.landed && result.amount > 0) { + const target = work.mobs[id]; + if (target) { + work.mobs[id] = applyStatus(target, { + kind: "status", + status: "stun", + durationMs: 3_000, + }, ability!.id, now, PLAYER_AGGRO_ID); + } + work.auras = work.auras.filter((aura) => aura.instanceId !== impactAura.instanceId); + } + const frostbiteRank = classId === "mage" && source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && school === "frost" + ? Math.min(3, Math.max(0, Math.trunc(work.talentRanks["talent-38"] ?? 0))) + : 0; + if (frostbiteRank > 0 && combatResult.landed && result.amount > 0 + && stableCombatUnit(`${seed}:mage-frostbite`) < frostbiteRank * 0.05) { + const target = work.mobs[id]; + if (target) { + work.mobs[id] = applyStatus(target, { + kind: "status", + status: "freeze", + durationMs: 4_000, + }, ability?.id ?? "wow335-mage-frostbite", now, PLAYER_AGGRO_ID); + } + } + if (classId === "mage" && source.actorId === PLAYER_AGGRO_ID && !periodic + && attackKind === "spell" && combatResult.landed && result.amount > 0 && ability) { + // Only the rank-3 active row links Impact to child 64343. Its active + // tooltip gives a 4% damaging-spell proc; it does not require a critical. + const impactRank = mageTalentRank(work.talentRanks, "Impact", 3); + if (impactRank >= 3 && school !== "physical" + && stableCombatUnit(`${seed}:mage-impact`) < 0.04) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_IMPACT_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + if (ability.name === "Scorch" && school === "fire") { + const rank = mageTalentRank(work.talentRanks, "Improved Scorch", 3); + if (rank > 0 && stableCombatUnit(`${seed}:mage-improved-scorch`) < rank / 3) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_IMPROVED_SCORCH_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: id, + now, + }).auras]; + } + } + if (school === "frost" && !["Blizzard", "Ice Lance"].includes(ability.name)) { + const rank = mageTalentRank(work.talentRanks, "Fingers of Frost", 2); + const chance = rank === 1 ? 0.07 : rank >= 2 ? 0.15 : 0; + if (chance > 0 && stableCombatUnit(`${seed}:mage-fingers-of-frost`) < chance) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_FINGERS_OF_FROST_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + if (combatResult.critical && MAGE_HOT_STREAK_CRITICAL_SPELLS.has(ability.name) + && previousEligibleHotStreakCritical(work.combatEvents, source.actorId, ability, now)) { + const rank = mageTalentRank(work.talentRanks, "Hot Streak", 3); + if (rank > 0 && stableCombatUnit(`${seed}:mage-hot-streak`) < rank / 3) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_HOT_STREAK_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + if (combatResult.critical && school === "fire") { + const rank = mageTalentRank(work.talentRanks, "Ignite", 5); + if (rank > 0) { + // DBC rank rows 11119/11120/12846/12847/12848 encode 8% per rank + // of the critical's damage over two ticks. The generated talent prose + // instead says 6%-30% and adds a healing-reduction rider; that rider + // is absent from the spell effects, so only the DBC burn is applied. + const totalBurn = Math.round(result.amount * rank * 0.08); + const firstTick = Math.ceil(totalBurn / 2); + const secondTick = totalBurn - firstTick; + const makeIgniteTick = (amount: number, tickAt: number): TimedCombatEffect => ({ + ...timedEffect("dot", ability.id, PLAYER_AGGRO_ID, id, { + coefficient: 0, + fixedAmount: amount, + }, 1, 2_000, now, "wow335-mage-ignite", "fire", work.gearStats.spellPenetration), + nextTickAt: tickAt, + }); + work.effects = [ + ...work.effects, + makeIgniteTick(firstTick, now + 2_000), + makeIgniteTick(secondTick, now + 4_000), + ]; + } + } + } + maybeProcWarlockDamageTalentsInWork( + work, + ability, + source.actorId, + id, + result, + attackKind, + periodic, + seed, + now, + ); if (ability?.source === "runewaker" && combatResult.critical && result.amount > 0) { const passiveTags = new Set(aurasForEntity(work.auras, source.actorId) .flatMap((aura) => aura.definition.utilityTags ?? [])); @@ -3023,6 +4762,38 @@ function resolveAndDamageMobInWork( attackKind, }, now); } + maybeProcPriestDamageTalentsInWork( + work, + ability, + source.actorId, + school, + combatResult.critical, + combatResult.landed, + periodic, + seed, + now, + ); + if (source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && !periodic + && combatResult.landed && result.amount > 0) { + maybeProcShamanismAfterDamage(work, ability, seed, now); + } + if (source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && !periodic + && combatResult.landed && result.amount > 0) { + maybeProcShamanElementalFocusInWork(work, ability, school, combatResult.critical, periodic, seed, now); + } + if (source.actorId === PLAYER_AGGRO_ID && !periodic && combatResult.landed && result.amount > 0) { + maybeProcShamanReverberationInWork(work, ability, result.amount, periodic, seed, now); + } + if (source.actorId === PLAYER_AGGRO_ID && !periodic && combatResult.landed) { + if (classId === "druid" && attackKind === "spell" && ability && result.amount > 0) { + maybeProcDruidNatureGrace(work, ability, combatResult.critical, `${seed}:druid-nature-grace`, now); + maybeProcDruidEclipse(work, ability, `${seed}:druid-eclipse`, now); + } + if (classId === "druid" && result.amount > 0 + && (attackKind === "spell" || (attackKind === "melee" && rules.isAutoAttack))) { + maybeProcDruidOmenOfClarity(work, `${seed}:druid-omen-of-clarity`, now); + } + } if ( combatResult.landed && result.amount > 0 @@ -3051,6 +4822,36 @@ function resolveAndDamageMobInWork( attackKind, }, now); } + if (classId === "paladin" && source.actorId === PLAYER_AGGRO_ID + && attackKind === "melee" && rules.isAutoAttack && !periodic + && combatResult.landed && result.amount > 0 + && work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(PALADIN_RECKONING_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.stacks > 0)) { + work.auras = [...consumePaladinReckoningCharge(work.auras, now)]; + const extraAttack = resolveAndDamageMobInWork( + classId, + work, + id, + amount, + now, + source, + attackKind, + school, + `${seed}:paladin-reckoning-extra-attack`, + undefined, + false, + { ...rules, isAutoAttack: false }, + criticalChanceBonusPercent, + ); + return { + amount: result.amount + extraAttack.amount, + killed: result.killed || extraAttack.killed, + xpAwarded: result.xpAwarded + extraAttack.xpAwarded, + levelsGained: result.levelsGained + extraAttack.levelsGained, + }; + } return result; } @@ -3136,10 +4937,10 @@ function executeTriggeredSpellInWork( allowCoaHidden = false, ): void { if (depth > 1) return; - const catalogAbility = work.abilities.find((candidate) => ( + const catalogAbility = triggeredAbilityBySpellId(spellId, allowCoaHidden) ?? work.abilities.find((candidate) => ( candidate.dbcSpellId === spellId || candidate.ranks?.some((rank) => rank.spellId === spellId) - )) ?? triggeredAbilityBySpellId(spellId, allowCoaHidden); + )) ?? null; if (!catalogAbility) return; const ability = abilityAtLevel(catalogAbility, work.progression.level); const source = playerThreatSource({ talentModifiers: work.talentModifiers }, ability.id, true); @@ -3172,7 +4973,10 @@ function executeTriggeredSpellInWork( } } else if (effect.kind === "heal") { const rawAmount = talentAdjustedAmount( - equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability), + equipmentAdjustedCombatAmount(effect, work.progression.level, work.gearStats, work.classId, ability) + + Math.round((sourceId === PLAYER_AURA_ENTITY_ID + ? work.maxHealth + : work.actors[sourceId]?.maxHealth ?? 0) * (effect.percentMaxHealth ?? 0)), work.talentModifiers, "healing-percent", ability, @@ -3208,7 +5012,10 @@ function executeTriggeredSpellInWork( } else if (effect.kind === "apply-aura") { const recipient = effect.recipient === "caster" ? sourceId + : effect.recipient === "pet" + ? activeHunterPetId(work.summons, now) : targetId ?? sourceId; + if (!recipient) continue; const previousAuras = work.auras; work.auras = [...applyCombatAura(previousAuras, { definition: auraDefinitionForAbility(effect.aura, ability), @@ -3289,12 +5096,16 @@ function executeTriggeredSpellInWork( kind: /totem/i.test(ability.name) ? "totem" : "summon", name: ability.name, ownerActorId: sourceId, - health: Math.max(1, Math.round(work.maxHealth * 0.3)), - maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)), + health: effect.healingPulse ? 5 : Math.max(1, Math.round(work.maxHealth * 0.3)), + maxHealth: effect.healingPulse ? 5 : Math.max(1, Math.round(work.maxHealth * 0.3)), position: getMobPosition(targetId ?? "") ?? [0, 0, 0], - nextActionAt: now + 1_000, - pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200, - attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))), + nextActionAt: now + (effect.healingPulse?.intervalMs ?? 1_000), + pulseIntervalMs: effect.healingPulse?.intervalMs + ?? (ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200), + attackDamage: effect.healingPulse + ? 0 + : Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))), + ...(effect.healingPulse ? { healingPulse: effect.healingPulse } : {}), ...initialSummonCombatRuntime(), }); } else if (effect.kind === "resource-drain") { @@ -3365,7 +5176,9 @@ function executeTriggeredSpellInWork( }; } } else if (effect.kind === "cooldown-reset") { - const candidates = cooldownResetCandidates(work.abilities, ability.id, effect.abilityName); + const candidates = cooldownResetCandidates( + work.abilities, ability.id, effect.abilityName, effect.excludeAbilityNames, + ); for (const candidate of candidates) { if (effect.mode === "reset") work.cooldowns[candidate.id] = 0; else { @@ -3432,7 +5245,12 @@ function timedEffect( abilityId: string, sourceActorId: AggroActorId, targetId: string | null, - scaling: AbilityAmountScaling & AbilityDamageResource & { readonly coefficient: number; readonly startsImmediately?: boolean }, + scaling: AbilityAmountScaling & AbilityDamageResource & { + readonly coefficient: number; + readonly startsImmediately?: boolean; + readonly percentMaxHealth?: number; + readonly fixedAmount?: number; + }, ticks: number, intervalMs: number, now: number, @@ -3457,6 +5275,8 @@ function timedEffect( ...(scaling.spiritCoefficient === undefined ? {} : { spiritCoefficient: scaling.spiritCoefficient }), ...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }), ...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }), + ...(scaling.percentMaxHealth === undefined ? {} : { percentMaxHealth: scaling.percentMaxHealth }), + ...(scaling.fixedAmount === undefined ? {} : { fixedAmount: scaling.fixedAmount }), ...(scaling.onDamageResource ? { onDamageResource: scaling.onDamageResource } : {}), remainingTicks: ticks, intervalMs, @@ -3467,6 +5287,51 @@ function timedEffect( }; } +const WARLOCK_SEED_PROFILE_BY_SPELL_ID: Readonly> = { + 27243: { threshold: 1_044, explosionSpellId: 27_285 }, + 47835: { threshold: 1_296, explosionSpellId: 47_833 }, + 47836: { threshold: 1_518, explosionSpellId: 47_834 }, +}; + +function warlockSeedProfileForAbility(ability: AbilityDefinition): { + threshold: number; + explosionSpellId: number; + radius: number; + accumulatedDamage: number; +} | null { + if (ability.name !== "Seed of Corruption") return null; + const profile = WARLOCK_SEED_PROFILE_BY_SPELL_ID[ability.dbcSpellId]; + return profile ? { ...profile, radius: 15, accumulatedDamage: 0 } : null; +} + +function activeWarlockConflagrateDot( + effects: readonly TimedCombatEffect[], + targetId: string, + sourceActorId: AggroActorId, + now: number, +): TimedCombatEffect | null { + const active = effects.filter((effect) => { + if (effect.kind !== "dot" || effect.targetId !== targetId || effect.sourceActorId !== sourceActorId + || effect.remainingTicks <= 0 || effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs < now) { + return false; + } + const name = abilityById(effect.sourceAbilityId)?.name; + return name === "Immolate" || name === "Shadowflame"; + }); + return active.find((effect) => abilityById(effect.sourceAbilityId)?.name === "Immolate") + ?? active.find((effect) => abilityById(effect.sourceAbilityId)?.name === "Shadowflame") + ?? null; +} + +function warlockCurseOfAgonyTickAmount(amount: number, tickNumber: number): number { + if (tickNumber <= 4) return Math.floor(amount / 2); + if (tickNumber <= 8) return amount; + return Math.floor((amount + 1) * 1.5); +} + function detonateLivingBombInWork(work: MutableCastWork, effect: TimedCombatEffect, now: number): void { if (effect.kind !== "dot" || !effect.targetId) return; const source = abilityById(effect.sourceAbilityId); @@ -4079,7 +5944,8 @@ export const useCombatStore = create((set, get) => ({ // RoM secondary pools use native resource limits, not the mapped WoW class's mana. const initialPools = initialResourcePools(classId, abilities, maxResource, isRomClassId(classId) ? defaultResourceMaximum("mana") - : deriveCharacterStats(baseClassFor(classId), progression.level, gearStats, raceId).mana); + : deriveCharacterStats(baseClassFor(classId), progression.level, gearStats, raceId).mana, + naturePowerMaximumForCharacter(classId, secondaryClassId, progression.level)); set((state) => ({ ...initialState(), classId, @@ -4590,6 +6456,12 @@ export const useCombatStore = create((set, get) => ({ setPlayerPosition: (position) => set((state) => { const next: CombatPosition = [position[0], position[1], position[2]]; + const movementLocked = state.auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.expiresAt === null || aura.expiresAt > combatNow()) + && aura.definition.utilityTags?.includes("rom-druid-movement-locked") + )); + if (movementLocked) return state; const cast = state.activeCast; const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && ( (next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04 @@ -4916,6 +6788,70 @@ export const useCombatStore = create((set, get) => ({ ? abilityAtLevel(catalogAbility, state.level, learnedTalentRank) : null; const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID); + const romWarriorPassiveSpellIds = state.classId === "rom-warrior" + ? state.abilities.filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-warrior" + && (!candidate.secondaryClassId || candidate.secondaryClassId === state.secondaryClassId) + && candidate.passive + && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) + .map((candidate) => candidate.dbcSpellId) + : []; + const warriorAdjustment = ability + ? romWarriorAbilityAdjustment(ability, romWarriorPassiveSpellIds) + : { rageCostReduction: 0, cooldownReductionMs: 0 }; + const rogueShadowWalkerPassiveSpellIds = state.classId === "rom-rogue" && state.secondaryClassId === "rom-priest" + ? state.abilities.filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-rogue" + && candidate.secondaryClassId === "rom-priest" + && candidate.passive + && candidate.dbcSpellId === ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID + && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) + .map((candidate) => candidate.dbcSpellId) + : []; + const rogueShadowWalkerAdjustment = ability + ? romRogueShadowWalkerAdjustment( + state.classId, + state.secondaryClassId, + ability.source, + ability.dbcSpellId, + rogueShadowWalkerPassiveSpellIds, + ) + : { allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 }; + const flameFollowerCooldownReduction = ability?.source === "runewaker" + && ability.dbcSpellId === 490204 + && activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower)) + ? 1_000 + : 0; + if (ability && (warriorAdjustment.rageCostReduction > 0 + || warriorAdjustment.cooldownReductionMs > 0 + || rogueShadowWalkerAdjustment.energyCostReduction > 0 + || rogueShadowWalkerAdjustment.cooldownIncreaseMs > 0 + || flameFollowerCooldownReduction > 0)) { + const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage" + ? { ...cost, amount: Math.max(0, cost.amount - warriorAdjustment.rageCostReduction) } + : cost.resource === "energy" + ? { ...cost, amount: Math.max(0, cost.amount - rogueShadowWalkerAdjustment.energyCostReduction) } + : cost); + ability = { + ...ability, + cooldownMs: Math.max(0, ability.cooldownMs + - warriorAdjustment.cooldownReductionMs + - flameFollowerCooldownReduction + + rogueShadowWalkerAdjustment.cooldownIncreaseMs), + cost: adjustedCosts[0] ?? ability.cost, + costs: adjustedCosts, + }; + } + if (state.classId === "rom-priest" && state.secondaryClassId === "rom-champion" && ability) { + const infusedLightPassives = state.abilities.filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-priest" + && candidate.secondaryClassId === "rom-champion" + && candidate.passive + && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) + .map((candidate) => candidate.dbcSpellId); + const cooldownOverride = romPriestChampionCooldownOverride(ability, infusedLightPassives); + if (cooldownOverride !== null) ability = { ...ability, cooldownMs: cooldownOverride }; + } if (ability?.dbcSpellId === 490133 && activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes("rom-whirlwind-mastery"))) { const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage" @@ -4930,11 +6866,41 @@ export const useCombatStore = create((set, get) => ({ const innerFocusAura = activePlayerAuras.find((aura) => ( aura.definition.utilityTags?.includes("next-heal-free-critical") )); + const innerFocusSerendipityBlocked = Boolean(innerFocusAura + && ability && INNER_FOCUS_SERENDIPITY_BLOCKED_SPELLS.has(ability.name)); const consumesInnerFocus = Boolean( innerFocusAura - && ability?.name !== "Inner Focus" - && ability?.effects.some((effect) => effect.kind === "heal"), + && ability + && ["Greater Heal", "Mind Blast", "Power Word: Shield"].includes(ability.name), ); + if (consumesInnerFocus && ability && ["Greater Heal", "Mind Blast"].includes(ability.name)) { + ability = { + ...ability, + castTimeMs: Math.round(ability.castTimeMs * 0.5), + ...(ability.name === "Mind Blast" ? { + effects: [...ability.effects, { kind: "status", status: "slow", durationMs: 5_000, magnitude: 0.7 }], + } : {}), + }; + } + const surgeOfLightAura = state.classId === "priest" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light") + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const consumesSurgeOfLight = Boolean(surgeOfLightAura + && ["Smite", "Flash Heal"].includes(ability?.name ?? "")); + const serendipityAura = state.classId === "priest" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity") + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const serendipityAbility = ["Greater Heal", "Prayer of Healing"].includes(ability?.name ?? ""); + const serendipityRank = state.classId === "priest" + ? priestTalentRank(state.talentRanks, "Serendipity", 3) + : 0; + const consumesSerendipity = Boolean(serendipityAura && serendipityRank > 0 + && serendipityAbility && !innerFocusSerendipityBlocked); + const serendipityReductionPercent = consumesSerendipity + ? ([0, 5, 10, 15][serendipityRank] ?? 0) * serendipityAura!.stacks + : 0; const hasRuneWakerState = (tag: string) => activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(tag)); const holyAuraFriendly = Boolean( ability?.source === "runewaker" @@ -4949,27 +6915,131 @@ export const useCombatStore = create((set, get) => ({ ? completingCast.targetId && state.mobs[completingCast.targetId] ? "hostile" : "friendly" : state.selectedFriendlyActorId || party.selectedMemberId || party.selfSelected ? "friendly" : "hostile" : ability?.target; - const modifiers: readonly TalentModifier[] = consumesInnerFocus ? [ + const druidClearcastingAura = state.classId === "druid" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-druid-clearcasting")) + : undefined; + const shamanClearcastingAura = state.classId === "shaman" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(SHAMAN_ELEMENTAL_FOCUS_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const consumesShamanClearcasting = Boolean( + shamanClearcastingAura && ability && shamanClearcastingEligible(ability), + ); + const warlockBacklashAura = state.classId === "warlock" && ability + && WARLOCK_BACKLASH_SPELLS.has(ability.name) + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WARLOCK_BACKLASH_READY_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const consumesWarlockBacklash = Boolean(warlockBacklashAura); + const warlockBacklashRank = consumesWarlockBacklash + ? warlockTalentRank(state.talentRanks, "Backlash", 3) + : 0; + const consumesDruidClearcasting = Boolean( + druidClearcastingAura + && ability + && druidClearcastingEligible(ability, effectiveTarget ?? ability.target), + ); + const modifiers: readonly TalentModifier[] = [ ...state.talentModifiers, - { + ...(consumesInnerFocus && ability?.name === "Greater Heal" ? [{ kind: "critical-chance-percent", amount: 25, unit: "percent", - scope: "your healing spells", + scope: "your Greater Heal spell", sourceTalentId: "talent-348:active", sourceTalentName: "Inner Focus", rank: 1, - description: "The next direct or area heal costs no resource and gains 25 percentage points of critical chance.", - }, - ] : state.talentModifiers; - const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0; - const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : []; + description: "Inner Focus increases the critical chance of your Greater Heal by 25 percentage points.", + } satisfies TalentModifier] : []), + ...(consumesSerendipity ? (["cast-time-percent", "resource-cost-percent"] as const).map((kind) => ({ + kind, + amount: -serendipityReductionPercent, + unit: "percent" as const, + scope: "your next Greater Heal or Prayer of Healing", + sourceTalentId: Object.entries(state.talentRanks).find(([nodeId]) => talentNodeById(nodeId)?.name === "Serendipity")?.[0] ?? "talent-serendipity", + sourceTalentName: "Serendipity", + rank: serendipityRank, + description: `Serendipity reduces this cast time and mana cost by ${serendipityReductionPercent}% per active stack.`, + })) : []), + ...(consumesWarlockBacklash ? [{ + kind: "resource-cost-percent" as const, + amount: -([0, 25, 50, 50][warlockBacklashRank] ?? 0), + unit: "percent" as const, + scope: "your spells", + sourceTalentId: "talent-1817:active", + sourceTalentName: "Backlash", + rank: warlockBacklashRank, + description: "Backlash reduces the mana cost of your next Shadow Bolt or Incinerate.", + }, { + kind: "damage-percent" as const, + amount: 10, + unit: "percent" as const, + scope: "your spells", + sourceTalentId: "talent-1817:active", + sourceTalentName: "Backlash", + rank: warlockBacklashRank, + description: "Backlash increases the damage of your next Shadow Bolt or Incinerate against monsters by 10%.", + }] : []), + ]; + const warriorFlatRageCostReduction = state.classId === "warrior" && ability?.name === "Heroic Strike" + ? Math.min(3, Math.max(0, Math.trunc(state.talentRanks["talent-124"] ?? 0))) * 2 + : state.classId === "warrior" && ability?.name === "Execute" + ? Math.min(3, Math.max(0, Math.trunc(state.talentRanks["talent-1542"] ?? 0))) * 2 + : 0; + const costs = ability && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight + ? resolvedAbilityCosts(ability, modifiers, state).map((cost) => { + if (state.classId !== "warrior" || cost.resource !== "rage") return cost; + return warriorFlatRageCostReduction > 0 + ? { ...cost, amount: Math.max(0, cost.amount - warriorFlatRageCostReduction) } + : cost; + }) + : []; + const resourceCost = ability && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight + ? Math.max(0, talentAdjustedResourceCost(ability, modifiers) + - (ability.cost.resource === "rage" ? warriorFlatRageCostReduction : 0)) + : 0; const costsToSpend = completingCast?.mode === "channel" ? [] : costs; const baseTargeting = ability ? targetingRulesForAbility(ability) : null; const targeting = ability?.dbcSpellId === 490457 && baseTargeting && hasRuneWakerState("rom-master-reflected-shot") ? { ...baseTargeting, maximumTargets: 5 } : baseTargeting; const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical"; + const natureSwiftnessAura = (state.classId === "shaman" || state.classId === "druid") + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-next-nature-instant")) + : undefined; + const presenceOfMindAura = state.classId === "mage" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-next-mage-instant")) + : undefined; + const hotStreakAura = state.classId === "mage" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)) + : undefined; + const fingersOfFrostAura = state.classId === "mage" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG)) + : undefined; + const consumesPresenceOfMind = Boolean( + presenceOfMindAura + && ability + && ability.name !== "Presence of Mind" + && ability.castMode !== "channel" + && ability.castTimeMs > 0 + && ability.castTimeMs < 10_000, + ); + const consumesHotStreak = Boolean(hotStreakAura && ability?.name === "Pyroblast"); + const consumesFingersOfFrost = Boolean(fingersOfFrostAura && ability); + const deepFreezeTargetId = request.targetActorId !== undefined ? request.targetActorId : state.selectedTargetId; + const deepFreezeTarget = deepFreezeTargetId ? state.mobs[deepFreezeTargetId] : undefined; + const deepFreezeTargetIsFrozen = Boolean(deepFreezeTarget?.statuses.some((status) => ( + status.kind === "freeze" && status.endsAt > now + ))); + const consumesNatureSwiftness = Boolean( + natureSwiftnessAura + && ability + && ability.name !== "Nature's Swiftness" + && ability.castMode !== "channel" + && ability.castTimeMs > 0 + && ability.castTimeMs < 10_000 + && spellSchool === "nature", + ); const runeCost = ability && baseClassFor(state.classId) === "death-knight" ? runeCostForAbility(ability) : null; @@ -4980,10 +7050,15 @@ export const useCombatStore = create((set, get) => ({ else if (ability.passive) failure = "passive"; else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported"; else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked"; + // The source tooltip gives no expiry window; a newer player damage event ends Smash's crit window. + else if (ability.source === "runewaker" && ability.dbcSpellId === 490019 + && !hasRecentPlayerCriticalHit(state.combatEvents, PLAYER_AGGRO_ID)) failure = "condition-not-met"; else if (state.health <= 0) failure = "player-dead"; else if ( state.controlledUntil > now && enemyControlPreventsActions(state.controlMechanic) + && ability?.dbcSpellId !== 498570 + && ability?.dbcSpellId !== 1953 ) failure = "player-controlled"; else if ( effectiveTarget === "hostile" @@ -4997,11 +7072,27 @@ export const useCombatStore = create((set, get) => ({ else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met"; else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met"; else if (ability.dbcSpellId === 493017 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met"; + else if (ability.dbcSpellId === 494037 && !hasRuneWakerState("rom-briar-shield")) failure = "condition-not-met"; + else if (state.classId === "rom-champion" && state.secondaryClassId === "rom-warlock" + && ability.source === "runewaker" && ability.dbcSpellId === 498600 + && state.health / Math.max(1, state.maxHealth) >= 0.65) failure = "condition-not-met"; + else if (state.classId === "rom-warden" && ability.source === "runewaker" + && ability.target === "hostile" && activeWardenNatureCrystal(state.summons, now)) failure = "condition-not-met"; + else if (state.classId === "paladin" && ability.name === "Redemption" + && Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met"; else if (abilityRequiresOutOfCombat(ability) + && !rogueShadowWalkerAdjustment.allowsCombatUse && !(ability.source === "runewaker" && state.classId === "rom-rogue" && hasRuneWakerState("rom-vanish")) && Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met"; else if (ability.name === "Enraged Regeneration" && !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met"; + else if (state.classId === "hunter" + && ["Bestial Wrath", "Intimidation", "Kill Command", "Mend Pet"].includes(ability.name) + && !activeHunterPetId(state.summons, now)) failure = "condition-not-met"; + else if (state.classId === "mage" && ability.name === "Deep Freeze" + && !deepFreezeTargetIsFrozen && !fingersOfFrostAura) failure = "condition-not-met"; + else if (state.classId === "death-knight" && ability.name === "Blood Tap" + && !canActivateDeathKnightBloodTapRune(state.deathKnightRunes, now)) failure = "not-enough-runes"; else if (costs.some((cost) => cost.resource === "health" ? state.health <= cost.amount : (cost.resource === resourceProfileForClass(state.classId).type @@ -5066,18 +7157,123 @@ export const useCombatStore = create((set, get) => ({ } } + if (!failure && state.classId === "death-knight" && ability?.name === "Death Coil" + && effectiveTarget === "friendly") { + const partyTarget = targetId ? party.members.find((member) => member.id === targetId) : null; + const summon = targetId ? state.summons.find((candidate) => candidate.id === targetId) : null; + const friendlyIsUndead = targetId === null + ? state.raceId === "undead" + : partyTarget?.raceId === "undead" + || (summon != null && [26125, 24207].includes(summon.creatureId)); + if (!friendlyIsUndead) failure = "condition-not-met"; + } + if (!failure && ability && targeting?.requiresLineOfSight && targetPosition) { if (!combatHasLineOfSight(playerOrigin, targetPosition)) failure = "line-of-sight"; } + const requiredTargetStateTag = ability ? romAbilityRequiredTargetStateTag(ability) : null; + if (!failure && requiredTargetStateTag && targetId + && !aurasForEntity(state.auras, targetId).some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(requiredTargetStateTag) + ))) failure = "condition-not-met"; + const rogueStealthOpeners = new Set(["Backstab", "Ambush", "Garrote", "Sap", "Cheap Shot", "Pick Pocket"]); + const shadowDanceOpeners = new Set(["Ambush", "Garrote", "Sap", "Cheap Shot", "Pick Pocket"]); + if (!failure && state.classId === "rogue" && ability && rogueStealthOpeners.has(ability.name)) { + const hasStealth = activePlayerAuras.some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes("stealth") + )); + const hasShadowDance = shadowDanceOpeners.has(ability.name) && activePlayerAuras.some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes("wow335-rogue-shadow-dance") + )); + if (!hasStealth && !hasShadowDance) failure = "condition-not-met"; + } + if (!failure && state.classId === "rogue" && ability + && ["Backstab", "Ambush"].includes(ability.name)) { + const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { + ownerId: PLAYER_EQUIPMENT_OWNER_ID, + ownerKind: "player", + classId: state.classId, + secondaryClassId: state.secondaryClassId, + specializationId: state.specializationId, + role: state.gearRole, + level: state.level, + }); + if (loadout["main-hand"]?.weaponType !== "dagger") failure = "condition-not-met"; + } + if (!failure && state.classId === "rogue" && ability?.name === "Sap" && targetId + && state.mobs[targetId]?.engaged) failure = "condition-not-met"; + if (!failure && state.classId === "rogue" && ability?.name === "Pick Pocket" && targetId + && state.mobs[targetId]?.engaged) failure = "condition-not-met"; + if (!failure && state.classId === "rogue" && ability?.name === "Riposte" + && !activePlayerAuras.some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes("wow335-rogue-riposte-ready") + ))) failure = "condition-not-met"; + if (!failure && state.classId === "hunter" && ability?.name === "Counterattack" + && !activePlayerAuras.some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready") + ))) failure = "condition-not-met"; + if (!failure && state.classId === "rogue" && ability?.name === "Tricks of the Trade" + && (!targetId || !party.members.some((member) => member.id === targetId))) failure = "no-target"; + if (!failure && state.classId === "rogue" && ability?.name === "Killing Spree" && targetId + && aurasForEntity(state.auras, targetId).some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.some((tag) => tag === "stealth" || tag === "invisible") + ))) failure = "condition-not-met"; + if (!failure && state.classId === "warlock" && ability?.name === "Conflagrate" + && (!targetId || !activeWarlockConflagrateDot(state.effects, targetId, PLAYER_AGGRO_ID, now))) { + failure = "condition-not-met"; + } if (!failure && ability?.name === "Kill Shot" && targetId) { const target = state.mobs[targetId]; if (target && target.health / Math.max(1, target.maxHealth) > 0.2) failure = "condition-not-met"; } + if (!failure && state.classId === "warrior" && ability?.name === "Execute" && targetId) { + const target = state.mobs[targetId]; + if (target && target.health / Math.max(1, target.maxHealth) >= 0.2) failure = "condition-not-met"; + } + if (!failure && state.classId === "paladin" && ability?.name === "Hammer of Wrath" && targetId) { + const target = state.mobs[targetId]; + if (target && target.health / Math.max(1, target.maxHealth) > 0.2) failure = "condition-not-met"; + } + if (!failure && state.classId === "paladin" && ability?.name === "Holy Shield") { + const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { + ownerId: PLAYER_EQUIPMENT_OWNER_ID, + ownerKind: "player", + classId: state.classId, + secondaryClassId: state.secondaryClassId, + specializationId: state.specializationId, + role: state.gearRole, + level: state.level, + }); + if (loadout["off-hand"]?.armorType !== "shield") failure = "condition-not-met"; + } + if (!failure && ability?.source === "runewaker" && ability.dbcSpellId === 490122 + && state.health / Math.max(1, state.maxHealth) >= 0.5) failure = "condition-not-met"; if (!failure && ability?.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetId ?? PLAYER_AURA_ENTITY_ID, now)) failure = "condition-not-met"; + if (!failure && state.classId === "druid" && ability?.name === "Swiftmend") { + const swiftmendTargetId = targetId ?? PLAYER_AURA_ENTITY_ID; + const hasConsumableHot = state.effects.some((effect) => { + if (effect.kind !== "hot" || (effect.targetId ?? PLAYER_AURA_ENTITY_ID) !== swiftmendTargetId + || effect.remainingTicks <= 0) return false; + const sourceName = abilityById(effect.sourceAbilityId)?.name; + return sourceName === "Rejuvenation" || sourceName === "Regrowth"; + }); + if (!hasConsumableHot) failure = "condition-not-met"; + } if (!failure && ability?.source === "runewaker") { const family = targetId ? state.mobs[targetId]?.creatureFamily : undefined; - if (ability.dbcSpellId === 491613 && family === "living" + if (state.classId === "rom-rogue" && state.secondaryClassId === "rom-warlock" + && ability.dbcSpellId === 498801 + && state.health / Math.max(1, state.maxHealth) >= 0.75) failure = "condition-not-met"; + if (state.classId === "rom-rogue" && state.secondaryClassId === "rom-knight" + && ability.dbcSpellId === 492924 && !hasRuneWakerState("rom-491552")) failure = "condition-not-met"; + if (ability.dbcSpellId === 491613 && family !== "undead" && family !== "demon" && !hasRuneWakerState("rom-evil-shackles")) failure = "condition-not-met"; if (ability.dbcSpellId === 490347 && family === "undead") failure = "condition-not-met"; if (ability.dbcSpellId === 492920 && !state.effects.some((effect) => @@ -5086,7 +7282,7 @@ export const useCombatStore = create((set, get) => ({ if (ability.dbcSpellId === 490151 && (!targetId || state.comboTargetId !== targetId || state.comboPoints <= 0)) failure = "condition-not-met"; if (ability.dbcSpellId === 490347 && !hasRuneWakerState("rom-poisonous")) failure = "condition-not-met"; - if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) { + if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability) || romAbilityNeedsTwoHand(ability) || romAbilityNeedsRangedWeapon(ability)) { const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", @@ -5098,6 +7294,10 @@ export const useCombatStore = create((set, get) => ({ }); if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") failure = "condition-not-met"; if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") failure = "condition-not-met"; + if (romAbilityNeedsTwoHand(ability) && loadout["main-hand"]?.inventorySlot !== "two-hand") { + failure = "condition-not-met"; + } + if (romAbilityNeedsRangedWeapon(ability) && !loadout.ranged) failure = "condition-not-met"; } } const playerSpatial = combatSpatialActor(PLAYER_AGGRO_ID); @@ -5171,7 +7371,9 @@ export const useCombatStore = create((set, get) => ({ const directionX = targetPosition[0] - playerOrigin[0]; const directionZ = targetPosition[2] - playerOrigin[2]; const targetDistance = Math.max(0.001, Math.hypot(directionX, directionZ)); - const reach = targetDistance + 4; + const reach = ability.dbcSpellId === 490239 + ? Math.min(targetDistance + 4, ability.range.max) + : targetDistance + 4; affectedMobIds = nearbyMobRuntimeIds(playerOrigin, reach).filter((id) => { const mob = state.mobs[id]; const position = getMobPosition(id); @@ -5182,8 +7384,8 @@ export const useCombatStore = create((set, get) => ({ const sideways = Math.abs(offsetX * directionZ - offsetZ * directionX) / targetDistance; return forward >= 0 && forward <= reach && sideways <= 1.25; }); - } else if (targeting?.shape === "cone" && effectiveTarget === "hostile") { - const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers); + } else if (targeting?.shape === "cone" && (effectiveTarget === "hostile" || effectiveTarget === "self")) { + const maximumRange = talentAdjustedRange(targeting.radius ?? ability.radius ?? ability.range.max, ability, modifiers); affectedMobIds = nearbyMobRuntimeIds(playerOrigin, maximumRange).filter((id) => { const mob = state.mobs[id]; const position = getMobPosition(id); @@ -5207,22 +7409,99 @@ export const useCombatStore = create((set, get) => ({ const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648 && ability.castMode !== "channel" && ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer"); + const hasDruidNaturesGrace = state.classId === "druid" && activePlayerAuras.some((aura) => ( + aura.definition.utilityTags?.includes("wow335-druid-natures-grace") + && (aura.expiresAt === null || aura.expiresAt > now) + )); + const hasWarlockEradication = state.classId === "warlock" && activePlayerAuras.some((aura) => ( + aura.definition.utilityTags?.includes(WARLOCK_ERADICATION_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + )); + const eradicationRank = hasWarlockEradication + ? warlockTalentRank(state.talentRanks, "Eradication", 3) + : 0; + const romMageSpellHasteModifiers: readonly TalentModifier[] = state.classId === "rom-mage" + ? activePlayerAuras.flatMap((aura) => { + if (aura.expiresAt !== null && aura.expiresAt <= now) return []; + const spellHaste = (aura.definition.modifiers ?? []) + .filter((modifier) => modifier.kind === "stat" && modifier.stat === "spell-haste" && modifier.operation === "flat") + .reduce((total, modifier) => total + modifier.value * (modifier.perStack === false ? 1 : aura.stacks), 0); + return spellHaste === 0 ? [] : [{ + kind: "haste-percent" as const, + amount: spellHaste, + unit: "percent" as const, + scope: "your spells", + sourceTalentId: aura.definition.id, + sourceTalentName: aura.definition.name, + rank: aura.stacks, + description: `${aura.definition.name} modifies your spell haste by ${spellHaste}%.`, + }]; + }) + : []; + const castTimeModifiers: readonly TalentModifier[] = [ + ...modifiers, + ...(hasDruidNaturesGrace ? [{ + kind: "haste-percent" as const, + amount: 20, + unit: "percent" as const, + scope: "your spells, ranged, and melee haste", + sourceTalentId: "talent-789:active", + sourceTalentName: "Nature's Grace", + rank: 1, + description: "Nature's Grace increases spell haste by 20%.", + }] : []), + ...(eradicationRank > 0 ? [{ + kind: "haste-percent" as const, + amount: [0, 7, 14, 20][eradicationRank] ?? 0, + unit: "percent" as const, + scope: "your spells", + sourceTalentId: "talent-1878:active", + sourceTalentName: "Eradication", + rank: eradicationRank, + description: "Eradication increases spell casting speed for 10 seconds.", + }] : []), + ...romMageSpellHasteModifiers, + ]; const ordinaryCastTimeMs = talentAdjustedCastTime( ability, - modifiers, + castTimeModifiers, state.gearStats, state.level, state.classId, ); - const castTimeMs = lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs; + const castTimeMs = consumesPresenceOfMind || consumesHotStreak || consumesNatureSwiftness + || consumesSurgeOfLight || consumesWarlockBacklash + ? 0 + : lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs; const gcdMs = talentAdjustedGcd( ability, - modifiers, + castTimeModifiers, state.gearStats, state.level, state.classId, ); - const cooldownMs = talentAdjustedCooldown(ability, modifiers); + const improvedRuneTapRank = state.classId === "death-knight" && ability.name === "Rune Tap" + ? Math.max(0, Math.min(3, Math.trunc(state.talentRanks["talent-1942"] ?? 0))) + : 0; + const improvedWhirlwindRank = state.classId === "warrior" && ability.name === "Whirlwind" + ? Math.max(0, Math.min(2, Math.trunc(state.talentRanks["talent-1655"] ?? 0))) + : 0; + const reverberationCooldownReductionMs = state.classId === "shaman" && ability.cooldownGroup === "shaman-shock" + ? shamanTalentRankFromRanks(state.talentRanks, "Reverberation", 3) * 500 + : 0; + const romMageChampionCooldownReductionMs = romMageCooldownReductionMs( + state.classId, + state.secondaryClassId, + ability, + state.level, + state.abilities, + ); + const cooldownMs = Math.max(0, + talentAdjustedCooldown(ability, modifiers) + - improvedRuneTapRank * 10_000 + - improvedWhirlwindRank * 1_000 + - reverberationCooldownReductionMs + - romMageChampionCooldownReductionMs); const activeResourceType = resourceProfileForClass(state.classId).type; const spentPools: Partial> = { ...state.resourcePools, @@ -5267,9 +7546,10 @@ export const useCombatStore = create((set, get) => ({ } set({ activeCast, - ...(lastPrayerReady ? { auras: state.auras.filter((aura) => !( + ...(lastPrayerReady || consumesWarlockBacklash ? { auras: state.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID - && aura.definition.utilityTags?.includes("rom-last-prayer") + && (aura.definition.utilityTags?.includes("rom-last-prayer") + || (consumesWarlockBacklash && aura.instanceId === warlockBacklashAura?.instanceId)) )) } : {}), ...autoAttackUpdate, playerAnimationEvent: nextAnimationEvent("ability-start", ability.id), @@ -5346,32 +7626,102 @@ export const useCombatStore = create((set, get) => ({ raceId: state.raceId, secondaryClassId: state.secondaryClassId, }; + if (consumesWarlockBacklash && warlockBacklashAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== warlockBacklashAura.instanceId); + } + let priestSerendipityTriggered = false; if (consumesInnerFocus && innerFocusAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== innerFocusAura.instanceId); } + if (consumesSurgeOfLight && surgeOfLightAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== surgeOfLightAura.instanceId); + } + if (consumesSerendipity && serendipityAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== serendipityAura.instanceId); + } + if (consumesDruidClearcasting && druidClearcastingAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== druidClearcastingAura.instanceId); + } + if (consumesShamanClearcasting && shamanClearcastingAura) { + work.auras = work.auras.flatMap((aura) => aura.instanceId !== shamanClearcastingAura.instanceId + ? [aura] + : aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []); + } + if (consumesNatureSwiftness && natureSwiftnessAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== natureSwiftnessAura.instanceId); + } + if (consumesPresenceOfMind && presenceOfMindAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== presenceOfMindAura.instanceId); + } + if (consumesHotStreak && hotStreakAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== hotStreakAura.instanceId); + } + if (consumesFingersOfFrost && fingersOfFrostAura) { + work.auras = work.auras.flatMap((aura) => aura.instanceId !== fingersOfFrostAura.instanceId + ? [aura] + : aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []); + } if (ability.name === "Enraged Regeneration") { work.auras = work.auras.filter((aura) => !consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now)); } - const castThreatSource = playerThreatSource(state, ability.id); + const baseCastThreatSource = playerThreatSource(state, ability.id); + const castThreatSource = { + ...baseCastThreatSource, + threatScale: (baseCastThreatSource.threatScale ?? 1) + * romWarriorThreatMultiplier(ability, romWarriorPassiveSpellIds), + }; if (targeting?.shape === "ground" && groundPosition && completingCast?.mode !== "channel") { - const periodic = ability.effects.filter((effect) => effect.kind === "dot" || effect.kind === "hot"); - const durationMs = periodic.reduce((maximum, effect) => ( - Math.max(maximum, effect.ticks * effect.intervalMs) - ), 0); - if (durationMs > 0) { + const hunterTrapKind = state.classId === "hunter" && ability.name === "Freezing Arrow" + ? "freezing" + : state.classId === "hunter" && ability.name === "Explosive Trap" + ? "explosive" + : null; + if (hunterTrapKind) { areaEffectSequence += 1; + const rank = ability.ranks?.find((candidate) => candidate.spellId === ability.dbcSpellId); + const directDamageScaling = hunterTrapKind === "explosive" + ? hunterExplosiveTrapDirectScaling(ability.dbcSpellId) ?? undefined + : undefined; + work.areaEffects = work.areaEffects.filter((area) => !( + area.sourceActorId === PLAYER_AGGRO_ID && area.hunterTrap + )); work.areaEffects.push({ - id: `area-effect-${areaEffectSequence}`, + id: `hunter-trap-${areaEffectSequence}`, sourceActorId: PLAYER_AGGRO_ID, sourceAbilityId: ability.id, position: groundPosition, - radius: talentAdjustedRange(targeting.radius ?? ability.radius ?? 8, ability, modifiers), + radius: 2, createdAt: now, - expiresAt: now + durationMs, - tickIntervalMs: Math.min(...periodic.map((effect) => effect.intervalMs)), - nextTickAt: now + Math.min(...periodic.map((effect) => effect.intervalMs)), + expiresAt: now + 30_000, + tickIntervalMs: 0, + nextTickAt: Number.MAX_SAFE_INTEGER, + hunterTrap: { + kind: hunterTrapKind, + triggerRadius: 2, + ...(hunterTrapKind === "freezing" ? { freezeDurationMs: 20_000 } : {}), + ...(directDamageScaling ? { directDamageScaling } : {}), + }, }); + } else { + const periodic = ability.effects.filter((effect) => effect.kind === "dot" || effect.kind === "hot"); + const durationMs = periodic.reduce((maximum, effect) => ( + Math.max(maximum, effect.ticks * effect.intervalMs) + ), 0); + if (durationMs > 0) { + areaEffectSequence += 1; + work.areaEffects.push({ + id: `area-effect-${areaEffectSequence}`, + sourceActorId: PLAYER_AGGRO_ID, + sourceAbilityId: ability.id, + position: groundPosition, + radius: talentAdjustedRange(targeting.radius ?? ability.radius ?? 8, ability, modifiers), + createdAt: now, + expiresAt: now + durationMs, + tickIntervalMs: Math.min(...periodic.map((effect) => effect.intervalMs)), + nextTickAt: now + Math.min(...periodic.map((effect) => effect.intervalMs)), + }); + } } } @@ -5401,7 +7751,9 @@ export const useCombatStore = create((set, get) => ({ const damageSchool = abilityDamageSchool(state.classId, ability); const attackKind = abilityAttackKind(state.classId, ability); let playerPositionAfterEffects: CombatPosition = state.playerPosition; - let clearedPlayerControl = false; + let clearedPlayerControl = ability.name === "Vanish" + && state.controlledUntil > now + && (state.controlMechanic === "root" || state.controlMechanic === "slow"); const cooldownAdjustments: Array> = []; const romMainHand = ability.source === "runewaker" && state.classId === "rom-rogue" ? resolvedEquipmentLoadout(state.equipment, state.inventory, { @@ -5414,37 +7766,217 @@ export const useCombatStore = create((set, get) => ({ level: state.level, })["main-hand"] : null; - const castEffects = romEffectsForCast(ability, { + const romCastTargetTags = work.auras.filter((aura) => aura.targetId === targetId) + .flatMap((aura) => aura.definition.utilityTags ?? []).concat( + romCreatureFamilyTargetTags(targetId && work.mobs[targetId] + ? work.mobs[targetId].creatureFamily : "unknown"), + work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0) + .flatMap((effect) => [ + ...(effect.tag ? [effect.tag] : []), + ...(effect.tag?.includes("501690") ? ["rom-501690"] : []), + ...(effect.tag?.includes("rom-deadly-poison") ? ["rom-deadly-poison"] : []), + ]), + ); + const selectedRomHealingMember = targetId ? party.members.find((member) => member.id === targetId) : null; + const selectedRomHealingSummon = targetId ? work.summons.find((summon) => summon.id === targetId) : null; + const selectedRomOnlineFriendly = targetId + ? onlinePlayerRuntimeActors().find((actor) => actor.id === targetId) + : null; + const selectedRomHealingActor = targetId ? work.actors[targetId] : null; + const romFriendlyHealth = !targetId || targetId === PLAYER_AURA_ENTITY_ID + ? work.health + : selectedRomHealingMember?.health ?? selectedRomHealingSummon?.health + ?? selectedRomOnlineFriendly?.health ?? selectedRomHealingActor?.health ?? null; + const romFriendlyMaxHealth = !targetId || targetId === PLAYER_AURA_ENTITY_ID + ? work.maxHealth + : selectedRomHealingMember?.maxHealth ?? selectedRomHealingSummon?.maxHealth + ?? selectedRomOnlineFriendly?.maxHealth ?? selectedRomHealingActor?.maxHealth ?? null; + const learnedRomPriestPassiveSpellIds = state.classId === "rom-priest" && state.secondaryClassId === "rom-scout" + ? work.abilities.filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-priest" + && candidate.secondaryClassId === "rom-scout" + && candidate.passive + && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= work.progression.level) + .map((candidate) => candidate.dbcSpellId) + : []; + const learnedRomWarlockPriestPassiveSpellIds = state.classId === "rom-warlock" && state.secondaryClassId === "rom-priest" + ? work.abilities.filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-warlock" + && candidate.secondaryClassId === "rom-priest" + && candidate.passive + && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= work.progression.level) + .map((candidate) => candidate.dbcSpellId) + : []; + const translatedCastEffects = romEffectsForCast(ability, { sealStacks: targetId && state.comboTargetId === targetId ? state.comboPoints : 0, bleedCount: romTargetBleedCount(state.effects, PLAYER_AGGRO_ID, targetId), charged: hasRuneWakerState("rom-charged"), mainHandDagger: romMainHand?.weaponType === "dagger", casterMaxHealth: state.maxHealth, + targetHealthPercent: targetId && state.mobs[targetId] + ? state.mobs[targetId]!.health / Math.max(1, state.mobs[targetId]!.maxHealth) + : 1, + friendlyTargetHealthPercent: effectiveTarget === "friendly" && romFriendlyHealth !== null + && romFriendlyMaxHealth !== null + ? romFriendlyHealth / Math.max(1, romFriendlyMaxHealth) + : 1, + slashBleed: romTargetHasSlashBleed(work.effects, targetId), + naturePower: state.resourcePools["nature-power"] ?? 0, + learnedPassiveSpellIds: [ + ...romWarriorPassiveSpellIds, + ...learnedRomPriestPassiveSpellIds, + ...learnedRomWarlockPriestPassiveSpellIds, + ], passiveTags: work.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID) .flatMap((aura) => aura.definition.utilityTags ?? []), - targetTags: work.auras.filter((aura) => aura.targetId === targetId) - .flatMap((aura) => aura.definition.utilityTags ?? []).concat( - romCreatureFamilyTargetTags(targetId && work.mobs[targetId] - ? work.mobs[targetId].creatureFamily : "unknown"), - work.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0) - .flatMap((effect) => [ - ...(effect.tag ? [effect.tag] : []), - ...(effect.tag?.includes("501690") ? ["rom-501690"] : []), - ...(effect.tag?.includes("rom-deadly-poison") ? ["rom-deadly-poison"] : []), - ]), - ), + eruptionProcRoll: stableCombatUnit( + `${PLAYER_AGGRO_ID}:${ability.id}:${targetId ?? "self"}:${now}:rom-eruption`, + ), + targetTags: romCastTargetTags, }); + let castEffects = state.classId === "rom-knight" && state.secondaryClassId === "rom-priest" + ? romPriestKnightConditionalCastEffects( + ability, + translatedCastEffects, + targetId && state.comboTargetId === targetId ? state.comboPoints : 0, + !targetId || targetId === PLAYER_AURA_ENTITY_ID, + ) + : translatedCastEffects; + if (state.classId === "rom-warlock" && ability.source === "runewaker" + && ability.dbcSpellId === 498645 && state.secondaryClassId === "rom-warrior") { + const rageConsumed = work.resourcePools.rage ?? 0; + const addedDurationMs = Math.floor(rageConsumed / 20) * 1_000; + castEffects = castEffects.map((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken") + ? { + ...effect, + aura: { ...effect.aura, durationMs: (effect.aura.durationMs ?? 0) + addedDurationMs }, + } + : effect); + work.resourcePools = { ...work.resourcePools, rage: 0 }; + } + if (state.classId === "rom-warlock" && ability.source === "runewaker" + && ability.dbcSpellId === 498642 && state.secondaryClassId === "rom-warrior") { + const rage = work.resourcePools.rage ?? 0; + const psiGain = Math.floor(rage / 35); + const maximumPsi = work.maxResourcePools.psi ?? defaultResourceMaximum("psi"); + work.resourcePools = { + ...work.resourcePools, + rage: 0, + psi: clamp((work.resourcePools.psi ?? 0) + psiGain, 0, maximumPsi), + }; + } + const warlockStateRefreshTags = romWarlockStateRefreshTags(ability, romCastTargetTags); + if (targetId && warlockStateRefreshTags.length) { + const refreshTagSet = new Set(warlockStateRefreshTags); + work.auras = work.auras.map((aura) => aura.targetId === targetId + && aura.definition.utilityTags?.some((tag) => refreshTagSet.has(tag)) + ? { ...aura, expiresAt: now + (aura.definition.durationMs ?? 15_000) } + : aura); + work.effects = work.effects.map((effect) => { + if (effect.kind !== "dot" || effect.targetId !== targetId || !effect.tag + || !refreshTagSet.has(effect.tag)) return effect; + const sourceAbility = work.abilities.find((candidate) => candidate.id === effect.sourceAbilityId); + const sourceDot = sourceAbility?.effects.find((candidate) => candidate.kind === "dot" + && candidate.tag === effect.tag); + return sourceDot?.kind === "dot" + ? { ...effect, remainingTicks: sourceDot.ticks, nextTickAt: now + sourceDot.intervalMs } + : effect; + }); + } if (ability.dbcSpellId === 492920 && targetId && castEffects.some((effect) => effect.kind === "dot" && effect.tag === "rom-622992")) { work.effects = work.effects.filter((effect) => !(effect.kind === "dot" && effect.targetId === targetId && effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")); } + if (state.classId === "rom-warrior" && ability.dbcSpellId === 492332 + && romWarriorPassiveSpellIds.includes(492912)) { + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "rom-warrior:blood-retention", + name: "Blood Retention", + disposition: "buff", + durationMs: 10_000, + modifiers: [{ + kind: "resource", + resource: "rage", + property: "regeneration", + operation: "flat", + value: 1.5, + }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } const effectsToResolve = completingCast?.mode === "channel" ? castEffects.filter((effect) => !["damage", "heal", "dot", "hot", "resource-drain"].includes(effect.kind)) : castEffects; + const divineFavorReady = state.classId === "paladin" + && ["Flash of Light", "Holy Light", "Holy Shock"].includes(ability.name) + && work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal") + && (aura.expiresAt === null || aura.expiresAt > now)); + const curingShotRecipients = ability.dbcSpellId === 492636 + ? (() => { + const partyMembers = usePartyStore.getState().members.filter((member) => member.health > 0); + const friendlySummons = work.summons.filter((summon) => summon.health > 0); + const onlineFriendlies = onlinePlayerRuntimeActors().filter((actor) => actor.health > 0); + const candidates = [...new Set([ + PLAYER_AURA_ENTITY_ID, + ...partyMembers.map((member) => member.id), + ...friendlySummons.map((summon) => summon.id), + ...onlineFriendlies.map((actor) => actor.id), + ])]; + const positionFor = (id: string): CombatPosition | null => id === PLAYER_AURA_ENTITY_ID + ? playerOrigin + : getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null; + return candidates + .filter((id) => { + const position = positionFor(id); + return Boolean(position + && Math.sqrt(distanceSquared(playerOrigin, position)) <= (targeting?.radius ?? 20) + && combatHasLineOfSight(playerOrigin, position)); + }) + .sort((left, right) => Number(right === targetId) - Number(left === targetId) || left.localeCompare(right)) + .slice(0, targeting?.maximumTargets ?? 5); + })() + : null; + const areaFriendlyRecipients = targeting?.shape === "circle" && effectiveTarget !== "hostile" + ? (() => { + const partyMembers = usePartyStore.getState().members.filter((member) => member.health > 0); + const friendlySummons = work.summons.filter((summon) => summon.health > 0); + const onlineFriendlies = onlinePlayerRuntimeActors().filter((actor) => actor.health > 0); + const candidates = [...new Set([ + PLAYER_AURA_ENTITY_ID, + ...partyMembers.map((member) => member.id), + ...friendlySummons.map((summon) => summon.id), + ...onlineFriendlies.map((actor) => actor.id), + ])]; + const positionFor = (id: string): CombatPosition | null => id === PLAYER_AURA_ENTITY_ID + ? playerOrigin + : getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null; + const center = targetId ? positionFor(targetId) ?? playerOrigin : playerOrigin; + const radius = talentAdjustedRange(targeting.radius ?? ability.radius ?? 0, ability, modifiers); + return candidates + .filter((id) => { + const position = positionFor(id); + return Boolean(position + && Math.sqrt(distanceSquared(center, position)) <= radius + && combatHasLineOfSight(center, position)); + }) + .sort((left, right) => Number(right === targetId) - Number(left === targetId) || left.localeCompare(right)) + .slice(0, targeting.maximumTargets ?? Infinity); + })() + : null; let clearedSingleTargetControls = false; + let abilityFeedback: { kind: CombatFeedbackKind; message: string } | null = null; for (const effect of effectsToResolve) { + if (state.classId === "warlock" && ability.name === "Seed of Corruption" + && effect.kind === "apply-aura" && effect.sourceAuraType === 4) continue; + if (state.classId === "hunter" && ability.name === "Explosive Trap" && effect.kind === "dot") continue; if (ability.name === "Penance" && effectiveTarget === "friendly" && (effect.kind === "damage" || effect.kind === "dot")) continue; if (ability.name === "Divine Hymn" && effect.kind === "apply-aura") continue; @@ -5463,19 +7995,42 @@ export const useCombatStore = create((set, get) => ({ ) continue; if (effect.kind === "damage") { const amount = talentAdjustedAmount( - equipmentAdjustedCombatAmount( - effect, - work.progression.level, - work.gearStats, - state.classId, - ability, - ) + (effect.bonusFlat ?? 0), + (() => { + const conflagrateDot = state.classId === "warlock" && ability.name === "Conflagrate" + ? activeWarlockConflagrateDot(work.effects, targetId ?? "", PLAYER_AGGRO_ID, now) + : null; + const sourceAbility = conflagrateDot ? abilityById(conflagrateDot.sourceAbilityId) : null; + const burstSeconds = sourceAbility?.name === "Immolate" ? 12 : 8; + const baseDamage = conflagrateDot && sourceAbility + ? (conflagrateDot.fixedAmount ?? equipmentAdjustedCombatAmount( + conflagrateDot, + work.progression.level, + work.gearStats, + state.classId, + sourceAbility, + )) * burstSeconds * 1_000 / Math.max(1, conflagrateDot.intervalMs) + : equipmentAdjustedCombatAmount( + effect, + work.progression.level, + work.gearStats, + state.classId, + ability, + ); + return Math.round(baseDamage) + (effect.bonusFlat ?? 0); + })(), modifiers, "damage-percent", ability, ); for (const [targetIndex, id] of affectedMobIds.entries()) { const effectSchool = effect.school ?? damageSchool; + const target = work.mobs[id]; + const faithAdjustedAmount = target + ? priestTestOfFaithAmount(amount, state.classId, state.talentRanks, ability, "damage", target.health, target.maxHealth) + : amount; + const targetAmount = consumesSurgeOfLight && ability.name === "Smite" + ? Math.round(faithAdjustedAmount * 1.6) + : faithAdjustedAmount; const sunderStacks = effect.bonusPerSunderStack ? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0 : 0; @@ -5483,7 +8038,7 @@ export const useCombatStore = create((set, get) => ({ state.classId, work, id, - Math.round((amount + sunderStacks * (effect.bonusPerSunderStack ?? 0)) + Math.round((targetAmount + sunderStacks * (effect.bonusPerSunderStack ?? 0)) * Math.pow(effect.falloffPerJump ?? 1, targetIndex)), now, castThreatSource, @@ -5492,8 +8047,28 @@ export const useCombatStore = create((set, get) => ({ `${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`, ability, false, - ability.name === "Shiv" ? { canCrit: false } : {}, + ability.name === "Shiv" || (consumesSurgeOfLight && ability.name === "Smite") + ? { canCrit: false } : {}, ); + if (state.classId === "priest" && ability.name === "Smite" && result.amount > 0 + && !innerFocusSerendipityBlocked) { + priestSerendipityTriggered = true; + } + if (state.classId === "warlock" && ability.name === "Conflagrate" + && result.combatResult?.landed) { + const consumedDot = activeWarlockConflagrateDot(work.effects, id, PLAYER_AGGRO_ID, now); + if (consumedDot) { + const consumedName = abilityById(consumedDot.sourceAbilityId)?.name; + work.effects = work.effects.filter((candidate) => candidate.id !== consumedDot.id); + if (consumedName) { + work.auras = work.auras.filter((aura) => !( + aura.sourceId === consumedDot.sourceActorId + && aura.targetId === id + && aura.definition.name.startsWith(consumedName) + )); + } + } + } grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now); if (ability.dbcSpellId === 490133) { const bonus = romScoutRogueWhirlwindBonusEffect( @@ -5605,7 +8180,9 @@ export const useCombatStore = create((set, get) => ({ : getPartyRuntimePosition(id) ?? friendlySummons.find((summon) => summon.id === id)?.position ?? null; const healingCenter = groundPosition ?? (targetId ? friendlyPosition(targetId) : null) ?? playerOrigin; let healingTargetIds: string[]; - if (targeting?.shape === "chain") { + if (curingShotRecipients) { + healingTargetIds = [...curingShotRecipients]; + } else if (targeting?.shape === "chain") { const ordered = [...allLivingFriendlies].sort((left, right) => { const selectedId = selectedFriendly?.id ?? selectedFriendlySummon?.id ?? PLAYER_AURA_ENTITY_ID; if (left === selectedId) return -1; @@ -5653,7 +8230,7 @@ export const useCombatStore = create((set, get) => ({ playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, modifiers), aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), ); - const criticalChance = Math.max(0, ( + const criticalChanceBasePercent = ( casterStats.spellCritPercent + applyTalentModifierValue( 0, @@ -5661,37 +8238,111 @@ export const useCombatStore = create((set, get) => ({ "critical-chance-percent", abilityTalentContext(ability), ) - ) / 100); + + (divineFavorReady ? 100 : 0) + ); + const receivesTidalForceBonus = state.classId === "shaman" + && ["Healing Wave", "Lesser Healing Wave", "Chain Heal"].includes(ability.name); let directHealing = 0; healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors, id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)]; - for (const healingTargetId of [...new Set(healingTargetIds)]) { + if (targeting?.maximumTargets) healingTargetIds = healingTargetIds.slice(0, targeting.maximumTargets); + for (const [targetIndex, healingTargetId] of [...new Set(healingTargetIds)].entries()) { const friendlyTarget = partyMembers.find((member) => member.id === healingTargetId); const friendlySummon = work.summons.find((summon) => summon.id === healingTargetId); const friendlyOnline = onlineFriendlies.find((actor) => actor.id === healingTargetId); const healingTargetMaxHealth = friendlyTarget?.maxHealth ?? friendlySummon?.maxHealth ?? friendlyOnline?.maxHealth ?? work.maxHealth; const healingTargetHealth = friendlyTarget?.health ?? friendlySummon?.health ?? friendlyOnline?.health ?? work.health; + const swiftmendCandidates = state.classId === "druid" && ability.name === "Swiftmend" + ? work.effects.filter((candidate) => { + if (candidate.kind !== "hot" + || (candidate.targetId ?? PLAYER_AURA_ENTITY_ID) !== healingTargetId + || candidate.remainingTicks <= 0) return false; + const sourceName = abilityById(candidate.sourceAbilityId)?.name; + return sourceName === "Rejuvenation" || sourceName === "Regrowth"; + }) + : []; + const casterSwiftmendCandidates = swiftmendCandidates.filter( + (candidate) => candidate.sourceActorId === PLAYER_AGGRO_ID, + ); + const swiftmendPool = casterSwiftmendCandidates.length > 0 + ? casterSwiftmendCandidates : swiftmendCandidates; + const swiftmendHot = swiftmendPool.reduce((shortest, candidate) => { + if (!shortest) return candidate; + const expiry = candidate.nextTickAt + Math.max(0, candidate.remainingTicks - 1) * candidate.intervalMs; + const shortestExpiry = shortest.nextTickAt + Math.max(0, shortest.remainingTicks - 1) * shortest.intervalMs; + return expiry < shortestExpiry ? candidate : shortest; + }, undefined); + const swiftmendHotAbility = swiftmendHot ? abilityById(swiftmendHot.sourceAbilityId) : null; + const swiftmendTickCount = swiftmendHotAbility?.name === "Rejuvenation" ? 4 + : swiftmendHotAbility?.name === "Regrowth" ? 6 + : 0; + const swiftmendAmount = swiftmendHot && swiftmendHotAbility + ? talentAdjustedAmount( + swiftmendHot.fixedAmount ?? equipmentAdjustedCombatAmount( + swiftmendHot, + work.progression.level, + work.gearStats, + state.classId, + swiftmendHotAbility, + ), + modifiers, + "healing-percent", + swiftmendHotAbility, + true, + ) * swiftmendTickCount + : null; + const deathStrikeDiseaseCount = ability.name === "Death Strike" && state.classId === "death-knight" + ? new Set(work.effects.filter((candidate) => candidate.kind === "dot" + && candidate.targetId === targetId + && candidate.sourceActorId === PLAYER_AGGRO_ID + && candidate.remainingTicks > 0 + && (candidate.tag === "blood-plague" || candidate.tag === "frost-fever")) + .map((candidate) => candidate.kind === "dot" ? candidate.tag : undefined)).size + : 1; + const runeTapBonus = state.classId === "death-knight" && ability.name === "Rune Tap" + ? [0, 0.33, 0.66, 1][improvedRuneTapRank] ?? 0 + : 0; + const sourceHealAmount = ((effect.percentOfDamage !== undefined + ? Math.round(work.totalDamage * effect.percentOfDamage) + : equipmentAdjustedCombatAmount( + effect, work.progression.level, work.gearStats, state.classId, ability, + ) + Math.round(healingTargetMaxHealth * (effect.percentMaxHealth ?? 0))) * deathStrikeDiseaseCount) + * (1 + runeTapBonus); const unmodifiedAmount = talentAdjustedAmount( - equipmentAdjustedCombatAmount( - effect, - work.progression.level, - work.gearStats, - state.classId, - ability, - ) + Math.round(healingTargetMaxHealth * (effect.percentMaxHealth ?? 0)), + swiftmendAmount ?? sourceHealAmount, modifiers, "healing-percent", ability, ); + const targetAdjustedAmount = priestTestOfFaithAmount( + unmodifiedAmount, + state.classId, + state.talentRanks, + ability, + "healing", + healingTargetHealth, + healingTargetMaxHealth, + ); + if (swiftmendHot) { + work.effects = work.effects.filter((candidate) => candidate.id !== swiftmendHot.id); + } + const falloffAdjustedAmount = Math.round(targetAdjustedAmount * Math.pow(effect.falloffPerJump ?? 1, targetIndex)); const amount = Math.round(resolveAuraHealingValue( resolveAuraHealingValue( - unmodifiedAmount, + falloffAdjustedAmount, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "done", ), aurasForEntity(work.auras, healingTargetId), "taken", )); + const tidalForceAura = state.classId === "shaman" + ? work.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-tidal-force") + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const tidalForceBonusPercent = receivesTidalForceBonus ? (tidalForceAura?.stacks ?? 0) * 20 : 0; + const criticalChance = Math.max(0, (criticalChanceBasePercent + tidalForceBonusPercent) / 100); const resolution = resolveHealing( amount, healingTargetHealth, @@ -5699,6 +8350,12 @@ export const useCombatStore = create((set, get) => ({ criticalChance, stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal`), ); + if (resolution.critical && tidalForceAura) { + work.auras = work.auras.flatMap((aura) => { + if (aura.instanceId !== tidalForceAura.instanceId) return [aura]; + return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; + }); + } const healed = friendlyTarget ? healCompanionActorInWork(work, healingTargetId, resolution.rawAmount) : friendlySummon @@ -5729,7 +8386,113 @@ export const useCombatStore = create((set, get) => ({ absorbed: 0, critical: resolution.critical, }); + if (state.classId === "shaman") { + maybeProcShamanHealingTalentsInWork( + work, + ability, + healingTargetId, + playerOrigin, + resolution.rawAmount, + spellSchool, + resolution.critical, + now, + ); + } + if (state.classId === "priest") { + const divineAegisRank = priestTalentRank(work.talentRanks, "Divine Aegis", 3); + if (divineAegisRank > 0 && resolution.critical && spellSchool === "holy") { + const absorbPercent = [0, 0.04, 0.07, 0.1][divineAegisRank] ?? 0; + const singleTargetMultiplier = healingTargetIds.length === 1 ? 2 : 1; + const requestedAbsorb = Math.round((healed + Math.max(0, resolution.rawAmount - healed) * 0.25) + * absorbPercent * singleTargetMultiplier); + const absorbCap = Math.round(healingTargetMaxHealth * 0.1); + const previousAegis = work.auras.find((aura) => aura.targetId === healingTargetId + && aura.sourceId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-priest-divine-aegis") + && (aura.expiresAt === null || aura.expiresAt > now)); + const previousAbsorb = previousAegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0) ?? 0; + const nextAbsorb = Math.min(absorbCap, previousAbsorb + requestedAbsorb); + const grantedAbsorb = Math.max(0, nextAbsorb - previousAbsorb); + if (nextAbsorb > 0) { + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:priest:divine-aegis", + name: "Divine Aegis", + description: "Absorbs damage for 6 seconds, up to 10% of the target's maximum health.", + disposition: "buff", + durationMs: 6_000, + stackBehavior: "replace", + utilityTags: ["wow335-priest-divine-aegis"], + absorbs: [{ id: "wow335:priest:divine-aegis:absorb", amount: nextAbsorb }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: healingTargetId, + now, + }).auras]; + } + if (grantedAbsorb > 0) recordCombatEventInWork(work, { + occurredAt: now, + kind: "absorb", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: healingTargetId, + abilityId: ability.id, + school: "holy", + rawAmount: grantedAbsorb, + effectiveAmount: grantedAbsorb, + overheal: 0, + absorbed: grantedAbsorb, + critical: false, + }); + } + + if (resolution.critical) { + const surgeRank = priestTalentRank(work.talentRanks, "Surge of Light", 2); + const surgeChance = [0, 0.25, 0.5][surgeRank] ?? 0; + if (surgeRank > 0 + && stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal:wow335-priest-surge-of-light`) < surgeChance) { + applyPriestSelfAuraInWork(work, PRIEST_SURGE_OF_LIGHT_AURA, now); + } + } + + if (resolution.critical && ["Flash Heal", "Greater Heal", "Binding Heal"].includes(ability.name)) { + const holyConcentrationRank = priestTalentRank(work.talentRanks, "Holy Concentration", 3); + if (holyConcentrationRank > 0) { + applyPriestSelfAuraInWork(work, { + ...PRIEST_HOLY_CONCENTRATION_BASE, + modifiers: [{ + kind: "stat", + stat: "spirit", + operation: "percent", + value: ([0, 0.1, 0.2, 0.3][holyConcentrationRank] ?? 0), + }], + }, now); + } + } + } + if (state.classId === "druid" && resolution.critical) { + maybeProcDruidNatureGrace( + work, + ability, + true, + `${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal:druid-nature-grace`, + now, + ); + } + // Omen of Clarity observes the healing spell itself, including + // healing that becomes overheal. Run it before the effective-heal + // guard so a full-health target does not suppress a valid proc. + if (state.classId === "druid" && resolution.rawAmount > 0) { + maybeProcDruidOmenOfClarity( + work, + `${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal:druid-omen-of-clarity`, + now, + ); + } directHealing += healed; + if (state.classId === "priest" && healed > 0 && !innerFocusSerendipityBlocked + && ["Binding Heal", "Flash Heal", "Holy Light", "Flash of Light"].includes(ability.name)) { + priestSerendipityTriggered = true; + } if (healed <= 0) continue; evaluateProcsInWork(work, { trigger: "heal", @@ -5750,10 +8513,17 @@ export const useCombatStore = create((set, get) => ({ } else if (beacon) { work.totalHealing += healCompanionActorInWork(work, beacon.targetId, directHealing); } - addHealingThreatInWork(work, castThreatSource, directHealing); + addHealingThreatInWork(work, castThreatSource, directHealing, now); } else if (effect.kind === "resurrection") { + const paladinRankHealth = state.classId === "paladin" && ability.name === "Redemption" + ? effect.flatHealth + : undefined; + const revivalActor = targetId ? ensureCompanionActorInWork(work, targetId) : null; + const reviveFraction = paladinRankHealth !== undefined && revivalActor + ? Math.min(1, paladinRankHealth / Math.max(1, revivalActor.maxHealth)) + : effect.percentMaxHealth; const restored = targetId - ? reviveCompanionActorInWork(work, targetId, effect.percentMaxHealth) + ? reviveCompanionActorInWork(work, targetId, reviveFraction) : 0; work.totalHealing += restored; if (restored > 0 && targetId) recordCombatEventInWork(work, { @@ -5768,12 +8538,13 @@ export const useCombatStore = create((set, get) => ({ absorbed: 0, critical: false, }); - addHealingThreatInWork(work, castThreatSource, restored); + addHealingThreatInWork(work, castThreatSource, restored, now); } else if (effect.kind === "summon") { summonSequence += 1; if (/totem/i.test(ability.name)) { work.summons = work.summons.filter((summon) => summon.exclusiveGroup !== "totem"); } + const summonPosition = groundPosition ?? playerOrigin; work.summons.push({ id: `player-summon-${summonSequence}`, sourceAbilityId: ability.id, @@ -5783,15 +8554,35 @@ export const useCombatStore = create((set, get) => ({ kind: /totem/i.test(ability.name) ? "totem" : "summon", name: ability.name, ownerActorId: PLAYER_AGGRO_ID, - health: Math.max(1, Math.round(work.maxHealth * 0.3)), - maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)), - position: groundPosition ?? playerOrigin, - nextActionAt: now + 1_000, - pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200, - attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))), + health: effect.healingPulse ? 5 : Math.max(1, Math.round(work.maxHealth * 0.3)), + maxHealth: effect.healingPulse ? 5 : Math.max(1, Math.round(work.maxHealth * 0.3)), + position: summonPosition, + nextActionAt: now + (effect.antiMagicZone?.intervalMs ?? effect.healingPulse?.intervalMs ?? 1_000), + pulseIntervalMs: effect.healingPulse?.intervalMs + ?? (ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200), + attackDamage: effect.healingPulse + || ability.name === "Lightwell" + ? 0 + : Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))), + ...(ability.name === "Lightwell" ? { clickCharges: 10 } : {}), + ...(effect.healingPulse ? { healingPulse: effect.healingPulse } : {}), + ...(effect.antiMagicZone ? { antiMagicZone: effect.antiMagicZone, attackDamage: 0 } : {}), ...initialSummonCombatRuntime(), ...(/totem/i.test(ability.name) ? { exclusiveGroup: "totem" } : {}), }); + if (effect.antiMagicZone) { + applyDeathKnightAntiMagicZoneInWork( + work, + summonPosition, + effect.antiMagicZone.radius, + effect.durationMs, + effect.antiMagicZone.capacityBase, + effect.antiMagicZone.capacityPerAttackPower, + effect.antiMagicZone.absorbFraction, + now, + playerOrigin, + ); + } if (ability.target === "hostile") { const amount = talentAdjustedAmount( equipmentAdjustedCombatAmount( @@ -5843,7 +8634,13 @@ export const useCombatStore = create((set, get) => ({ && combatHasLineOfSight(playerOrigin, position)); }).map((member) => member.id)] : [targetId ?? PLAYER_AURA_ENTITY_ID]; - for (const recipientId of recipients) { + const prioritizedRecipients = targetId && recipients.includes(targetId) + ? [targetId, ...recipients.filter((id) => id !== targetId)] + : recipients; + const limitedRecipients = targeting?.maximumTargets + ? prioritizedRecipients.slice(0, targeting.maximumTargets) + : recipients; + for (const recipientId of limitedRecipients) { if (recipientId === PLAYER_AURA_ENTITY_ID) { work.shield = Math.max(work.shield, amount); work.shieldExpiresAt = now + durationMs; @@ -5944,7 +8741,21 @@ export const useCombatStore = create((set, get) => ({ }); } else if (effect.kind === "combo") { const comboTargetId = targetId && work.mobs[targetId] ? targetId : null; - if (ability.dbcSpellId === 490151 && comboTargetId) { + const godsVengeancePreservesSeal = state.classId === "rom-knight" + && state.secondaryClassId === "rom-rogue" + && ability.dbcSpellId === 490151 + && comboTargetId !== null + && work.comboTargetId === comboTargetId + && work.comboPoints > 0 + && work.combatResults.some((result) => result.landed) + && work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_KNIGHT_GODS_VENGEANCE_TAG)) + && stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${comboTargetId}:${now}:rom-gods-vengeance`) < 0.5; + // RuneWaker's 499934 tooltip says Punishment can deal its normal seal + // payoff without consuming Holy Seal. The current CoA revision changed + // this passive; keep the local snapshot's explicit 50% behavior here. + if (godsVengeancePreservesSeal) continue; + if ([490151, 490194].includes(ability.dbcSpellId) && comboTargetId) { work.effects = work.effects.filter((entry) => !(entry.kind === "dot" && entry.tag === "rom-holy-seal" && entry.sourceActorId === PLAYER_AGGRO_ID && entry.targetId === comboTargetId)); @@ -5965,13 +8776,14 @@ export const useCombatStore = create((set, get) => ({ }); continue; } + const seedProfile = state.classId === "warlock" ? warlockSeedProfileForAbility(ability) : null; for (const id of affectedMobIds) { work.effects = work.effects.filter((entry) => !( entry.kind === "dot" && entry.targetId === id && (effect.tag ? entry.tag === effect.tag : entry.sourceAbilityId === ability.id) )); - work.effects.push(timedEffect( + const timed = timedEffect( "dot", ability.id, castThreatSource.actorId, @@ -5985,14 +8797,17 @@ export const useCombatStore = create((set, get) => ({ effect.tag, effect.school ?? damageSchool, work.gearStats.spellPenetration, - )); + ); + work.effects.push(seedProfile + ? { ...timed, tag: "wow335-warlock-seed", warlockSeed: seedProfile } + : timed); } } else if (effect.kind === "hot") { if (targeting?.shape === "ground") continue; const hotTargetId = effectiveTarget === "friendly" ? targetId : null; const recipients = healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors, id => id === PLAYER_AURA_ENTITY_ID ? playerOrigin : getPartyRuntimePosition(id) ?? getMobPosition(id), - [hotTargetId ?? PLAYER_AURA_ENTITY_ID], combatHasLineOfSight, now); + curingShotRecipients ?? areaFriendlyRecipients ?? [hotTargetId ?? PLAYER_AURA_ENTITY_ID], combatHasLineOfSight, now); for (const recipient of recipients) { const normalizedTarget = recipient === PLAYER_AURA_ENTITY_ID ? null : recipient; work.effects = work.effects.filter((entry) => !( @@ -6004,6 +8819,10 @@ export const useCombatStore = create((set, get) => ({ "hot", ability.id, castThreatSource.actorId, normalizedTarget, effect, effect.ticks, effect.intervalMs, now, )); } + if (state.classId === "priest" && ability.name === "Renew" && recipients.length > 0 + && !innerFocusSerendipityBlocked) { + priestSerendipityTriggered = true; + } } else if (effect.kind === "taunt") { const friendlyAttackers = effectiveTarget === "friendly" && targetId ? Object.values(work.mobs) @@ -6047,7 +8866,9 @@ export const useCombatStore = create((set, get) => ({ } for (const id of candidates) { const mob = work.mobs[id]; - if (mob?.characterDefender && ability.effects.some((candidate) => candidate.kind === "apply-aura" && candidate.control?.kind === effect.status)) continue; + if (mob?.characterDefender && ability.effects.some((candidate) => ( + candidate.kind === "apply-aura" && candidate.control?.kind === effect.status + ))) continue; if (mob) work.mobs[id] = applyStatus(mob, { ...effect, durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers), @@ -6068,17 +8889,88 @@ export const useCombatStore = create((set, get) => ({ && effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG) ? sourceDurationMs : talentAdjustedDuration(sourceDurationMs, ability, modifiers); - const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs); - const recipients = effect.scope === "party" - ? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.filter((member) => member.health > 0).map((member) => member.id)] + const sourceAura = ability.dbcSpellId === 48707 && state.classId === "death-knight" + ? { + ...effect.aura, + absorbs: effect.aura.absorbs?.map((absorb) => ({ + ...absorb, + amount: Math.round(work.maxHealth * absorb.amount), + })), + } + : effect.aura; + const excludesLiquidationTarget = state.classId === "rom-warlock" + && state.secondaryClassId === "rom-rogue" + && ability.dbcSpellId === 498695 + && effect.scope === "party" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "threat" && modifier.value < 0); + if (ability.name === "Prayer of Mending") { + work.auras = work.auras.filter((aura) => !( + aura.sourceId === PLAYER_AURA_ENTITY_ID && isPrayerOfMendingAura(aura) + )); + } + const romWardenWillAttackTargets = ability.source === "runewaker" + && ability.dbcSpellId === 493391 + && effect.aura.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG); + const recipients = romWardenWillAttackTargets + ? affectedMobIds + : effect.scope === "party" + ? (() => { + const partyMembers = usePartyStore.getState().members; + const classGroupBlessing = state.classId === "paladin" + && ability.name.startsWith("Greater Blessing of "); + const selectedClass = classGroupBlessing && targetId + ? partyMembers.find((member) => member.id === targetId)?.classId ?? state.classId + : state.classId; + const partyRecipientIds = [ + ...(!classGroupBlessing || state.classId === selectedClass ? [PLAYER_AURA_ENTITY_ID] : []), + ...partyMembers + .filter((member) => member.health > 0) + .filter((member) => !classGroupBlessing || member.classId === selectedClass) + .filter((member) => { + if (!targeting || (targeting.shape !== "circle" && ability.radius === undefined)) return true; + const position = getPartyRuntimePosition(member.id); + const radius = targeting.radius ?? ability.radius ?? 0; + return Boolean(position + && Math.sqrt(distanceSquared(playerOrigin, position)) <= radius + && combatHasLineOfSight(playerOrigin, position)); + }) + .map((member) => member.id)]; + const prioritized = targetId && partyRecipientIds.includes(targetId) + ? [targetId, ...partyRecipientIds.filter((id) => id !== targetId)] + : partyRecipientIds; + return targeting?.maximumTargets ? prioritized.slice(0, targeting.maximumTargets) : prioritized; + })() + : effect.control && affectedMobIds.length + ? affectedMobIds : holyAuraFriendly && ability.name === "Holy Aura" && targetId ? [targetId] + : effect.recipient === "pet" + ? [activeHunterPetId(work.summons, now)].filter((id): id is string => id !== null) : effect.recipient === "caster" ? [PLAYER_AURA_ENTITY_ID] : effectiveTarget === "hostile" ? affectedMobIds : [targetId ?? PLAYER_AURA_ENTITY_ID]; - for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) { + for (const recipientId of recipients.filter((id) => (!effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID) + && !(excludesLiquidationTarget && targetId && id === targetId))) { + const recipientMaxHealth = recipientId === PLAYER_AURA_ENTITY_ID + ? work.maxHealth + : usePartyStore.getState().members.find((member) => member.id === recipientId)?.maxHealth + ?? work.summons.find((summon) => summon.id === recipientId)?.maxHealth + ?? work.actors[recipientId]?.maxHealth + ?? onlinePlayerRuntimeActor(recipientId)?.maxHealth + ?? work.maxHealth; + const recipientAura = ability.classId === "rom-warden" && ability.dbcSpellId === 498201 + ? { + ...sourceAura, + absorbs: sourceAura.absorbs?.map((absorb) => ({ + ...absorb, + amount: Math.round(recipientMaxHealth * 0.1), + })), + } + : sourceAura; + const auraDefinition = auraDefinitionForAbility(recipientAura, ability, durationMs); const previousAuras = work.auras; const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id; if (previousAuras.some((aura) => aura.targetId === recipientId @@ -6093,7 +8985,9 @@ export const useCombatStore = create((set, get) => ({ work.auras = [...auraResult.auras]; if (ability.name === "Power Word: Shield") { work.auras = [...applyCombatAura(work.auras, { - definition: WEAKENED_SOUL_AURA, + definition: consumesInnerFocus + ? { ...WEAKENED_SOUL_AURA, durationMs: Math.max(0, (WEAKENED_SOUL_AURA.durationMs ?? 0) - 10_000) } + : WEAKENED_SOUL_AURA, sourceId: PLAYER_AURA_ENTITY_ID, targetId: recipientId, now, @@ -6129,7 +9023,14 @@ export const useCombatStore = create((set, get) => ({ } } } - if (effect.control && work.mobs[recipientId]) { + // If this ability also has an explicit status effect for the same + // mechanic, that effect owns the status event. The aura still gets + // applied here, but applying its control a second time would consume + // diminishing returns twice on one cast. + const hasStandaloneControlStatus = effect.control && ability.effects.some((candidate) => ( + candidate.kind === "status" && candidate.status === effect.control!.kind + )); + if (effect.control && !hasStandaloneControlStatus && work.mobs[recipientId]) { const durationMs = auraDefinition.durationMs ?? 1_000; const previousStatuses = work.mobs[recipientId].statuses; work.mobs[recipientId] = applyStatus(work.mobs[recipientId], { @@ -6151,8 +9052,25 @@ export const useCombatStore = create((set, get) => ({ : targetId ?? PLAYER_AURA_ENTITY_ID; const recipientIds = effect.scope === "party" ? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.map((member) => member.id)] + : targeting?.shape === "circle" && effectiveTarget !== "hostile" + ? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members + .filter((member) => member.health > 0) + .filter((member) => { + const position = getPartyRuntimePosition(member.id); + const radius = targeting.radius ?? ability.radius ?? 0; + return Boolean(position + && Math.sqrt(distanceSquared(playerOrigin, position)) <= radius + && combatHasLineOfSight(playerOrigin, position)); + }) + .map((member) => member.id)] : primaryRecipientId ? [primaryRecipientId] : []; - for (const recipientId of recipientIds) { + const prioritizedRecipientIds = targetId && recipientIds.includes(targetId) + ? [targetId, ...recipientIds.filter((id) => id !== targetId)] + : recipientIds; + const limitedRecipientIds = targeting?.maximumTargets + ? prioritizedRecipientIds.slice(0, targeting.maximumTargets) + : recipientIds; + for (const recipientId of limitedRecipientIds) { const relationship = effect.relationship === "target" ? (work.mobs[recipientId] ? "hostile" : "friendly") : effect.relationship; @@ -6280,7 +9198,9 @@ export const useCombatStore = create((set, get) => ({ if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource; } } else if (effect.kind === "threat") { - const threatTargets = affectedMobIds.length ? affectedMobIds : Object.keys(work.mobs); + const threatTargets = ability.name === "Soulshatter" + ? affectedMobIds + : affectedMobIds.length ? affectedMobIds : Object.keys(work.mobs); for (const id of threatTargets) { const mob = work.mobs[id]; if (!mob || mob.dead) continue; @@ -6406,7 +9326,9 @@ export const useCombatStore = create((set, get) => ({ } else if (effect.kind === "totem") { work.summons = work.summons.filter((summon) => !/totem/i.test(abilityById(summon.sourceAbilityId)?.name ?? "")); } else if (effect.kind === "rune") { - work.deathKnightRunes = activateDeathKnightRunes(work.deathKnightRunes, effect.count); + work.deathKnightRunes = effect.runeType === "blood" && effect.convertToDeathMs !== undefined + ? activateDeathKnightBloodTapRune(work.deathKnightRunes, now, effect.convertToDeathMs) + : activateDeathKnightRunes(work.deathKnightRunes, effect.count); } else if (effect.kind === "cooldown-reset") { cooldownAdjustments.push(effect); } else if (effect.kind === "trigger-spell") { @@ -6429,6 +9351,11 @@ export const useCombatStore = create((set, get) => ({ } else if (/food|refreshment|health|firestone/i.test(ability.name)) { work.totalHealing += healPlayerInWork(work, Math.max(1, Math.round(work.maxHealth * 0.25))); } + } else if (effect.action === "pickpocket" && targetId && work.mobs[targetId]) { + abilityFeedback = { + kind: "loot", + message: `Pick Pocket succeeds on ${work.mobs[targetId].name}.`, + }; } else if (effect.action === "portal") { summonSequence += 1; work.summons.push({ @@ -6448,6 +9375,185 @@ export const useCombatStore = create((set, get) => ({ attackDamage: 0, ...initialSummonCombatRuntime(), }); + } else if (effect.action === "class-script" && ability.name === "Tricks of the Trade" && targetId) { + const durationMs = Math.max(1_000, effect.durationMs); + const targetAura = { + id: "wow335:rogue:tricks-damage-buff", + name: ability.name, + description: "Increases damage dealt by 100% while receiving the Rogue's threat.", + disposition: "buff" as const, + durationMs, + maxStacks: 1, + stackBehavior: "refresh" as const, + modifiers: [{ kind: "damage" as const, direction: "dealt" as const, operation: "percent" as const, value: 1 }], + utilityTags: ["wow335-rogue-tricks-damage-buff"], + }; + const recipientAura = { + id: "wow335:rogue:tricks-threat-recipient", + name: ability.name, + description: "Redirects threat generated by the Rogue to the selected party member.", + disposition: "buff" as const, + durationMs, + maxStacks: 1, + stackBehavior: "refresh" as const, + exclusiveGroup: `${TRICKS_THREAT_REDIRECT_GROUP_PREFIX}${targetId}`, + utilityTags: ["wow335-rogue-tricks-threat-routing"], + }; + work.auras = work.auras.filter((aura) => !( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-rogue-tricks-threat-routing") + )); + work.auras = [...applyCombatAura(work.auras, { + definition: auraDefinitionForAbility(targetAura, ability, durationMs), + sourceId: PLAYER_AURA_ENTITY_ID, + targetId, + now, + }).auras]; + work.auras = [...applyCombatAura(work.auras, { + definition: auraDefinitionForAbility(recipientAura, ability, durationMs), + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } else if (effect.action === "class-script" && ability.name === "Shadowstep" && targetId) { + executeTriggeredSpellInWork(work, 36563, PLAYER_AURA_ENTITY_ID, targetId, now, 0); + executeTriggeredSpellInWork(work, 44373, PLAYER_AURA_ENTITY_ID, null, now, 0); + const target = getMobRuntimeTransform(targetId); + if (target) playerPositionAfterEffects = [ + target.position[0] - Math.sin(target.yaw) * 1.5, + target.position[1], + target.position[2] - Math.cos(target.yaw) * 1.5, + ]; + } else if (effect.action === "class-script" && ability.name === "Killing Spree" && targetId) { + const spreeAura = { + id: "wow335:rogue:killing-spree-damage", + name: ability.name, + description: "Increases all damage dealt by 1% during Killing Spree.", + disposition: "buff" as const, + durationMs: Math.max(500, effect.durationMs), + maxStacks: 1, + stackBehavior: "refresh" as const, + modifiers: [{ kind: "damage" as const, direction: "dealt" as const, operation: "percent" as const, value: 0.01 }], + utilityTags: ["wow335-rogue-killing-spree"], + }; + work.auras = [...applyCombatAura(work.auras, { + definition: auraDefinitionForAbility(spreeAura, ability), + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + const loadout = resolvedEquipmentLoadout(state.equipment, state.inventory, { + ownerId: PLAYER_EQUIPMENT_OWNER_ID, + ownerKind: "player", + classId: state.classId, + secondaryClassId: state.secondaryClassId, + specializationId: state.specializationId, + role: state.gearRole, + level: state.level, + }); + const hands: (InventoryItem | null)[] = [loadout["main-hand"], loadout["off-hand"]] + .map((item) => item?.weaponType ? item : null); + if (!hands.some(Boolean)) hands.splice(0, hands.length, null); + const attacker = playerCharacterStats( + state.classId, + work.progression.level, + work.gearStats, + work.raceId, + work.secondaryClassId, + work.talentModifiers, + ); + const strikeAmount = (weapon: InventoryItem | null) => { + const weaponStats = weapon?.stats.meleeWeapon ?? work.gearStats.meleeWeapon; + if (!weaponStats) return Math.max(1, Math.round(playerCombatPower(work.progression.level) * 0.35)); + const coefficient = weapon?.weaponType === "dagger" ? 1.95 : 1.3; + const speedSeconds = weaponStats.speedMs / 1_000; + return Math.max(1, Math.round(((weaponStats.damageMin + weaponStats.damageMax) / 2 + + attacker.attackPower / 14 * speedSeconds) * coefficient)); + }; + const hiddenTarget = (mobId: string) => aurasForEntity(work.auras, mobId).some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.some((tag) => tag === "stealth" || tag === "invisible") + )); + let previousTargetId: string | null = targetId; + let previousPosition = getMobPosition(targetId) ?? targetPosition ?? playerOrigin; + const source = castThreatSource; + for (let assault = 0; assault < 5; assault += 1) { + const candidates = Object.keys(work.mobs).filter((mobId) => { + const mob = work.mobs[mobId]; + const position = getMobPosition(mobId); + return Boolean(mob && !mob.dead && position && !hiddenTarget(mobId) + && distanceSquared(previousPosition, position) <= 100 + && combatHasLineOfSight(previousPosition, position)); + }).sort((left, right) => { + const leftPosition = getMobPosition(left)!; + const rightPosition = getMobPosition(right)!; + return distanceSquared(previousPosition, leftPosition) - distanceSquared(previousPosition, rightPosition) + || left.localeCompare(right); + }); + const nextTargetId = candidates.find((mobId) => mobId !== previousTargetId) + ?? candidates.find((mobId) => mobId === previousTargetId) + ?? candidates[0]; + if (!nextTargetId) break; + const position = getMobPosition(nextTargetId); + if (!position) break; + previousTargetId = nextTargetId; + previousPosition = position; + for (const [handIndex, weapon] of hands.entries()) { + const mob = work.mobs[nextTargetId]; + if (!mob || mob.dead) break; + const amount = strikeAmount(weapon); + const seed = `${PLAYER_AGGRO_ID}:${ability.id}:${assault}:${handIndex}:${nextTargetId}:${now}`; + if (handIndex === 0) { + resolveAndDamageMobInWork( + state.classId, work, nextTargetId, amount, now, source, "melee", "physical", seed, ability, + ); + } else { + const modifiedAmount = resolveAuraDamageValue(amount, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), { + direction: "dealt", school: "physical", attackKind: "melee", + }); + const result = resolvePlayerDamageResult( + state.classId, work, PLAYER_AURA_ENTITY_ID, mob, modifiedAmount, "melee", "physical", seed, ability, + ); + damageMobInWork( + state.classId, work, nextTargetId, amount, now, source, "physical", + work.gearStats.spellPenetration, result, + ); + } + } + } + const finalTarget = previousTargetId ? getMobRuntimeTransform(previousTargetId) : null; + if (finalTarget) playerPositionAfterEffects = [ + finalTarget.position[0] - Math.sin(finalTarget.yaw) * 1.5, + finalTarget.position[1], + finalTarget.position[2] - Math.cos(finalTarget.yaw) * 1.5, + ]; + } else if (effect.action === "class-script" && ability.name === "Pestilence" && targetId) { + const diseaseTags = new Set(["blood-plague", "frost-fever"]); + const diseases = work.effects.filter((entry) => ( + entry.kind === "dot" + && entry.targetId === targetId + && entry.sourceActorId === PLAYER_AGGRO_ID + && entry.tag !== undefined + && diseaseTags.has(entry.tag) + && entry.remainingTicks > 0 + )); + for (const recipientId of affectedMobIds) { + if (recipientId === targetId) continue; + for (const disease of diseases) { + work.effects = work.effects.filter((entry) => !( + entry.kind === "dot" + && entry.targetId === recipientId + && entry.sourceActorId === disease.sourceActorId + && entry.tag === disease.tag + )); + effectSequence += 1; + work.effects.push({ + ...disease, + id: `${ability.id}:${effectSequence}`, + targetId: recipientId, + }); + } + } } else if (effect.action === "class-script" && ability.name === "Divine Intervention") { work.health = 1; work.progression.health = 1; @@ -6462,6 +9568,42 @@ export const useCombatStore = create((set, get) => ({ } } + if (state.classId === "hunter" && ability.name === "Bestial Wrath") { + const talentRank = Math.min(2, Math.max(0, Math.trunc(work.talentRanks["talent-1803"] ?? 0))); + const petId = activeHunterPetId(work.summons, now); + const procChance = talentRank >= 2 ? 1 : talentRank === 1 ? 0.5 : 0; + if (petId && procChance > 0 + && stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${petId}:${now}:the-beast-within`) < procChance) { + const catalogPassive = work.abilities.find((candidate) => candidate.name === "The Beast Within"); + const passive = catalogPassive + ? learnedAbilityForBuild(catalogPassive, work.progression.level, work.talentRanks) + : null; + for (const effect of passive?.effects ?? []) { + if (!passive || effect.kind !== "apply-aura") continue; + const previousAuras = work.auras; + work.auras = [...applyCombatAura(previousAuras, { + definition: auraDefinitionForAbility(effect.aura, passive), + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + applyAuraHealthTransitionInWork(work, PLAYER_AURA_ENTITY_ID, previousAuras, work.auras); + } + } + } + + // Arcane Blast's triggered child aura persists through a non-Arcane Blast + // Arcane cast, boosting that spell, then is consumed on successful resolve. + // Channel ticks happen before their completingCast reaches this point. + if (ability.name !== "Arcane Blast" + && spellSchool === "arcane" + && ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot")) { + work.auras = work.auras.filter((aura) => !( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-arcane-blast-stack") + )); + } + const consumedRuneWakerTags = ability.id === "rom-mage-primary-55" ? ["rom-charged"] : ability.id === "rom-mage-primary-56" @@ -6475,10 +9617,58 @@ export const useCombatStore = create((set, get) => ({ && consumedRuneWakerTags.some((tag) => aura.definition.utilityTags?.includes(tag)) )); } + if (divineFavorReady) { + work.auras = work.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal"))); + } + if (state.classId === "warrior" && ability.name === "Execute" && targetId + && (work.talentRanks["talent-1542"] ?? 0) >= 3) { + const executeTarget = state.mobs[targetId]; + if (executeTarget && executeTarget.health / Math.max(1, executeTarget.maxHealth) < 0.2) { + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:warrior:improved-execute-critical", + name: "Improved Execute", + description: "Using Execute below 20% health grants 10% melee critical strike chance for 5 seconds, stacking up to 3 times.", + disposition: "buff", + durationMs: 5_000, + maxStacks: 3, + stackBehavior: "add", + modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 10 }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + if (state.classId === "priest") { + if (priestSerendipityTriggered) { + work.auras = work.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-priest-serendipity") + && aura.expiresAt !== null && aura.expiresAt <= now)); + applyPriestSelfAuraInWork(work, PRIEST_SERENDIPITY_AURA, now); + } + const holyConcentrationRank = ["Flash Heal", "Greater Heal", "Binding Heal", "Circle of Healing"].includes(ability.name) + ? priestTalentRank(work.talentRanks, "Holy Concentration", 3) + : 0; + if (holyConcentrationRank > 0) { + maybeExtendPriestRenewInWork( + work, + holyConcentrationRank, + playerOrigin, + `${PLAYER_AGGRO_ID}:${ability.id}:${targetId ?? PLAYER_AURA_ENTITY_ID}:${now}`, + now, + ); + } + } + const deathchillAbilityNames = ["Icy Touch", "Howling Blast", "Frost Strike", "Obliterate"]; if (hasHostileEffect && ability.name !== "Cold Blood") { work.auras = work.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("next-offensive-critical") + && (!aura.definition.utilityTags.includes("wow335-deathchill-next-critical") + || (state.classId === "death-knight" && deathchillAbilityNames.includes(ability.name))) )); } @@ -6502,24 +9692,53 @@ export const useCombatStore = create((set, get) => ({ critical: false, outcome: "completed", }); + if (state.classId === "rogue") { + maybeProcRogueCastTalentsInWork( + work, + ability, + targetId, + state.comboPoints, + state.comboTargetId, + now, + ); + } // Judgement of Light restores 2% health when the marked target is struck. if (work.totalDamage > 0 && targetId && work.mobs[targetId]?.statuses.some((entry) => entry.kind === "marked")) { const healed = healPlayerInWork(work, talentAdjustedAmount(Math.round(work.maxHealth * 0.02), modifiers, "healing-percent", ability)); work.totalHealing += healed; - addHealingThreatInWork(work, castThreatSource, healed); + addHealingThreatInWork(work, castThreatSource, healed, now); + } + + if (state.classId === "rogue" && ability.name === "Riposte") { + work.auras = work.auras.filter((aura) => !( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-rogue-riposte-ready") + )); + } + if (state.classId === "hunter" && ability.name === "Counterattack") { + work.auras = work.auras.filter((aura) => !( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready") + )); } const cooldowns: Record = { ...work.cooldowns }; if (cooldownMs > 0 && completingCast?.mode !== "channel") cooldowns[ability.id] = now + cooldownMs; if (ability.cooldownGroup && completingCast?.mode !== "channel") { for (const grouped of state.abilities.filter((entry) => entry.cooldownGroup === ability.cooldownGroup)) { - cooldowns[grouped.id] = now + Math.max(cooldownMs, talentAdjustedCooldown(grouped, modifiers)); + const groupedReverberationReductionMs = state.classId === "shaman" && grouped.cooldownGroup === "shaman-shock" + ? shamanTalentRankFromRanks(state.talentRanks, "Reverberation", 3) * 500 + : 0; + const groupedCooldownMs = Math.max(0, talentAdjustedCooldown(grouped, modifiers) - groupedReverberationReductionMs); + cooldowns[grouped.id] = now + Math.max(cooldownMs, groupedCooldownMs); } } for (const adjustment of cooldownAdjustments) { - const candidates = cooldownResetCandidates(state.abilities, ability.id, adjustment.abilityName); + const candidates = cooldownResetCandidates( + state.abilities, ability.id, adjustment.abilityName, adjustment.excludeAbilityNames, + ); for (const candidate of candidates) { if (adjustment.mode === "reset") cooldowns[candidate.id] = 0; else { @@ -6531,6 +9750,13 @@ export const useCombatStore = create((set, get) => ({ } } } + if (state.classId === "priest" && ability.id === "priest-flash-heal" + && (work.talentRanks["talent-1773"] ?? 0) > 0) { + const innerFocus = work.abilities.find((candidate) => candidate.name === "Inner Focus"); + if (innerFocus && (cooldowns[innerFocus.id] ?? 0) > now) { + cooldowns[innerFocus.id] = Math.max(now, cooldowns[innerFocus.id] - 2_000); + } + } const burstReset = romFireLightningBurstReset( ability, Boolean(targetId && work.mobs[targetId] && !work.mobs[targetId].dead && work.mobs[targetId].health > 0), @@ -6603,8 +9829,8 @@ export const useCombatStore = create((set, get) => ({ playerPosition: playerPositionAfterEffects, ...(clearedPlayerControl ? { controlledUntil: 0, controlMechanic: null } : {}), feedback: nextFeedback( - work.progression.levelsGained > 0 ? "level-up" : "success", - levelMessage, + abilityFeedback?.kind ?? (work.progression.levelsGained > 0 ? "level-up" : "success"), + abilityFeedback?.message ?? levelMessage, { abilityId: ability.id, targetId: targetId ?? undefined, amount: work.totalDamage || work.totalHealing }, ), }); @@ -6733,6 +9959,7 @@ export const useCombatStore = create((set, get) => ({ health: progression.health, maxHealth: progression.maxHealth, resource: clamp(work.resource, 0, work.maxResource), + ...(state.classId === "death-knight" ? { resourcePools: { ...work.resourcePools } } : {}), maxResource: work.maxResource, talentModifiers: work.talentModifiers, lastCombatResult: result.combatResult ?? state.lastCombatResult, @@ -6998,11 +10225,12 @@ export const useCombatStore = create((set, get) => ({ * resolveAuraStatValue(1, aurasForEntity(state.auras, source.actorId), "threat"); if (perMob <= 0) return state; const mobs = { ...state.mobs }; + const threatActorId = activeTricksThreatRecipient(state.auras, source.actorId, combatNow()) ?? source.actorId; for (const id of engagedIds) { const mob = mobs[id]; mobs[id] = { ...mob, - threatByActor: addActorThreat(mob.threatByActor, source.actorId, perMob), + threatByActor: addActorThreat(mob.threatByActor, threatActorId, perMob), }; } return { mobs }; @@ -7584,7 +10812,9 @@ export const useCombatStore = create((set, get) => ({ summonSequence += 1; const isTotem = /totem/i.test(ability.name); const id = `${ownerActorId}:${isTotem ? "totem" : "summon"}-${summonSequence}`; - const maxHealth = Math.max(1, Math.round(owner.maxHealth * (isTotem ? 0.1 : 0.3))); + const maxHealth = effect.healingPulse + ? 5 + : Math.max(1, Math.round(owner.maxHealth * (isTotem ? 0.1 : 0.3))); const summon: CombatSummon = { id, sourceAbilityId: ability.id, @@ -7597,9 +10827,14 @@ export const useCombatStore = create((set, get) => ({ health: maxHealth, maxHealth, position, - nextActionAt: now + 1_000, - pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : isTotem ? 2_000 : 2_200, - attackDamage: Math.max(1, Math.round(owner.level * 4 * Math.max(0.15, effect.coefficient))), + nextActionAt: now + (effect.healingPulse?.intervalMs ?? 1_000), + pulseIntervalMs: effect.healingPulse?.intervalMs + ?? (ability.name === "Shadowfiend" ? 1_500 : isTotem ? 2_000 : 2_200), + attackDamage: effect.healingPulse || ability.name === "Lightwell" + ? 0 + : Math.max(1, Math.round(owner.level * 4 * Math.max(0.15, effect.coefficient))), + ...(ability.name === "Lightwell" ? { clickCharges: 10 } : {}), + ...(effect.healingPulse ? { healingPulse: effect.healingPulse } : {}), ...initialSummonCombatRuntime(), ...(isTotem ? { exclusiveGroup: `${ownerActorId}:totem` } : {}), }; @@ -7611,6 +10846,50 @@ export const useCombatStore = create((set, get) => ({ return id; }, + clickLightwell: (summonId, targetActorId, now = combatNow()) => { + const state = get(); + const well = state.summons.find((summon) => summon.id === summonId + && summon.sourceAbilityId === "wow335-priest-lightwell" + && (summon.clickCharges ?? 0) > 0 + && summon.expiresAt > now); + if (!well) return 0; + const target = targetActorId ?? state.selectedFriendlyActorId ?? PLAYER_AURA_ENTITY_ID; + const partyMember = usePartyStore.getState().members.some((member) => member.id === target); + if (target !== PLAYER_AURA_ENTITY_ID && !partyMember) return 0; + const targetActor = target === PLAYER_AURA_ENTITY_ID ? state.actors[target] : state.actors[target]; + if (!targetActor?.alive) return 0; + const ownerLevel = well.ownerActorId === PLAYER_AGGRO_ID + ? state.level + : state.actors[well.ownerActorId]?.level ?? state.level; + // The extracted DBC row omits the child heal amount. This combat-scaled + // value keeps each six-second click heal useful while the source payload + // remains missing. + const amount = Math.max(1, Math.round(playerCombatPower(ownerLevel) * 18.5)); + const tickAmount = Math.max(1, Math.round(amount / 6)); + const periodicHeal = { + ...timedEffect( + "hot", + well.sourceAbilityId, + well.ownerActorId, + target === PLAYER_AURA_ENTITY_ID ? null : target, + { coefficient: tickAmount }, + 6, + 1_000, + now, + ), + fixedAmount: tickAmount, + } satisfies TimedCombatEffect; + set((current) => ({ + effects: [...current.effects, periodicHeal], + summons: current.summons.flatMap((summon) => { + if (summon.id !== summonId) return [summon]; + const clickCharges = Math.max(0, (summon.clickCharges ?? 0) - 1); + return clickCharges > 0 ? [{ ...summon, clickCharges }] : []; + }), + })); + return amount; + }, + createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => { const state = get(); const sourceActor = state.actors[sourceActorId]; @@ -7654,9 +10933,9 @@ export const useCombatStore = create((set, get) => ({ return id; }, - damagePlayer: (amount, school = "physical", attackerLevel) => { + damagePlayer: (amount, school = "physical", attackerLevel, now = combatNow()) => { const state = get(); - const now = combatNow(); + if (state.classId === "rom-warden" && activeWardenNatureCrystal(state.summons, now)) return 0; const mitigated = damageAfterEquipmentMitigation( talentAdjustedIncomingDamage(amount, state.talentModifiers), state.gearStats, @@ -7664,28 +10943,68 @@ export const useCombatStore = create((set, get) => ({ school, attackerLevel ?? state.level, ); - const incoming = resolveAuraDamageValue( - mitigated, - aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID), - { direction: "taken", school, attackKind: "spell" }, + const priestMitigated = priestTestOfFaithIncomingDamage( + mitigated, state.classId, state.talentRanks, state.activeCast, school, now, ); + const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID); + const damageTransferPet = activeWardenDamageTransferPet(state.summons, now, state.playerPosition); + const damageTransferActive = Boolean(damageTransferPet && playerAuras.some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) + ))); + const damageTransferAuras = playerAuras.filter((aura) => ( + !aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) + )); + const damageTransferContext = { direction: "taken" as const, school, attackKind: "spell" as const }; + const damageBeforeTransfer = damageTransferActive + ? resolveAuraDamageValue(priestMitigated, damageTransferAuras, damageTransferContext) + : 0; + const incoming = resolveAuraDamageValue( + priestMitigated, + damageTransferActive ? playerAuras : damageTransferAuras, + damageTransferContext, + ); + const damageTransferredToPet = damageTransferActive + ? Math.round(damageBeforeTransfer * ROM_WARDEN_DAMAGE_TRANSFER_FRACTION) + : 0; const namedAbsorb = absorbAuraDamage(state.auras, { targetId: PLAYER_AURA_ENTITY_ID, amount: incoming, school, now, + countsAsAttack: true, }); const absorbed = Math.min(state.shield, namedAbsorb.remainingDamage); const healthDamage = Math.min(state.health, namedAbsorb.remainingDamage - absorbed); + const petAbsorb = damageTransferPet && damageTransferredToPet > 0 + ? absorbAuraDamage(namedAbsorb.auras, { + targetId: damageTransferPet.id, + amount: damageTransferredToPet, + school, + now, + countsAsAttack: true, + }) + : null; + const petHealthDamage = damageTransferPet && petAbsorb + ? Math.min(damageTransferPet.health, Math.max(0, Math.round(petAbsorb.remainingDamage))) + : 0; + const nextSummons = damageTransferPet && petAbsorb && petHealthDamage > 0 + ? state.summons.flatMap((summon) => summon.id !== damageTransferPet.id + ? [summon] + : summon.health - petHealthDamage > 0 + ? [{ ...summon, health: summon.health - petHealthDamage }] + : []) + : state.summons; const remodeledBody = namedAbsorb.auras.find((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("rom-remodeled-body") )); const preventedDeath = Boolean(remodeledBody && healthDamage >= state.health); const health = preventedDeath ? state.maxHealth : state.health - healthDamage; + const postAbsorbAuras = petAbsorb?.auras ?? namedAbsorb.auras; const aurasAfterCheatDeath = preventedDeath - ? namedAbsorb.auras.filter((aura) => aura.instanceId !== remodeledBody!.instanceId) - : namedAbsorb.auras; + ? postAbsorbAuras.filter((aura) => aura.instanceId !== remodeledBody!.instanceId) + : postAbsorbAuras; const rage = gainRomRage( state.classId, state.secondaryClassId, @@ -7703,15 +11022,25 @@ export const useCombatStore = create((set, get) => ({ const procResult = evaluateAuraProcs(aurasAfterCheatDeath, { trigger: "damage-taken", subjectId: PLAYER_AURA_ENTITY_ID, - amount: healthDamage, + amount: incoming, school, }, now, ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:damage-taken:${state.health}`)); - let nextAuras = procResult.auras; + let nextAuras = spendDeathKnightBoneShieldCharge(procResult.auras, procResult.triggered); + nextAuras = maybeApplyWarlockBacklash( + nextAuras, + state.classId, + state.talentRanks, + school === "physical" ? "melee" : "spell", + school, + incoming > 0, + `${PLAYER_AURA_ENTITY_ID}:external:${school}:${now}:${state.health}`, + now, + ); if ( healthDamage > 0 && state.abilities.some((ability) => ability.id === "rom-rogue-rom-warden-elite-60" && isAbilityUnlocked(ability, state.level)) ) { - nextAuras = applyCombatAura(nextAuras, { + nextAuras = [...applyCombatAura(nextAuras, { definition: { id: "rom-state-revival-charge", name: "Revival", @@ -7725,27 +11054,44 @@ export const useCombatStore = create((set, get) => ({ sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now, - }).auras; + }).auras]; + } + let combatEvents = recordCombatEvent(state.combatEvents, { + occurredAt: now, + kind: "damage", + sourceActorId: "external", + targetActorId: PLAYER_AURA_ENTITY_ID, + school, + rawAmount: incoming, + effectiveAmount: healthDamage, + overheal: 0, + absorbed: namedAbsorb.absorbed + absorbed, + critical: false, + outcome: healthDamage > 0 ? "hit" : "absorb", + }); + if (damageTransferPet && petAbsorb && damageTransferredToPet > 0) { + combatEvents = recordCombatEvent(combatEvents, { + occurredAt: now, + kind: "damage", + sourceActorId: "external", + targetActorId: damageTransferPet.id, + school, + rawAmount: damageTransferredToPet, + effectiveAmount: petHealthDamage, + overheal: 0, + absorbed: petAbsorb.absorbed, + critical: false, + outcome: petHealthDamage > 0 ? "hit" : "absorb", + }); } set({ auras: nextAuras, + summons: nextSummons, shield: state.shield - absorbed, health, resource: rage.resource, resourcePools: rage.resourcePools, - combatEvents: recordCombatEvent(state.combatEvents, { - occurredAt: now, - kind: "damage", - sourceActorId: "external", - targetActorId: PLAYER_AURA_ENTITY_ID, - school, - rawAmount: incoming, - effectiveAmount: healthDamage, - overheal: 0, - absorbed: namedAbsorb.absorbed + absorbed, - critical: false, - outcome: healthDamage > 0 ? "hit" : "absorb", - }), + combatEvents, activeCast: health <= 0 ? null : healthDamage > 0 @@ -8368,6 +11714,7 @@ export const useCombatStore = create((set, get) => ({ }).auras]; } else if (effect.kind === "target-aura") { for (const actorId of targets) { + if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, effect.damageSchool ?? attack.school)) continue; namedAuras = [...applyCombatAura(namedAuras, { definition: { id: `enemy-debuff:${id}:${attack.id}:${effect.aura}`, @@ -8414,6 +11761,7 @@ export const useCombatStore = create((set, get) => ({ } } for (const actorId of targets) { + if (deathKnightAntiMagicShellBlocksMagicEffect(namedAuras, actorId, attack.school)) continue; if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue; const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs }; if (actorId === PLAYER_AGGRO_ID) playerControl = control; @@ -8625,6 +11973,9 @@ export const useCombatStore = create((set, get) => ({ } let appliedDamage = 0; for (const hit of incomingHits) { + if (hit.actorId === PLAYER_AURA_ENTITY_ID + && state.classId === "rom-warden" + && activeWardenNatureCrystal(get().summons, now)) continue; const impactOrigin = getMobPosition(hit.mob.id); const impactTarget = actorPosition(get(), hit.actorId); if (impactOrigin && impactTarget) { @@ -8689,7 +12040,7 @@ export const useCombatStore = create((set, get) => ({ ), aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID), ); - const combatResult = resolveEnemyDamageResult( + const resolvedCombatResult = resolveEnemyDamageResult( hit.mob, hit.attack, defender, @@ -8700,20 +12051,74 @@ export const useCombatStore = create((set, get) => ({ bonusCriticalChancePercent, aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID), ); + const combatResult = { + ...resolvedCombatResult, + finalDamage: romWardenWillAttackDamage( + resolvedCombatResult.finalDamage, current.auras, hit.mob.id, hit.attack, + ), + }; commitMeleeAutoProc(combatResult.landed); - const auraAdjustedDamage = resolveAuraDamageValue( + const priestMitigated = priestTestOfFaithIncomingDamage( combatResult.finalDamage, - aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID), - { direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) }, + current.classId, + current.talentRanks, + current.activeCast, + hit.attack.school, + now, ); + const playerAuras = aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID); + const damageTransferPet = activeWardenDamageTransferPet(current.summons, now, current.playerPosition); + const damageTransferActive = Boolean(damageTransferPet && playerAuras.some((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) + ))); + const damageTransferAuras = playerAuras.filter((aura) => ( + !aura.definition.utilityTags?.includes(ROM_WARDEN_DAMAGE_TRANSFER_TAG) + )); + const damageTransferContext = { + direction: "taken" as const, + school: hit.attack.school, + attackKind: enemyAttackKind(hit.attack), + }; + const damageBeforeTransfer = damageTransferActive + ? resolveAuraDamageValue(priestMitigated, damageTransferAuras, damageTransferContext) + : 0; + const auraAdjustedDamage = resolveAuraDamageValue( + priestMitigated, + damageTransferActive ? playerAuras : damageTransferAuras, + damageTransferContext, + ); + const damageTransferredToPet = damageTransferActive + ? Math.round(damageBeforeTransfer * ROM_WARDEN_DAMAGE_TRANSFER_FRACTION) + : 0; const namedAbsorb = absorbAuraDamage(current.auras, { targetId: PLAYER_AURA_ENTITY_ID, amount: auraAdjustedDamage, school: hit.attack.school, now, + countsAsAttack: true, }); const absorbed = Math.min(current.shield, namedAbsorb.remainingDamage); const healthDamage = Math.min(current.health, namedAbsorb.remainingDamage - absorbed); + const petAbsorb = damageTransferPet && damageTransferredToPet > 0 + ? absorbAuraDamage(namedAbsorb.auras, { + targetId: damageTransferPet.id, + amount: damageTransferredToPet, + school: hit.attack.school, + now, + countsAsAttack: true, + }) + : null; + const petHealthDamage = damageTransferPet && petAbsorb + ? Math.min(damageTransferPet.health, Math.max(0, Math.round(petAbsorb.remainingDamage))) + : 0; + const nextSummons = damageTransferPet && petAbsorb && petHealthDamage > 0 + ? current.summons.flatMap((summon) => summon.id !== damageTransferPet.id + ? [summon] + : summon.health - petHealthDamage > 0 + ? [{ ...summon, health: summon.health - petHealthDamage }] + : []) + : current.summons; const health = current.health - healthDamage; const rage = gainRomRage( current.classId, @@ -8737,14 +12142,15 @@ export const useCombatStore = create((set, get) => ({ ? "block" : null; const procEvent: ProcEvent | null = avoidTrigger - ? { trigger: avoidTrigger, subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id } - : healthDamage > 0 + ? { trigger: avoidTrigger, subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id, + ...(avoidTrigger === "block" ? { amount: combatResult.blockedAmount } : {}) } + : healthDamage > 0 || (combatResult.landed && auraAdjustedDamage > 0) ? { trigger: "damage-taken", subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id, - amount: healthDamage, + amount: Math.max(healthDamage, auraAdjustedDamage), school: hit.attack.school, attackKind: enemyAttackKind(hit.attack), } @@ -8756,8 +12162,8 @@ export const useCombatStore = create((set, get) => ({ abilities: current.abilities, mobs: { ...get().mobs }, effects: [...current.effects], - auras: [...namedAbsorb.auras], - summons: [...current.summons], + auras: [...(petAbsorb?.auras ?? namedAbsorb.auras)], + summons: [...nextSummons], areaEffects: [...current.areaEffects], combatEvents: [...current.combatEvents], actors: { ...current.actors }, @@ -8790,10 +12196,71 @@ export const useCombatStore = create((set, get) => ({ && aura.definition.utilityTags?.includes("stealth") )); } + if (current.classId === "rogue" && avoidTrigger === "parry") { + procWork.auras = [...applyCombatAura(procWork.auras, { + definition: { + id: "wow335:rogue:riposte-ready", + name: "Riposte ready", + disposition: "buff", + durationMs: null, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-rogue-riposte-ready"], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + if (current.classId === "hunter" && avoidTrigger === "parry") { + procWork.auras = [...applyCombatAura(procWork.auras, { + definition: { + id: "wow335:hunter:counterattack-ready", + name: "Counterattack ready", + disposition: "buff", + durationMs: null, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-hunter-counterattack-ready"], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + if (current.classId === "rom-knight" && avoidTrigger === "parry" + && procWork.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(ROM_KNIGHT_SHIELD_OF_VALOR_TAG))) { + const holySeal = current.abilities.find((ability) => ability.source === "runewaker" + && ability.dbcSpellId === 490149); + if (holySeal) { + const readyAt = procWork.cooldowns[holySeal.id] ?? now; + procWork.cooldowns[holySeal.id] = Math.max(now, readyAt - 1_000); + } + } procWork.auras = consumeInnerFireCharges( procWork.auras, PLAYER_AURA_ENTITY_ID, combatResult.landed, enemyAttackKind(hit.attack), ); if (procEvent) evaluateProcsInWork(procWork, procEvent, now); + procWork.auras = maybeApplyWarlockBacklash( + procWork.auras, + current.classId, + current.talentRanks, + enemyAttackKind(hit.attack), + hit.attack.school, + combatResult.landed, + `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${now}`, + now, + ); + maybeProcPaladinReckoningInWork( + procWork, + enemyAttackKind(hit.attack), + combatResult.landed || avoidTrigger !== null, + `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${now}`, + now, + ); + maybeProcPaladinEyeForAnEyeInWork(procWork, hit.mob.id, healthDamage, combatResult.critical, now); recordCombatEventInWork(procWork, { occurredAt: now, kind: "damage", @@ -8808,8 +12275,25 @@ export const useCombatStore = create((set, get) => ({ critical: combatResult.critical, outcome: combatResult.outcome, }); + if (damageTransferPet && petAbsorb && damageTransferredToPet > 0) { + recordCombatEventInWork(procWork, { + occurredAt: now, + kind: "damage", + sourceActorId: hit.mob.id, + targetActorId: damageTransferPet.id, + abilityId: hit.attack.id, + school: hit.attack.school, + rawAmount: damageTransferredToPet, + effectiveAmount: petHealthDamage, + overheal: 0, + absorbed: petAbsorb.absorbed, + critical: combatResult.critical, + outcome: petHealthDamage > 0 ? "hit" : "absorb", + }); + } set({ auras: procWork.auras, + summons: procWork.summons, mobs: procWork.mobs, shield: procWork.shield, health: procWork.health, @@ -8845,9 +12329,12 @@ export const useCombatStore = create((set, get) => ({ const current = get(); const critical = bonusCriticalChancePercent > 0; const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1)); + const wardenAdjustedAmount = romWardenWillAttackDamage( + incomingAmount, current.auras, hit.mob.id, hit.attack, + ); commitMeleeAutoProc(true); const adjusted = resolveAuraDamageValue( - incomingAmount, + wardenAdjustedAmount, aurasForEntity(current.auras, summon.id), { direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) }, ); @@ -8856,6 +12343,7 @@ export const useCombatStore = create((set, get) => ({ amount: adjusted, school: hit.attack.school, now, + countsAsAttack: true, }); const applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage))); set({ @@ -8888,9 +12376,12 @@ export const useCombatStore = create((set, get) => ({ const current = get(); const critical = bonusCriticalChancePercent > 0; const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1)); + const wardenAdjustedAmount = romWardenWillAttackDamage( + incomingAmount, current.auras, hit.mob.id, hit.attack, + ); commitMeleeAutoProc(true); const adjusted = resolveAuraDamageValue( - incomingAmount, + wardenAdjustedAmount, aurasForEntity(current.auras, onlineActor.id), { direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) }, ); @@ -8899,6 +12390,7 @@ export const useCombatStore = create((set, get) => ({ amount: adjusted, school: hit.attack.school, now, + countsAsAttack: true, }); const damaged = Math.min(onlineActor.health, Math.max(0, Math.round(absorbed.remainingDamage))); const nextActor: CombatActorState = { @@ -8947,7 +12439,7 @@ export const useCombatStore = create((set, get) => ({ ), aurasForEntity(get().auras, member.id), ); - const combatResult = resolveEnemyDamageResult( + const resolvedCombatResult = resolveEnemyDamageResult( hit.mob, hit.attack, defender, @@ -8958,6 +12450,12 @@ export const useCombatStore = create((set, get) => ({ bonusCriticalChancePercent, aurasForEntity(get().auras, member.id), ); + const combatResult = { + ...resolvedCombatResult, + finalDamage: romWardenWillAttackDamage( + resolvedCombatResult.finalDamage, get().auras, hit.mob.id, hit.attack, + ), + }; commitMeleeAutoProc(combatResult.landed); const currentAuras = get().auras; const auraAdjustedDamage = resolveAuraDamageValue( @@ -8970,16 +12468,36 @@ export const useCombatStore = create((set, get) => ({ amount: auraAdjustedDamage, school: hit.attack.school, now, + countsAsAttack: true, }); const redirectAura = aurasForEntity(namedAbsorb.auras, hit.actorId) .find((aura) => (aura.definition.redirectDamagePercent ?? 0) > 0); const redirectFraction = Math.min(1, Math.max(0, redirectAura?.definition.redirectDamagePercent ?? 0)); - const redirected = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID + const proposedRedirect = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID ? Math.round(namedAbsorb.remainingDamage * redirectFraction) : 0; - const aurasAfterRedirect = redirected > 0 && redirectAura?.definition.utilityTags?.includes("rom-disregard-danger-one-hit") - ? namedAbsorb.auras.filter((aura) => aura.instanceId !== redirectAura.instanceId) - : namedAbsorb.auras; + const paladinSacrifice = redirectAura?.sourceId === PLAYER_AURA_ENTITY_ID + && redirectAura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice") + ? namedAbsorb.auras.find((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID + && aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")) + : undefined; + const redirectCap = paladinSacrifice + ? Math.round(get().maxHealth * Math.max(0, paladinSacrifice.definition.redirectDamageCapPercent ?? 0)) + : Number.POSITIVE_INFINITY; + const redirected = Math.min( + proposedRedirect, + Math.max(0, redirectCap - (paladinSacrifice?.redirectedDamage ?? 0)), + ); + let aurasAfterRedirect = namedAbsorb.auras; + if (paladinSacrifice && redirected > 0) { + aurasAfterRedirect = aurasAfterRedirect.map((aura) => aura.instanceId === paladinSacrifice.instanceId + ? { ...aura, redirectedDamage: (aura.redirectedDamage ?? 0) + redirected } + : aura); + } + if (redirected > 0 && redirectAura?.definition.utilityTags?.includes("rom-disregard-danger-one-hit")) { + aurasAfterRedirect = aurasAfterRedirect.filter((aura) => aura.instanceId !== redirectAura.instanceId); + } const damaged = Math.min( authoritativeActor.health, Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)), @@ -9022,25 +12540,79 @@ export const useCombatStore = create((set, get) => ({ appliedDamage += transferred; } appliedDamage += damaged; - set({ - actors: { ...get().actors, [nextActor.id]: nextActor }, - auras: consumeInnerFireCharges( - aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack), - ), - combatEvents: recordCombatEvent(get().combatEvents, { - occurredAt: now, - kind: "damage", - sourceActorId: hit.mob.id, - targetActorId: member.id, + const procState = get(); + const aurasAfterHit = consumeInnerFireCharges( + aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack), + ); + let actorsAfterHit = { ...procState.actors, [nextActor.id]: nextActor }; + let aurasAfterProcs = aurasAfterHit; + let eventsAfterHit = recordCombatEvent(procState.combatEvents, { + occurredAt: now, + kind: "damage", + sourceActorId: hit.mob.id, + targetActorId: member.id, + abilityId: hit.attack.id, + school: hit.attack.school, + rawAmount: combatResult.rawDamage, + effectiveAmount: damaged, + overheal: 0, + absorbed: namedAbsorb.absorbed, + critical: combatResult.critical, + outcome: combatResult.outcome, + }); + if (damaged > 0 && aurasAfterHit.some((aura) => aura.targetId === member.id && isPrayerOfMendingAura(aura))) { + const procProgression = progressionFromState(procState); + const procWork: MutableCastWork = { + classId: procState.classId, + abilities: procState.abilities, + mobs: { ...procState.mobs }, + effects: [...procState.effects], + auras: [...aurasAfterHit], + summons: [...procState.summons], + areaEffects: [...procState.areaEffects], + combatEvents: [...eventsAfterHit], + actors: actorsAfterHit, + cooldowns: { ...procState.cooldowns }, + health: procState.health, + maxHealth: procState.maxHealth, + shield: procState.shield, + shieldExpiresAt: procState.shieldExpiresAt, + resource: procState.resource, + maxResource: procState.maxResource, + resourcePools: { ...procState.resourcePools }, + maxResourcePools: { ...procState.maxResourcePools }, + lastResourceSpentAt: { ...procState.lastResourceSpentAt }, + comboPoints: procState.comboPoints, + comboTargetId: procState.comboTargetId, + deathKnightRunes: procState.deathKnightRunes, + progression: procProgression, + totalDamage: 0, + totalHealing: 0, + combatResults: [], + talentRanks: procState.talentRanks, + talentModifiers: procState.talentModifiers, + gearStats: procState.gearStats, + raceId: procState.raceId, + secondaryClassId: procState.secondaryClassId, + }; + evaluatePrayerOfMendingInWork(procWork, { + trigger: "damage-taken", + subjectId: member.id, + otherId: hit.mob.id, abilityId: hit.attack.id, + amount: damaged, school: hit.attack.school, - rawAmount: combatResult.rawDamage, - effectiveAmount: damaged, - overheal: 0, - absorbed: namedAbsorb.absorbed, - critical: combatResult.critical, - outcome: combatResult.outcome, - }), + attackKind: enemyAttackKind(hit.attack), + }, now); + actorsAfterHit = procWork.actors; + aurasAfterProcs = procWork.auras; + eventsAfterHit = procWork.combatEvents; + } + set({ + actors: actorsAfterHit, + auras: aurasAfterProcs, + combatEvents: eventsAfterHit, + health: procState.health, lastCombatResult: combatResult, feedback: nextFeedback( "damage", @@ -9131,17 +12703,25 @@ export const useCombatStore = create((set, get) => ({ const resourceProfile = resourceProfileForClass(state.classId); const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead); const auraExpiration = expireAuras(state.auras, now); - const activeAuras = [...auraExpiration.active]; + let activeAuras = [...auraExpiration.active]; + const hasChampionRuneOverload = state.classId === "rom-champion" + && activeAuras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_CHAMPION_RUNE_OVERLOAD_TAG)); + const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID); + const ordinaryBaseRegeneration = (resourceProfile.type === "rage" || resourceProfile.type === "runic-power") + ? 0 + : resourceProfile.regenerationPerSecond + + state.gearStats.mp5 / 5 + + state.gearStats.spirit * 0.02; + const manaPercentRegeneration = resourceProfile.type === "mana" + ? resolveAuraStatValue(0, playerAuras, "mana-regeneration-max-percent-per-second") + * (state.maxResourcePools.mana ?? state.maxResource) + : 0; const baseRegeneration = applyTalentModifierValue( - (resourceProfile.type === "rage" || resourceProfile.type === "runic-power") - ? 0 - : resourceProfile.regenerationPerSecond - + state.gearStats.mp5 / 5 - + state.gearStats.spirit * 0.02, + ordinaryBaseRegeneration + manaPercentRegeneration, state.talentModifiers, "resource-regeneration-percent", ); - const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID); const regeneration = resolveAuraResourceValue( resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"), playerAuras, @@ -9155,13 +12735,16 @@ export const useCombatStore = create((set, get) => ({ regeneration, state.lastResourceSpentAt[resourceProfile.type] ?? 0, ), { - inCombat, + inCombat: inCombat || hasChampionRuneOverload, actorLevel: state.level, now, deltaSeconds: delta, manaSpiritRegenerationPerSecond: state.gearStats.spirit * 0.02, }); let resource = activePool.current; + if (hasChampionRuneOverload) { + resource = Math.max(0, resource - ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND * delta); + } const resourcePools: Partial> = { ...state.resourcePools }; const hasNativeRomWarriorRage = hasRomWarriorRagePool(state.classId, state.secondaryClassId); const passiveRates: Partial> = { @@ -9188,6 +12771,12 @@ export const useCombatStore = create((set, get) => ({ }).current; } resourcePools[resourceProfile.type] = resource; + if (state.classId === "rom-champion" && (resourcePools.rage ?? 0) <= 0) { + activeAuras = activeAuras.filter((aura) => !( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_CHAMPION_RUNE_OVERLOAD_TAG) + )); + } let shield = state.shield; let shieldExpiresAt = state.shieldExpiresAt; if (shield > 0 && shieldExpiresAt > 0 && now >= shieldExpiresAt) { @@ -9200,7 +12789,7 @@ export const useCombatStore = create((set, get) => ({ classId: state.classId, abilities: state.abilities, mobs: { ...state.mobs }, - effects: [], + effects: state.effects.filter((effect) => effect.kind === "dot" && effect.warlockSeed), auras: activeAuras, summons: state.summons.filter((summon) => summon.expiresAt > now), areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now), @@ -9252,6 +12841,7 @@ export const useCombatStore = create((set, get) => ({ const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID); const canAutoAttack = ( !state.activeCast + && !(state.classId === "rom-warden" && activeWardenNatureCrystal(work.summons, now)) && ( state.controlledUntil <= now || !enemyControlPreventsActions(state.controlMechanic) @@ -9360,13 +12950,16 @@ export const useCombatStore = create((set, get) => ({ return Boolean(actor?.alive && actor.health > 0); }; for (const effect of state.effects) { + if (effect.kind === "dot" && effect.warlockSeed + && !work.effects.some((candidate) => candidate.id === effect.id)) continue; // Check before due ticks/procs, not only after applying their health changes. if (!timedEffectTargetAlive(effect)) continue; let remainingTicks = effect.remainingTicks; let nextTickAt = effect.nextTickAt; while (remainingTicks > 0 && nextTickAt <= now && timedEffectTargetAlive(effect)) { + const warPrayerSealTick = effect.kind === "dot" && effect.tag === "rom-knight-war-prayer-seal"; const ability = abilityById(effect.sourceAbilityId); - const baseAmount = effect.fixedAmount ?? (ability + const sourceAmount = effect.fixedAmount ?? (ability ? equipmentAdjustedCombatAmount( effect, progression.level, @@ -9375,7 +12968,20 @@ export const useCombatStore = create((set, get) => ({ ability, ) : Math.max(0, Math.round(effect.coefficient * playerCombatPower(progression.level)))); - const amount = ability && effect.fixedAmount === undefined + const spiritOfLifePercent = effect.kind === "hot" + && state.classId === "rom-druid" + && ability?.dbcSpellId === 494938 + ? effect.percentMaxHealth ?? 0 + : 0; + const hotTargetMaxHealth = effect.targetId + ? work.actors[effect.targetId]?.maxHealth + ?? usePartyStore.getState().members.find((member) => member.id === effect.targetId)?.maxHealth + ?? 0 + : work.maxHealth; + const baseAmount = spiritOfLifePercent > 0 + ? romDruidPeriodicHealAmount(sourceAmount, hotTargetMaxHealth, spiritOfLifePercent) + : sourceAmount; + const unadjustedTickAmount = ability && effect.fixedAmount === undefined ? talentAdjustedAmount( baseAmount, state.talentModifiers, @@ -9384,7 +12990,16 @@ export const useCombatStore = create((set, get) => ({ true, ) : baseAmount; - if (effect.kind === "dot" && effect.tag === "rom-self-health-drain" && effect.targetId) { + const totalTicks = ability?.effects.find((candidate) => candidate.kind === "dot")?.ticks ?? 12; + const tickNumber = totalTicks - remainingTicks + 1; + const amount = state.classId === "warlock" && ability?.name === "Curse of Agony" + ? warlockCurseOfAgonyTickAmount(unadjustedTickAmount, tickNumber) + : unadjustedTickAmount; + if (warPrayerSealTick && effect.targetId) { + const targetId = effect.targetId; + work.comboPoints = clamp((work.comboTargetId === targetId ? work.comboPoints : 0) + 1, 0, 4); + work.comboTargetId = targetId; + } else if (effect.kind === "dot" && effect.tag === "rom-self-health-drain" && effect.targetId) { if (effect.targetId === PLAYER_AURA_ENTITY_ID) { const drained = Math.min(work.health, amount); work.health -= drained; @@ -9454,7 +13069,7 @@ export const useCombatStore = create((set, get) => ({ ); } } - evaluateProcsInWork(work, { + if (!warPrayerSealTick) evaluateProcsInWork(work, { trigger: "periodic", subjectId: effect.sourceActorId, otherId: effect.targetId ?? PLAYER_AURA_ENTITY_ID, @@ -9462,7 +13077,7 @@ export const useCombatStore = create((set, get) => ({ amount, ...(effect.damageSchool ? { school: effect.damageSchool } : {}), }, nextTickAt); - if (ability) { + if (!warPrayerSealTick && ability) { const sourceActorId = effect.sourceActorId; const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party"; const origin = sourceActorId === PLAYER_AGGRO_ID @@ -9494,18 +13109,99 @@ export const useCombatStore = create((set, get) => ({ detonateLivingBombInWork(work, effect, nextTickAt - effect.intervalMs); } if (remainingTicks > 0 && timedEffectTargetAlive(effect)) { - work.effects.push( - remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt - ? effect - : { ...effect, remainingTicks, nextTickAt }, - ); + const updated = remainingTicks === effect.remainingTicks && nextTickAt === effect.nextTickAt + ? effect + : { ...effect, remainingTicks, nextTickAt }; + if (effect.kind === "dot" && effect.warlockSeed) { + const existing = work.effects.find((candidate) => candidate.id === effect.id); + if (existing) work.effects = work.effects.map((candidate) => ( + candidate.id === effect.id ? { ...updated, warlockSeed: existing.warlockSeed } : candidate + )); + } else work.effects.push(updated); } } - work.areaEffects = work.areaEffects.map((area) => { + work.areaEffects = work.areaEffects.flatMap((area) => { let nextTickAt = area.nextTickAt; const catalogAbility = abilityById(area.sourceAbilityId); const ability = catalogAbility ? abilityAtLevel(catalogAbility, state.level) : null; + if (state.classId === "hunter" && area.sourceActorId === PLAYER_AGGRO_ID && area.hunterTrap) { + const entrantId = nearbyMobRuntimeIds(area.position, area.hunterTrap.triggerRadius) + .filter((id) => { + const position = getMobPosition(id); + return Boolean(position + && !work.mobs[id]?.dead + && Math.sqrt(distanceSquared(area.position, position)) <= area.hunterTrap!.triggerRadius + && combatHasLineOfSight(area.position, position)); + }) + .sort((left, right) => ( + distanceSquared(area.position, getMobPosition(left) ?? area.position) + - distanceSquared(area.position, getMobPosition(right) ?? area.position) + || left.localeCompare(right) + ))[0]; + if (!entrantId) return [area]; + if (area.hunterTrap.kind === "freezing") { + const target = work.mobs[entrantId]; + const freezingArrow = work.abilities.find((candidate) => candidate.dbcSpellId === 60192); + if (target && freezingArrow) { + work.mobs[entrantId] = applyStatus(target, { + kind: "status", + status: "freeze", + durationMs: area.hunterTrap.freezeDurationMs ?? 20_000, + breakOnDamage: true, + }, freezingArrow.id, now, area.sourceActorId); + } + return []; + } + const explosiveTrap = ability; + if (!explosiveTrap) return []; + const directScaling = area.hunterTrap.directDamageScaling; + if (directScaling) { + const directDamage = talentAdjustedAmount( + equipmentAdjustedCombatAmount( + directScaling, + state.level, + state.gearStats, + state.classId, + explosiveTrap, + ), + work.talentModifiers, + "damage-percent", + explosiveTrap, + ); + for (const id of nearbyMobRuntimeIds(area.position, 10)) { + const position = getMobPosition(id); + if (!position || work.mobs[id]?.dead || !combatHasLineOfSight(area.position, position)) continue; + resolveAndDamageMobInWork( + state.classId, + work, + id, + directDamage, + now, + threatSourceForActor(state, area.sourceActorId, explosiveTrap.id, true), + "spell", + "fire", + `${area.id}:${id}:${now}:explosive-trap-impact`, + explosiveTrap, + false, + { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + ); + } + } + const periodic = explosiveTrap.effects.filter((effect) => effect.kind === "dot"); + const tickIntervalMs = periodic.length ? Math.min(...periodic.map((effect) => effect.intervalMs)) : 2_000; + const durationMs = periodic.length + ? Math.max(...periodic.map((effect) => effect.ticks * effect.intervalMs)) + : 20_000; + return [{ + ...area, + radius: 10, + expiresAt: now + durationMs, + tickIntervalMs, + nextTickAt: now + tickIntervalMs, + hunterTrap: undefined, + }]; + } while (ability && nextTickAt <= now && nextTickAt < area.expiresAt) { const resolvedFixedKinds = new Set<"dot" | "hot">(); for (const periodic of ability.effects) { @@ -9571,7 +13267,7 @@ export const useCombatStore = create((set, get) => ({ } nextTickAt += area.tickIntervalMs; } - return nextTickAt === area.nextTickAt ? area : { ...area, nextTickAt }; + return [nextTickAt === area.nextTickAt ? area : { ...area, nextTickAt }]; }); work.summons = work.summons.map((summon) => { @@ -9579,10 +13275,102 @@ export const useCombatStore = create((set, get) => ({ summon.health <= 0 || summon.controlledUntil > now || summon.cast - || summon.attackDamage <= 0 + || (summon.attackDamage <= 0 && !summon.healingPulse && !summon.antiMagicZone) ) return summon; const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now; const summonAbility = abilityById(summon.sourceAbilityId); + if (summon.antiMagicZone) { + if (!readyToAct) return summon; + applyDeathKnightAntiMagicZoneInWork( + work, + summon.position, + summon.antiMagicZone.radius, + Math.max(0, summon.expiresAt - now), + summon.antiMagicZone.capacityBase, + summon.antiMagicZone.capacityPerAttackPower, + summon.antiMagicZone.absorbFraction, + now, + state.playerPosition, + ); + return { ...summon, nextActionAt: now + summon.antiMagicZone.intervalMs }; + } + if (summon.kind === "totem" && summon.healingPulse) { + if (!readyToAct) return summon; + const partyMembers = usePartyStore.getState().members; + const inRange = [ + { + id: PLAYER_AURA_ENTITY_ID, + health: work.health, + maxHealth: work.maxHealth, + position: state.playerPosition, + }, + ...partyMembers.map((member) => { + const actor = ensureCompanionActorInWork(work, member.id); + return { + id: member.id, + health: actor?.health ?? member.health, + maxHealth: actor?.maxHealth ?? member.maxHealth, + position: getPartyRuntimePosition(member.id), + }; + }), + ].filter((candidate) => candidate.health > 0 + && candidate.health < candidate.maxHealth + && candidate.position + && Math.sqrt(distanceSquared(summon.position, candidate.position)) <= summon.healingPulse!.radius + && combatHasLineOfSight(summon.position, candidate.position)); + let totalPulseHealing = 0; + for (const recipient of inRange) { + const pulseBaseAmount = summonAbility + ? talentAdjustedAmount( + summon.healingPulse.amount, + state.talentModifiers, + "healing-percent", + summonAbility, + true, + ) + : summon.healingPulse.amount; + const requested = Math.round(resolveAuraHealingValue( + resolveAuraHealingValue( + pulseBaseAmount, + aurasForEntity(work.auras, summon.ownerActorId), + "done", + ), + aurasForEntity(work.auras, recipient.id), + "taken", + )); + const healed = recipient.id === PLAYER_AURA_ENTITY_ID + ? healPlayerInWork(work, requested) + : healCompanionActorInWork(work, recipient.id, requested); + if (healed <= 0) continue; + totalPulseHealing += healed; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "healing", + sourceActorId: summon.id, + targetActorId: recipient.id, + abilityId: summon.sourceAbilityId, + rawAmount: requested, + effectiveAmount: healed, + overheal: Math.max(0, requested - healed), + absorbed: 0, + critical: false, + }); + evaluateProcsInWork(work, { + trigger: "periodic", + subjectId: summon.ownerActorId, + otherId: recipient.id, + abilityId: summon.sourceAbilityId, + amount: healed, + }, now); + } + work.totalHealing += totalPulseHealing; + addHealingThreatInWork(work, { + actorId: summon.ownerActorId, + role: "healer", + abilityId: summon.sourceAbilityId, + }, totalPulseHealing); + return { ...summon, nextActionAt: now + summon.healingPulse.intervalMs }; + } if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) { if (!readyToAct) return summon; const partyMembers = usePartyStore.getState().members; @@ -9646,16 +13434,37 @@ export const useCombatStore = create((set, get) => ({ && Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5) && combatHasLineOfSight(position, targetPosition) ) { + const hunterPetAttack = state.classId === "hunter" + && summon.kind === "pet" + && summon.ownerActorId === PLAYER_AGGRO_ID; + const petAttackDamage = hunterPetAttack + ? Math.max(1, Math.round(resolveAuraDamageValue( + summon.attackDamage, + aurasForEntity(work.auras, summon.id), + { direction: "dealt", school: "physical", attackKind: "melee" }, + ))) + : summon.attackDamage; const result = damageMobInWork( state.classId, work, targetId, - summon.attackDamage, + petAttackDamage, now, { actorId: summonAbility?.name === "Shadowfiend" ? summon.id : summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId }, summonAbility?.name === "Shadowfiend" ? "shadow" : "physical", ); + if (hunterPetAttack && result.amount > 0) { + evaluateProcsInWork(work, { + trigger: "hit", + subjectId: summon.id, + otherId: targetId, + abilityId: summon.sourceAbilityId, + amount: result.amount, + school: "physical", + attackKind: "melee", + }, now); + } if (summonAbility?.name === "Shadowfiend" && result.amount > 0) { shadowfiendManaRestores.push({ ownerActorId: summon.ownerActorId, summonId: summon.id, abilityId: summon.sourceAbilityId, diff --git a/src/game/cooldownReset.ts b/src/game/cooldownReset.ts index e3f8e036..8b0ce161 100644 --- a/src/game/cooldownReset.ts +++ b/src/game/cooldownReset.ts @@ -9,10 +9,13 @@ export function cooldownResetCandidates( abilities: readonly AbilityDefinition[], sourceAbilityId: string, abilityName?: string, + excludeAbilityNames: readonly string[] = [], ): readonly AbilityDefinition[] { + const excludedNames = new Set(excludeAbilityNames.map((name) => name.toLowerCase())); + const eligible = abilities.filter((candidate) => !excludedNames.has(candidate.name.toLowerCase())); if (abilityName) { const normalizedName = abilityName.toLowerCase(); - return abilities.filter((candidate) => candidate.name.toLowerCase() === normalizedName); + return eligible.filter((candidate) => candidate.name.toLowerCase() === normalizedName); } - return abilities.filter((candidate) => candidate.id !== sourceAbilityId); + return eligible.filter((candidate) => candidate.id !== sourceAbilityId); } diff --git a/src/game/deathKnightEmpowerRuneWeapon.test.ts b/src/game/deathKnightEmpowerRuneWeapon.test.ts new file mode 100644 index 00000000..a937f0b1 --- /dev/null +++ b/src/game/deathKnightEmpowerRuneWeapon.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCombatStore } from "./combatStore"; + +describe("Death Knight Empower Rune Weapon runtime", () => { + beforeEach(() => { + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); + }); + + it("restores all six spent runes when cast", () => { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Empower Rune Weapon"); + expect(ability).toBeDefined(); + useCombatStore.setState((state) => ({ + deathKnightRunes: state.deathKnightRunes.map((rune) => ({ ...rune, readyAt: 11_000 })), + })); + + const result = useCombatStore.getState().castAbility(ability!.id, { origin: [0, 0, 0], now: 1_000 }); + + expect(result.ok).toBe(true); + expect(useCombatStore.getState().deathKnightRunes).toHaveLength(6); + expect(useCombatStore.getState().deathKnightRunes.every((rune) => rune.readyAt === 0)).toBe(true); + }); + + it("generates the source-listed 25 Runic Power when cast", () => { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Empower Rune Weapon"); + expect(ability).toBeDefined(); + useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } }); + + const result = useCombatStore.getState().castAbility(ability!.id, { origin: [0, 0, 0], now: 1_000 }); + + expect(result.ok).toBe(true); + expect(useCombatStore.getState().resource).toBe(25); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(25); + }); +}); diff --git a/src/game/deathKnightSourceRuntime.test.ts b/src/game/deathKnightSourceRuntime.test.ts new file mode 100644 index 00000000..057decad --- /dev/null +++ b/src/game/deathKnightSourceRuntime.test.ts @@ -0,0 +1,379 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { installCombatClock } from "./combatClock"; +import { createDeathKnightRunes } from "./combatActors"; +import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { talentNodesForTree } from "./talentCatalog"; + +let now = 1_000; +let restoreClock: (() => void) | undefined; + +function ability(name: string) { + const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + if (!found) throw new Error("Missing Death Knight ability: " + name); + return found; +} + +function learnActiveAbility(name: string) { + const found = ability(name); + if (found.talentNodeId) { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [found.talentNodeId!]: 1 }, + })); + } + return found; +} + +function registerTarget(id = "dk-target", position: readonly [number, number, number] = [2, 0, 0]) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 }); + setMobPosition(id, position); + useCombatStore.getState().selectMob(id); +} + +function selectPartyMember(index = 0, raceId: "undead" | "human" = "human") { + const initial = usePartyStore.getState().members[index]; + if (!initial) throw new Error("Expected a party member for the Death Knight test."); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === initial.id + ? { ...member, raceId, health: 1 } + : member), + })); + registerPartyRuntimePosition(initial.id, [2, 0, 0]); + usePartyStore.getState().selectMember(initial.id); + return initial.id; +} + +function setButchery(rank: number, runicPower = 0) { + const node = talentNodesForTree("death-knight-blood").find((candidate) => candidate.name === "Butchery"); + if (!node) throw new Error("Missing Death Knight Butchery talent."); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [node.id]: rank }, + resource: runicPower, + resourcePools: { ...state.resourcePools, "runic-power": runicPower }, + })); +} + +function registerXpTarget(id: string, xpReward: number) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1, xpReward }); + setMobPosition(id, [2, 0, 0]); +} + +describe("Death Knight source-backed runtime mechanics", () => { + beforeEach(() => { + now = 1_000; + restoreClock = installCombatClock(() => now); + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); + useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } }); + usePartyStore.getState().initializeParty("death-knight-source-runtime", 80, + useCombatStore.getState().health, "tank", 5); + }); + + afterEach(() => { + restoreClock?.(); + restoreClock = undefined; + }); + + it("heals a friendly Undead with Death Coil and rejects a living ally", () => { + const allyId = selectPartyMember(0, "human"); + const deathCoil = ability("Death Coil"); + const rejected = useCombatStore.getState().castAbility(deathCoil.id, { + targetActorId: allyId, + now, + }); + expect(rejected).toMatchObject({ ok: false, reason: "condition-not-met" }); + + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === allyId + ? { ...member, raceId: "undead" } + : member), + })); + now = 3_000; + const healed = useCombatStore.getState().castAbility(deathCoil.id, { + targetActorId: allyId, + now, + }); + expect(healed.ok).toBe(true); + expect(healed.healing).toBeGreaterThan(0); + expect(usePartyStore.getState().members.find((member) => member.id === allyId)!.health).toBeGreaterThan(1); + }); + + it("uses Death Coil's damaging branch against a hostile living target", () => { + registerTarget(); + const deathCoil = ability("Death Coil"); + const attempts = [1_000, 3_000, 5_000].map((castAt) => { + now = castAt; + useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } }); + return useCombatStore.getState().castAbility(deathCoil.id, { now }); + }); + expect(attempts.every((result) => result.ok)).toBe(true); + expect(attempts.some((result) => result.damage > 0)).toBe(true); + expect(attempts.every((result) => result.healing === 0)).toBe(true); + }); + + it("absorbs three quarters of magical damage up to half of maximum health", () => { + const shell = ability("Anti-Magic Shell"); + expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true); + const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID + && candidate.definition.name === "Anti-Magic Shell"); + expect(aura?.absorbs[0]).toMatchObject({ + remaining: Math.round(useCombatStore.getState().maxHealth * 0.5), + fraction: 0.75, + schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], + }); + + now += 1; + expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25); + expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura!.instanceId) + ?.absorbs[0].remaining).toBe(Math.round(useCombatStore.getState().maxHealth * 0.5) - 75); + }); + + it("blocks harmful magic controls during Anti-Magic Shell while allowing physical controls", () => { + const shell = ability("Anti-Magic Shell"); + expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true); + const addControlAttacker = (id: string, school: "shadow" | "physical") => { + useCombatStore.getState().registerMob(id, { + name: id, + maxHealth: 1_000, + xpReward: 0, + attacks: [{ + id: `${id}-stun`, + name: "Stunning attack", + delivery: "projectile", + target: "primary", + school, + animation: "attack", + range: 30, + cooldownMs: 1_000, + damageMultiplier: 0, + effect: { kind: "control", mechanic: "stun", durationMs: 5_000 }, + }], + }); + setMobPosition(id, [2, 0, 0]); + useCombatStore.getState().damageMob(id, 1, now); + return useCombatStore.getState().mobs[id].nextAttackAt; + }; + + const magicReadyAt = addControlAttacker("magic-stun", "shadow"); + useCombatStore.getState().advanceMobCombat(magicReadyAt, [0, 0, 0]); + expect(useCombatStore.getState().mobs["magic-stun"].attackRevision).toBe(1); + expect(useCombatStore.getState().controlledUntil).toBe(0); + + const physicalReadyAt = addControlAttacker("physical-stun", "physical"); + useCombatStore.getState().advanceMobCombat(physicalReadyAt, [0, 0, 0]); + expect(useCombatStore.getState().mobs["physical-stun"].attackRevision).toBe(1); + expect(useCombatStore.getState().controlledUntil).toBeGreaterThan(physicalReadyAt); + }); + + it("places Anti-Magic Zone at the selected point and protects party members within 10 yards", () => { + const inRangeAlly = selectPartyMember(0, "human"); + const outOfRangeAlly = usePartyStore.getState().members[1].id; + usePartyStore.setState((state) => ({ + members: state.members.map((member) => ({ ...member, health: 1 })), + })); + registerPartyRuntimePosition(inRangeAlly, [5, 0, 0]); + registerPartyRuntimePosition(outOfRangeAlly, [11, 0, 0]); + const antiMagicZone = learnActiveAbility("Anti-Magic Zone"); + const cast = useCombatStore.getState().castAbility(antiMagicZone.id, { + now, + groundPosition: [0, 0, 0], + }); + + expect(cast.ok).toBe(true); + expect(useCombatStore.getState().summons).toContainEqual(expect.objectContaining({ + creatureId: 28306, + position: [0, 0, 0], + expiresAt: now + 10_000, + antiMagicZone: expect.objectContaining({ radius: 10, absorbFraction: 0.75 }), + })); + const partyAuras = useCombatStore.getState().auras.filter((aura) => ( + aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone") + )); + expect(partyAuras.map((aura) => aura.targetId)).toEqual(expect.arrayContaining([ + PLAYER_AURA_ENTITY_ID, + inRangeAlly, + ])); + expect(partyAuras.map((aura) => aura.targetId)).not.toContain(outOfRangeAlly); + expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)?.absorbs[0]).toMatchObject({ + remaining: expect.any(Number), + fraction: 0.75, + schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], + }); + expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)!.absorbs[0].remaining).toBeGreaterThan(10_000); + + expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25); + expect(useCombatStore.getState().damagePlayer(100, "physical")).toBe(100); + + registerPartyRuntimePosition(inRangeAlly, [11, 0, 0]); + now += 250; + useCombatStore.getState().tick(0.25, now); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === inRangeAlly + && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone"))).toBe(false); + }); + + it("spends Bone Shield's four charges on landed damage while keeping its reduction non-stacking", () => { + const boneShield = learnActiveAbility("Bone Shield"); + const boneCast = useCombatStore.getState().castAbility(boneShield.id, { now }); + expect(boneCast.reason).toBeUndefined(); + expect(boneCast.ok).toBe(true); + const auraId = useCombatStore.getState().auras.find((aura) => aura.definition.name === "Bone Shield")!.instanceId; + + for (const remaining of [3, 2, 1]) { + now += 1; + expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8); + expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === auraId)?.stacks).toBe(remaining); + } + now += 1; + expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8); + expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false); + now += 1; + expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(10); + }); + + it("converts exactly one spent Blood Rune for Blood Tap without activating a Frost Rune", () => { + const initialRunes = createDeathKnightRunes().map((rune) => ( + rune.slot === 0 || rune.slot === 2 ? { ...rune, readyAt: now + 10_000 } : rune + )); + useCombatStore.setState({ deathKnightRunes: initialRunes }); + const bloodTap = ability("Blood Tap"); + expect(useCombatStore.getState().castAbility(bloodTap.id, { now }).ok).toBe(true); + + const runes = useCombatStore.getState().deathKnightRunes; + expect(runes.filter((rune) => rune.type === "death")).toEqual([ + expect.objectContaining({ slot: 0, baseType: "blood", readyAt: now, deathRuneExpiresAt: now + 20_000 }), + ]); + expect(runes.find((rune) => rune.slot === 2)).toMatchObject({ type: "frost", readyAt: now + 10_000 }); + expect(runes.filter((rune) => rune.readyAt <= now)).toHaveLength(5); + }); + + it("consumes Deathchill only on Icy Touch, Howling Blast, Frost Strike, or Obliterate", () => { + registerTarget(); + const deathchill = learnActiveAbility("Deathchill"); + expect(useCombatStore.getState().castAbility(deathchill.id, { now }).ok).toBe(true); + const auraId = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes("wow335-deathchill-next-critical") + ))!.instanceId; + + now = 3_000; + expect(useCombatStore.getState().castAbility(ability("Chains of Ice").id, { now }).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(true); + + now = 5_000; + const icyTouch = ability("Icy Touch"); + expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.abilityId === icyTouch.id && event.kind === "damage" && event.critical + ))).toBe(true); + }); + + it("heals Death Strike for five percent of maximum health per active disease", () => { + registerTarget(); + const icyTouch = ability("Icy Touch"); + const plagueStrike = ability("Plague Strike"); + const deathStrike = ability("Death Strike"); + expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true); + now = 3_000; + useCombatStore.setState((state) => ({ ...state, health: 1 })); + const oneDisease = useCombatStore.getState().castAbility(deathStrike.id, { now }); + expect(oneDisease.ok).toBe(true); + expect(oneDisease.healing).toBeGreaterThan(0); + + now = 5_000; + expect(useCombatStore.getState().castAbility(plagueStrike.id, { now }).ok).toBe(true); + useCombatStore.setState((state) => ({ + ...state, + health: 1, + deathKnightRunes: createDeathKnightRunes(now), + })); + now = 7_000; + const twoDiseases = useCombatStore.getState().castAbility(deathStrike.id, { now }); + expect(twoDiseases.ok).toBe(true); + expect(twoDiseases.healing).toBeGreaterThan(oneDisease.healing); + expect(twoDiseases.healing).toBeCloseTo(oneDisease.healing * 2, -1); + }); + + it.each([ + { rank: 1, expectedRunicPower: 10 }, + { rank: 2, expectedRunicPower: 20 }, + ])("grants Butchery rank $rank's runic power on an eligible player kill", ({ rank, expectedRunicPower }) => { + const targetId = `butchery-rank-${rank}`; + registerXpTarget(targetId, 1_000); + setButchery(rank); + + const result = useCombatStore.getState().damageMob(targetId, 10, now); + + expect(result.killed).toBe(true); + expect(useCombatStore.getState().resource).toBe(expectedRunicPower); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(expectedRunicPower); + }); + + it("caps Butchery at the runic power maximum", () => { + const targetId = "butchery-cap-target"; + registerXpTarget(targetId, 1_000); + setButchery(2, 95); + + useCombatStore.getState().damageMob(targetId, 10, now); + + expect(useCombatStore.getState().resource).toBe(100); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(100); + }); + + it("does not grant Butchery without the talent or for a target with no XP reward", () => { + const noTalentTargetId = "butchery-no-talent-target"; + registerXpTarget(noTalentTargetId, 1_000); + setButchery(0); + useCombatStore.getState().damageMob(noTalentTargetId, 10, now); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0); + + const noXpTargetId = "butchery-no-xp-target"; + registerXpTarget(noXpTargetId, 0); + setButchery(2); + useCombatStore.getState().damageMob(noXpTargetId, 10, now + 1); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0); + }); + + it("does not grant Butchery for a party or pet killing blow", () => { + setButchery(2); + const partyActorId = usePartyStore.getState().members[0].id; + for (const [targetId, actorId] of [ + ["butchery-party-target", partyActorId], + ["butchery-pet-target", "butchery-test-pet"], + ] as const) { + registerXpTarget(targetId, 1_000); + useCombatStore.getState().damageMob(targetId, 10, now, { + actorId, + role: "damage", + abilityId: "test-non-player-kill", + }); + } + + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0); + }); + + it("applies Improved Rune Tap's rank-specific healing and cooldown reductions", () => { + useCombatStore.setState((state) => ({ + ...state, + health: 1, + talentRanks: { ...state.talentRanks, "talent-1941": 1 }, + })); + const runeTap = ability("Rune Tap"); + const baseline = useCombatStore.getState().castAbility(runeTap.id, { now }); + expect(baseline.ok).toBe(true); + now = 3_000; + useCombatStore.setState((state) => ({ + ...state, + health: 1, + cooldowns: {}, + deathKnightRunes: createDeathKnightRunes(now), + talentRanks: { ...state.talentRanks, "talent-1942": 3 }, + })); + const improved = useCombatStore.getState().castAbility(runeTap.id, { now }); + expect(improved.ok).toBe(true); + expect(improved.healing).toBeCloseTo(baseline.healing * 2, -1); + expect(useCombatStore.getState().cooldowns[runeTap.id]).toBe(now + 30_000); + }); +}); diff --git a/src/game/healingAbilityRules.ts b/src/game/healingAbilityRules.ts index 15b04f3f..f9eae593 100644 --- a/src/game/healingAbilityRules.ts +++ b/src/game/healingAbilityRules.ts @@ -22,12 +22,15 @@ export function healingRecipientIds( if (!center) return []; const candidates = Object.values(actors) .filter(actor => actor.kind !== "enemy" && actor.alive && actor.health > 0) + .filter(actor => !effect.excludeCaster || actor.id !== sourceId) .filter(actor => { const position = positionFor(actor.id); return position && position.reduce((sum, value, index) => sum + (value - center[index]) ** 2, 0) <= (effect.radius ?? 30) ** 2 && hasLineOfSight(center, position); }) - .sort((a, b) => a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id)); + .sort((a, b) => effect.selection === "lowest-max-health" + ? a.maxHealth - b.maxHealth || a.id.localeCompare(b.id) + : a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id)); if (effect.selection === "random" && candidates.length > 0) { let hash = 2166136261; for (const value of `${seed}:${sourceId}:${targetId}`) hash = Math.imul(hash ^ value.charCodeAt(0), 16777619); diff --git a/src/game/inputManager.test.ts b/src/game/inputManager.test.ts index 8f60111c..fa1e30b3 100644 --- a/src/game/inputManager.test.ts +++ b/src/game/inputManager.test.ts @@ -81,6 +81,46 @@ function keyboardEvent(type: "keydown" | "keyup", code: string, modifiers: Parti } describe("remappable gameplay controls", () => { + it("allows editor movement and jumping while combat actions remain disabled", () => { + stubInputEventTargets(); + const next = inputActionSpies(true); + next.movementBlocked = () => false; + const dispose = installInput(next); + for (const [code, expected] of [ + ["KeyW", { moveX: 0, moveForward: 1 }], ["KeyS", { moveX: 0, moveForward: -1 }], + ["KeyA", { moveX: -1, moveForward: 0 }], ["KeyD", { moveX: 1, moveForward: 0 }], + ] as const) { + keyboardEvent("keydown", code); expect(readInputSnapshot()).toMatchObject(expected); + keyboardEvent("keyup", code); expect(readInputSnapshot()).toMatchObject({ moveX: 0, moveForward: 0 }); + } + keyboardEvent("keydown", "Space"); expect(consumeJumpRequest()).not.toBeNull(); + keyboardEvent("keyup", "Space"); + for (const code of ["Digit1", "KeyE", "Tab", "F1"]) keyboardEvent("keydown", code); + expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.lootActiveBoss).not.toHaveBeenCalled(); + expect(next.cycleTarget).not.toHaveBeenCalled(); expect(next.setPartyCommand).not.toHaveBeenCalled(); + setExternalControllerMovement({ moveX: .7, moveY: -1, lookX: 0, lookY: 0 }); + expect(readInputSnapshot().moveForward).toBeGreaterThan(0); + emitControllerToken({ token: "Button0", repeat: false, pressed: true }); + expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.lootActiveBoss).not.toHaveBeenCalled(); + dispose(); + }); + it("still clears held editor movement on panel focus and blocking overlays", () => { + stubInputEventTargets(); + const next = inputActionSpies(true); + next.movementBlocked = vi.fn(() => false); next.inputSuspended = vi.fn(() => false); + const dispose = installInput(next); + keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(1); + (next.inputSuspended as ReturnType).mockReturnValue(true); + expect(readInputSnapshot().moveForward).toBe(0); + (next.inputSuspended as ReturnType).mockReturnValue(false); + expect(readInputSnapshot().moveForward).toBe(0); + keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(1); + (next.movementBlocked as ReturnType).mockReturnValue(true); + expect(readInputSnapshot().moveForward).toBe(0); + (next.movementBlocked as ReturnType).mockReturnValue(false); + expect(readInputSnapshot().moveForward).toBe(0); + dispose(); + }); it("keeps moving forward when a legacy WoW skill also owns W", () => { stubInputEventTargets(); const next = inputActionSpies(); const preferences = normalizeInputPreferences(null); diff --git a/src/game/inputManager.ts b/src/game/inputManager.ts index b1dd9973..cd44e4ea 100644 --- a/src/game/inputManager.ts +++ b/src/game/inputManager.ts @@ -46,6 +46,8 @@ export interface InputActions { confirmGroundTarget: () => void; cancelGroundTarget: () => void; gameplayActionsBlocked: () => boolean; + /** Editors may disable combat actions while retaining movement and camera controls. */ + movementBlocked?: () => boolean; setCameraLookActive: (active: boolean) => void; setInputMode: (mode: "keyboard" | "gamepad") => void; setActionLayer: (layer: ActionBindingLayer) => void; @@ -243,6 +245,7 @@ export function installInput(nextActions: InputActions): () => void { actions = nextActions; const keyboardModifiers = new Set(); const suspended = () => isCapturingInput() || nextActions.inputSuspended?.(); + const movementBlocked = () => nextActions.movementBlocked?.() ?? nextActions.gameplayActionsBlocked(); const publishActionLayer = () => { const held = getControllerHeldTokens(); const layerHeld = (layer: string) => Object.entries(bindingsFor("controller")).some(([id, key]) => { @@ -265,6 +268,7 @@ export function installInput(nextActions: InputActions): () => void { }; const candidates = (device: InputDevice) => INPUT_ACTIONS.filter((entry) => { if (entry.context === "global" || entry.context === "menu" || entry.context === "ability-editor") return false; + if (entry.continuous || entry.id === "jump") return !movementBlocked(); if (entry.context === "ground") return nextActions.groundTargetActive() && !nextActions.gameplayActionsBlocked(); if (entry.id.startsWith("bar:") && (!nextActions.desktopActionBarsActive?.() || Number(entry.id.split(":")[1]) >= 5)) return false; if (device === "pc" && entry.id.startsWith("ability:") && nextActions.desktopActionBarsActive?.()) return false; @@ -303,11 +307,11 @@ export function installInput(nextActions: InputActions): () => void { const key = inputKeyForEvent(event); if (!key) return; const bindings = bindingsFor("pc"); - if (nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return; + if (movementBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return; const matches = candidates("pc").filter((entry) => bindings[entry.id] === key) .sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground")); // Preserve ordinary movement while a modifier is held (e.g. Shift + W). - const continuous = !nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key)); + const continuous = !movementBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key)); if (continuous) { keys.add(event.code); keys.add(key); event.preventDefault(); nextActions.setInputMode("keyboard"); } if (!matches.length) return; const entry = matches[0]; @@ -389,7 +393,7 @@ export function consumeMouseLook(): readonly [number, number] { } export function readInputSnapshot(): InputSnapshot { - if (isCapturingInput() || actions?.inputSuspended?.() || actions?.gameplayActionsBlocked()) { + if (isCapturingInput() || actions?.inputSuspended?.() || (actions?.movementBlocked?.() ?? actions?.gameplayActionsBlocked())) { keys.clear(); return { moveX: 0, moveForward: 0, lookX: 0, lookY: 0, gamepadActive: false }; } diff --git a/src/game/mageBlinkRuntime.test.ts b/src/game/mageBlinkRuntime.test.ts new file mode 100644 index 00000000..c93ef7bc --- /dev/null +++ b/src/game/mageBlinkRuntime.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; + +describe("Mage Blink runtime", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + }); + + it.each(["root", "stun"] as const)("clears active %s control on a successful cast", (mechanic) => { + useCombatStore.setState({ controlledUntil: 10_000, controlMechanic: mechanic }); + + const result = useCombatStore.getState().castAbility("wow335-mage-blink", { + origin: [0, 0, 0], + now: 1_000, + }); + + expect(result).toMatchObject({ ok: true, abilityId: "wow335-mage-blink" }); + expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null }); + expect(useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID)) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ + expiresAt: 2_000, + definition: expect.objectContaining({ controlImmunities: ["root"] }), + }), + expect.objectContaining({ + expiresAt: 2_000, + definition: expect.objectContaining({ controlImmunities: ["stun"] }), + }), + ])); + }); + + it("does not move away from a target when forward facing is unavailable", () => { + useCombatStore.getState().registerMob("blink-target", { + name: "Blink target", + maxHealth: 100, + xpReward: 0, + }); + setMobPosition("blink-target", [5, 0, 0]); + useCombatStore.getState().selectMob("blink-target"); + + const result = useCombatStore.getState().castAbility("wow335-mage-blink", { + origin: [0, 0, 0], + now: 1_000, + }); + + expect(result.ok).toBe(true); + expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 0]); + }); +}); diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index 62a8bb36..3aa8888c 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -84,7 +84,7 @@ import { ROGUE_DEADLY_POISON_DOSE_TAG, ROGUE_ENVENOM_BUFF_TAG, } from "./roguePoison"; -import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics"; +import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics"; import { romCreatureFamilyTargetTags } from "./romCreatureFamilies"; import { romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides"; import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset } from "./romMageWarlockOverrides"; @@ -797,7 +797,7 @@ function partyAbilityTargetIsValid( && aura.definition.utilityTags?.includes("rom-charged"))) return false; if (ability.dbcSpellId === 491172 && !combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("rom-static-field-charge"))) return false; - if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) { + if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability) || romAbilityNeedsRangedWeapon(ability)) { const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, @@ -805,6 +805,7 @@ function partyAbilityTargetIsValid( }); if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") return false; if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") return false; + if (romAbilityNeedsRangedWeapon(ability) && !loadout.ranged) return false; } } const targetPosition = hostile ? getMobPosition(targetId) : partyFriendlyPosition(targetId); @@ -1239,6 +1240,10 @@ function executePartyAbility( specializationId: member.specializationId, role: member.role, level: member.level, })["main-hand"]?.weaponType === "dagger", casterMaxHealth: member.maxHealth, + targetHealthPercent: combat.mobs[targetId] + ? combat.mobs[targetId]!.health / Math.max(1, combat.mobs[targetId]!.maxHealth) + : 1, + naturePower: member.resourcePools["nature-power"] ?? 0, passiveTags: combat.auras.filter((aura) => aura.targetId === member.id) .flatMap((aura) => aura.definition.utilityTags ?? []).concat( abilitiesForCharacter(member.classId, member.secondaryClassId) @@ -1455,9 +1460,10 @@ function executePartyAbility( } } else if (effect.kind === "resource") { for (const friendlyTargetId of friendlyTargets.length ? friendlyTargets : [member.id]) { + const resource = effect.resource ?? member.resourceType; effectiveAmount += useCombatStore.getState().restoreActorResource( friendlyTargetId, - effect.resource ?? member.resourceType, + resource, effect.amount, effect.percentage, member.id, @@ -1473,12 +1479,20 @@ function executePartyAbility( runtimeMember, useCombatStore.getState().actors[member.id], ); + const restoredPool = resourceMirror.resourcePools[resource]; + if (restoredPool === undefined) continue; runtimeMember = { ...runtimeMember, - resource: resourceMirror.resource, - maxResource: resourceMirror.maxResource, - resourcePools: resourceMirror.resourcePools, - lastResourceSpentAt: resourceMirror.lastResourceSpentAt, + ...(resource === runtimeMember.resourceType + ? { resource: restoredPool, maxResource: resourceMirror.maxResource } + : {}), + resourcePools: { ...runtimeMember.resourcePools, [resource]: restoredPool }, + ...(resourceMirror.lastResourceSpentAt[resource] === undefined + ? {} + : { lastResourceSpentAt: { + ...runtimeMember.lastResourceSpentAt, + [resource]: resourceMirror.lastResourceSpentAt[resource], + } }), }; } } diff --git a/src/game/partyStore.ts b/src/game/partyStore.ts index 5715217b..40150c86 100644 --- a/src/game/partyStore.ts +++ b/src/game/partyStore.ts @@ -44,6 +44,7 @@ import type { PartyRole, PartySize } from "./partyRoles"; import { abilitiesForCharacter, learnedAbilityForBuild, + naturePowerMaximumForCharacter, resourceProfileForClass, type ResourceType, } from "./abilityCatalog"; @@ -251,8 +252,10 @@ function memberMaxResource( : profile.maximum; } -function secondaryResourceMaximum(type: ResourceType): number { - if (type === "nature-power") return 10; +function secondaryResourceMaximum(type: ResourceType, member: PartyMember): number { + if (type === "nature-power") { + return naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level); + } if (type === "psi") return 6; return 100; } @@ -279,7 +282,7 @@ function withMemberAbilityResourcePools(member: PartyMember): PartyMember { resourcePools[type] = member.resource; continue; } - const maximum = secondaryResourceMaximum(type); + const maximum = secondaryResourceMaximum(type, member); resourcePools[type] = Math.max(0, Math.min( maximum, member.resourcePools[type] ?? initialSecondaryResource(type, maximum), diff --git a/src/game/romAbilityCatalog.ts b/src/game/romAbilityCatalog.ts index 21feced0..cf8ebf9e 100644 --- a/src/game/romAbilityCatalog.ts +++ b/src/game/romAbilityCatalog.ts @@ -15,8 +15,12 @@ import { type RomSourceComponent, } from "./romAbilitySemantics"; import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides"; +import { overrideRomKnightEffects } from "./romKnightOverrides"; +import { overrideRomWarriorEffects } from "./romWarriorOverrides"; import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides"; import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides"; +import { overrideRomWarlockEffects } from "./romWarlockOverrides"; +import { overrideRomDruidEffects } from "./romDruidOverrides"; import { romPriestDescription } from "./romPriestTooltips"; export type RomSkillGroup = "primary" | "general" | "elite" | "passive"; @@ -89,7 +93,38 @@ function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] { })); } -type GeneratedSemanticRow = Pick; +function costsFor(row: Pick): readonly AbilityCost[] { + let resolved = costs(row.costs); + if (row.sourceSkillId === 490136) { + resolved = resolved.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost); + } + if (![493536, 494217].includes(row.sourceSkillId) || resolved.some((entry) => entry.resource === "nature-power")) return resolved; + return [...resolved, { resource: "nature-power", amount: 1 }]; +} + +function cooldownMsFor(row: Pick): number { + // The local snapshot omitted Prayer of Tribulation's timing. CoA's current + // skill list identifies this same Priest/Warlock spell (498770) as a 1.5s + // channel with a 5s cooldown; its healing and buff payload still come from + // the local RuneWaker tooltip/components below. + if (row.sourceSkillId === 498770) return 5_000; + return row.sourceSkillId === 490136 ? 40_000 : row.cooldownMs; +} + +function castTimeMsFor(row: Pick): number { + if (row.sourceSkillId === 498770) return 1_500; + if (row.sourceSkillId === 491164) return 4_000; + return row.castTimeMs; +} + +function castModeFor(row: Pick): AbilityDefinition["castMode"] { + return row.sourceSkillId === 491164 || row.sourceSkillId === 498770 + ? "channel" + : row.castTimeMs > 0 ? "cast" : "instant"; +} + +type GeneratedSemanticRow = Pick + & Partial>; function slug(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "effect"; @@ -128,6 +163,16 @@ function auraRecipient(row: GeneratedSemanticRow, semantic: Extract): GeneratedSemanticRow["target"] { + if ([490290, 490491, 492636, 493531, 494021, 494027, 498641].includes(row.sourceSkillId)) return "friendly"; + // RuneWaker's tooltip calls this a four-pulse heal on a friendly target; + // sourceTarget 7 is stale/conflicting and was incorrectly making it hostile. + if (row.sourceSkillId === 498770) return "friendly"; + if (row.sourceSkillId === 490239) return "hostile"; + if (row.sourceSkillId === 494217) return "self"; + return row.target; +} + const MAGIC_SCHOOLS: readonly DamageSchool[] = ["arcane", "fire", "frost", "holy", "nature", "shadow"]; function percent(value: number): number { @@ -340,7 +385,10 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic, if (semantic.kind === "aura") { const recipient = auraRecipient(row, semantic); const modifiers = auraModifiers(semantic); - const partyScope = /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description); + const partyScope = [490248, 491335, 491644, 494027, 498201, 498202, 498219, 498649, 498719] + .includes(row.sourceSkillId) + || (row.sourceSkillId === 498722 && semantic.sourceComponentIds.includes(621463)) + || /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description); const aura: AuraDefinition = { id: `rom-aura-${effectId}-${slug(semantic.effect)}`, name: `${row.name}: ${semantic.effect}`, @@ -354,7 +402,12 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic, ? { utilityTags: ["stealth"] } : {}), }; - return { kind: "apply-aura", aura, recipient, ...(partyScope ? { scope: "party" as const } : {}), sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0 }; + return { + kind: "apply-aura", aura, recipient, + ...(partyScope ? { scope: "party" as const } : {}), + ...(row.sourceSkillId === 498719 ? { excludeCaster: true } : {}), + sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0, + }; } if (semantic.kind === "absorb") { if (semantic.amount <= 0 && semantic.perSkillLevel <= 0) { @@ -369,7 +422,11 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic, dispelCategory: "magic", absorbs: [{ id: `rom-absorb-pool-${effectId}`, amount: Math.max(0, semantic.amount + semantic.perSkillLevel), school: combatSchool(semantic.school) }], }; - return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0 }; + return { + kind: "apply-aura", aura, recipient, + ...([491513, 494027, 498201].includes(row.sourceSkillId) ? { scope: "party" as const } : {}), + sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0, + }; } if (semantic.kind === "proc") { const recipient = auraRecipient(row, semantic); @@ -390,7 +447,10 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic, return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: semantic.triggerSkillId }; } if (semantic.kind === "resource") { - const resourceType = knownResource(semantic.resource); + const manaPercentRegen = semantic.resource === "mana-percent" + && semantic.intervalSeconds !== null + && semantic.intervalSeconds > 0; + const resourceType = manaPercentRegen ? "mana" : knownResource(semantic.resource); if (semantic.intervalSeconds && semantic.intervalSeconds > 0 && resourceType) { const amountPerSecond = semantic.amount / semantic.intervalSeconds; const aura: AuraDefinition = { @@ -398,15 +458,31 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic, name: row.name, disposition: semantic.operation === "drain" ? "debuff" : "buff", durationMs: semantic.durationSeconds && semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null, - modifiers: [{ - kind: "resource", - resource: resourceType, - property: "regeneration", - operation: "flat", - value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond, - }], + modifiers: manaPercentRegen + ? [{ + kind: "stat", + stat: "mana-regeneration-max-percent-per-second", + operation: "flat", + value: (semantic.operation === "drain" ? -amountPerSecond : amountPerSecond) / 100, + }] + : [{ + kind: "resource", + resource: resourceType, + property: "regeneration", + operation: "flat", + value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond, + }], + }; + return { + kind: "apply-aura", + aura, + recipient: row.sourceSkillId === 498529 || row.target === "self" ? "caster" : "ability-target", + ...(row.sourceSkillId === 491644 ? { scope: "party" as const } : {}), + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: semantic.amount, + triggerSpellId: 0, }; - return { kind: "apply-aura", aura, recipient: row.target === "hostile" ? "ability-target" : "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: semantic.amount, triggerSpellId: 0 }; } if (semantic.operation === "drain") return { kind: "resource-drain", @@ -462,7 +538,137 @@ function stateAuraEffect( }; } +function targetStateAuraEffect(row: GeneratedSemanticRow, stateId: string, name: string, duration: number): AbilityEffect { + return { + kind: "apply-aura", + recipient: "ability-target", + aura: { + id: `rom-state-${stateId}`, + name, + disposition: "debuff", + durationMs: duration, + utilityTags: [`rom-${stateId}`], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: row.sourceSkillId, + }; +} + function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null { + if (row.classId === "rom-warden" && row.sourceSkillId === 494610) { + // This passive is a trigger marker; its source damage reduction is applied + // to Frantic Briar's target, not permanently to the Warden. + return [stateAuraEffect(row, "will-attack", row.name, null)]; + } + if (row.classId === "rom-warden" && row.sourceSkillId === 493344) { + return translateRomAbilitySemantics(row).map((semantic, index) => { + const effect = semanticEffect(row, semantic, index); + return effect.kind === "summon" + ? { ...effect, durationMs: Number.MAX_SAFE_INTEGER } + : effect; + }); + } + if (row.classId === "rom-warden" && row.sourceSkillId === 491668) { + let keptAbsorb = false; + return translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index)) + .filter((effect) => { + if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return true; + if (keptAbsorb) return false; + keptAbsorb = true; + return true; + }) + .map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length + ? { + ...effect, + recipient: "caster" as const, + aura: { + ...effect.aura, + absorbs: effect.aura.absorbs.map((absorb) => ({ + ...absorb, + amount: 0, + hitCharges: 2, + })), + }, + } + : effect); + } + if (row.classId === "rom-champion" && row.sourceSkillId === 498564) { + return [ + ...translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index)), + { kind: "pull", toward: "caster" }, + ]; + } + if (row.sourceSkillId === 494611) { + const modifiers = [...new Map(translateRomAbilitySemantics(row) + .filter((semantic): semantic is Extract => semantic.kind === "aura") + .flatMap(auraModifiers) + .map((modifier) => [JSON.stringify(modifier), modifier] as const)).values()]; + if (!modifiers.length) return null; + const aura: AuraDefinition = { + id: "rom-coat-of-arms", + name: row.name, + disposition: "buff", + durationMs: durationMs(Math.max(0, ...(row.source?.components ?? []).map((component) => Number(component.effectTime ?? 0)))), + dispelCategory: "magic", + modifiers, + }; + const apply = (recipient: "caster" | "ability-target"): AbilityEffect => ({ + kind: "apply-aura", + aura, + recipient, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + }); + return [apply("caster"), apply("ability-target")]; + } + if (row.sourceSkillId === 491173) { + return [stateAuraEffect(row, "cripple-joint", row.name, null, { + procs: [{ + id: "rom-cripple-joint-root", + triggers: ["hit", "crit"], + chance: 0.55, + action: { kind: "custom", id: "rom-cripple-joint" }, + }], + })]; + } + if (row.sourceSkillId === 491351) { + return [{ + kind: "apply-aura", + aura: { + id: "rom-free-will-immunity", + name: row.name, + disposition: "buff", + durationMs: durationMs(row.source?.components?.[0]?.effectTime ?? 0), + controlImmunities: ["root", "slow"], + }, + recipient: "ability-target", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + }]; + } + if (row.sourceSkillId === 498570) { + return [{ + kind: "apply-aura", + aura: { + id: "rom-overrule-immunity", + name: row.name, + disposition: "buff", + durationMs: durationMs(row.source?.components?.[0]?.effectTime ?? 3), + controlImmunities: ["fear", "confuse", "sleep", "stun", "root", "slow"], + }, + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + }]; + } if (row.sourceSkillId === 498744) { return [{ kind: "cooldown-reset", mode: "reset", abilityName: "Electrostatic Charge" }]; } @@ -524,10 +730,88 @@ function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] { const specialized = specializedEffects(row); if (specialized) return specialized; - const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index)); - const pairOverride = overrideRomPriestKnightEffects(row, translated) + const semantics = translateRomAbilitySemantics(row); + const translated = semantics.map((semantic, index) => semanticEffect(row, semantic, index)) + .map((effect) => row.sourceSkillId === 493642 && effect.kind === "apply-aura" ? { + ...effect, + aura: { + ...effect.aura, + utilityTags: [...(effect.aura.utilityTags ?? []), "rom-briar-shield"], + }, + } : effect); + const druidOverride = row.classId === "rom-druid" ? overrideRomDruidEffects(row, translated) : null; + if (druidOverride) return druidOverride; + if (row.sourceSkillId === 498825) return [ + ...translated, + { kind: "cooldown-reset", mode: "reset", abilityName: "Lightning" }, + { kind: "cooldown-reset", mode: "reset", abilityName: "Discharge" }, + ]; + if (row.sourceSkillId === 491606 || row.sourceSkillId === 490078) { + const damageComponentId = row.sourceSkillId === 491606 ? 502050 : 502102; + const triggerComponentId = row.sourceSkillId === 491606 ? 502049 : 502103; + const triggerComponent = row.source?.components?.find((component) => component.id === triggerComponentId); + const intervalSeconds = triggerComponent?.triggers?.onTimeSeconds ?? (row.sourceSkillId === 491606 ? 2 : 3); + const durationSeconds = triggerComponent?.effectTime ?? (row.sourceSkillId === 491606 ? 6 : 18); + const ticks = Math.max(1, Math.round(durationSeconds / intervalSeconds)); + const damageSemanticIndex = semantics.findIndex((semantic) => semantic.kind === "damage" + && semantic.sourceComponentIds.includes(damageComponentId)); + const damageSemantic = semantics[damageSemanticIndex]; + const damageEffect = damageSemantic?.kind === "damage" + ? semanticEffect(row, damageSemantic, damageSemanticIndex) + : null; + const periodicDamage: AbilityEffect | null = damageEffect?.kind === "damage" && row.sourceSkillId === 491606 + ? { + kind: "dot", + coefficient: damageEffect.coefficient, + ...(damageEffect.school ? { school: damageEffect.school } : {}), + ...(damageEffect.basePoints === undefined ? {} : { basePoints: damageEffect.basePoints }), + ...(damageEffect.dieSides === undefined ? {} : { dieSides: damageEffect.dieSides }), + ...(damageEffect.pointsPerLevel === undefined ? {} : { pointsPerLevel: damageEffect.pointsPerLevel }), + ...(damageEffect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: damageEffect.coefficientPerLevel }), + ...(damageEffect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: damageEffect.bonusPowerCoefficient }), + ...(damageEffect.sourceLevel === undefined ? {} : { sourceLevel: damageEffect.sourceLevel }), + ticks, + intervalMs: durationMs(intervalSeconds), + tag: "rom-knight-holy-light-domain", + } + : row.sourceSkillId === 490078 + ? { + kind: "dot", + coefficient: 0, + school: "holy", + basePoints: 0, + pointsPerLevel: 0, + sourceLevel: 1, + ticks, + intervalMs: durationMs(intervalSeconds), + tag: "rom-knight-war-prayer-seal", + } + : null; + if (!periodicDamage) return translated; + const output: AbilityEffect[] = [periodicDamage]; + if (row.sourceSkillId === 490078 && damageEffect?.kind === "damage") output.unshift(damageEffect); + if (row.sourceSkillId === 491606) { + semantics.forEach((semantic, index) => { + if (!semantic.sourceComponentIds.includes(502051)) return; + output.push(semanticEffect(row, semantic, index)); + }); + } + return output; + } + if (row.sourceSkillId === 491133) { + const vulnerableDuration = row.source?.components?.find((component) => component.id === 501502)?.effectTime ?? 6; + return [...translated, targetStateAuraEffect(row, "vulnerable", "Vulnerable", durationMs(vulnerableDuration))]; + } + if (row.sourceSkillId === 491136) { + const weakenedDuration = row.source?.components?.find((component) => component.id === 501503)?.effectTime ?? 6; + return [...translated, targetStateAuraEffect(row, "weakened", "Weakened", durationMs(weakenedDuration))]; + } + const pairOverride = overrideRomKnightEffects(row, translated) + ?? overrideRomWarriorEffects(row, translated) + ?? overrideRomPriestKnightEffects(row, translated) ?? overrideRomScoutRogueEffects(row, translated) - ?? overrideRomMageWarlockEffects(row, translated); + ?? overrideRomMageWarlockEffects(row, translated) + ?? overrideRomWarlockEffects(row, translated); if (pairOverride) return pairOverride; const outputStrength = (effect: AbilityEffect): number => ( "coefficient" in effect ? effect.coefficient : 0 @@ -541,6 +825,42 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] { const holySealDot = (strongest: boolean): readonly AbilityEffect[] => oneOutput("dot", strongest).map((effect) => ( effect.kind === "dot" ? { ...effect, tag: "rom-holy-seal" } : effect )); + if (row.sourceSkillId === 494025) { + return oneOutput("damage", true).map((effect) => effect.kind === "damage" + ? { ...effect, falloffPerJump: 0.7 } + : effect); + } + if (row.sourceSkillId === 499558) { + const shot = semantics.find((semantic): semantic is Extract => ( + semantic.kind === "damage" && semantic.sourceComponentIds.includes(620194) + )); + const healSource = semantics.find((semantic) => semantic.sourceComponentIds.includes(501888)); + const output: AbilityEffect[] = []; + if (shot) { + const shotEffect = semanticEffect(row, shot, semantics.indexOf(shot)); + if (shotEffect.kind === "damage") output.push(shotEffect); + } + if (healSource && (healSource.kind === "damage" || healSource.kind === "heal")) { + const healSemantic: RomAbilitySemantic = { ...healSource, kind: "heal" }; + const healEffect = semanticEffect({ ...row, target: "friendly" }, healSemantic, semantics.indexOf(healSource)); + if (healEffect.kind === "heal") output.push({ + ...healEffect, + recipient: "ability-target", + scope: "party", + maximumTargets: 1, + selection: "lowest-max-health", + }); + } + return output; + } + if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warden" && row.sourceSkillId === 494323) { + return translated.filter((effect) => effect.kind === "heal").slice(0, 2).map((effect) => effect.kind === "heal" + ? { ...effect, falloffPerJump: 0.5 } + : effect); + } + // Source order is the Nature's Power enhanced HoT (504529), then the + // unconditional baseline HoT (620446). Runtime selects exactly one. + if (row.sourceSkillId === 494021) return translated.filter((effect) => effect.kind === "hot").slice(0, 2); // These source rows carry alternate/duplicate payloads, while their player // tooltips specify a single projectile hit. The first attack component is // the primary shot; executing every component also applies unrelated Shot @@ -576,6 +896,26 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] { aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] }, } : effect); } + if (row.sourceSkillId === 498770) { + const heal = translated.find((effect) => effect.kind === "heal"); + const buffs = translated.filter((effect) => effect.kind === "apply-aura"); + if (!heal) return translated; + const buff = buffs[0]; + const defenseModifiers = buffs.flatMap((effect) => effect.kind === "apply-aura" + ? effect.aura.modifiers ?? [] + : []); + // Local text says four heals; preserve that count as four separate channel + // payloads so the runtime emits one equal portion on each of four pulses. + return [ + ...Array.from({ length: 4 }, () => heal.kind === "heal" + ? { ...heal, recipient: "ability-target" as const } + : heal), + ...(buff?.kind === "apply-aura" ? [{ + ...buff, + aura: { ...buff.aura, modifiers: defenseModifiers }, + }] : []), + ]; + } if (row.sourceSkillId === 490506 || row.sourceSkillId === 490218) { return translated.filter((effect) => effect.kind === "apply-aura"); } @@ -615,7 +955,10 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] { return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }]; } if (row.sourceSkillId === 490438) { - return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }]; + // Neck Strike has a six-second NPC stun (component 500877); the separate + // 503863 component supplies the reduced three-second player duration. + const durationMsForTarget = durationMs(row.source?.components?.find((component) => component.id === 500877)?.effectTime ?? 6); + return [{ kind: "status", status: "stun", durationMs: durationMsForTarget, maxTargets: 1, breakOnDamage: true }]; } if (row.sourceSkillId === 491172) { return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }]; @@ -673,12 +1016,30 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] { function areaRadius(row: Pick): number | undefined { if (row.description.includes("up to 3 targets") && row.description.includes("Inflicts")) return 6; - if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) within (?:a )?range)\b/i.test(row.description)) return undefined; - const sourceRange = row.description.match(/\b(?:range|radius) of\s+(\d+(?:\.\d+)?)/i)?.[1]; + if (!/\b(?:multiple targets|multiple enemies|all targets|all enemies|nearby targets|nearby party members|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) (?:within (?:a )?range|in a range|in range))\b/i.test(row.description)) return undefined; + const sourceRange = row.description.match(/\b(?:range|radius)\s+(?:of\s+)?(\d+(?:\.\d+)?)/i)?.[1]; return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range > 1 ? row.range : 10)); } function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"] | undefined { + if (sourceSkillId === 490311 || sourceSkillId === 490337) return { shape: "unit", requiresBehindTarget: true }; + if (sourceSkillId === 490239) return { shape: "line", radius: 12 }; + if (sourceSkillId === 498201 || sourceSkillId === 498202) return { shape: "circle", radius: 7.5 }; + if (sourceSkillId === 490136) return { shape: "circle", radius: 10 }; + if (sourceSkillId === 491513) return { shape: "circle", radius: 5, maximumTargets: 6 }; + if (sourceSkillId === 492636) return { shape: "circle", radius: 20, maximumTargets: 5 }; + if (sourceSkillId === 494021) return { shape: "circle", radius: 20, maximumTargets: 5 }; + if (sourceSkillId === 494026) return { shape: "circle", radius: 12 }; + if (sourceSkillId === 494027) return { shape: "circle", radius: 6, maximumTargets: 3 }; + if (sourceSkillId === 494217) return { shape: "circle", radius: 5 }; + if (sourceSkillId === 494025) return { shape: "chain", maximumTargets: 3, jumpRange: 6 }; + if (sourceSkillId === 494323) return { shape: "circle", radius: 6, maximumTargets: 3 }; + if (sourceSkillId === 499936) return { shape: "circle", radius: 10 }; + if (sourceSkillId === 498641) return { shape: "circle", radius: 20, maximumTargets: 6 }; + if (sourceSkillId === 493540) return { shape: "line" }; + if (sourceSkillId === 498658) return { shape: "line", maximumTargets: 8 }; + if (sourceSkillId === 498721) return { shape: "circle", radius: 10 }; + if (sourceSkillId === 498534) return { shape: "cone", radius: 10, attackArcRadians: Math.PI / 2 }; if (sourceSkillId === 490400) return { shape: "line", maximumTargets: 8 }; if (sourceSkillId === 490457) return { shape: "chain", maximumTargets: 3, jumpRange: 6 }; if (sourceSkillId === 492920) return { shape: "circle", radius: 5, maximumTargets: 8 }; @@ -686,22 +1047,32 @@ function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"] return undefined; } +function sourceRange(sourceSkillId: number, fallback: number): number { + // RoM tooltip ranges are expressed in decimeters; Phoenix's source row is + // self-targeted, so use its described 120-unit path length for line casting. + return sourceSkillId === 490239 ? 12 : fallback; +} + function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition { - const rankCosts = costs(row.costs); + const rankCosts = costsFor(row); + const target = abilityTarget({ ...row, target: row.target ?? parent.target }); return { rank: row.rank, spellId: row.sourceSkillId, level: row.level, description: parent.classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description, - castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant", - castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs, - cooldownMs: row.cooldownMs, + castMode: castModeFor(row), + castTimeMs: castTimeMsFor(row), + cooldownMs: cooldownMsFor(row), gcdMs: 1_500, + ...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}), cost: rankCosts[0] ?? { resource: "mana", amount: 0 }, costs: rankCosts, effects: effects({ ...row, - target: row.target ?? parent.target, + classId: parent.classId, + secondaryClassId: parent.secondaryClassId, + target, source: row.source?.components?.length ? row.source : parent.source, }), }; @@ -713,7 +1084,8 @@ function ability(row: GeneratedSkill): AbilityDefinition { const secondaryClassId = row.secondaryClassId && ROM_CLASS_IDS.includes(row.secondaryClassId as RomClassId) ? row.secondaryClassId as RomClassId : undefined; - const abilityCosts = costs(row.costs); + const abilityCosts = costsFor(row); + const target = abilityTarget(row); return { id: row.id, classId, @@ -724,19 +1096,20 @@ function ability(row: GeneratedSkill): AbilityDefinition { icon: row.icon, sigil: row.name.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW", description: classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description, - target: row.target, - range: { min: 0, max: row.range }, + target, + range: { min: 0, max: sourceRange(row.sourceSkillId, row.range) }, ...(sourceTargeting(row.sourceSkillId)?.radius !== undefined ? { radius: sourceTargeting(row.sourceSkillId)!.radius } : areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}), ...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}), - castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant", - castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs, - cooldownMs: row.cooldownMs, + castMode: castModeFor(row), + castTimeMs: castTimeMsFor(row), + cooldownMs: cooldownMsFor(row), gcdMs: 1_500, + ...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}), cost: abilityCosts[0] ?? { resource: "mana", amount: 0 }, costs: abilityCosts, - effects: effects(row), + effects: effects({ ...row, target, classId }), passive: row.passive, source: "runewaker", sourceUnlockLevel: row.unlockLevel, diff --git a/src/game/romAbilityCatalogSemantics.test.ts b/src/game/romAbilityCatalogSemantics.test.ts index e1a1015e..f20e7804 100644 --- a/src/game/romAbilityCatalogSemantics.test.ts +++ b/src/game/romAbilityCatalogSemantics.test.ts @@ -9,6 +9,12 @@ function named(name: string) { return ability; } +function sourceSkill(sourceSkillId: number) { + const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === sourceSkillId); + if (!ability) throw new Error(`Missing RuneWaker source skill: ${sourceSkillId}`); + return ability; +} + function isMeaningfulEffect(effect: AbilityEffect): boolean { if (effect.kind === "scripted" || effect.kind === "unsupported") return false; if (["damage", "heal", "dot", "hot", "shield"].includes(effect.kind)) { @@ -53,11 +59,272 @@ describe("executable RuneWaker ability mappings", () => { expect(named("Shout").effects).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "apply-aura", control: { kind: "stun" } }), ])); + expect(named("Shout")).toMatchObject({ + target: "self", + radius: 10, + targeting: { shape: "circle", radius: 10 }, + cooldownMs: 40_000, + cost: { resource: "rage", amount: 15 }, + }); expect(named("Cleanse").effects).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "dispel", mode: "cleanse", relationship: "friendly" }), ])); }); + it("keeps Priest and Priest/Scout support casts on friendly targets", () => { + for (const name of ["Grace of Life", "Amplified Attack"]) { + const ability = named(name); + expect(ability.target, name).toBe("friendly"); + expect(ability.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "apply-aura", recipient: "ability-target" }), + ])); + } + const curingShot = named("Curing Shot"); + expect(curingShot.target).toBe("friendly"); + expect(curingShot.effects.map((effect) => effect.kind)).toEqual(expect.arrayContaining(["heal", "hot"])); + expect(curingShot.effects.some((effect) => effect.kind === "dot")).toBe(false); + }); + + it("fans Priest-secondary class buffs out to the described party recipients", () => { + for (const sourceId of [491335, 498649]) { + const auras = sourceSkill(sourceId).effects.filter((effect) => effect.kind === "apply-aura"); + expect(auras.length, `source ${sourceId}`).toBeGreaterThan(0); + expect(auras.every((effect) => effect.scope === "party"), `source ${sourceId}`).toBe(true); + } + + const salvation = sourceSkill(498719).effects.filter((effect) => effect.kind === "apply-aura"); + expect(salvation.length).toBeGreaterThan(0); + expect(salvation.every((effect) => effect.scope === "party" && effect.excludeCaster)).toBe(true); + + const advance = sourceSkill(498722).effects.filter((effect) => effect.kind === "apply-aura"); + const partyBuffs = advance.filter((effect) => effect.scope === "party"); + const casterPenalties = advance.filter((effect) => effect.scope !== "party"); + expect(partyBuffs.some((effect) => effect.aura.modifiers?.some((modifier) => ( + modifier.kind === "damage" && modifier.direction === "dealt" && modifier.value > 0 + )))).toBe(true); + expect(casterPenalties.some((effect) => effect.aura.modifiers?.some((modifier) => ( + modifier.kind === "stat" && ["armor", "magical-defense"].includes(modifier.stat) && modifier.value < 0 + )))).toBe(true); + expect(casterPenalties.every((effect) => effect.recipient === "caster")).toBe(true); + }); + + it("keeps Scout/Knight Healing Shot damage and lowest-max-health party heal separate", () => { + const healingShot = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-scout-rom-knight-elite-20")!; + expect(healingShot).toMatchObject({ target: "hostile" }); + expect(healingShot.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "damage" }), + expect.objectContaining({ + kind: "heal", scope: "party", maximumTargets: 1, selection: "lowest-max-health", + }), + ])); + }); + + it("requires Nature's Power for Druid/Scout Group Exorcism", () => { + const exorcism = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-druid-rom-scout-elite-30")!; + expect(exorcism).toMatchObject({ target: "self", targeting: { shape: "circle", radius: 5 } }); + expect(exorcism.costs).toEqual(expect.arrayContaining([ + expect.objectContaining({ resource: "nature-power", amount: 1 }), + ])); + expect(exorcism.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "dispel", mode: "cleanse", relationship: "friendly" }), + ])); + }); + + it("roots nearby enemies with Knight Shackles of Light", () => { + const shackles = named("Shackles of Light"); + expect(shackles).toMatchObject({ target: "self", radius: 10 }); + expect(shackles.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "apply-aura", control: { kind: "root" } }), + ])); + }); + + it("maps Scout Cripple Joint to its 55% Joint Blow root proc", () => { + const crippleJoint = sourceSkill(491173); + expect(crippleJoint.passive).toBe(true); + expect(crippleJoint.effects).toEqual([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ + utilityTags: ["rom-cripple-joint"], + procs: [{ + id: "rom-cripple-joint-root", + triggers: ["hit", "crit"], + chance: 0.55, + action: { kind: "custom", id: "rom-cripple-joint" }, + }], + }), + }), + ]); + expect(sourceSkill(490420)).toMatchObject({ name: "Joint Blow", dbcSpellId: 490420 }); + }); + + it("uses Neck Strike's sourced six-second NPC stun instead of an unsupported ten seconds", () => { + expect(sourceSkill(490438).effects).toEqual([{ + kind: "status", + status: "stun", + durationMs: 6_000, + maxTargets: 1, + breakOnDamage: true, + }]); + }); + + it("gives Champion nearby and fan spells their source-shaped target areas", () => { + const bySpellId = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!; + expect(bySpellId(498481)).toMatchObject({ target: "self", radius: 10 }); + expect(bySpellId(498537)).toMatchObject({ target: "self", radius: 7.5 }); + expect(bySpellId(498721)).toMatchObject({ + target: "self", + radius: 10, + targeting: { shape: "circle", radius: 10 }, + }); + expect(bySpellId(498534)).toMatchObject({ + target: "self", + radius: 10, + targeting: { shape: "cone", radius: 10, attackArcRadians: Math.PI / 2 }, + }); + }); + + it("restores Rune Pulse Rage on the Champion after its hostile hit", () => { + const runePulse = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498529)!; + expect(runePulse.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ modifiers: expect.arrayContaining([ + expect.objectContaining({ kind: "resource", resource: "rage", property: "regeneration" }), + ]) }), + }), + ])); + }); + + it("puts Scout/Mage Aggro Transfer mana regeneration on the selected ally", () => { + const aggroTransfer = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493255)!; + expect(aggroTransfer.target).toBe("friendly"); + expect(aggroTransfer.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "ability-target", + aura: expect.objectContaining({ modifiers: expect.arrayContaining([ + expect.objectContaining({ kind: "resource", resource: "mana", property: "regeneration" }), + ]) }), + }), + ])); + }); + + it("preserves Druid elite healing and chain targeting semantics", () => { + const bySpellId = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!; + const arrows = bySpellId(494021); + expect(arrows).toMatchObject({ target: "friendly", targeting: { shape: "circle", radius: 20, maximumTargets: 5 } }); + expect(arrows.effects.map((effect) => effect.kind)).toEqual(["hot", "hot"]); + const arrowHots = arrows.effects.filter((effect): effect is Extract => effect.kind === "hot"); + expect(arrowHots).toHaveLength(2); + expect(arrowHots[0]!.basePoints).toBeCloseTo(23.6); + expect(arrowHots[1]!.basePoints).toBeCloseTo(18.88); + const earthChain = bySpellId(494025); + expect(earthChain).toMatchObject({ targeting: { shape: "chain", maximumTargets: 3, jumpRange: 6 } }); + expect(earthChain.effects.filter((effect) => effect.kind === "damage")).toEqual([ + expect.objectContaining({ kind: "damage", falloffPerJump: 0.7 }), + expect.objectContaining({ kind: "damage", falloffPerJump: 0.7 }), + ]); + const ripple = bySpellId(494323); + expect(ripple).toMatchObject({ targeting: { shape: "circle", radius: 6, maximumTargets: 3 } }); + expect(ripple.effects.filter((effect) => effect.kind === "heal")).toEqual([ + expect.objectContaining({ falloffPerJump: 0.5 }), + expect.objectContaining({ falloffPerJump: 0.5 }), + ]); + expect(bySpellId(493540).targeting?.shape).toBe("line"); + expect(bySpellId(494217)).toMatchObject({ target: "self", targeting: { shape: "circle", radius: 5 } }); + expect(bySpellId(494217).effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "dispel", relationship: "friendly", mode: "cleanse" }), + ])); + expect(bySpellId(494027)).toMatchObject({ target: "friendly", targeting: { shape: "circle", radius: 6, maximumTargets: 3 } }); + expect(bySpellId(494026).targeting).toMatchObject({ shape: "circle", radius: 12 }); + }); + + it("models Champion Overrule as control immunity instead of applying control", () => { + const overrule = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498570)!; + expect(overrule.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ + durationMs: 3_000, + controlImmunities: expect.arrayContaining(["stun", "root", "slow"]), + }), + }), + ])); + expect(overrule.effects.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false); + }); + + it("models Free Will as a 10-second Root and Slow immunity for its friendly target", () => { + const freeWill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491351)!; + expect(freeWill).toMatchObject({ target: "friendly" }); + expect(freeWill.effects).toEqual([expect.objectContaining({ + kind: "apply-aura", + recipient: "ability-target", + aura: expect.objectContaining({ + disposition: "buff", + durationMs: 10_000, + controlImmunities: ["root", "slow"], + }), + })]); + expect(freeWill.effects.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false); + }); + + it("applies Fire Ward to the Priest's party", () => { + expect(named("Fire Ward").effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "apply-aura", scope: "party" }), + ])); + }); + + it("fans out Warden Crystalline Protection and Acceleration within their source radius", () => { + const protection = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498201)!; + const acceleration = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498202)!; + for (const ability of [protection, acceleration]) { + expect(ability).toMatchObject({ + target: "self", + radius: 7.5, + targeting: { shape: "circle", radius: 7.5 }, + }); + expect(ability.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "apply-aura", scope: "party" }), + ])); + } + expect(protection.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + scope: "party", + aura: expect.objectContaining({ absorbs: expect.arrayContaining([expect.any(Object)]) }), + }), + ])); + expect(acceleration.effects.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : [])) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "stat", stat: "attack-speed" }), + expect.objectContaining({ kind: "stat", stat: "spell-haste" }), + ])); + }); + + it("applies Coat of Arms defenses to both the caster and selected ally", () => { + const coatOfArms = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494611)!; + expect(coatOfArms.target).toBe("friendly"); + expect(coatOfArms.effects).toHaveLength(2); + expect(coatOfArms.effects).toEqual([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ + durationMs: 20_000, + modifiers: expect.arrayContaining([ + expect.objectContaining({ kind: "stat", stat: "armor", operation: "percent", value: 0.3 }), + expect.objectContaining({ kind: "stat", stat: "magical-defense", operation: "percent", value: 0.3 }), + ]), + }), + }), + expect.objectContaining({ kind: "apply-aura", recipient: "ability-target" }), + ]); + }); + it("retains every executable part of multi-effect abilities", () => { const kinds = named("Sneak Attack").effects.map((effect) => effect.kind); expect(kinds).toEqual(["damage", "apply-aura", "dot"]); @@ -170,6 +437,18 @@ describe("executable RuneWaker ability mappings", () => { ])); }); + it("marks Briar Shield as the source prerequisite for Thorn Arrow", () => { + expect(sourceSkill(493642).effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ utilityTags: expect.arrayContaining(["rom-briar-shield"]) }), + }), + ])); + expect(sourceSkill(494037).effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "damage" }), + ])); + }); + it("models the Knight's Holy Seal build-and-spend loop", () => { expect(named("Holy Strike").effects).toEqual(expect.arrayContaining([ { kind: "combo", amount: 1 }, diff --git a/src/game/romAbilitySemantics.ts b/src/game/romAbilitySemantics.ts index 66c32190..84631fd8 100644 --- a/src/game/romAbilitySemantics.ts +++ b/src/game/romAbilitySemantics.ts @@ -94,6 +94,7 @@ export interface RomSourceComponent { } export interface RomGeneratedAbilityLike { + readonly classId?: string; readonly sourceSkillId?: number; readonly name: string; readonly description: string; diff --git a/src/game/romChampionOverrides.ts b/src/game/romChampionOverrides.ts new file mode 100644 index 00000000..749175ec --- /dev/null +++ b/src/game/romChampionOverrides.ts @@ -0,0 +1,117 @@ +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import type { AuraDefinition } from "./combatAuras"; + +export const ROM_CHAMPION_FORGE_STACK_AURA: AuraDefinition = { + id: "rom-champion-forge-stack", + name: "Forge", + description: "Each normal attack hit grants 2% physical attack for 8 seconds, stacking up to 5 times.", + disposition: "buff", + durationMs: 8_000, + maxStacks: 5, + stackBehavior: "add", + modifiers: [{ + kind: "stat", + stat: "attack-power", + operation: "percent", + value: 0.02, + perStack: true, + }], +}; + +export const ROM_CHAMPION_RUNE_OVERLOAD_TAG = "rom-champion-rune-overload"; +export const ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND = 15; + +const ROM_CHAMPION_FORGE_ACTIVE_AURA: AuraDefinition = { + id: "rom-champion-forge-active", + name: "Forge", + description: "For 5 minutes, normal attack hits build Forge stacks and restore Rage.", + disposition: "buff", + durationMs: 300_000, + utilityTags: ["rom-champion-forge-active"], + procs: [{ + id: "rom-champion-forge-normal-hit", + triggers: ["hit", "crit"], + attackKinds: ["melee"], + chance: 1, + action: { kind: "custom", id: "rom-champion-forge-hit" }, + }], +}; + +function scaleDamage(effect: Extract, factor: number): AbilityEffect { + return { + ...effect, + coefficient: effect.coefficient * factor, + ...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }), + ...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }), + ...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }), + ...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }), + }; +} + +/** Apply source-backed Champion cast branches that need runtime state. */ +export function romChampionCastEffects( + ability: AbilityDefinition, + effects: readonly AbilityEffect[], + targetHealthPercent: number, + casterMaxHealth: number, +): readonly AbilityEffect[] { + if (ability.source !== "runewaker" || ability.classId !== "rom-champion") return effects; + + switch (ability.dbcSpellId) { + case 498584: // Champion/Warrior Determination Rune is a 30%-of-max-HP caster shield. + if (ability.secondaryClassId !== "rom-warrior") return effects; + return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length + ? { + ...effect, + aura: { + ...effect.aura, + absorbs: effect.aura.absorbs.map((absorb) => ({ + ...absorb, + amount: Math.max(1, Math.round(Math.max(0, casterMaxHealth) * absorb.amount / 100)), + })), + }, + } + : effect); + case 498523: // Rank 1 Forge's guaranteed on-normal-attack trigger is child skill 498524. + return [{ + kind: "apply-aura", + aura: ROM_CHAMPION_FORGE_ACTIVE_AURA, + recipient: "caster", + sourceEffectType: 0, + sourceAuraType: 0, + miscValue: 0, + triggerSpellId: 498524, + }]; + case 498572: // Rune Overload ends when its Rage drain reaches zero. + return effects.map((effect) => effect.kind === "apply-aura" + ? { + ...effect, + aura: { + ...effect.aura, + utilityTags: [...(effect.aura.utilityTags ?? []), ROM_CHAMPION_RUNE_OVERLOAD_TAG], + }, + } + : effect); + case 498531: // Fearless Blow's +30% hit applies only below 30% target health. + return effects.flatMap((effect) => { + if (effect.kind === "apply-aura" && effect.aura.id.includes("-621179-")) return []; + if (effect.kind === "apply-aura" && effect.aura.id.includes("-621183-")) { + return [{ ...effect, recipient: "caster" as const }]; + } + if (effect.kind === "damage" && targetHealthPercent < 0.3) return [scaleDamage(effect, 1.3)]; + return [effect]; + }); + case 498535: // Chain Drive's proc chance and damage bonus are absent from the source payload. + return effects.filter((effect) => effect.kind !== "cooldown-reset"); + case 498599: // Dark Energy Strike's cooldown proc chance is absent from the source payload. + if (ability.secondaryClassId !== "rom-warlock") return effects; + return effects.filter((effect) => effect.kind !== "cooldown-reset"); + case 498651: // Heart Collection Rune explicitly resets Rune Siphon. + if (ability.secondaryClassId !== "rom-warlock") return effects; + return effects.map((effect) => effect.kind === "cooldown-reset" + ? { ...effect, abilityName: "Rune Siphon" } + : effect); + default: + return effects; + } +} diff --git a/src/game/romChampionRuntime.test.ts b/src/game/romChampionRuntime.test.ts new file mode 100644 index 00000000..c2d8330c --- /dev/null +++ b/src/game/romChampionRuntime.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics"; +import { romChampionCastEffects } from "./romChampionOverrides"; + +const empty: RomCastMechanics = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 2_000, + targetHealthPercent: 1, +}; + +function sourceAbility(spellId: number) { + const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId); + if (!ability) throw new Error(`Missing RoM source ability ${spellId}`); + return ability; +} + +function castEffects(spellId: number, context: Partial = {}) { + return romEffectsForCast(sourceAbility(spellId), { ...empty, ...context }); +} + +describe("RoM Champion source-specific behavior", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + + it("ends Rune Overload when its Rage drain reaches zero", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 40 }); + useCombatStore.setState((state) => ({ + resource: 30, + resourcePools: { ...state.resourcePools, rage: 30 }, + })); + + expect(useCombatStore.getState().castAbility("rom-champion-primary-17", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("rom-champion-rune-overload") + ))).toBe(true); + + expect(useCombatStore.getState().resourcePools.rage).toBe(15); + useCombatStore.getState().tick(1, 2_000); + + expect(useCombatStore.getState().resourcePools.rage).toBe(0); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("rom-champion-rune-overload") + ))).toBe(false); + }); + + it("applies Fearless Blow's bonus only below 30% and gives its Rage rider to the caster", () => { + const atThreshold = castEffects(498531, { targetHealthPercent: 0.3 }); + const belowThreshold = castEffects(498531, { targetHealthPercent: 0.299 }); + const baseDamage = atThreshold.find((effect) => effect.kind === "damage"); + const boostedDamage = belowThreshold.find((effect) => effect.kind === "damage"); + expect(baseDamage?.kind).toBe("damage"); + expect(boostedDamage?.kind).toBe("damage"); + if (baseDamage?.kind === "damage" && boostedDamage?.kind === "damage") { + expect(boostedDamage.coefficient).toBeCloseTo(baseDamage.coefficient * 1.3); + expect(boostedDamage.basePoints ?? 0).toBeCloseTo((baseDamage.basePoints ?? 0) * 1.3); + } + expect(atThreshold.some((effect) => effect.kind === "apply-aura" && effect.aura.id.includes("621179"))).toBe(false); + const rage = belowThreshold.find((effect) => effect.kind === "apply-aura" && effect.aura.id.includes("621183")); + expect(rage?.kind).toBe("apply-aura"); + if (rage?.kind === "apply-aura") expect(rage.recipient).toBe("caster"); + }); + + it("adds Rune Draw's source-described pull and moves the selected target toward the Champion", () => { + const runeDraw = sourceAbility(498564); + expect(runeDraw.effects.filter((effect) => effect.kind === "pull")).toHaveLength(1); + expect(runeDraw.effects.some((effect) => effect.kind === "interrupt")).toBe(true); + expect(runeDraw.effects.some((effect) => effect.kind === "taunt")).toBe(true); + + useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 20 }); + useCombatStore.getState().registerMob("champion-rune-draw-target", { + name: "Rune Draw Target", + maxHealth: 100_000, + }); + setMobPosition("champion-rune-draw-target", [8, 0, 0]); + useCombatStore.getState().selectMob("champion-rune-draw-target"); + + expect(useCombatStore.getState().castAbility("rom-champion-primary-1", undefined, 1_000)).toMatchObject({ ok: true }); + expect(getMobPosition("champion-rune-draw-target")?.[0]).toBeCloseTo(1.5); + }); + + it("rejects Indomitable Spirit at 65% health and permits it below the threshold", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", + secondaryClassId: "rom-warlock", + raceId: "rom-dwarf", + level: 70, + }); + const maxHealth = useCombatStore.getState().maxHealth; + useCombatStore.setState((state) => ({ + health: Math.ceil(maxHealth * 0.65), + resource: 20, + resourcePools: { ...state.resourcePools, rage: 20 }, + })); + expect(useCombatStore.getState().castAbility("rom-champion-rom-warlock-elite-35", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + + useCombatStore.setState((state) => ({ + health: Math.floor(maxHealth * 0.65) - 1, + resource: 20, + resourcePools: { ...state.resourcePools, rage: 20 }, + })); + expect(useCombatStore.getState().castAbility("rom-champion-rom-warlock-elite-35", undefined, 1_000)) + .toMatchObject({ ok: true }); + }); + + it("activates Forge for five minutes and grants one stack and Rage on a normal hit", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 20 }); + useCombatStore.getState().registerMob("champion-forge-target", { name: "Forge Target", maxHealth: 100_000 }); + setMobPosition("champion-forge-target", [3, 0, 0]); + useCombatStore.getState().selectMob("champion-forge-target"); + + const forge = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498523)!; + expect(useCombatStore.getState().castAbility(forge.id, undefined, 1_000)).toMatchObject({ ok: true }); + const activeForge = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-champion-forge-active"); + expect(activeForge?.expiresAt).toBe(301_000); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-champion-forge-stack")).toBe(false); + + useCombatStore.setState({ autoAttackTargetId: "champion-forge-target", nextAutoAttackAt: 1_000 }); + useCombatStore.getState().tick(0.1, 1_000); + const forgeStack = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-champion-forge-stack"); + expect(forgeStack?.stacks).toBe(1); + expect(forgeStack?.expiresAt).toBe(9_000); + expect(useCombatStore.getState().resourcePools.rage).toBeGreaterThanOrEqual(10); + }); + + it("scales Determination Rune's source max-health shield and leaves unrelated casts unchanged", () => { + const determinationRune = sourceAbility(498584); + const sourceShield = determinationRune.effects.find((effect) => effect.kind === "apply-aura" + && effect.aura.absorbs?.length); + expect(sourceShield?.kind).toBe("apply-aura"); + if (sourceShield?.kind !== "apply-aura") throw new Error("Missing Determination Rune shield effect"); + expect(sourceShield.aura.absorbs?.[0]?.amount).toBe(30); + expect(determinationRune.ranks?.[0]?.effects.find((effect) => effect.kind === "apply-aura" + && effect.aura.absorbs?.length)).toMatchObject({ + kind: "apply-aura", + aura: { absorbs: [expect.objectContaining({ amount: 30 })] }, + }); + + const twoThousandHp = castEffects(498584, { casterMaxHealth: 2_000 }); + const fiveThousandHp = castEffects(498584, { casterMaxHealth: 5_000 }); + expect(twoThousandHp.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length)) + .toMatchObject({ kind: "apply-aura", aura: { absorbs: [expect.objectContaining({ amount: 600 })] } }); + expect(fiveThousandHp.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length)) + .toMatchObject({ kind: "apply-aura", aura: { absorbs: [expect.objectContaining({ amount: 1_500 })] } }); + + const otherClass = { ...determinationRune, classId: "rom-mage" as const }; + expect(romChampionCastEffects(otherClass, determinationRune.effects, 1, 2_000)) + .toBe(determinationRune.effects); + const otherPair = { ...determinationRune, secondaryClassId: "rom-mage" as const }; + expect(romChampionCastEffects(otherPair, determinationRune.effects, 1, 2_000)) + .toBe(determinationRune.effects); + const otherChampionAbility = sourceAbility(498568); + expect(romChampionCastEffects(otherChampionAbility, otherChampionAbility.effects, 1, 2_000)) + .toBe(otherChampionAbility.effects); + }); + + it("casts Determination Rune with an absorb equal to 30% of the current max HP", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", + secondaryClassId: "rom-warrior", + raceId: "rom-dwarf", + level: 30, + }); + const maxHealth = useCombatStore.getState().maxHealth; + + expect(useCombatStore.getState().castAbility("rom-champion-rom-warrior-elite-30", undefined, 1_000)) + .toMatchObject({ ok: true }); + + const shield = useCombatStore.getState().auras.find((aura) => aura.definition.absorbs?.length); + expect(shield?.definition.absorbs?.[0]?.amount).toBe(Math.round(maxHealth * 0.3)); + }); + + it("keeps Champion cooldown resets source-scoped and removes undefined proc chances", () => { + const heartRuneResets = castEffects(498651) + .filter((effect) => effect.kind === "cooldown-reset"); + expect(heartRuneResets.length).toBeGreaterThan(0); + expect(heartRuneResets.every((effect) => effect.kind === "cooldown-reset" + && effect.abilityName === "Rune Siphon")).toBe(true); + + expect(castEffects(498599).some((effect) => effect.kind === "damage")).toBe(true); + expect(castEffects(498599).some((effect) => effect.kind === "cooldown-reset")).toBe(false); + expect(castEffects(498535).some((effect) => effect.kind === "cooldown-reset")).toBe(false); + }); +}); diff --git a/src/game/romCombatMechanics.test.ts b/src/game/romCombatMechanics.test.ts index 55724624..d5a7ed4e 100644 --- a/src/game/romCombatMechanics.test.ts +++ b/src/game/romCombatMechanics.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { abilityById, type AbilityEffect } from "./abilityCatalog"; -import { romEffectsForCast, romTargetBleedCount, type RomCastMechanics } from "./romCombatMechanics"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorThreatMultiplier, type RomCastMechanics } from "./romCombatMechanics"; const empty: RomCastMechanics = { sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 2_000, + targetHealthPercent: 1, }; function cast(id: string, overrides: Partial = {}): readonly AbilityEffect[] { @@ -17,6 +19,28 @@ function kinds(effects: readonly AbilityEffect[]): readonly string[] { } describe("RoM primary class conditional combat effects", () => { + it("recognizes source-required shields and ranged weapons across primary and elite rows", () => { + const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!; + for (const spellId of [491333, 491484]) expect(romAbilityNeedsShield(sourceAbility(spellId))).toBe(true); + for (const spellId of [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629]) { + expect(romAbilityNeedsRangedWeapon(sourceAbility(spellId))).toBe(true); + } + }); + + it("selects the Nature's Power Healing Arrows HoT and consumes one point only when available", () => { + const baseline = cast("rom-druid-rom-scout-elite-25", { naturePower: 0 }); + const empowered = cast("rom-druid-rom-scout-elite-25", { naturePower: 1 }); + expect(baseline).toHaveLength(1); + expect(baseline[0]?.kind).toBe("hot"); + if (baseline[0]?.kind === "hot") expect(baseline[0].basePoints).toBeCloseTo(18.88); + expect(empowered).toHaveLength(2); + expect(empowered[0]?.kind).toBe("hot"); + if (empowered[0]?.kind === "hot") expect(empowered[0].basePoints).toBeCloseTo(23.6); + expect(empowered[1]).toEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }); + expect(cast("rom-druid-rom-scout-elite-25", { naturePower: 0.5 }).filter((effect) => effect.kind === "resource-drain")) + .toEqual([]); + }); + it("counts only active Rogue bleeds applied by the caster to that target", () => { const timed = [ { kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "rogue", targetId: "mob", remainingTicks: 3 }, @@ -73,6 +97,83 @@ describe("RoM primary class conditional combat effects", () => { expect(kinds(cast("rom-rogue-primary-9", { mainHandDagger: true }))).toEqual(["damage", "apply-aura", "dot"]); }); + it("requires an active Vulnerable target for Keen Attack", () => { + const keenAttackId = "rom-warrior-rom-rogue-elite-35"; + expect(cast(keenAttackId)).toEqual([]); + expect(kinds(cast(keenAttackId, { targetTags: ["rom-vulnerable"] }))).toContain("damage"); + }); + + it("gates Open Flank on Vulnerable and Opportunity on the resulting Weakened state", () => { + const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!; + expect(romAbilityRequiredTargetStateTag(sourceAbility(491136))).toBe("rom-vulnerable"); + expect(romAbilityRequiredTargetStateTag(sourceAbility(492617))).toBe("rom-weakened"); + expect(cast("rom-warrior-primary-2")).toEqual([]); + const openFlank = cast("rom-warrior-primary-2", { targetTags: ["rom-vulnerable"] }); + expect(openFlank.some((effect) => effect.kind === "apply-aura" + && effect.aura.utilityTags?.includes("rom-weakened"))).toBe(true); + expect(cast("rom-warrior-rom-priest-elite-35")).toEqual([]); + expect(kinds(cast("rom-warrior-rom-priest-elite-35", { targetTags: ["rom-weakened"] }))) + .toContain("damage"); + }); + + it("only applies Tactical Attack's Slash bleed when a Slash bleed is active", () => { + expect(kinds(cast("rom-warrior-primary-3"))).toEqual(["damage"]); + expect(kinds(cast("rom-warrior-primary-3", { slashBleed: true }))).toEqual(["damage", "dot"]); + const effects = [ + { kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "target", remainingTicks: 1 }, + { kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "other", remainingTicks: 3 }, + { kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "target", remainingTicks: 0 }, + { kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "warrior", targetId: "target", remainingTicks: 3 }, + ]; + expect(romTargetHasSlashBleed(effects, "target")).toBe(true); + expect(romTargetHasSlashBleed(effects.slice(1), "target")).toBe(false); + }); + + it("applies explicit Warrior passive Rage and cooldown reductions to their source skills", () => { + const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!; + expect(romAbilityNeedsTwoHand(sourceAbility(491142))).toBe(true); + expect(romWarriorAbilityAdjustment(sourceAbility(490121), [491458])) + .toEqual({ rageCostReduction: 10, cooldownReductionMs: 20_000 }); + expect(romWarriorAbilityAdjustment(sourceAbility(490133), [493248])) + .toEqual({ rageCostReduction: 20, cooldownReductionMs: 10_000 }); + expect(romWarriorAbilityAdjustment(sourceAbility(490133), [499491])) + .toEqual({ rageCostReduction: 25, cooldownReductionMs: 15_000 }); + expect(romWarriorAbilityAdjustment(sourceAbility(490133), [494377])) + .toEqual({ rageCostReduction: 10, cooldownReductionMs: 10_000 }); + expect(romWarriorAbilityAdjustment(sourceAbility(490121), [])).toEqual({ + rageCostReduction: 0, cooldownReductionMs: 0, + }); + expect(romWarriorAbilityAdjustment(sourceAbility(490053), [499827])) + .toEqual({ rageCostReduction: 10, cooldownReductionMs: 0 }); + }); + + it("applies Warrior/Warlock's physical-damage passive and Heroic Surprise Attack's on-hit payload", () => { + const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!; + const rageBlow = sourceAbility(498706); + const baseDamage = romEffectsForCast(rageBlow, empty).find((effect) => effect.kind === "damage"); + const trainedDamage = romEffectsForCast(rageBlow, { + ...empty, learnedPassiveSpellIds: [499806], + }).find((effect) => effect.kind === "damage"); + expect(baseDamage?.kind).toBe("damage"); + expect(trainedDamage?.kind).toBe("damage"); + if (baseDamage?.kind === "damage" && trainedDamage?.kind === "damage") { + expect(baseDamage.school).toBe("physical"); + expect(trainedDamage.coefficient).toBeCloseTo(baseDamage.coefficient * 1.2); + } + + const surpriseAttack = sourceAbility(490129); + const heroicEffects = romEffectsForCast(surpriseAttack, { + ...empty, learnedPassiveSpellIds: [492661], + }); + const heroicHit = heroicEffects.find((effect) => effect.kind === "damage"); + expect(heroicHit?.kind).toBe("damage"); + if (heroicHit?.kind === "damage") { + expect(heroicHit.onDamageResource).toEqual({ resource: "rage", amount: 25 }); + } + expect(romWarriorThreatMultiplier(surpriseAttack, [492661])).toBe(5); + expect(romWarriorThreatMultiplier(surpriseAttack, [])).toBe(1); + }); + it("keeps auxiliary source rows from adding extra hits, heals, or false control", () => { expect(kinds(cast("rom-priest-general-2"))).toEqual(["hot"]); expect(kinds(cast("rom-mage-primary-1"))).toEqual(["damage"]); @@ -88,6 +189,19 @@ describe("RoM primary class conditional combat effects", () => { ]); }); + it("executes Moon Cleave's three source-authored attacks", () => { + const effects = cast("rom-warrior-primary-8"); + expect(effects.filter((effect) => effect.kind === "damage")).toHaveLength(3); + expect(effects.filter((effect) => effect.kind === "scripted")).toHaveLength(1); + }); + + it("only adds The Final Battle's second hit below 30% target health", () => { + const atThreshold = cast("rom-warrior-rom-scout-elite-50", { targetHealthPercent: 0.3 }); + const belowThreshold = cast("rom-warrior-rom-scout-elite-50", { targetHealthPercent: 0.29 }); + expect(atThreshold.filter((effect) => effect.kind === "damage")).toHaveLength(1); + expect(belowThreshold.filter((effect) => effect.kind === "damage")).toHaveLength(2); + }); + it("holds Shield of Discipline for 30 seconds and reflects only on being hit", () => { const effects = cast("rom-knight-general-7"); expect(kinds(effects)).toEqual(["apply-aura", "apply-aura", "apply-aura"]); diff --git a/src/game/romCombatMechanics.ts b/src/game/romCombatMechanics.ts index 1650c33a..0802e138 100644 --- a/src/game/romCombatMechanics.ts +++ b/src/game/romCombatMechanics.ts @@ -1,7 +1,13 @@ import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; import { romPriestKnightCastEffects } from "./romPriestKnightOverrides"; +import { romPriestWarlockCastEffects } from "./romPriestWarlockOverrides"; import { romMageWarlockCastEffects } from "./romMageWarlockOverrides"; import { romScoutRogueCastEffects } from "./romScoutRogueOverrides"; +import { romDruidCastEffects } from "./romDruidOverrides"; +import { romChampionCastEffects } from "./romChampionOverrides"; +import { romWardenCastEffects } from "./romWardenOverrides"; +import { romWarlockCastEffects } from "./romWarlockOverrides"; +import { romKnightCastEffects } from "./romKnightOverrides"; const ROGUE_BLEED_ABILITIES = new Set([ "rom-rogue-primary-1", // Low Blow's conditional Grievous Wound @@ -16,8 +22,19 @@ export interface RomCastMechanics { readonly charged: boolean; readonly mainHandDagger: boolean; readonly casterMaxHealth: number; + readonly targetHealthPercent: number; + readonly friendlyTargetHealthPercent?: number; + readonly naturePower?: number; readonly passiveTags?: readonly string[]; + readonly learnedPassiveSpellIds?: readonly number[]; + readonly eruptionProcRoll?: number; readonly targetTags?: readonly string[]; + readonly slashBleed?: boolean; +} + +export interface RomWarriorAbilityAdjustment { + readonly rageCostReduction: number; + readonly cooldownReductionMs: number; } /** Source periodic values for self-sacrifice toggle skills are percentages of HP. */ @@ -34,7 +51,74 @@ export function romSingleTargetControl(ability: AbilityDefinition | null): boole export function romAbilityNeedsShield(ability: AbilityDefinition): boolean { return ability.source === "runewaker" - && [490175, 490186, 490192, 490468].includes(ability.dbcSpellId); + && [490175, 490186, 490192, 490468, 491333, 491484].includes(ability.dbcSpellId); +} + +export function romAbilityNeedsTwoHand(ability: AbilityDefinition): boolean { + return ability.source === "runewaker" && [490121, 491142].includes(ability.dbcSpellId); +} + +export function romAbilityNeedsRangedWeapon(ability: AbilityDefinition): boolean { + return ability.source === "runewaker" + && [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629].includes(ability.dbcSpellId); +} + +export function romAbilityRequiredTargetStateTag(ability: AbilityDefinition): string | null { + if (ability.source !== "runewaker") return null; + if (ability.dbcSpellId === 492617) return "rom-weakened"; + if (ability.dbcSpellId === 491136 || ability.dbcSpellId === 492619) return "rom-vulnerable"; + return null; +} + +/** Source-described Warrior elite passives that reduce a skill's Rage cost and cooldown. */ +export function romWarriorAbilityAdjustment( + ability: AbilityDefinition, + learnedPassiveSpellIds: readonly number[], +): RomWarriorAbilityAdjustment { + if (ability.source !== "runewaker" || ability.classId !== "rom-warrior") { + return { rageCostReduction: 0, cooldownReductionMs: 0 }; + } + const passiveIds = new Set(learnedPassiveSpellIds); + const rules: Readonly> = { + 491458: { abilitySpellId: 490121, rageCostReduction: 10, cooldownReductionMs: 20_000 }, // Moon Cleave Perfection + 493248: { abilitySpellId: 490133, rageCostReduction: 20, cooldownReductionMs: 10_000 }, // Exhaustion + 499491: { abilitySpellId: 490133, rageCostReduction: 25, cooldownReductionMs: 15_000 }, // Whirlwind Battle Technique + 494377: { abilitySpellId: 490133, rageCostReduction: 10, cooldownReductionMs: 10_000 }, // Recovery of Life + 499827: { abilitySpellId: 490053, rageCostReduction: 10, cooldownReductionMs: 0 }, // Speed Slash + }; + const applicable = Object.entries(rules) + .filter(([passiveSpellId, rule]) => passiveIds.has(Number(passiveSpellId)) + && ability.dbcSpellId === rule.abilitySpellId) + .map(([, rule]) => rule); + return { + rageCostReduction: applicable.reduce((sum, rule) => sum + rule.rageCostReduction, 0), + cooldownReductionMs: applicable.reduce((sum, rule) => sum + rule.cooldownReductionMs, 0), + }; +} + +/** Heroic Surprise Attack makes Surprise Attack generate five times its usual threat. */ +export function romWarriorThreatMultiplier( + ability: AbilityDefinition, + learnedPassiveSpellIds: readonly number[], +): number { + return ability.source === "runewaker" + && ability.classId === "rom-warrior" + && ability.dbcSpellId === 490129 + && learnedPassiveSpellIds.includes(492661) + ? 5 + : 1; +} + +/** Some Warrior source components mark physical weapon attacks as an untyped spell. */ +export function romWarriorPhysicalWeaponTooltip(ability: AbilityDefinition): boolean { + return ability.source === "runewaker" + && ability.classId === "rom-warrior" + && /\b(?:physical DPS|main hand weapon DPS)\b/i.test(ability.description) + && !/\b(?:Dark|Wind|Fire|Water|Light|Earth) Damage\b/i.test(ability.description); } export function romTargetBleedCount( @@ -51,6 +135,21 @@ export function romTargetBleedCount( .map((effect) => effect.sourceAbilityId)).size; } +export function romTargetHasSlashBleed( + effects: readonly { + readonly kind: string; + readonly sourceAbilityId: string; + readonly targetId: string | null; + readonly remainingTicks: number; + }[], + targetId: string | null, +): boolean { + return Boolean(targetId && effects.some((effect) => effect.kind === "dot" + && effect.remainingTicks > 0 + && effect.targetId === targetId + && effect.sourceAbilityId === "rom-warrior-general-0")); +} + function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect { if (effect.kind !== "damage" && effect.kind !== "dot") return effect; return { @@ -67,18 +166,95 @@ function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect { export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] { if (ability.source !== "runewaker") return ability.effects; const priestKnightEffects = romPriestKnightCastEffects( - ability, ability.effects, context.passiveTags ?? [], context.casterMaxHealth, context.targetTags ?? [], + ability, + ability.effects, + context.passiveTags ?? [], + context.casterMaxHealth, + context.targetTags ?? [], + context.friendlyTargetHealthPercent ?? 1, + context.learnedPassiveSpellIds ?? [], ); - const mageWarlockEffects = romMageWarlockCastEffects(ability, priestKnightEffects, new Set(context.passiveTags ?? [])); - const effects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []); - const damage = effects.filter((effect) => effect.kind === "damage"); + const priestWarlockEffects = romPriestWarlockCastEffects( + ability, + priestKnightEffects, + context.friendlyTargetHealthPercent ?? 1, + ); + const knightEffects = romKnightCastEffects(ability, priestWarlockEffects, context.passiveTags ?? []); + const mageWarlockEffects = romMageWarlockCastEffects( + ability, + knightEffects, + new Set(context.passiveTags ?? []), + context.eruptionProcRoll, + ); + const scoutRogueEffects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []); + const druidEffects = romDruidCastEffects(ability, scoutRogueEffects, context.naturePower ?? 0, context.passiveTags ?? []); + const championEffects = romChampionCastEffects( + ability, druidEffects, context.targetHealthPercent, context.casterMaxHealth, + ); + const wardenEffects = romWardenCastEffects( + ability, championEffects, context.naturePower ?? 0, context.passiveTags ?? [], + ); + const effects = romWarlockCastEffects( + ability, + wardenEffects, + context.targetTags ?? [], + context.learnedPassiveSpellIds ?? [], + ); + const warriorPassives = new Set(context.learnedPassiveSpellIds ?? []); + const sourceSchoolAdjustedEffects = romWarriorPhysicalWeaponTooltip(ability) + ? effects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.school === "arcane" + ? { ...effect, school: "physical" as const } + : effect) + : effects; + const warriorAdjustedEffects = ability.classId === "rom-warrior" && warriorPassives.has(499806) + ? sourceSchoolAdjustedEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") + && (effect.school === undefined || effect.school === "physical") + ? scaleOutput(effect, 1.2) + : effect) + : sourceSchoolAdjustedEffects; + const requiredTargetStateTag = romAbilityRequiredTargetStateTag(ability); + if (requiredTargetStateTag && !context.targetTags?.includes(requiredTargetStateTag)) return []; + const damage = warriorAdjustedEffects.filter((effect) => effect.kind === "damage"); const seals = Math.max(0, Math.min(4, context.sealStacks)); switch (ability.dbcSpellId) { + case 491142: // Tactical Attack's copied Slash bleed only applies when Slash is already bleeding the target. + return [ + ...damage, + ...(context.slashBleed ? warriorAdjustedEffects.filter((effect) => effect.kind === "dot") : []), + ...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage" && effect.kind !== "dot" + && !(effect.kind === "scripted" && effect.label.startsWith("RoM conditional-use:"))), + ]; + case 494021: { // Healing Arrows chooses the Nature's Power HoT when a point is available. + const hots = warriorAdjustedEffects.filter((effect) => effect.kind === "hot"); + const empowered = (context.naturePower ?? 0) >= 1; + const selectedHot = empowered ? hots[0] : hots[1] ?? hots[0]; + return [ + ...(selectedHot ? [selectedHot] : []), + ...(empowered ? [{ kind: "resource-drain" as const, amount: 1, burn: false, resource: "nature-power" as const }] : []), + ]; + } + case 493247: // The Final Battle only adds its second hit below 30% target HP. + return [ + ...damage.slice(0, context.targetHealthPercent < 0.3 ? 2 : 1), + ...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage"), + ]; + case 490121: // Moon Cleave has three main-hand attack pulses. + return [...damage, ...damage, ...damage, + ...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage")]; + case 490129: { // Heroic Surprise Attack restores Rage only when its hit lands. + if (!warriorPassives.has(492661)) return warriorAdjustedEffects; + let attachedRage = false; + return warriorAdjustedEffects.map((effect) => { + if (effect.kind !== "damage" || attachedRage) return effect; + attachedRage = true; + return { ...effect, onDamageResource: { resource: "rage", amount: 25 } }; + }); + } case 492625: // Infection's poison rider needs Poisonous active. return context.passiveTags?.includes("rom-poisonous") - ? effects : effects.filter((effect) => effect.kind !== "dot"); + ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot"); case 491156: // Electrostatic Charge's source shield is a percent of max HP. - return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length + return warriorAdjustedEffects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length ? { ...effect, aura: { @@ -93,25 +269,25 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe case 490150: { // Charge adds two stacks. const gained = ability.dbcSpellId === 490148 ? 1 : 2; const nextStacks = Math.min(4, seals + gained); - return effects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect); + return warriorAdjustedEffects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect); } case 490313: // Wound Attack: one hit plus one rider per self-applied bleed. return damage.slice(0, 1 + Math.min(2, context.bleedCount)); case 490323: // Low Blow only creates Grievous Wound on a bleeding target. - return context.bleedCount > 0 ? effects : effects.filter((effect) => effect.kind !== "dot"); + return context.bleedCount > 0 ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot"); case 491170: // Electric Explosion has mutually exclusive normal/Charged hits. return context.charged ? damage.slice(0, 1) : damage.slice(-1); case 490151: // Punishment spends Holy Seals and scales its one payoff. - return seals > 0 ? effects.map((effect) => scaleOutput(effect, seals / 4)) : []; + return seals > 0 ? warriorAdjustedEffects.map((effect) => scaleOutput(effect, seals / 4)) : []; case 490153: { // Threaten: target debuffs and extra hit require Holy Seals. const base = damage[0]; - const threat = effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster"); - const debuffs = seals > 0 ? effects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : []; + const threat = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster"); + const debuffs = seals > 0 ? warriorAdjustedEffects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : []; const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null; return [...(base ? [base] : []), ...(threat ? [threat] : []), ...debuffs, ...(bonus ? [bonus] : [])]; } case 490154: { // Shock: one hit, one stun, plus a seal-dependent rider. - const stun = effects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun"); + const stun = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun"); const scaledStun = stun && stun.kind === "apply-aura" ? { ...stun, aura: { ...stun.aura, durationMs: 1_000 * (1 + seals) } } : null; @@ -119,7 +295,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe return [...(damage[0] ? [damage[0]] : []), ...(scaledStun ? [scaledStun] : []), ...(bonus ? [bonus] : [])]; } case 490186: { // Shield Bash shields its caster only when consuming seals. - const absorb = effects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length); + const absorb = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length); const shield = seals > 0 && absorb?.kind === "apply-aura" ? { ...absorb, recipient: "caster" as const, @@ -133,10 +309,10 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe return [...(damage[0] ? [damage[0]] : []), ...(shield ? [shield, { kind: "combo" as const, amount: -5 }] : [])]; } case 490306: // Shadowstab's bleed needs a main-hand dagger. - return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot"); + return context.mainHandDagger ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot"); case 490337: // Sneak Attack always stuns, but its bleed needs a dagger. - return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot"); + return context.mainHandDagger ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot"); default: - return effects; + return warriorAdjustedEffects; } } diff --git a/src/game/romCombatRuntime.test.ts b/src/game/romCombatRuntime.test.ts index d5c49104..ed797d78 100644 --- a/src/game/romCombatRuntime.test.ts +++ b/src/game/romCombatRuntime.test.ts @@ -11,6 +11,7 @@ import { useCombatStore } from "./combatStore"; import { companionActorFromMember, companionActorWithMemberRuntime } from "./combatActorAuthority"; import { targetingRulesForAbility } from "./abilityRules"; import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; +import { applyAura } from "./combatAuras"; describe("RuneWaker combat runtime", () => { beforeEach(() => { @@ -178,6 +179,43 @@ describe("RuneWaker combat runtime", () => { expect(useCombatStore.getState().mobs["lasso-b"]!.statuses.some((status) => status.kind === "root")).toBe(false); }); + it("roots only Joint Blow targets when Cripple Joint procs", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-human", level: 80 }); + const passive = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-state-cripple-joint"); + expect(passive?.definition.procs?.[0]?.chance).toBe(0.55); + useCombatStore.setState((state) => ({ + auras: state.auras.map((aura) => aura.instanceId !== passive?.instanceId + ? aura + : { + ...aura, + definition: { + ...aura.definition, + procs: aura.definition.procs?.map((proc) => ({ ...proc, chance: 1 })), + }, + }), + })); + for (const id of ["cripple-other-attack", "cripple-joint-target"]) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 }); + setMobPosition(id, [3, 0, 0]); + } + + useCombatStore.getState().selectMob("cripple-other-attack"); + expect(useCombatStore.getState().castAbility("rom-scout-primary-1", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs["cripple-other-attack"]!.statuses.some((status) => status.kind === "root")) + .toBe(false); + + useCombatStore.getState().selectMob("cripple-joint-target"); + expect(useCombatStore.getState().castAbility("rom-scout-general-3", undefined, 3_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs["cripple-joint-target"]!.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "root", + sourceAbilityId: "rom-scout-general-3", + endsAt: 8_000, + breakOnDamage: true, + }), + ])); + }); + it("moves Rogue Shadow Step behind its target", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 50 }); useCombatStore.getState().registerMob("step-dummy", { name: "Step Dummy", maxHealth: 100_000 }); @@ -233,6 +271,387 @@ describe("RuneWaker combat runtime", () => { expect(useCombatStore.getState().mobs["coefficient-dummy"]!.health).toBeLessThan(1_000); }); + it("requires Probing Attack's Vulnerable state before Keen Attack", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 50, + }); + useCombatStore.getState().registerMob("keen-vulnerable-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("keen-vulnerable-target", [3, 0, 0]); + useCombatStore.getState().selectMob("keen-vulnerable-target"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100, energy: 100 }, + })); + + expect(useCombatStore.getState().castAbility("rom-warrior-rom-rogue-elite-35", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "keen-vulnerable-target" + && aura.definition.utilityTags?.includes("rom-vulnerable"))).toBe(true); + + const result = useCombatStore.getState().castAbility("rom-warrior-rom-rogue-elite-35", undefined, 2_600); + expect(result).toMatchObject({ ok: true, affectedMobIds: ["keen-vulnerable-target"] }); + expect(result.damage).toBeGreaterThan(0); + }); + + it("requires Vulnerable for Open Flank and then permits Opportunity against its Weakened target", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-priest", raceId: "rom-human", level: 50, + }); + useCombatStore.getState().registerMob("opportunity-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("opportunity-target", [3, 0, 0]); + useCombatStore.getState().selectMob("opportunity-target"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100, mana: 100 }, + })); + + expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-35", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-2", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-2", undefined, 2_600).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "opportunity-target" + && aura.definition.utilityTags?.includes("rom-weakened"))).toBe(true); + expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-35", undefined, 4_200)) + .toMatchObject({ ok: true, affectedMobIds: ["opportunity-target"] }); + }); + + it("applies Tactical Attack's conditional Slash bleed only after Slash lands", () => { + const weapon = { + instanceId: "warrior-two-hand-slash-test", + itemId: 1, + itemKey: "warrior-two-hand-slash-test", + name: "Test Two-Hander", + quality: "uncommon" as const, + inventorySlot: "two-hand", + armorType: null, + weaponType: "sword", + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...useCombatStore.getState().gearStats }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", raceId: "rom-human", level: 20, + inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } }, + }); + useCombatStore.getState().registerMob("tactical-bleed-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("tactical-bleed-target", [3, 0, 0]); + useCombatStore.getState().selectMob("tactical-bleed-target"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + })); + + expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().castAbility("rom-warrior-general-0", undefined, 2_600).ok).toBe(true); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" + && effect.sourceAbilityId === "rom-warrior-general-0" + && effect.targetId === "tactical-bleed-target")).toBe(true); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-3", undefined, 4_200).ok).toBe(true); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" + && effect.sourceAbilityId === "rom-warrior-primary-3" + && effect.targetId === "tactical-bleed-target")).toBe(true); + }); + + it("applies Warrior passive Rage and cooldown reductions to Moon Cleave and Whirlwind", () => { + const weapon = { + instanceId: "warrior-two-hand-passive-test", + itemId: 1, + itemKey: "warrior-two-hand-passive-test", + name: "Test Two-Hander", + quality: "uncommon" as const, + inventorySlot: "two-hand", + armorType: null, + weaponType: "sword", + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...useCombatStore.getState().gearStats }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30, + inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } }, + }); + useCombatStore.getState().registerMob("passive-moon-cleave-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("passive-moon-cleave-target", [3, 0, 0]); + useCombatStore.getState().selectMob("passive-moon-cleave-target"); + useCombatStore.setState((state) => ({ + resource: 40, + resourcePools: { ...state.resourcePools, rage: 40 }, + })); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-8", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(0); + expect(useCombatStore.getState().cooldowns["rom-warrior-primary-8"]).toBe(11_000); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 50, + }); + useCombatStore.getState().registerMob("passive-whirlwind-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("passive-whirlwind-target", [3, 0, 0]); + useCombatStore.getState().selectMob("passive-whirlwind-target"); + useCombatStore.setState((state) => ({ + resource: 30, + resourcePools: { ...state.resourcePools, rage: 30, energy: 100 }, + })); + expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(0); + expect(useCombatStore.getState().cooldowns["rom-warrior-general-2"]).toBe(21_000); + + for (const [secondaryClassId, level, startingRage, expectedRage, expectedCooldown] of [ + ["rom-knight", 60, 100, 75, 16_000], // Whirlwind Battle Technique: -25 Rage, -15 seconds. + ["rom-druid", 40, 100, 60, 21_000], // Recovery of Life: -10 Rage, -10 seconds. + ] as const) { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId, raceId: "rom-human", level, + }); + const targetId = `passive-whirlwind-${secondaryClassId}`; + useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 100_000 }); + setMobPosition(targetId, [3, 0, 0]); + useCombatStore.getState().selectMob(targetId); + useCombatStore.setState((state) => ({ + resource: startingRage, + resourcePools: { ...state.resourcePools, rage: startingRage }, + })); + expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(expectedRage); + expect(useCombatStore.getState().cooldowns["rom-warrior-general-2"]).toBe(expectedCooldown); + } + }); + + it("applies Warrior/Warlock Speed Slash cost and Warrior/Knight Heroic Surprise Attack hit effects", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-warlock", raceId: "rom-human", level: 70, + }); + useCombatStore.getState().registerMob("speed-slash-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("speed-slash-target", [3, 0, 0]); + useCombatStore.getState().selectMob("speed-slash-target"); + useCombatStore.setState((state) => ({ + resource: 15, + resourcePools: { ...state.resourcePools, rage: 15 }, + })); + const slash = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490053)!; + expect(useCombatStore.getState().castAbility(slash.id, undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(0); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-knight", raceId: "rom-human", level: 50, + }); + useCombatStore.getState().registerMob("heroic-surprise-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("heroic-surprise-target", [3, 0, 0]); + useCombatStore.getState().selectMob("heroic-surprise-target"); + useCombatStore.setState((state) => ({ + resource: 0, + resourcePools: { ...state.resourcePools, rage: 0 }, + })); + const surpriseAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490129)!; + const result = useCombatStore.getState().castAbility(surpriseAttack.id, undefined, 2_000); + expect(result.ok).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(25); + expect(useCombatStore.getState().mobs["heroic-surprise-target"]?.threatByActor[PLAYER_AGGRO_ID]) + .toBeCloseTo(1 + result.damage * 5); + }); + + it("adds Blood Retention's 3 Rage per 2 seconds after Bloodlust", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-priest", raceId: "rom-human", level: 40, + }); + useCombatStore.getState().registerMob("blood-retention-dummy", { name: "Target", maxHealth: 100_000 }); + setMobPosition("blood-retention-dummy", [3, 0, 0]); + useCombatStore.getState().selectMob("blood-retention-dummy"); + useCombatStore.setState((state) => ({ + resource: 25, + resourcePools: { ...state.resourcePools, rage: 25 }, + })); + + expect(useCombatStore.getState().castAbility("rom-warrior-general-0", undefined, 1_000).ok).toBe(true); + useCombatStore.setState({ autoAttackTargetId: null }); + expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-30", undefined, 2_600).ok).toBe(true); + const afterBloodlust = useCombatStore.getState().resourcePools.rage!; + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-warrior:blood-retention")) + .toBe(true); + + useCombatStore.getState().tick(1, 3_600); + useCombatStore.getState().tick(1, 4_600); + expect(useCombatStore.getState().resourcePools.rage).toBeCloseTo(afterBloodlust + 3); + }); + + it("only deals The Final Battle's second hit below 30% target health", () => { + const damageAtTargetHealth = (targetId: string, healthPercent: number) => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", level: 50, + }); + useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 1_000_000 }); + setMobPosition(targetId, [3, 0, 0]); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + mobs: { + ...state.mobs, + [targetId]: { + ...state.mobs[targetId]!, + health: Math.round(state.mobs[targetId]!.maxHealth * healthPercent), + }, + }, + })); + useCombatStore.getState().selectMob(targetId); + const result = useCombatStore.getState().castAbility("rom-warrior-rom-scout-elite-50", undefined, 1_000); + expect(result).toMatchObject({ ok: true, affectedMobIds: [targetId] }); + return result.damage; + }; + + const aboveThresholdDamage = damageAtTargetHealth("final-battle-above", 0.4); + const belowThresholdDamage = damageAtTargetHealth("final-battle-below", 0.29); + expect(aboveThresholdDamage).toBeGreaterThan(0); + expect(belowThresholdDamage).toBeGreaterThan(aboveThresholdDamage); + }); + + it("gates Warrior Survival Instinct and Moon Cleave source requirements", () => { + const oneHandWeapon = { + instanceId: "warrior-one-hand-test", + itemId: 1, + itemKey: "warrior-one-hand-test", + name: "Test Sword", + quality: "uncommon" as const, + inventorySlot: "one-hand", + armorType: null, + weaponType: null, + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...useCombatStore.getState().gearStats }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", raceId: "rom-human", level: 50, + inventory: [oneHandWeapon], equipment: { player: { "main-hand": oneHandWeapon.instanceId } }, + }); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + })); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-14", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-8", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + + useCombatStore.setState((state) => ({ health: Math.floor(state.maxHealth * 0.49) })); + expect(useCombatStore.getState().castAbility("rom-warrior-primary-14", undefined, 1_000).ok).toBe(true); + }); + + it.each(["rom-warrior-rom-knight-elite-15", "rom-warrior-rom-knight-elite-30"])( + "requires an equipped shield for %s", + (abilityId) => { + const shield = { + instanceId: `knight-shield-gate-${abilityId}`, + itemId: 1, + itemKey: `knight-shield-gate-${abilityId}`, + name: "Test Shield", + quality: "uncommon" as const, + inventorySlot: "off-hand", + armorType: "shield", + weaponType: null, + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...useCombatStore.getState().gearStats }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + const prepareCombat = (withShield: boolean) => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80, + ...(withShield ? { inventory: [shield], equipment: { player: { "off-hand": shield.instanceId } } } : {}), + }); + useCombatStore.getState().registerMob("shield-gated-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("shield-gated-target", [3, 0, 0]); + useCombatStore.getState().selectMob("shield-gated-target"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100, mana: 100 }, + })); + }; + + prepareCombat(false); + expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + + prepareCombat(true); + expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true }); + }, + ); + + it("requires Briar Shield before Scout/Warden Thorn Arrow", () => { + const rangedWeapon = { + instanceId: "thorn-arrow-ranged-test", + itemId: 5243, + itemKey: "thorn-arrow-ranged-test", + name: "Test Ranged Weapon", + quality: "uncommon" as const, + inventorySlot: "ranged", + armorType: null, + weaponType: "bow", + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...useCombatStore.getState().gearStats }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", secondaryClassId: "rom-warden", raceId: "rom-elf", level: 80, + inventory: [rangedWeapon], equipment: { player: { ranged: rangedWeapon.instanceId } }, + }); + useCombatStore.getState().registerMob("thorn-arrow-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("thorn-arrow-target", [3, 0, 0]); + useCombatStore.getState().selectMob("thorn-arrow-target"); + expect(useCombatStore.getState().castAbility("rom-scout-rom-warden-elite-15", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + + expect(useCombatStore.getState().castAbility("rom-warden-general-1", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes("rom-briar-shield"))).toBe(true); + expect(useCombatStore.getState().castAbility("rom-scout-rom-warden-elite-15", undefined, 2_600)) + .toMatchObject({ ok: true }); + }); + + it("stuns all enemies within range 100 around a Warrior Shout", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 30 }); + for (let index = 0; index < 6; index += 1) { + const id = "shout-target-" + index; + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000 }); + setMobPosition(id, [index + 1, 0, 0]); + } + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + })); + + const result = useCombatStore.getState().castAbility("rom-warrior-primary-9", undefined, 1_000); + + expect(result.ok).toBe(true); + expect(result.affectedMobIds).toHaveLength(6); + expect(useCombatStore.getState().resourcePools.rage).toBe(85); + expect(useCombatStore.getState().cooldowns["rom-warrior-primary-9"]).toBe(41_000); + for (let index = 0; index < 6; index += 1) { + expect(useCombatStore.getState().mobs[`shout-target-${index}`]!.statuses) + .toEqual(expect.arrayContaining([expect.objectContaining({ kind: "stun" })])); + } + }); + it("finishes a RuneWaker direct heal on the selected party member", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 20 }); usePartyStore.getState().initializeParty("rom-urgent-heal", 20, useCombatStore.getState().health, "healer", 2, "rom"); @@ -249,6 +668,390 @@ describe("RuneWaker combat runtime", () => { expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth); }); + it("casts Priest/Scout Curing Shot as a five-target friendly heal and HoT", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80, + }); + usePartyStore.getState().initializeParty("rom-curing-shot", 80, useCombatStore.getState().health, "healer", 2, "rom"); + const members = usePartyStore.getState().members.slice(0, 4); + const injuredHealth = new Map(members.map((member, index) => { + usePartyStore.getState().damageMember(member.id, 500); + registerPartyRuntimePosition(member.id, [3 + index, 0, 0]); + return [member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health] as const; + })); + const member = members[0]!; + usePartyStore.getState().selectMember(member.id); + const curingShot = abilityById("rom-priest-rom-scout-elite-35")!; + expect(curingShot.target).toBe("friendly"); + expect(curingShot.targeting).toMatchObject({ shape: "circle", radius: 20, maximumTargets: 5 }); + + expect(useCombatStore.getState().castAbility(curingShot.id, undefined, 1_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(2.1, 3_100); + for (const [id, health] of injuredHealth) { + expect(usePartyStore.getState().members.find((candidate) => candidate.id === id)!.health).toBeGreaterThan(health); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" && effect.targetId === id)).toBe(true); + } + }); + + it.each([[0, 18.88], [1, 23.6]] as const)( + "casts Druid/Scout Healing Arrows with %i Nature's Power point(s) using the matching HoT", + (naturePower, expectedHoTAmount) => { + const rangedWeapon = { + instanceId: "healing-arrows-ranged-test", + itemId: 5243, + itemKey: "healing-arrows-ranged-test", + name: "Test Ranged Weapon", + quality: "uncommon" as const, + inventorySlot: "ranged", + armorType: null, + weaponType: "bow", + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...useCombatStore.getState().gearStats }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 80, + inventory: [rangedWeapon], equipment: { player: { ranged: rangedWeapon.instanceId } }, + }); + usePartyStore.getState().initializeParty( + `rom-healing-arrows-${naturePower}`, 80, useCombatStore.getState().health, "healer", 5, "rom", + ); + const member = usePartyStore.getState().members[0]!; + usePartyStore.getState().damageMember(member.id, 500); + registerPartyRuntimePosition(member.id, [3, 0, 0]); + usePartyStore.getState().selectMember(member.id); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, focus: 100, mana: 100, "nature-power": naturePower }, + })); + + const ability = abilityById("rom-druid-rom-scout-elite-25")!; + expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(2.1, 3_100); + + const state = useCombatStore.getState(); + expect(state.resourcePools["nature-power"]).toBe(0); + const memberHot = state.effects.find((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id && effect.targetId === member.id); + expect(memberHot?.kind).toBe("hot"); + if (memberHot?.kind === "hot") expect(memberHot.basePoints).toBeCloseTo(expectedHoTAmount); + }, + ); + + it("casts Warlock/Priest Ring of Comfort as a six-target friendly heal", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warlock", secondaryClassId: "rom-priest", raceId: "rom-dwarf", level: 80, + }); + usePartyStore.getState().initializeParty("rom-ring-of-comfort", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const members = usePartyStore.getState().members; + const extraMembers = ["one", "two"].map((suffix) => ({ + ...members[0]!, id: `ring-of-comfort-extra-ally-${suffix}`, name: `Extra Ally ${suffix}`, + })); + usePartyStore.setState((state) => ({ members: [...state.members, ...extraMembers] })); + useCombatStore.getState().damagePlayer(500); + const before = new Map(); + for (const [index, member] of [...members, ...extraMembers].entries()) { + usePartyStore.getState().damageMember(member.id, 500); + registerPartyRuntimePosition(member.id, [2 + index, 0, 0]); + before.set(member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health); + } + usePartyStore.getState().selectMember(members[0]!.id); + useCombatStore.getState().registerMob("ring-of-comfort-hostile", { name: "Hostile", maxHealth: 10_000 }); + setMobPosition("ring-of-comfort-hostile", [3, 0, 0]); + + const ability = abilityById("rom-warlock-rom-priest-elite-25")!; + expect(ability).toMatchObject({ + target: "friendly", + targeting: { shape: "circle", radius: 20, maximumTargets: 6 }, + }); + expect(ability.effects).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "heal" })])); + expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true }); + + const healingEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "healing" + && event.abilityId === ability.id); + expect(healingEvents).toHaveLength(6); + const healedActorIds = new Set(healingEvents.map((event) => event.targetActorId)); + expect(healedActorIds.size).toBe(6); + expect(healedActorIds).toContain(members[0]!.id); + expect(healedActorIds).not.toContain(extraMembers[1]!.id); + expect(usePartyStore.getState().members.find((member) => member.id === members[0]!.id)!.health) + .toBeGreaterThan(before.get(members[0]!.id)!); + expect(useCombatStore.getState().mobs["ring-of-comfort-hostile"]!.health).toBe(10_000); + }); + + it.each([ + ["Spiritual Leader", "rom-scout", "rom-priest", "rom-human", 30, "rom-scout-rom-priest-elite-30"], + ["Salvation Engraving", "rom-champion", "rom-priest", "rom-dwarf", 30, "rom-champion-rom-priest-elite-30"], + ["Suicide Advance", "rom-champion", "rom-priest", "rom-dwarf", 45, "rom-champion-rom-priest-elite-45"], + ["Soul Enlightenment", "rom-warlock", "rom-priest", "rom-dwarf", 40, "rom-warlock-rom-priest-elite-40"], + ] as const)("applies %s auras to its party recipients", (name, classId, secondaryClassId, raceId, level, abilityId) => { + useCombatStore.getState().initializeCharacter({ classId, secondaryClassId, raceId, level }); + usePartyStore.getState().initializeParty(`rom-${abilityId}`, level, useCombatStore.getState().health, "healer", 3, "rom"); + const memberIds = usePartyStore.getState().members.map((member) => member.id); + memberIds.forEach((id, index) => registerPartyRuntimePosition(id, [2 + index, 0, 0])); + const ability = abilityById(abilityId)!; + const auraEffects = ability.effects.filter((effect) => effect.kind === "apply-aura"); + expect(auraEffects.length, name).toBeGreaterThan(0); + + expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true }); + + const allRecipients = [PLAYER_AGGRO_ID, ...memberIds]; + const activeAuras = useCombatStore.getState().auras; + for (const effect of auraEffects) { + const expectedRecipients = effect.scope === "party" + ? allRecipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AGGRO_ID) + : effect.recipient === "caster" ? [PLAYER_AGGRO_ID] : []; + for (const recipientId of allRecipients) { + expect(activeAuras.some((aura) => aura.targetId === recipientId + && aura.definition.id === effect.aura.id), `${name} -> ${recipientId} (${effect.aura.id})`) + .toBe(expectedRecipients.includes(recipientId)); + } + } + + if (name === "Salvation Engraving") { + expect(auraEffects.every((effect) => effect.scope === "party" && effect.excludeCaster)).toBe(true); + expect(activeAuras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.id.startsWith("rom-aura-498719-"))).toBe(false); + } + if (name === "Suicide Advance") { + expect(auraEffects.some((effect) => effect.scope === "party" && effect.aura.modifiers?.some((modifier) => ( + modifier.kind === "damage" && modifier.direction === "dealt" && modifier.value > 0 + )))).toBe(true); + expect(auraEffects.some((effect) => effect.scope !== "party" && effect.recipient === "caster" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.value < 0))).toBe(true); + } + }); + + it("casts Scout/Priest Mana Arrows as a six-target party absorb around the caster", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80, + }); + usePartyStore.getState().initializeParty("rom-mana-arrows", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const members = [ + ...usePartyStore.getState().members, + { ...usePartyStore.getState().members[0]!, id: "mana-arrows-extra-ally", name: "Nearby Ally" }, + ]; + usePartyStore.setState({ members }); + members.forEach((member, index) => registerPartyRuntimePosition(member.id, [1 + index, 0, 0])); + const manaArrows = abilityById("rom-scout-rom-priest-elite-20")!; + + expect(manaArrows.target).toBe("self"); + expect(manaArrows.targeting).toMatchObject({ shape: "circle", radius: 5, maximumTargets: 6 }); + expect(useCombatStore.getState().castAbility(manaArrows.id, undefined, 1_000)).toMatchObject({ ok: true }); + + const shields = useCombatStore.getState().combatEvents.filter((event) => event.kind === "absorb" + && event.abilityId === manaArrows.id); + expect(shields).toHaveLength(6); + expect(shields.map((event) => event.targetActorId)).toEqual(expect.arrayContaining([ + PLAYER_AGGRO_ID, + ...members.map((member) => member.id), + ])); + expect(useCombatStore.getState().auras.filter((aura) => aura.definition.name === "Mana Arrows")) + .toHaveLength(6); + }); + + it("casts Druid/Mage Spring of Blessings as a nearby party HoT", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80, + }); + usePartyStore.getState().initializeParty("rom-spring-of-blessings", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const members = usePartyStore.getState().members.slice(0, 4); + const injuredHealth = new Map(members.map((member, index) => { + usePartyStore.getState().damageMember(member.id, 500); + registerPartyRuntimePosition(member.id, [2 + index, 0, 0]); + return [member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health] as const; + })); + const spring = abilityById("rom-druid-rom-mage-elite-20")!; + expect(spring.target).toBe("self"); + expect(spring.targeting).toMatchObject({ shape: "circle", radius: 12 }); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, mana: 100 }, + })); + + expect(useCombatStore.getState().castAbility(spring.id, undefined, 1_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(2.1, 3_100); + for (const [id, health] of injuredHealth) { + expect(usePartyStore.getState().members.find((candidate) => candidate.id === id)!.health).toBeGreaterThan(health); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" && effect.targetId === id)).toBe(true); + } + }); + + it("heals only the lowest-max-health ally with Scout/Knight Healing Shot", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80, + }); + usePartyStore.getState().initializeParty("rom-healing-shot", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const members = usePartyStore.getState().members.slice(0, 4); + for (const [index, member] of members.entries()) { + usePartyStore.getState().damageMember(member.id, 500); + registerPartyRuntimePosition(member.id, [2 + index, 0, 0]); + } + useCombatStore.getState().synchronizeActors(); + const expectedRecipient = usePartyStore.getState().members.slice(0, 4) + .reduce((lowest, member) => member.maxHealth < lowest.maxHealth ? member : lowest); + const healthBefore = expectedRecipient.health; + useCombatStore.getState().registerMob("healing-shot-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("healing-shot-target", [3, 0, 0]); + useCombatStore.getState().selectMob("healing-shot-target"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, focus: 100, mana: 100 }, + })); + + expect(useCombatStore.getState().castAbility("rom-scout-rom-knight-elite-20", undefined, 1_000)).toMatchObject({ ok: true }); + const healingEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "healing" + && event.abilityId === "rom-scout-rom-knight-elite-20"); + expect(healingEvents).toHaveLength(1); + expect(healingEvents[0]!.targetActorId).toBe(expectedRecipient.id); + expect(usePartyStore.getState().members.find((member) => member.id === expectedRecipient.id)!.health) + .toBeGreaterThan(healthBefore); + }); + + it("requires Nature's Power and cleanses nearby allies with Group Exorcism", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 80, + }); + usePartyStore.getState().initializeParty("rom-group-exorcism", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const members = usePartyStore.getState().members.slice(0, 2); + members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0])); + const auraDefinition = { id: "test:group-exorcism-debuff", name: "Test debuff", disposition: "debuff" as const, + dispelCategory: "magic" as const, durationMs: 20_000 }; + let auras = useCombatStore.getState().auras; + for (const member of members) auras = [...applyAura(auras, { + definition: auraDefinition, sourceId: "test:source", targetId: member.id, now: 1_000, + }).auras]; + useCombatStore.setState({ auras, resource: 100, resourcePools: { mana: 100, "nature-power": 0 } }); + const exorcismId = "rom-druid-rom-scout-elite-30"; + expect(useCombatStore.getState().castAbility(exorcismId, undefined, 1_000)) + .toMatchObject({ ok: false, reason: "not-enough-resource" }); + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, "nature-power": 1 }, + })); + + expect(useCombatStore.getState().castAbility(exorcismId, undefined, 2_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(2.1, 4_100); + expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(0); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === auraDefinition.id)).toBe(false); + }); + + it("resolves Champion Vacuum Wave against nearby enemies around the caster", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", raceId: "rom-human", level: 80, + }); + useCombatStore.getState().registerMob("vacuum-wave-nearby", { name: "Nearby", maxHealth: 10_000 }); + useCombatStore.getState().registerMob("vacuum-wave-far", { name: "Far", maxHealth: 10_000 }); + setMobPosition("vacuum-wave-nearby", [5, 0, 0]); + setMobPosition("vacuum-wave-far", [9, 0, 0]); + useCombatStore.setState({ resource: 100, resourcePools: { rage: 100 } }); + + const result = useCombatStore.getState().castAbility("rom-champion-primary-10", undefined, 1_000); + + expect(result).toMatchObject({ ok: true, affectedMobIds: ["vacuum-wave-nearby"] }); + }); + + it("resolves Champion Light Pulse against nearby enemies and injured party members", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80, + }); + usePartyStore.getState().initializeParty("rom-light-pulse", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const member = usePartyStore.getState().members[0]!; + usePartyStore.getState().damageMember(member.id, 500); + registerPartyRuntimePosition(member.id, [3, 0, 0]); + const memberBefore = usePartyStore.getState().members[0]!.health; + useCombatStore.getState().registerMob("light-pulse-nearby", { name: "Nearby", maxHealth: 10_000 }); + setMobPosition("light-pulse-nearby", [5, 0, 0]); + useCombatStore.setState({ resource: 100, resourcePools: { rage: 100, mana: 100 } }); + + const result = useCombatStore.getState().castAbility("rom-champion-rom-priest-elite-40", undefined, 1_000); + + expect(result).toMatchObject({ ok: true, affectedMobIds: ["light-pulse-nearby"] }); + expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(memberBefore); + }); + + it("lets Champion Overrule clear and protect against control", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", raceId: "rom-human", level: 80, + }); + useCombatStore.setState({ + controlledUntil: 2_000, + controlMechanic: "stun", + resource: 100, + resourcePools: { rage: 100 }, + }); + + expect(useCombatStore.getState().castAbility("rom-champion-primary-15", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.controlImmunities?.includes("stun"))).toBe(true); + }); + + it("applies Scout/Mage Aggro Transfer mana regeneration to its selected ally", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80, + }); + usePartyStore.getState().initializeParty("rom-aggro-transfer", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const member = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(member.id, [3, 0, 0]); + usePartyStore.getState().selectMember(member.id); + + expect(useCombatStore.getState().castAbility("rom-scout-rom-mage-elite-50", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id + && aura.definition.modifiers?.some((modifier) => modifier.kind === "resource" + && modifier.resource === "mana" && modifier.property === "regeneration"))).toBe(true); + }); + + it("chains Druid/Mage Earth Chain across three targets with 70% falloff", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80, + }); + const targetIds = ["earth-chain-a", "earth-chain-b", "earth-chain-c", "earth-chain-outside"]; + const positions = [[3, 0, 0], [8, 0, 0], [13, 0, 0], [21, 0, 0]] as const; + for (const [index, id] of targetIds.entries()) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 }); + setMobPosition(id, positions[index]!); + } + useCombatStore.getState().selectMob(targetIds[0]!); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, mana: 100, "nature-power": 100 }, + })); + + expect(useCombatStore.getState().castAbility("rom-druid-rom-mage-elite-15", undefined, 1_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(2, 3_000); + + const hits = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" + && event.abilityId === "rom-druid-rom-mage-elite-15"); + expect(hits.map((event) => event.targetActorId)).toEqual(targetIds.slice(0, 3)); + expect(hits[0]!.effectiveAmount).toBeGreaterThan(hits[1]!.effectiveAmount); + expect(hits[1]!.effectiveAmount).toBeGreaterThan(hits[2]!.effectiveAmount); + expect(useCombatStore.getState().mobs[targetIds[3]!]!.health).toBe(100_000); + }); + + it("applies Druid/Mage Shield of Light to up to three nearby allies", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80, + }); + usePartyStore.getState().initializeParty("rom-shield-of-light", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const members = usePartyStore.getState().members.slice(0, 4); + members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0])); + usePartyStore.getState().selectMember(members[2]!.id); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, mana: 100 }, + })); + expect(useCombatStore.getState().castAbility("rom-druid-rom-mage-elite-25", undefined, 1_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(2, 3_000); + const shieldEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "absorb" + && event.abilityId === "rom-druid-rom-mage-elite-25"); + expect(shieldEvents).toHaveLength(3); + expect(shieldEvents.some((event) => event.targetActorId === members[2]!.id)).toBe(true); + }); + it("uses native RoM Rage gains from auto attacks and health damage", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", diff --git a/src/game/romDruidNaturePower.test.ts b/src/game/romDruidNaturePower.test.ts new file mode 100644 index 00000000..627cbb04 --- /dev/null +++ b/src/game/romDruidNaturePower.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import type { AbilityEffect } from "./abilityCatalog"; +import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog"; +import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics"; + +const empty: RomCastMechanics = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 10_000, + targetHealthPercent: 1, +}; + +function cast(spellId: number, naturePower: number, passiveTags: readonly string[] = []): readonly AbilityEffect[] { + const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === spellId); + expect(ability, `RoM Druid spell ${spellId}`).toBeDefined(); + return romEffectsForCast(ability!, { ...empty, naturePower, passiveTags }); +} + +function passiveTag(spellId: number): string { + const passive = ROM_ELITE_ABILITIES.find((entry) => entry.dbcSpellId === spellId); + expect(passive, `RoM Druid passive ${spellId}`).toBeDefined(); + const effect = passive!.effects.find((entry) => entry.kind === "apply-aura"); + expect(effect?.kind).toBe("apply-aura"); + return effect?.kind === "apply-aura" ? effect.aura.utilityTags?.[0] ?? "" : ""; +} + +describe("RoM Druid Nature Power branches", () => { + it("selects the matching Mother Earth's Fountain heal and spends one point for DMG0", () => { + const baseline = cast(493529, 0).filter((effect) => effect.kind === "heal"); + const empowered = cast(493529, 1); + const poweredHeal = empowered.find((effect) => effect.kind === "heal"); + expect(baseline).toHaveLength(1); + expect(baseline[0]).toMatchObject({ kind: "heal", basePoints: 84 }); + expect(poweredHeal).toMatchObject({ + kind: "heal", basePoints: 67.2, scope: "party", center: "ability-target", radius: 10, + }); + expect(empowered.filter((effect) => effect.kind === "resource-drain")).toEqual([ + { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }, + ]); + }); + + it("orders powered Seed, Wrath, and Widow consumption before their source refund", () => { + for (const spellId of [493546, 493545, 493549, 494022]) { + const effects = cast(spellId, 1); + const drain = effects.findIndex((effect) => effect.kind === "resource-drain" + && effect.resource === "nature-power"); + const refund = effects.findIndex((effect) => effect.kind === "resource" + && effect.resource === "nature-power"); + expect(drain, `spell ${spellId} drain`).toBeGreaterThanOrEqual(0); + expect(refund, `spell ${spellId} refund`).toBeGreaterThan(drain); + } + expect(cast(493549, 0).filter((effect) => effect.kind === "damage")).toHaveLength(1); + expect(cast(493549, 1).filter((effect) => effect.kind === "damage")).toHaveLength(1); + }); + + it("selects the powered Sandstorm and Earth Chain payloads without simultaneous alternatives", () => { + const sandstormBase = cast(493542, 0); + const sandstormPowered = cast(493542, 1); + expect(sandstormBase.filter((effect) => effect.kind === "damage")).toHaveLength(1); + expect(sandstormPowered.filter((effect) => effect.kind === "damage")).toHaveLength(2); + expect(sandstormPowered.some((effect) => effect.kind === "summon")).toBe(false); + expect(sandstormPowered.some((effect) => effect.kind === "resource-drain" + && effect.resource === "nature-power")).toBe(true); + + const chain = cast(494025, 1); + expect(chain.filter((effect) => effect.kind === "damage")).toEqual([ + expect.objectContaining({ basePoints: 49.6, falloffPerJump: 0.7 }), + ]); + expect(chain.some((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power")).toBe(true); + }); + + it("selects Healing Wind's empowered heal and applies it to nearby party members", () => { + const effects = cast(494365, 1); + expect(effects.filter((effect) => effect.kind === "heal")).toEqual([ + expect.objectContaining({ kind: "heal", basePoints: 60, scope: "party", center: "caster", radius: 10 }), + ]); + expect(effects.some((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power")).toBe(true); + }); + + it("selects Heart Piercing's powered strike without dropping its Rage payload", () => { + const baseline = cast(494216, 0); + const empowered = cast(494216, 1); + expect(baseline.filter((effect) => effect.kind === "damage")).toHaveLength(1); + expect(empowered.filter((effect) => effect.kind === "damage")).toHaveLength(1); + expect(empowered).toContainEqual({ kind: "resource", resource: "rage", amount: 15 }); + expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }); + }); + + it("selects Cross of Thorns' two-hit branch and consumes one point without an unknown refund chance", () => { + const baseline = cast(494018, 0); + const empowered = cast(494018, 1); + const baselineHits = baseline.filter((effect) => effect.kind === "damage"); + const empoweredHits = empowered.filter((effect) => effect.kind === "damage"); + expect(baselineHits).toHaveLength(2); + expect(empoweredHits).toHaveLength(2); + expect(empoweredHits[0]).not.toEqual(baselineHits[0]); + expect(empowered.filter((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power")) + .toEqual([{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }]); + expect(empowered.some((effect) => effect.kind === "resource" && effect.resource === "nature-power")) + .toBe(false); + }); +}); + +describe("RoM Druid passive state triggers", () => { + it("applies Holy Fury's source-backed six-second Wrath duration only without Nature Power", () => { + const holyFury = passiveTag(494935); + const baseline = cast(493549, 0); + const withPassive = cast(493549, 0, [holyFury]); + const empowered = cast(493549, 1, [holyFury]); + expect(baseline.find((effect) => effect.kind === "dot" && effect.school === "nature")) + .toMatchObject({ ticks: 6, intervalMs: 2_000 }); + expect(withPassive.find((effect) => effect.kind === "dot" && effect.school === "nature")) + .toMatchObject({ ticks: 9, intervalMs: 2_000 }); + expect(empowered.find((effect) => effect.kind === "dot" && effect.school === "nature")) + .toMatchObject({ ticks: 6, intervalMs: 2_000 }); + }); + + it("extends Recover by six seconds with Extended Recovery and gates Knee Ulcer's five-second root", () => { + const recoverBase = cast(493528, 0); + const recoverExtended = cast(493528, 0, [passiveTag(494200)]); + expect(recoverBase.find((effect) => effect.kind === "hot")).toMatchObject({ ticks: 7, intervalMs: 2_000 }); + expect(recoverExtended.find((effect) => effect.kind === "hot")).toMatchObject({ ticks: 10, intervalMs: 2_000 }); + + expect(cast(494321, 0).filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root")) + .toHaveLength(0); + expect(cast(494321, 0, [passiveTag(499944)]).filter((effect) => effect.kind === "apply-aura" + && effect.control?.kind === "root")).toEqual([ + expect.objectContaining({ control: { kind: "root" }, aura: expect.objectContaining({ durationMs: 5_000 }) }), + ]); + }); +}); diff --git a/src/game/romDruidOverrides.ts b/src/game/romDruidOverrides.ts new file mode 100644 index 00000000..fcd8c2b8 --- /dev/null +++ b/src/game/romDruidOverrides.ts @@ -0,0 +1,273 @@ +import type { AbilityEffect } from "./abilityCatalog"; +import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; + +function sourceDurationMs(row: RomGeneratedAbilityLike, fallbackSeconds: number): number { + const duration = row.source?.components?.find((component) => (component.effectTime ?? 0) > 0)?.effectTime; + return Math.max(1_000, Math.round((duration ?? fallbackSeconds) * 1_000)); +} + +function spiritOfLifeHot(effects: readonly AbilityEffect[]): AbilityEffect { + const heals = effects.filter((effect): effect is Extract => effect.kind === "heal"); + const sum = (key: "coefficient" | "basePoints" | "dieSides" | "pointsPerLevel" | "coefficientPerLevel" + | "bonusPowerCoefficient" | "attackPowerCoefficient" | "spiritCoefficient" | "percentMaxHealth") => ( + heals.reduce((total, effect) => total + (effect[key] ?? 0), 0) + ); + const hot: Extract = { + kind: "hot", + coefficient: sum("coefficient"), + ticks: 10, + intervalMs: 4_000, + recipient: "caster", + scope: "party", + center: "caster", + radius: 7, + ...(sum("percentMaxHealth") ? { percentMaxHealth: sum("percentMaxHealth") } : {}), + ...(sum("basePoints") ? { basePoints: sum("basePoints") } : {}), + ...(sum("dieSides") ? { dieSides: sum("dieSides") } : {}), + ...(sum("pointsPerLevel") ? { pointsPerLevel: sum("pointsPerLevel") } : {}), + ...(sum("coefficientPerLevel") ? { coefficientPerLevel: sum("coefficientPerLevel") } : {}), + ...(sum("bonusPowerCoefficient") ? { bonusPowerCoefficient: sum("bonusPowerCoefficient") } : {}), + ...(sum("attackPowerCoefficient") ? { attackPowerCoefficient: sum("attackPowerCoefficient") } : {}), + ...(sum("spiritCoefficient") ? { spiritCoefficient: sum("spiritCoefficient") } : {}), + ...(heals.find((effect) => effect.sourceLevel !== undefined)?.sourceLevel === undefined + ? {} : { sourceLevel: heals.find((effect) => effect.sourceLevel !== undefined)!.sourceLevel }), + }; + return hot; +} + +function stateAura( + id: string, + name: string, + durationMs: number, + modifiers: NonNullable['aura']['modifiers']>, +): AbilityEffect { + return { + kind: "apply-aura", + recipient: "caster", + aura: { + id, + name, + disposition: "buff", + durationMs, + modifiers, + utilityTags: ["rom-druid-unity-with-mother-earth", "rom-druid-movement-locked"], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + }; +} + +function passiveState(sourceId: number, name: string, tag: string): AbilityEffect { + return { + kind: "apply-aura", + recipient: "caster", + aura: { + id: `rom-druid-passive-${sourceId}`, + name, + disposition: "buff", + durationMs: null, + utilityTags: [tag], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: sourceId, + }; +} + +/** Source-specific player effects for Druid rows the generic translator cannot express. */ +export function overrideRomDruidEffects( + row: RomGeneratedAbilityLike, + translated: readonly AbilityEffect[], +): readonly AbilityEffect[] | null { + switch (row.sourceSkillId) { + case 493531: // Savage Blessing buffs a selected friendly target. + return translated.map((effect) => effect.kind === "apply-aura" ? { + ...effect, + recipient: "ability-target", + aura: { ...effect.aura, disposition: "buff" }, + } : effect); + case 493536: { // Spirit Guidance charms a non-player enemy for the source effect duration. + const effectTimeSeconds = row.source?.components?.find((component) => component.id === 503805)?.effectTime ?? 3; + return [{ + kind: "status", + status: "charm", + durationMs: Math.max(1_000, Math.round(effectTimeSeconds * 1_000)), + maxTargets: 1, + breakOnDamage: false, + }]; + } + case 493548: // Unity guarantees critical spell results and adds 10% healing for 15 seconds. + return [stateAura( + "rom-druid-unity-with-mother-earth", + row.name, + sourceDurationMs(row, 15), + [ + { kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 100 }, + { kind: "healing", direction: "done", operation: "percent", value: 0.1 }, + ], + )]; + case 494200: // Extended Recovery: passive state extends Recover by six seconds. + return [passiveState(494200, row.name, "rom-druid-extended-recovery")]; + case 494935: // Holy Fury: enables the zero-Nature-Power Wrath duration branch. + return [passiveState(494935, row.name, "rom-druid-holy-fury")]; + case 499944: // Knee Ulcer gates the five-second root child on Necrotic Wound. + return [passiveState(499944, row.name, "rom-druid-knee-ulcer")]; + case 494025: // Preserve both Earth Chain payload branches for cast-time selection. + return translated.filter((effect) => effect.kind === "damage").map((effect) => ({ + ...effect, + falloffPerJump: 0.7, + })); + case 494321: // Its root is supplied by the optional Knee Ulcer passive. + return translated.filter((effect) => effect.kind !== "apply-aura" || effect.control?.kind !== "root"); + case 494938: // 505938 supplies the 40-second lifetime; the tooltip states 4-second party pulses. + return [spiritOfLifeHot(translated)]; + default: + return null; + } +} + +function hasTag(tags: readonly string[] | ReadonlySet, tag: string): boolean { + return Array.isArray(tags) ? tags.includes(tag) : (tags as ReadonlySet).has(tag); +} + +function naturePowerDrain(): AbilityEffect { + return { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }; +} + +function spendBeforeNaturePowerRefund(effects: readonly AbilityEffect[]): readonly AbilityEffect[] { + const refundIndex = effects.findIndex((effect) => effect.kind === "resource" + && effect.resource === "nature-power" && effect.amount > 0); + if (refundIndex < 0) return [...effects, naturePowerDrain()]; + return [...effects.slice(0, refundIndex), naturePowerDrain(), ...effects.slice(refundIndex)]; +} + +/** Resolve Druid source branches that depend on current Nature Power or learned passive state. */ +export function romDruidCastEffects( + ability: import("./abilityCatalog").AbilityDefinition, + effects: readonly AbilityEffect[], + naturePower: number, + passiveTags: readonly string[] | ReadonlySet = [], +): readonly AbilityEffect[] { + if (ability.source !== "runewaker") return effects; + const empowered = naturePower >= 1; + const damage = effects.filter((effect) => effect.kind === "damage"); + const heals = effects.filter((effect): effect is Extract => effect.kind === "heal"); + const selectDamage = (index: number, count = 1): readonly AbilityEffect[] => { + const selected = damage[index]; + return selected ? Array.from({ length: count }, () => selected) : []; + }; + + switch (ability.dbcSpellId) { + case 493529: { // DMG1 is baseline; DMG0 is the one-point Nature Power branch. + const selected = heals[empowered ? 1 : 0] ?? heals[0]; + if (!selected) return effects; + return [ + ...effects.filter((effect) => effect.kind !== "heal"), + { + ...selected, + scope: "party", + center: "ability-target", + radius: 10, + }, + ...(empowered ? [naturePowerDrain()] : []), + ]; + } + case 493546: // Weakening Seed consumes and refunds one point when powered. + case 493545: // Withering Seed has the same source resource branch. + return empowered ? spendBeforeNaturePowerRefund(effects) : effects; + case 493549: { // Select DMG1/DMG0, keep the single source DoT, and consume one point if powered. + const selectedDamage = damage[empowered ? 1 : 0]; + if (!selectedDamage) return effects; + const selected = effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage) + .map((effect) => ( + effect.kind === "dot" && !empowered && hasTag(passiveTags, "rom-druid-holy-fury") + ? { ...effect, ticks: effect.ticks + 3 } + : effect + )); + return empowered ? spendBeforeNaturePowerRefund(selected) : selected; + } + case 493542: { // The powered DMG0 component hits all targets twice; no creature summon row exists. + const hits = selectDamage(empowered ? 1 : 0, empowered ? 2 : 1); + return [ + ...hits, + ...effects.filter((effect) => effect.kind !== "damage" && effect.kind !== "summon"), + ...(empowered ? [naturePowerDrain()] : []), + ]; + } + case 494025: { // Earth Chain keeps its 70% per-jump falloff in either branch. + const selectedDamage = damage[empowered ? 1 : 0]; + if (!selectedDamage) return effects; + return [selectedDamage, ...(empowered ? [naturePowerDrain()] : [])]; + } + case 494365: { // Healing Wind selects DMG1 or DMG0 and heals the caster's nearby party. + const selected = heals[empowered ? 1 : 0] ?? heals[0]; + if (!selected) return effects; + return [{ + ...selected, + scope: "party", + center: "caster", + radius: 10, + }, ...(empowered ? [naturePowerDrain()] : [])]; + } + case 494216: { // Heart Piercing selects its DMG1/DMG0 strike and spends one point when powered. + const selectedDamage = damage[empowered ? 1 : 0]; + if (!selectedDamage) return effects; + return [ + ...effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage), + ...(empowered ? [naturePowerDrain()] : []), + ]; + } + case 494018: { // Cross of Thorns uses two DMG1 hits or two DMG0 hits after spending one point. + const selectedHits = empowered ? selectDamage(1, 2) : [damage[0], damage[2]].filter((effect) => effect !== undefined); + if (!selectedHits.length) return effects; + const selected = effects.filter((effect) => effect.kind !== "damage" + && !(empowered && effect.kind === "resource" && effect.resource === "nature-power")); + return [...selectedHits, ...selected, ...(empowered ? [naturePowerDrain()] : [])]; + } + case 494022: { // Poisonous Widow Embrace selects one damage component and refunds a spent point. + const selectedDamage = damage[empowered ? 1 : 0]; + if (!selectedDamage) return effects; + const selected = effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage); + return empowered ? spendBeforeNaturePowerRefund(selected) : selected; + } + case 493528: { // Extended Recovery's source-backed duration increase; proc chance is absent. + if (!hasTag(passiveTags, "rom-druid-extended-recovery")) return effects; + return effects.map((effect) => effect.kind === "hot" + ? { ...effect, ticks: effect.ticks + 3 } + : effect); + } + case 494321: { // The passive's root is only emitted when Knee Ulcer is learned. + if (!hasTag(passiveTags, "rom-druid-knee-ulcer")) return effects; + return [...effects, { + kind: "apply-aura", + recipient: "ability-target", + aura: { + id: "rom-druid-knee-ulcer-root", + name: "Knee Ulcer", + disposition: "debuff", + durationMs: 5_000, + dispelCategory: "magic", + }, + control: { kind: "root" }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 499944, + }]; + } + default: + return effects; + } +} + +/** Add Spirit of Life's source target-max-health component to a periodic heal tick. */ +export function romDruidPeriodicHealAmount( + baseAmount: number, + targetMaxHealth: number, + percentMaxHealth: number, +): number { + return Math.max(0, baseAmount) + Math.max(0, targetMaxHealth) * Math.max(0, percentMaxHealth); +} diff --git a/src/game/romDruidRuntime.test.ts b/src/game/romDruidRuntime.test.ts new file mode 100644 index 00000000..904f00b3 --- /dev/null +++ b/src/game/romDruidRuntime.test.ts @@ -0,0 +1,188 @@ +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { installCombatClock } from "./combatClock"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { companionResourceMaximum } from "./combatActorAuthority"; +import { romDruidPeriodicHealAmount } from "./romDruidOverrides"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; + +const TARGET_ID = "rom-druid-runtime-target"; + +function ability(spellId: number) { + const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId); + expect(result, `RoM Druid spell ${spellId}`).toBeDefined(); + return result!; +} + +function finishCast(now: number) { + const cast = useCombatStore.getState().activeCast; + if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt); +} + +function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout") { + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", + ...(secondaryClassId ? { secondaryClassId } : {}), + raceId: "rom-elf", + level: 80, + }); + useCombatStore.setState((state) => ({ + resource: 1_000, + maxResource: 1_000, + resourcePools: { ...state.resourcePools, mana: 1_000, "nature-power": 0 }, + maxResourcePools: { ...state.maxResourcePools, mana: 1_000 }, + })); +} + +describe("RuneWaker Druid runtime", () => { + beforeAll(async () => { + await preloadAbilityCatalogsForClasses(["rom-druid"]); + }); + + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + it("casts Spirit Guidance for one Nature Power and applies the source three-second Charm", () => { + prepareDruid(); + useCombatStore.getState().registerMob(TARGET_ID, { name: "Guidance Target", maxHealth: 10_000 }); + setMobPosition(TARGET_ID, [5, 0, 0]); + const spiritGuidance = ability(493536); + + expect(spiritGuidance.costs).toContainEqual({ resource: "nature-power", amount: 1 }); + expect(useCombatStore.getState().castAbility(spiritGuidance.id, { targetActorId: TARGET_ID, now: 1_000 })) + .toMatchObject({ ok: false, reason: "not-enough-resource" }); + useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, "nature-power": 1 } })); + expect(useCombatStore.getState().castAbility(spiritGuidance.id, { targetActorId: TARGET_ID, now: 3_000 }).ok).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + expect(activeCast?.abilityId).toBe(spiritGuidance.id); + finishCast(3_000); + expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(0); + expect(useCombatStore.getState().mobs[TARGET_ID]!.statuses).toContainEqual(expect.objectContaining({ + kind: "charm", + endsAt: activeCast!.completesAt + 3_000, + })); + // The combat model supports the Charm control effect; it has no faction/allegiance conversion state. + }); + + it("applies Unity with Mother Earth as a 15-second healing and guaranteed spell-crit aura", () => { + prepareDruid(); + const unity = ability(493548); + expect(unity.target).toBe("self"); + expect(useCombatStore.getState().castAbility(unity.id, { now: 1_000 }).ok).toBe(true); + const aura = useCombatStore.getState().auras.find((entry) => ( + entry.targetId === PLAYER_AURA_ENTITY_ID + && entry.definition.utilityTags?.includes("rom-druid-unity-with-mother-earth") + )); + expect(aura?.expiresAt).toBe(16_000); + expect(aura?.definition.modifiers).toContainEqual({ + kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 100, + }); + expect(aura?.definition.modifiers).toContainEqual({ + kind: "healing", direction: "done", operation: "percent", value: 0.1, + }); + + useCombatStore.getState().registerMob(TARGET_ID, { name: "Unity Target", maxHealth: 10_000 }); + setMobPosition(TARGET_ID, [5, 0, 0]); + const arrow = ability(493538); + expect(useCombatStore.getState().castAbility(arrow.id, { targetActorId: TARGET_ID, now: 3_000 }).ok).toBe(true); + finishCast(3_000); + expect(useCombatStore.getState().lastCombatResult?.critical).toBe(true); + }); + + it("prevents movement during Unity and restores movement when its 15-second aura expires", () => { + let now = 1_000; + const restoreClock = installCombatClock(() => now); + try { + prepareDruid(); + const unity = ability(493548); + expect(useCombatStore.getState().castAbility(unity.id, { now }).ok).toBe(true); + useCombatStore.getState().setPlayerPosition([6, 0, 0]); + expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 0]); + + now = 16_001; + useCombatStore.getState().setPlayerPosition([6, 0, 0]); + expect(useCombatStore.getState().playerPosition).toEqual([6, 0, 0]); + } finally { + restoreClock(); + } + }); + + it("raises Nature Power's maximum to 15 only after learning Brilliance of Nature", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 44, + }); + expect(useCombatStore.getState().maxResourcePools["nature-power"]).toBe(10); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 45, + }); + expect(useCombatStore.getState().abilities.some((entry) => entry.dbcSpellId === 494931 && entry.passive)).toBe(true); + expect(useCombatStore.getState().maxResourcePools["nature-power"]).toBe(15); + + const companion = createConfiguredPartyMember({ + name: "Druid Companion", + classId: "rom-druid", + secondaryClassId: "rom-scout", + raceId: "rom-elf", + specialization: "Primary", + role: "healer", + }, 45, 0, "rom-druid-brilliance-cap"); + expect(companionResourceMaximum(companion, "nature-power")).toBe(15); + }); + + it("adds exactly one percent of each healing target's max health to the Spirit of Life tick", () => { + expect(romDruidPeriodicHealAmount(500, 10_000, 0.01)).toBe(600); + expect(romDruidPeriodicHealAmount(500, 5_000, 0.01)).toBe(550); + }); + + it("gives Spirit of Life a 40-second, four-second party heal within its source radius", () => { + prepareDruid("rom-warden"); + usePartyStore.getState().initializeParty("rom-druid-spirit-of-life", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const [near, far] = usePartyStore.getState().members; + expect(near).toBeDefined(); + expect(far).toBeDefined(); + useCombatStore.setState({ health: 1 }); + usePartyStore.setState((state) => ({ members: state.members.map((member) => ({ ...member, health: 1 })) })); + registerPartyRuntimePosition(near!.id, [5, 0, 0]); + registerPartyRuntimePosition(far!.id, [12, 0, 0]); + useCombatStore.getState().synchronizeActors(); + const spirit = ability(494938); + const hot = spirit.effects.find((effect) => effect.kind === "hot"); + expect(hot).toMatchObject({ + ticks: 10, intervalMs: 4_000, scope: "party", radius: 7, center: "caster", percentMaxHealth: 0.01, + }); + expect(spirit.effects.some((effect) => effect.kind === "summon" || effect.kind === "heal")).toBe(false); + const beforePlayer = useCombatStore.getState().health; + const beforeNear = usePartyStore.getState().members.find((member) => member.id === near!.id)!.health; + const beforeFar = usePartyStore.getState().members.find((member) => member.id === far!.id)!.health; + expect(useCombatStore.getState().castAbility(spirit.id, { now: 1_000 }).ok).toBe(true); + expect(useCombatStore.getState().summons.some((summon) => summon.sourceAbilityId === spirit.id)).toBe(false); + useCombatStore.getState().tick(4.1, 5_100); + + expect(useCombatStore.getState().health).toBeGreaterThan(beforePlayer); + expect(usePartyStore.getState().members.find((member) => member.id === near!.id)!.health).toBeGreaterThan(beforeNear); + expect(usePartyStore.getState().members.find((member) => member.id === far!.id)!.health).toBe(beforeFar); + }); + + it("casts Savage Blessing on the selected friendly target with beneficial hit buffs", () => { + prepareDruid(); + usePartyStore.getState().initializeParty("rom-druid-savage-blessing", 80, useCombatStore.getState().health, "healer", 5, "rom"); + const ally = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(ally.id, [4, 0, 0]); + useCombatStore.getState().synchronizeActors(); + const blessing = ability(493531); + + expect(blessing.target).toBe("friendly"); + expect(useCombatStore.getState().castAbility(blessing.id, { targetActorId: ally.id, now: 1_000 }).ok).toBe(true); + finishCast(1_000); + const buffs = useCombatStore.getState().auras.filter((aura) => aura.definition.name.startsWith("Savage Blessing")); + expect(buffs.length).toBeGreaterThanOrEqual(2); + expect(buffs.every((aura) => aura.targetId === ally.id && aura.definition.disposition === "buff")).toBe(true); + }); +}); diff --git a/src/game/romKnightOverrides.ts b/src/game/romKnightOverrides.ts new file mode 100644 index 00000000..63e20c2d --- /dev/null +++ b/src/game/romKnightOverrides.ts @@ -0,0 +1,81 @@ +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; + +export const ROM_KNIGHT_DIVINE_PUNISHMENT_TAG = "rom-divine-punishment"; +export const ROM_KNIGHT_GODS_VENGEANCE_TAG = "rom-gods-vengeance"; +export const ROM_KNIGHT_SHIELD_OF_VALOR_TAG = "rom-shield-of-valor"; + +function divinePunishmentPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] { + return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-knight-passive-491608", + name: row.name, + disposition: "buff", + durationMs: null, + utilityTags: [ROM_KNIGHT_DIVINE_PUNISHMENT_TAG], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 491608, + }]; +} + +/** Source-backed Knight passive and cast adjustments. */ +export function overrideRomKnightEffects( + row: RomGeneratedAbilityLike, + translated: readonly AbilityEffect[], +): readonly AbilityEffect[] | null { + if (row.sourceSkillId === 491608 && row.passive) return divinePunishmentPassive(row); + if (row.sourceSkillId === 499934 && row.passive) return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-knight-passive-499934", + name: row.name, + disposition: "buff", + durationMs: null, + utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 499934, + }]; + if (row.sourceSkillId === 490468) return translated.map((effect) => effect.kind === "apply-aura" ? { + ...effect, + aura: { + ...effect.aura, + utilityTags: [...(effect.aura.utilityTags ?? []), ROM_KNIGHT_SHIELD_OF_VALOR_TAG], + }, + } : effect); + return null; +} + +/** Apply Divine Punishment's extracted +1% only to its named source skill. */ +export function romKnightCastEffects( + ability: AbilityDefinition, + effects: readonly AbilityEffect[], + passiveTags: readonly string[] | ReadonlySet = [], +): readonly AbilityEffect[] { + if (ability.source !== "runewaker" + || ability.classId !== "rom-knight" + || ability.dbcSpellId !== 490151 + || !(Array.isArray(passiveTags) + ? passiveTags.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG) + : (passiveTags as ReadonlySet).has(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG))) return effects; + + return effects.map((effect) => { + if (effect.kind !== "damage" && effect.kind !== "dot") return effect; + return { + ...effect, + coefficient: effect.coefficient * 1.01, + ...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 1.01 }), + ...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * 1.01 }), + ...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * 1.01 }), + ...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * 1.01 }), + }; + }); +} diff --git a/src/game/romKnightPeriodicRuntime.test.ts b/src/game/romKnightPeriodicRuntime.test.ts new file mode 100644 index 00000000..d2e170bc --- /dev/null +++ b/src/game/romKnightPeriodicRuntime.test.ts @@ -0,0 +1,323 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { + ROM_KNIGHT_DIVINE_PUNISHMENT_TAG, + ROM_KNIGHT_GODS_VENGEANCE_TAG, + ROM_KNIGHT_SHIELD_OF_VALOR_TAG, +} from "./romKnightOverrides"; +import { romEffectsForCast } from "./romCombatMechanics"; + +function stableCombatUnit(seed: string): number { + let hash = 2166136261; + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function godsVengeanceCastTime(wantProc: boolean): number { + for (let now = 11_500; now < 100_000; now += 100) { + const roll = stableCombatUnit( + `${PLAYER_AURA_ENTITY_ID}:rom-knight-general-0:gods-vengeance-target:${now}:rom-gods-vengeance`, + ); + if ((roll < 0.5) === wantProc) return now; + } + throw new Error("Could not find a deterministic God's Vengeance cast-time seed."); +} + +describe("RuneWaker Knight periodic skills", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + it("keeps Holy Light Domain as a three-pulse Holy area effect", () => { + const domain = abilityById("rom-knight-rom-mage-elite-15")!; + expect(domain).toMatchObject({ dbcSpellId: 491606, target: "self", radius: 10 }); + expect(domain.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "dot", + school: "holy", + ticks: 3, + intervalMs: 2_000, + tag: "rom-knight-holy-light-domain", + }), + ])); + expect(domain.effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.procs?.some((proc) => proc.action.kind === "custom" + && proc.action.id === "rom-trigger-skill"))).toBe(false); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80, + }); + for (const [id, x] of [["domain-near", 5], ["domain-edge", 9], ["domain-far", 12]] as const) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 }); + setMobPosition(id, [x, 0, 0]); + } + const initialHealth = Object.fromEntries(Object.entries(useCombatStore.getState().mobs) + .map(([id, mob]) => [id, mob.health])); + expect(useCombatStore.getState().castAbility(domain.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().effects.filter((effect) => effect.kind === "dot") + .map((effect) => effect.targetId).sort()).toEqual(["domain-edge", "domain-near"]); + expect(useCombatStore.getState().mobs["domain-near"]!.health).toBe(initialHealth["domain-near"]); + + useCombatStore.getState().tick(0.1, 3_000); + const afterFirstPulse = useCombatStore.getState(); + expect(afterFirstPulse.mobs["domain-near"]!.health).toBeLessThan(initialHealth["domain-near"]!); + expect(afterFirstPulse.mobs["domain-edge"]!.health).toBeLessThan(initialHealth["domain-edge"]!); + expect(afterFirstPulse.mobs["domain-far"]!.health).toBe(initialHealth["domain-far"]); + }); + + it("adds War Prayer Holy Seal stacks on its six three-second pulses", () => { + const prayer = abilityById("rom-knight-rom-mage-elite-50")!; + expect(prayer).toMatchObject({ dbcSpellId: 490078, target: "hostile" }); + expect(prayer.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "damage", school: "holy" }), + expect.objectContaining({ + kind: "dot", + ticks: 6, + intervalMs: 3_000, + tag: "rom-knight-war-prayer-seal", + }), + ])); + expect(prayer.effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.procs?.some((proc) => proc.action.kind === "custom" + && proc.action.id === "rom-trigger-skill"))).toBe(false); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80, + }); + useCombatStore.getState().registerMob("war-prayer-target", { name: "War Prayer Target", maxHealth: 100_000 }); + setMobPosition("war-prayer-target", [3, 0, 0]); + useCombatStore.getState().selectMob("war-prayer-target"); + expect(useCombatStore.getState().castAbility(prayer.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().comboPoints).toBe(0); + expect(useCombatStore.getState().effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "dot", targetId: "war-prayer-target", remainingTicks: 6 }), + ])); + + for (let tick = 1; tick <= 6; tick += 1) { + useCombatStore.getState().tick(0.1, 1_000 + tick * 3_000); + expect(useCombatStore.getState().comboTargetId).toBe("war-prayer-target"); + expect(useCombatStore.getState().comboPoints).toBe(Math.min(tick, 4)); + } + }); + + it("limits Divine Punishment's sourced 1% bonus to Punishment", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80, + }); + const state = useCombatStore.getState(); + const passive = state.abilities.find((ability) => ability.dbcSpellId === 491608); + expect(passive?.passive).toBe(true); + const passiveAura = state.auras.find((aura) => aura.definition.utilityTags?.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG)); + expect(passiveAura).toBeDefined(); + expect(passive?.effects).toEqual([ + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ + utilityTags: [ROM_KNIGHT_DIVINE_PUNISHMENT_TAG], + }), + }), + ]); + const passiveEffect = passive?.effects[0]; + expect(passiveEffect?.kind).toBe("apply-aura"); + if (passiveEffect?.kind === "apply-aura") expect(passiveEffect.aura.modifiers).toBeUndefined(); + + const punishment = abilityById("rom-knight-general-0")!; + const context = { + sealStacks: 4, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: state.maxHealth, + targetHealthPercent: 1, + } as const; + const baseline = romEffectsForCast(punishment, context); + const boosted = romEffectsForCast(punishment, { + ...context, + passiveTags: passiveAura?.definition.utilityTags ?? [], + }); + const damageValues = (effects: typeof baseline) => effects + .filter((effect) => effect.kind === "damage" || effect.kind === "dot") + .map((effect) => ({ + coefficient: effect.coefficient, + basePoints: "basePoints" in effect ? effect.basePoints ?? 0 : 0, + pointsPerLevel: "pointsPerLevel" in effect ? effect.pointsPerLevel ?? 0 : 0, + })); + expect(damageValues(baseline)).toHaveLength(2); + expect(damageValues(boosted)).toHaveLength(2); + for (const [index, base] of damageValues(baseline).entries()) { + expect(damageValues(boosted)[index]!.coefficient).toBeCloseTo(base.coefficient * 1.01); + expect(damageValues(boosted)[index]!.basePoints).toBeCloseTo(base.basePoints * 1.01); + expect(damageValues(boosted)[index]!.pointsPerLevel).toBeCloseTo(base.pointsPerLevel * 1.01); + } + expect(romEffectsForCast(abilityById("rom-knight-primary-1")!, { + ...context, + passiveTags: passiveAura?.definition.utilityTags ?? [], + })).toEqual(romEffectsForCast(abilityById("rom-knight-primary-1")!, context)); + }); + + it.each([true, false])("lets God's Vengeance preserve Holy Seal when the 50% roll is %s", (preservesSeal) => { + const godVengeance = abilityById("rom-knight-rom-rogue-elite-50")!; + expect(godVengeance).toMatchObject({ dbcSpellId: 499934, passive: true }); + expect(godVengeance.effects).toEqual([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG] }), + }), + ]); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 80, + }); + useCombatStore.getState().registerMob("gods-vengeance-target", { + name: "God's Vengeance Target", maxHealth: 1_000_000, + }); + setMobPosition("gods-vengeance-target", [3, 0, 0]); + useCombatStore.getState().selectMob("gods-vengeance-target"); + + expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + targetId: PLAYER_AURA_ENTITY_ID, + definition: expect.objectContaining({ utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG] }), + }), + ])); + expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().comboPoints).toBe(4); + + const castTime = godsVengeanceCastTime(preservesSeal); + expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, castTime)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().comboPoints).toBe(preservesSeal ? 4 : 0); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" + && effect.targetId === "gods-vengeance-target" && effect.tag === "rom-holy-seal")) + .toBe(preservesSeal); + expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage" + && event.abilityId === "rom-knight-general-0" && event.targetActorId === "gods-vengeance-target" + && event.effectiveAmount > 0)).toBe(true); + }); + + it("reduces Holy Seal cooldown by one second when Shield of Valor parries", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", raceId: "rom-human", level: 80, role: "tank", + }); + const holySeal = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490149)!; + const shieldOfValor = abilityById("rom-knight-primary-10")!; + expect(shieldOfValor).toMatchObject({ dbcSpellId: 490468 }); + + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, parryRating: 100_000 }, + })); + expect(useCombatStore.getState().castAbility(shieldOfValor.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + targetId: PLAYER_AURA_ENTITY_ID, + definition: expect.objectContaining({ utilityTags: expect.arrayContaining([ROM_KNIGHT_SHIELD_OF_VALOR_TAG]) }), + }), + ])); + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [holySeal.id]: 25_000 }, + })); + + useCombatStore.getState().registerMob("shield-of-valor-attacker", { + name: "Shield of Valor Attacker", + maxHealth: 100_000, + attacks: [DEFAULT_ENEMY_ATTACK], + }); + setMobPosition("shield-of-valor-attacker", [2, 0, 0]); + useCombatStore.getState().damageMob("shield-of-valor-attacker", 1, 1_100); + let parryAt: number | null = null; + for (let now = 1_450; now < 16_000; now += 100) { + useCombatStore.getState().advanceMobCombat(now); + if (useCombatStore.getState().lastCombatResult?.parried) { + parryAt = now; + break; + } + } + + expect(parryAt).not.toBeNull(); + expect(useCombatStore.getState().cooldowns[holySeal.id]).toBe(24_000); + }); + + it("activates the source-backed Evil Shackles passive state", () => { + const passive = abilityById("rom-knight-rom-scout-elite-60")!; + expect(passive).toMatchObject({ dbcSpellId: 499926, passive: true }); + expect(passive.effects).toEqual([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ + durationMs: null, + utilityTags: ["rom-evil-shackles"], + }), + }), + ]); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80, + }); + expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + definition: expect.objectContaining({ utilityTags: ["rom-evil-shackles"] }), + }), + ])); + }); + + it("lets Evil Shackles root non-Undead/Demon targets for half duration", () => { + const holyChains = abilityById("rom-knight-rom-scout-elite-25")!; + const rootDuration = (targetTags: readonly string[]) => { + const root = romEffectsForCast(holyChains, { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 2_000, + targetHealthPercent: 1, + passiveTags: ["rom-evil-shackles"], + targetTags, + }).find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"); + return root?.kind === "apply-aura" ? root.aura.durationMs : undefined; + }; + expect(rootDuration(["rom-target-undead-demon"])).toBe(10_000); + expect(rootDuration(["rom-target-living"])).toBe(5_000); + expect(rootDuration([])).toBe(5_000); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 50, + }); + useCombatStore.getState().registerMob("shackles-target", { + name: "Living target", maxHealth: 100_000, creatureFamily: "living", + }); + setMobPosition("shackles-target", [3, 0, 0]); + useCombatStore.getState().selectMob("shackles-target"); + expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 1_000)).toMatchObject({ + ok: false, reason: "condition-not-met", + }); + useCombatStore.getState().registerMob("shackles-target", { + name: "Unknown target", maxHealth: 100_000, creatureFamily: "unknown", + }); + expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 2_000)).toMatchObject({ + ok: false, reason: "condition-not-met", + }); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80, + }); + useCombatStore.getState().registerMob("shackles-target", { + name: "Living target", maxHealth: 100_000, creatureFamily: "living", + }); + setMobPosition("shackles-target", [3, 0, 0]); + useCombatStore.getState().selectMob("shackles-target"); + expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 3_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs["shackles-target"]!.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "root", endsAt: 8_000 }), + ])); + }); +}); diff --git a/src/game/romMageMechanics.test.ts b/src/game/romMageMechanics.test.ts new file mode 100644 index 00000000..9c3553b4 --- /dev/null +++ b/src/game/romMageMechanics.test.ts @@ -0,0 +1,365 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityById, resourceProfileForClass } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { applyAura, resolveResourceValue } from "./combatAuras"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + ROM_MAGE_WARLOCK_PASSIVE_TAGS, + romMageWarlockCastEffects, +} from "./romMageWarlockOverrides"; +import { applyTalentModifierValue } from "./talentModifiers"; + +function initializeMage(secondaryClassId?: "rom-druid" | "rom-warden" | "rom-champion", level = 80): void { + useCombatStore.getState().initializeCharacter({ + classId: "rom-mage", + secondaryClassId, + raceId: "rom-human", + level, + }); +} + +function addTarget(id: string, x: number, z = 0): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 }); + setMobPosition(id, [x, 0, z]); +} + +describe("RoM Mage mechanics", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + it("maps Elemental Catalysis mana percentage regeneration against maximum mana", () => { + const catalysis = abilityById("rom-mage-primary-0")!; + expect(catalysis.effects).toContainEqual(expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ + durationMs: 20_000, + modifiers: expect.arrayContaining([ + expect.objectContaining({ + kind: "stat", + stat: "mana-regeneration-max-percent-per-second", + operation: "flat", + value: 0.01, + }), + ]), + }), + })); + + initializeMage(undefined, 60); + addTarget("catalysis-combat", 3); + useCombatStore.setState((state) => ({ + ...state, + resource: state.maxResource / 2, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 }, + lastResourceSpentAt: { mana: 1_000 }, + mobs: { ...state.mobs, "catalysis-combat": { ...state.mobs["catalysis-combat"]!, engaged: true } }, + })); + const before = useCombatStore.getState().resourcePools.mana!; + expect(useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "mana-regeneration-max-percent-per-second" && modifier.value === 0.01))).toBe(true); + useCombatStore.getState().tick(1, 2_000); + useCombatStore.getState().tick(1, 3_000); + const restored = useCombatStore.getState().resourcePools.mana! - before; + expect(restored).toBeGreaterThanOrEqual(useCombatStore.getState().maxResourcePools.mana! * 0.02); + }); + + it("uses rank-two Catalysis's explicit 20-second damage and spell-speed payload without adding a mana tick", () => { + initializeMage(undefined, 65); + const catalysis = abilityById("rom-mage-primary-0")!; + const rankTwo = catalysis.ranks?.find((rank) => rank.spellId === 497971); + expect(rankTwo).toBeDefined(); + expect(rankTwo?.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ + durationMs: 20_000, + modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", attackKind: "spell", value: 0.2 })], + }), + }), + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ + durationMs: 20_000, + modifiers: [expect.objectContaining({ kind: "stat", stat: "spell-haste", value: 50 })], + }), + }), + ])); + expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "mana-regeneration-max-percent-per-second"))).toBe(false); + + addTarget("catalysis-rank-two-combat", 3); + useCombatStore.setState((state) => ({ + ...state, + lastResourceSpentAt: { mana: 1_000 }, + mobs: { ...state.mobs, "catalysis-rank-two-combat": { ...state.mobs["catalysis-rank-two-combat"]!, engaged: true } }, + })); + const result = useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.id.includes("497971") + && aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" + && modifier.attackKind === "spell" && modifier.value === 0.2))).toBe(true); + }); + + it("applies rank-two Catalysis spell haste to Mage cast time while rank one stays unchanged", () => { + const flame = abilityById("rom-mage-primary-1")!; + expect(flame).toMatchObject({ name: "Flame", castTimeMs: 3_000 }); + + initializeMage(undefined, 65); + addTarget("catalysis-haste-rank-two-baseline", 3); + useCombatStore.getState().selectMob("catalysis-haste-rank-two-baseline"); + expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true }); + const rankTwoBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs; + + initializeMage(undefined, 65); + clearMobRuntimeRegistry(); + addTarget("catalysis-haste-rank-two-target", 3); + useCombatStore.getState().selectMob("catalysis-haste-rank-two-target"); + expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 3_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.id.includes("497971") + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "spell-haste" && modifier.value === 50))).toBe(true); + expect(useCombatStore.getState().castAbility(flame.id, undefined, 5_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().activeCast).toMatchObject({ + abilityId: flame.id, + startedAt: 5_000, + originalDurationMs: Math.round(rankTwoBaselineMs / 1.5), + completesAt: 5_000 + Math.round(rankTwoBaselineMs / 1.5), + }); + + initializeMage(undefined, 60); + clearMobRuntimeRegistry(); + addTarget("catalysis-haste-rank-one-baseline", 3); + useCombatStore.getState().selectMob("catalysis-haste-rank-one-baseline"); + expect(useCombatStore.getState().castAbility(flame.id, undefined, 7_000)).toMatchObject({ ok: true }); + const rankOneBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs; + + initializeMage(undefined, 60); + clearMobRuntimeRegistry(); + addTarget("catalysis-haste-rank-one-target", 3); + useCombatStore.getState().selectMob("catalysis-haste-rank-one-target"); + expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 9_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "spell-haste"))).toBe(false); + expect(useCombatStore.getState().castAbility(flame.id, undefined, 11_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().activeCast).toMatchObject({ + abilityId: flame.id, + startedAt: 11_000, + originalDurationMs: rankOneBaselineMs, + completesAt: 11_000 + rankOneBaselineMs, + }); + }); + + it("does not apply a RoM spell-haste aura to another class's cast time", () => { + useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 }); + addTarget("non-mage-haste-baseline", 3); + useCombatStore.getState().selectMob("non-mage-haste-baseline"); + expect(useCombatStore.getState().castAbility("priest-smite", undefined, 1_000)).toMatchObject({ ok: true }); + const priestBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs; + + useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 }); + clearMobRuntimeRegistry(); + addTarget("non-mage-haste-target", 3); + useCombatStore.getState().selectMob("non-mage-haste-target"); + useCombatStore.setState((state) => ({ + auras: applyAura(state.auras, { + definition: { + id: "rom-catalysis-non-mage-test", + name: "Test Catalysis haste", + disposition: "buff", + durationMs: 20_000, + modifiers: [{ kind: "stat", stat: "spell-haste", operation: "flat", value: 50 }], + }, + sourceId: PLAYER_AGGRO_ID, + targetId: PLAYER_AGGRO_ID, + now: 3_000, + }).auras, + })); + expect(useCombatStore.getState().castAbility("priest-smite", undefined, 3_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().activeCast).toMatchObject({ + abilityId: "priest-smite", + originalDurationMs: priestBaselineMs, + completesAt: 3_000 + priestBaselineMs, + }); + }); + + it("leaves ordinary mana regeneration unchanged without the Catalysis aura", () => { + useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 }); + addTarget("ordinary-mana-combat", 3); + useCombatStore.setState((state) => ({ + ...state, + resource: state.maxResource / 2, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 }, + lastResourceSpentAt: { mana: 1_000 }, + mobs: { ...state.mobs, "ordinary-mana-combat": { ...state.mobs["ordinary-mana-combat"]!, engaged: true } }, + })); + const beforeState = useCombatStore.getState(); + const before = beforeState.resourcePools.mana!; + const playerAuras = beforeState.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID); + const baseRegeneration = applyTalentModifierValue( + resourceProfileForClass("priest").regenerationPerSecond + + beforeState.gearStats.mp5 / 5 + + beforeState.gearStats.spirit * 0.02, + beforeState.talentModifiers, + "resource-regeneration-percent", + ); + const expectedPerSecond = resolveResourceValue( + resolveResourceValue(baseRegeneration, playerAuras, "mana", "regeneration"), + playerAuras, + "power", + "regeneration", + ); + expect(beforeState.auras.some((aura) => aura.definition.modifiers?.some((modifier) => ( + modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second" + )))).toBe(false); + useCombatStore.getState().tick(1, 2_000); + useCombatStore.getState().tick(1, 3_000); + expect(useCombatStore.getState().resourcePools.mana! - before).toBeCloseTo(expectedPerSecond * 2); + }); + + it("casts Phoenix down a capped line and leaves other line spells' extension intact", () => { + initializeMage(); + for (const [id, x, z] of [ + ["phoenix-target", 4, 0], + ["phoenix-path", 8, 0], + ["phoenix-off-line", 7, 2], + ["phoenix-beyond", 12.1, 0], + ] as const) addTarget(id, x, z); + useCombatStore.getState().selectMob("phoenix-target"); + expect(abilityById("rom-mage-primary-6")).toMatchObject({ + target: "hostile", + range: { max: 12 }, + targeting: { shape: "line", radius: 12 }, + }); + expect(useCombatStore.getState().castAbility("rom-mage-primary-6", undefined, 1_000)) + .toMatchObject({ ok: true, affectedMobIds: ["phoenix-target", "phoenix-path"] }); + + useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-elf", level: 60 }); + clearMobRuntimeRegistry(); + for (const [id, x] of [["arrow-target", 3], ["arrow-path", 7], ["arrow-beyond", 7.1]] as const) { + addTarget(id, x); + } + useCombatStore.getState().selectMob("arrow-target"); + expect(useCombatStore.getState().castAbility("rom-scout-primary-5", undefined, 4_000)) + .toMatchObject({ ok: true, affectedMobIds: ["arrow-target", "arrow-path"] }); + }); + + it("doubles only Fireball and Flame on the 10% Eruption proc and runs Control Flame once", () => { + const fireball = abilityById("rom-mage-general-0")!; + const tags = new Set([ + ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption, + ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame, + ]); + const procced = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.099); + const ordinary = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.1); + expect(procced.filter((effect) => effect.kind === "damage")).toEqual( + fireball.effects.filter((effect) => effect.kind === "damage").map((effect) => effect.kind === "damage" + ? expect.objectContaining({ + coefficient: effect.coefficient * 2, + basePoints: (effect.basePoints ?? 0) * 2, + pointsPerLevel: (effect.pointsPerLevel ?? 0) * 2, + }) + : effect), + ); + expect(ordinary.filter((effect) => effect.kind === "damage")).toEqual( + fireball.effects.filter((effect) => effect.kind === "damage"), + ); + expect(procced).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ + id: "rom-mage-druid-control-flame-buff", + durationMs: 12_000, + modifiers: [expect.objectContaining({ stat: "magical-attack", value: 0.08 })], + }), + }), + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ + id: "rom-mage-druid-control-flame-cooldown", + durationMs: 20_000, + }), + }), + ])); + expect(romMageWarlockCastEffects(fireball, fireball.effects, new Set([ + ...tags, + ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown, + ]), 0.099).some((effect) => effect.kind === "apply-aura" + && effect.aura.id === "rom-mage-druid-control-flame-buff")).toBe(false); + }); + + it("applies Eruption and Control Flame when a Fireball proc succeeds", () => { + initializeMage("rom-druid"); + addTarget("eruption-target", 3); + useCombatStore.getState().selectMob("eruption-target"); + const before = useCombatStore.getState().mobs["eruption-target"]!.health; + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption))).toBe(true); + expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_002)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs["eruption-target"]!.health).toBeLessThan(before); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff")) + .toBe(true); + const flameBuff = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff"); + expect(flameBuff?.definition.modifiers).toEqual([ + expect.objectContaining({ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }), + ]); + }); + + it("reduces Fireball cooldown by one second with Flame Follower", () => { + initializeMage("rom-warden"); + addTarget("follower-target", 3); + useCombatStore.getState().selectMob("follower-target"); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower))).toBe(true); + expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(6_000); + }); + + it("resets only Lightning and Discharge with Brain Shock", () => { + initializeMage("rom-champion"); + useCombatStore.setState((state) => ({ + cooldowns: { + ...state.cooldowns, + "rom-mage-general-1": 8_000, + "rom-mage-primary-4": 10_000, + "rom-mage-general-0": 9_000, + }, + resourcePools: { ...state.resourcePools, rage: 100 }, + })); + + expect(useCombatStore.getState().castAbility("rom-mage-rom-champion-elite-20", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().cooldowns).toMatchObject({ + "rom-mage-general-1": 0, + "rom-mage-primary-4": 0, + "rom-mage-general-0": 9_000, + }); + }); + + it("reduces Electrostatic Charge cooldown by 30 seconds with Mage/Champion expertise", () => { + initializeMage(undefined, 80); + expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000); + + initializeMage("rom-champion", 40); + expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000); + + initializeMage("rom-champion", 80); + expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(91_000); + }); + + it("keeps Mage Fire Ward party-scoped", () => { + const fireWard = abilityById("rom-mage-general-5")!; + expect(fireWard.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party")) + .toBe(true); + }); +}); diff --git a/src/game/romMageRuntime.ts b/src/game/romMageRuntime.ts new file mode 100644 index 00000000..49bd910d --- /dev/null +++ b/src/game/romMageRuntime.ts @@ -0,0 +1,24 @@ +import type { AbilityDefinition } from "./abilityCatalog"; + +const ELECTROSTATIC_CHARGE_EXPERTISE_SPELL_ID = 498_871; +const ELECTROSTATIC_CHARGE_SPELL_ID = 491_156; + +/** Mage/Champion's level-45 passive shortens Electrostatic Charge by 30 seconds. */ +export function romMageCooldownReductionMs( + classId: string | null, + secondaryClassId: string | null | undefined, + ability: Pick, + level: number, + abilities: readonly Pick[], +): number { + if (classId !== "rom-mage" + || secondaryClassId !== "rom-champion" + || ability.dbcSpellId !== ELECTROSTATIC_CHARGE_SPELL_ID + || level < 45) return 0; + + return abilities.some((candidate) => candidate.passive + && candidate.dbcSpellId === ELECTROSTATIC_CHARGE_EXPERTISE_SPELL_ID + && candidate.unlockLevel <= level) + ? 30_000 + : 0; +} diff --git a/src/game/romMageWarlockOverrides.ts b/src/game/romMageWarlockOverrides.ts index 5ab15907..8fc4911c 100644 --- a/src/game/romMageWarlockOverrides.ts +++ b/src/game/romMageWarlockOverrides.ts @@ -5,6 +5,10 @@ import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; export const ROM_ELECTROSTATIC_PERCENT_ABSORB_TAG = "rom-electrostatic-percent-max-health"; export const ROM_MAGE_WARLOCK_PASSIVE_TAGS = Object.freeze({ + eruption: "rom-mage-eruption", + controlFlame: "rom-mage-druid-control-flame", + controlFlameCooldown: "rom-mage-druid-control-flame-cooldown", + flameFollower: "rom-mage-warden-flame-follower", industryFire: "rom-mage-warlock-industry-fire", lightningDisplay: "rom-mage-warlock-lightning-display", elementsOfPride: "rom-mage-warlock-elements-of-pride", @@ -23,6 +27,55 @@ const INDUSTRY_FIRE_HIT_BASE = 140; const INDUSTRY_FIRE_HIT_PER_LEVEL = 40; const INDUSTRY_FIRE_DOT_BASE = 23.4; const INDUSTRY_FIRE_DOT_PER_LEVEL = 10.4; +const MAGE_ERUPTION_PROC_CHANCE = 0.1; + +function scaledDamage(effect: AbilityEffect, factor: number): AbilityEffect { + if (effect.kind !== "damage") return effect; + return { + ...effect, + coefficient: effect.coefficient * factor, + ...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }), + ...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }), + ...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }), + ...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }), + ...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * factor }), + }; +} + +function controlFlameTriggeredAuras(): readonly AbilityEffect[] { + return [ + { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-mage-druid-control-flame-buff", + name: "Control Flame", + disposition: "buff", + durationMs: 12_000, + modifiers: [{ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }], + }, + sourceEffectType: -1, + sourceAuraType: 171, + miscValue: 8, + triggerSpellId: 494054, + }, + { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-mage-druid-control-flame-cooldown", + name: "Control Flame cooldown", + disposition: "buff", + durationMs: 20_000, + utilityTags: [ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 494078, + }, + ]; +} function stateAura(id: string, tag: string, durationMs: number | null): AbilityEffect { return { @@ -53,6 +106,10 @@ export function overrideRomMageWarlockEffects( row: RomGeneratedAbilityLike, translated: readonly AbilityEffect[], ): readonly AbilityEffect[] | null { + if (row.sourceSkillId === 490221) return [stateAura("eruption", ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption, null)]; + if (row.sourceSkillId === 494054) return [stateAura("control-flame", ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame, null)]; + if (row.sourceSkillId === 494051) return [stateAura("flame-follower", ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower, null)]; + if (row.sourceSkillId === 491156) { return translated.map((effect) => { if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return effect; @@ -113,9 +170,20 @@ export function romMageWarlockCastEffects( ability: AbilityDefinition, effects: readonly AbilityEffect[], passiveTags: ReadonlySet, + eruptionProcRoll = 1, ): readonly AbilityEffect[] { if (ability.source !== "runewaker") return effects; + if ([490204, 491150].includes(ability.dbcSpellId) + && passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption) + && eruptionProcRoll < MAGE_ERUPTION_PROC_CHANCE) { + const doubledDamage = effects.map((effect) => scaledDamage(effect, 2)); + return passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame) + && !passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown) + ? [...doubledDamage, ...controlFlameTriggeredAuras()] + : doubledDamage; + } + if (ability.dbcSpellId === 497964 && passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire)) { return [ { diff --git a/src/game/romPlayerCatalog.test.ts b/src/game/romPlayerCatalog.test.ts index 6dc3bb92..0e02e492 100644 --- a/src/game/romPlayerCatalog.test.ts +++ b/src/game/romPlayerCatalog.test.ts @@ -47,6 +47,11 @@ describe("generated RuneWaker player catalog", () => { it("resolves every ordered pair to all ten elite milestones and the source-backed Devotion Halo", () => { expect(ROM_ELITE_ABILITIES).toHaveLength(661); + const knownEmptyEffectElites = new Set([ + "rom-warlock-rom-warrior-elite-30", + "rom-warlock-rom-priest-elite-35", + "rom-warlock-rom-priest-elite-50", + ]); const pairGroups = new Map(); for (const ability of ROM_ELITE_ABILITIES) { const key = `${ability.classId}:${ability.secondaryClassId}`; @@ -56,7 +61,15 @@ describe("generated RuneWaker player catalog", () => { for (const [pair, elites] of pairGroups) { expect(elites.map((ability) => ability.unlockLevel)).toEqual(pair === "rom-priest:rom-knight" ? [...ELITE_LEVELS, 70] : ELITE_LEVELS); - expect(elites.every((ability) => ability.source === "runewaker" && ability.effects.length > 0)).toBe(true); + for (const ability of elites) { + expect(ability.source, `${pair}:${ability.id} source`).toBe("runewaker"); + if (knownEmptyEffectElites.has(ability.id)) { + expect(ability.passive).toBe(true); + expect(ability.effects, `${pair}:${ability.id} source payload is incomplete`).toEqual([]); + } else { + expect(ability.effects, `${pair}:${ability.id} effects`).not.toEqual([]); + } + } } expect(ROM_ALL_ABILITIES).toHaveLength(930); expect(ROM_ALL_ABILITIES.filter((ability) => ability.id !== "rom-priest-rom-knight-elite-70-devotion-halo").every((ability) => ( @@ -93,13 +106,24 @@ describe("generated RuneWaker player catalog", () => { expect(abilityAtLevel(ranked, 80).dbcSpellId).toBe(eligible.spellId); }); - it("preserves every source rank and its full flat or percentage cost", () => { + it("preserves every source rank and cost except documented runtime overrides", () => { const normalizedCosts = (rows: readonly { resource: string; amount: number; percentage?: boolean }[]) => rows.map((cost) => ({ resource: cost.resource, amount: cost.amount, ...(cost.percentage ? { percentage: true } : {}), })); const generatedRanks = generated.skills.reduce((sum, ability) => sum + ability.ranks.length, 0); + const sourceCosts = (sourceSkillId: number, rows: typeof generated.skills[number]["costs"]) => { + let normalized = normalizedCosts(rows); + // The current CoA Shout entry (same ID/name) uses 15 Rage while the + // older local RuneWaker snapshot records 30; the runtime follows CoA. + if (sourceSkillId === 490136) { + normalized = normalized.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost); + } + if (![493536, 494217].includes(sourceSkillId) + || normalized.some((entry) => entry.resource === "nature-power")) return normalized; + return [...normalized, { resource: "nature-power", amount: 1 }]; + }; expect(generatedRanks).toBe(934); expect(Math.max(...generated.skills.flatMap((ability) => ( ability.costs.filter((cost) => !cost.percentage).map((cost) => cost.amount) @@ -107,11 +131,13 @@ describe("generated RuneWaker player catalog", () => { for (const source of generated.skills) { const runtime = ROM_ALL_ABILITIES.find((ability) => ability.id === source.id)!; - expect(runtime.costs, `${source.name} base costs`).toEqual(normalizedCosts(source.costs)); + expect(runtime.costs, `${source.name} base costs`).toEqual(sourceCosts(source.sourceSkillId, source.costs)); expect(runtime.ranks).toHaveLength(source.ranks.length); for (const [index, sourceRank] of source.ranks.entries()) { expect(runtime.ranks?.[index]?.spellId, `${source.name} rank ${sourceRank.rank}`).toBe(sourceRank.sourceSkillId); - expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual(normalizedCosts(sourceRank.costs)); + expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual( + sourceCosts(sourceRank.sourceSkillId, sourceRank.costs), + ); } } }); diff --git a/src/game/romPriestChampionOverrides.ts b/src/game/romPriestChampionOverrides.ts new file mode 100644 index 00000000..8ee0c163 --- /dev/null +++ b/src/game/romPriestChampionOverrides.ts @@ -0,0 +1,13 @@ +import type { AbilityDefinition } from "./abilityCatalog"; + +/** Infused Light Chain removes Chain of Light's base cooldown for Priest/Champion. */ +export function romPriestChampionCooldownOverride( + ability: AbilityDefinition, + learnedPassiveSourceIds: readonly number[], +): number | null { + if (ability.source !== "runewaker" + || ability.classId !== "rom-priest" + || ability.dbcSpellId !== 491164 + || !learnedPassiveSourceIds.includes(498610)) return null; + return 0; +} diff --git a/src/game/romPriestKnightOverrides.ts b/src/game/romPriestKnightOverrides.ts index 8a8d6398..b977c4f5 100644 --- a/src/game/romPriestKnightOverrides.ts +++ b/src/game/romPriestKnightOverrides.ts @@ -33,6 +33,8 @@ export function overrideRomPriestKnightEffects( return passiveState(499968, row.name, "rom-splendor-ripple"); case 499957: // Light Guidance: activate healing bonus only with source state 499966. return passiveState(499957, row.name, "rom-light-guidance"); + case 492638: // Lightning Rod: Lightning shocks targets already afflicted by Bone Chill. + return passiveState(492638, row.name, "rom-priest-mage-lightning-rod"); case 491610: // Enhanced Holy Strike: improves Holy Strike weapon damage. return passiveState(491610, row.name, "rom-enhanced-holy-strike"); case 490066: // Weaken Weapon Strength: Heavenly Arrow weakens target physical damage. @@ -151,8 +153,36 @@ export function romPriestKnightCastEffects( passiveTags: readonly string[] | ReadonlySet, casterMaxHealth: number, targetTags: readonly string[] | ReadonlySet = [], + friendlyTargetHealthPercent = 1, + learnedPassiveSpellIds: readonly number[] = [], ): readonly AbilityEffect[] { if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects; + if (ability.dbcSpellId === 490261 + && learnedPassiveSpellIds.includes(493039) + && friendlyTargetHealthPercent < 0.3) { + // Saint's Blessing's active source components grant +30 healing points and + // +1% healing power for 10 seconds. Put the aura before Heal so the + // triggering cast itself receives the sourced bonus as well. + return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-priest-saints-blessing-healing", + name: "Saint's Blessing", + disposition: "buff", + durationMs: 10_000, + modifiers: [ + { kind: "healing", direction: "done", operation: "flat", value: 30 }, + { kind: "healing", direction: "done", operation: "percent", value: 0.01 }, + ], + dispelCategory: "magic", + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 493039, + }, ...effects]; + } switch (ability.dbcSpellId) { case 490290: // Enhanced Grace of Life mirrors source Grace's +50 and +1% HP as MP. if (!hasTag(passiveTags, "rom-enhanced-grace-of-life")) return effects; @@ -236,6 +266,28 @@ export function romPriestKnightCastEffects( miscValue: 5, triggerSpellId: 623015, }]; + case 490212: // Lightning Rod adds source child 502907 only while Bone Chill is on the target. + if (!hasTag(passiveTags, "rom-priest-mage-lightning-rod") + || !hasTag(targetTags, "rom-491165-501548-0")) return effects; + return [...effects, { + kind: "apply-aura", + recipient: "ability-target", + aura: { + id: "rom-priest-mage-lightning-rod-shock", + name: "Electric Shock", + disposition: "debuff", + durationMs: 20_000, + modifiers: [ + { kind: "stat", stat: "resistance:frost", operation: "flat", value: -50 }, + { kind: "stat", stat: "resistance:nature", operation: "flat", value: -50 }, + ], + utilityTags: ["rom-priest-mage-lightning-rod-shock"], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 502907, + }]; case 490434: // Source 499924 adds component 623167 to the base Blood Arrow. if (!hasTag(passiveTags, "rom-blood-punishment")) return effects; return [...effects, { @@ -260,6 +312,65 @@ export function romPriestKnightCastEffects( } } +/** Select the pair-specific payload branches that depend on target state. */ +export function romPriestKnightConditionalCastEffects( + ability: AbilityDefinition, + effects: readonly AbilityEffect[], + sealStacks: number, + targetIsCaster: boolean, +): readonly AbilityEffect[] { + if (ability.source !== "runewaker" + || ability.classId !== "rom-knight" + || ability.secondaryClassId !== "rom-priest") return effects; + + if (ability.dbcSpellId === 490194) { + const baseHit = effects.find((effect) => effect.kind === "damage"); + if (!baseHit) return effects; + // The generated row carries a placeholder heal with no source-backed + // amount; only the Holy Seal components grant healing. + const nonDamage = effects.filter((effect) => effect.kind !== "damage" && effect.kind !== "heal"); + const seals = Math.min(4, Math.max(0, Math.trunc(sealStacks))); + if (seals === 0) return [baseHit, ...nonDamage]; + const healPercent = Math.min(8, seals * 2) / 100; + return [ + baseHit, + ...nonDamage, + { kind: "heal", coefficient: 0, percentMaxHealth: healPercent, recipient: "caster" }, + { + kind: "heal", + coefficient: 0, + percentMaxHealth: healPercent, + scope: "party", + center: "caster", + maximumTargets: 1, + excludeCaster: true, + }, + { kind: "combo", amount: -seals }, + ]; + } + + if (ability.dbcSpellId === 491640) { + const selectedThreatPolarity = targetIsCaster ? 1 : -1; + const hasThreatPayload = effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat")); + if (!hasThreatPayload) return effects; + return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => + modifier.kind === "stat" && modifier.stat === "threat") + ? { + ...effect, + aura: { + ...effect.aura, + modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "stat" && modifier.stat === "threat" + ? { ...modifier, value: Math.abs(modifier.value) * selectedThreatPolarity } + : modifier), + }, + } + : effect); + } + + return effects; +} + /** Source skill 499966 is granted by the level-70 Priest/Knight elite pair. */ export function romPriestKnightAdditionalAbilities( catalog: readonly AbilityDefinition[], diff --git a/src/game/romPriestKnightRuntimeEffects.test.ts b/src/game/romPriestKnightRuntimeEffects.test.ts new file mode 100644 index 00000000..748a84a7 --- /dev/null +++ b/src/game/romPriestKnightRuntimeEffects.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { healingRecipientIds } from "./healingAbilityRules"; +import type { CombatActorState } from "./combatActors"; +import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog"; +import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides"; +import { romPriestKnightConditionalCastEffects } from "./romPriestKnightOverrides"; + +const holySmite = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 490194)!; +const holyProtection = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 491640)!; + +describe("RoM Knight/Priest conditional combat payloads", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + }); + + it("replaces Holy Smite's conditional damage with stack-scaled caster and ally heals", () => { + const noSeals = romPriestKnightConditionalCastEffects(holySmite, holySmite.effects, 0, false); + expect(noSeals.filter((effect) => effect.kind === "damage")).toHaveLength(1); + expect(noSeals.filter((effect) => effect.kind === "heal")).toHaveLength(0); + + const fourSeals = romPriestKnightConditionalCastEffects(holySmite, holySmite.effects, 4, false); + expect(fourSeals.filter((effect) => effect.kind === "damage")).toHaveLength(1); + expect(fourSeals.filter((effect) => effect.kind === "heal")).toEqual([ + expect.objectContaining({ kind: "heal", percentMaxHealth: 0.08, recipient: "caster" }), + expect.objectContaining({ kind: "heal", percentMaxHealth: 0.08, scope: "party", excludeCaster: true }), + ]); + expect(fourSeals).toContainEqual({ kind: "combo", amount: -4 }); + }); + + it("chooses only the matching Holy Protection threat payload for self versus ally", () => { + const threatAmount = (targetIsCaster: boolean) => { + const selected = romPriestKnightConditionalCastEffects( + holyProtection, holyProtection.effects, 0, targetIsCaster, + ); + const aura = selected.find((effect) => effect.kind === "apply-aura"); + if (aura?.kind !== "apply-aura") return null; + return aura.aura.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat")?.value; + }; + expect(threatAmount(false)).toBe(-0.02); + expect(threatAmount(true)).toBe(0.02); + for (const targetIsCaster of [false, true]) { + const selected = romPriestKnightConditionalCastEffects( + holyProtection, holyProtection.effects, 0, targetIsCaster, + ); + expect(selected.filter((effect) => effect.kind === "apply-aura")).toHaveLength(3); + expect(selected.some((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" + && (modifier.stat === "armor" || modifier.stat === "magical-defense")))).toBe(true); + } + }); + + it("excludes only the caster from Holy Smite's second heal recipient", () => { + const actors = { + [PLAYER_AGGRO_ID]: { id: PLAYER_AGGRO_ID, kind: "player", alive: true, health: 50, maxHealth: 100 }, + ally: { id: "ally", kind: "companion", alive: true, health: 25, maxHealth: 100 }, + } as unknown as Readonly>; + const positionFor = (id: string) => id === PLAYER_AGGRO_ID + ? [0, 0, 0] as const + : id === "ally" ? [3, 0, 0] as const : null; + const normalPartyHeal = healingRecipientIds( + { scope: "party", center: "caster" }, PLAYER_AGGRO_ID, null, actors, positionFor, [], () => true, + ); + const holySmitePartyHeal = healingRecipientIds( + { scope: "party", center: "caster", maximumTargets: 1, excludeCaster: true }, + PLAYER_AGGRO_ID, null, actors, positionFor, [], () => true, + ); + expect(normalPartyHeal).toContain(PLAYER_AGGRO_ID); + expect(holySmitePartyHeal).toEqual(["ally"]); + }); + + it("removes Chain of Light's cooldown only with Priest/Champion Infused Light Chain", () => { + const chain = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491164)!; + expect(romPriestChampionCooldownOverride(chain, [])).toBeNull(); + expect(romPriestChampionCooldownOverride(chain, [498610])).toBe(0); + expect(romPriestChampionCooldownOverride({ ...chain, classId: "rom-knight" }, [498610])).toBeNull(); + }); + + it("applies Infused Light Chain to the active Priest/Champion channel", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-dwarf", level: 30, + }); + useCombatStore.getState().registerMob("priest-champion-chain", { name: "Channel target", maxHealth: 100_000 }); + setMobPosition("priest-champion-chain", [3, 0, 0]); + useCombatStore.getState().selectMob("priest-champion-chain"); + expect(useCombatStore.getState().castAbility("rom-priest-primary-9", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().activeCast?.mode).toBe("channel"); + expect(useCombatStore.getState().cooldowns["rom-priest-primary-9"]).toBeUndefined(); + }); + + it("consumes Holy Seals only when Holy Smite uses them and heals the caster plus one ally", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 30, role: "tank", + }); + usePartyStore.getState().initializeParty("knight-priest-holy-smite", 30, useCombatStore.getState().health, "tank", 2, "rom"); + const ally = usePartyStore.getState().members[0]!; + usePartyStore.getState().damageMember(ally.id, Math.floor(ally.maxHealth * 0.5)); + registerPartyRuntimePosition(ally.id, [3, 0, 0]); + useCombatStore.getState().damagePlayer(Math.floor(useCombatStore.getState().maxHealth * 0.5)); + useCombatStore.getState().registerMob("knight-priest-holy-smite-target", { + name: "Holy Seal target", maxHealth: 100_000, xpReward: 0, + }); + setMobPosition("knight-priest-holy-smite-target", [2, 0, 0]); + useCombatStore.getState().selectMob("knight-priest-holy-smite-target"); + + expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().comboPoints).toBe(4); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" + && effect.tag === "rom-holy-seal" && effect.sourceActorId === PLAYER_AGGRO_ID)).toBe(true); + + const playerHealth = useCombatStore.getState().health; + const allyHealth = usePartyStore.getState().members[0]!.health; + expect(useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-20", undefined, 2_600)) + .toMatchObject({ ok: true }); + const afterCast = useCombatStore.getState(); + expect(afterCast.comboPoints).toBe(0); + expect(afterCast.effects.some((effect) => effect.kind === "dot" + && effect.tag === "rom-holy-seal" && effect.sourceActorId === PLAYER_AGGRO_ID)).toBe(false); + expect(afterCast.health).toBeGreaterThan(playerHealth); + expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth); + expect(afterCast.combatEvents.filter((event) => event.kind === "damage" + && event.abilityId === "rom-knight-rom-priest-elite-20")).toHaveLength(1); + }); + + it("casts only the self or ally Holy Protection threat payload", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 30, role: "tank", + }); + usePartyStore.getState().initializeParty("knight-priest-holy-protection", 30, useCombatStore.getState().health, "tank", 2, "rom"); + const ally = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(ally.id, [3, 0, 0]); + + expect(useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-25", undefined, 1_000)) + .toMatchObject({ ok: true }); + const selfProtection = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "threat")); + expect(selfProtection?.definition.modifiers).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "stat", stat: "threat", value: 0.02 }), + ])); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "threat" && modifier.value < 0))).toBe(false); + + usePartyStore.getState().selectMember(ally.id); + const allyCast = useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-25", undefined, 2_600); + expect(allyCast).toMatchObject({ ok: true }); + const allyProtection = useCombatStore.getState().auras.find((aura) => aura.targetId === ally.id + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "threat")); + expect(allyProtection?.definition.modifiers).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "stat", stat: "threat", value: -0.02 }), + ])); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === ally.id + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "threat" && modifier.value > 0))).toBe(false); + }); +}); diff --git a/src/game/romPriestLightningRod.test.ts b/src/game/romPriestLightningRod.test.ts new file mode 100644 index 00000000..c44fec25 --- /dev/null +++ b/src/game/romPriestLightningRod.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog"; +import { romEffectsForCast } from "./romCombatMechanics"; +import { romPriestKnightCastEffects } from "./romPriestKnightOverrides"; + +const lightningRodPassive = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 492638)!; +const lightning = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490212)!; +const boneChill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491165)!; +const lightningRodTag = "rom-priest-mage-lightning-rod"; +const boneChillTag = "rom-491165-501548-0"; + +describe("RoM Priest/Mage Lightning Rod", () => { + it("exposes the passive and Bone Chill child tags used by the cast hook", () => { + expect(lightningRodPassive.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ utilityTags: [lightningRodTag] }), + }), + ])); + expect(boneChill.effects.some((effect) => effect.kind === "dot" && effect.tag === boneChillTag)).toBe(true); + }); + + it("applies the 20-second Water/Wind resistance shock only with the passive and Bone Chill", () => { + const withoutPassive = romPriestKnightCastEffects( + lightning, lightning.effects, [], 1_000, [boneChillTag], + ); + const withoutBoneChill = romPriestKnightCastEffects( + lightning, lightning.effects, [lightningRodTag], 1_000, [], + ); + expect(withoutPassive).toEqual(lightning.effects); + expect(withoutBoneChill).toEqual(lightning.effects); + + const withBothConditions = romPriestKnightCastEffects( + lightning, lightning.effects, [lightningRodTag], 1_000, [boneChillTag], + ); + expect(withBothConditions).toContainEqual(expect.objectContaining({ + kind: "apply-aura", + recipient: "ability-target", + triggerSpellId: 502907, + aura: expect.objectContaining({ + id: "rom-priest-mage-lightning-rod-shock", + name: "Electric Shock", + disposition: "debuff", + durationMs: 20_000, + modifiers: [ + { kind: "stat", stat: "resistance:frost", operation: "flat", value: -50 }, + { kind: "stat", stat: "resistance:nature", operation: "flat", value: -50 }, + ], + }), + })); + }); + + it("keeps the proc on the shared RoM cast-effects path", () => { + const mechanics = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 1_000, + targetHealthPercent: 1, + }; + const withoutBoneChill = romEffectsForCast(lightning, { + ...mechanics, + passiveTags: [lightningRodTag], + }); + const withBoneChill = romEffectsForCast(lightning, { + ...mechanics, + passiveTags: [lightningRodTag], + targetTags: [boneChillTag], + }); + expect(withoutBoneChill.some((effect) => effect.kind === "apply-aura" + && effect.aura.id === "rom-priest-mage-lightning-rod-shock")).toBe(false); + expect(withBoneChill.some((effect) => effect.kind === "apply-aura" + && effect.aura.id === "rom-priest-mage-lightning-rod-shock")).toBe(true); + }); +}); diff --git a/src/game/romPriestSaintsBlessing.test.ts b/src/game/romPriestSaintsBlessing.test.ts new file mode 100644 index 00000000..44845e6b --- /dev/null +++ b/src/game/romPriestSaintsBlessing.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { romEffectsForCast } from "./romCombatMechanics"; + +const heal = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490261)!; + +describe("RoM Priest/Scout Saint's Blessing", () => { + beforeEach(() => { + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", + secondaryClassId: "rom-scout", + raceId: "rom-human", + level: 50, + }); + usePartyStore.getState().initializeParty( + "rom-priest-saints-blessing", + 50, + useCombatStore.getState().health, + "healer", + 2, + "rom", + ); + }); + + it("arms the extracted 10-second +30 healing and +1% healing aura only below 30% HP", () => { + const base = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 1_000, + targetHealthPercent: 1, + learnedPassiveSpellIds: [493039], + }; + const lowTarget = romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.299 }); + expect(lowTarget[0]).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + triggerSpellId: 493039, + aura: { + id: "rom-priest-saints-blessing-healing", + durationMs: 10_000, + modifiers: [ + { kind: "healing", direction: "done", operation: "flat", value: 30 }, + { kind: "healing", direction: "done", operation: "percent", value: 0.01 }, + ], + }, + }); + expect(lowTarget[1]).toEqual(heal.effects[0]); + expect(romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.3 })).toEqual(heal.effects); + expect(romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.1, learnedPassiveSpellIds: [] })) + .toEqual(heal.effects); + }); + + it("uses the selected ally's current HP and applies the aura before the triggering Heal", () => { + const member = usePartyStore.getState().members[0]!; + usePartyStore.setState((party) => ({ + members: party.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: Math.floor(candidate.maxHealth * 0.2) } + : candidate), + })); + registerPartyRuntimePosition(member.id, [3, 0, 0]); + usePartyStore.getState().selectMember(member.id); + + const injuredHealth = usePartyStore.getState().members[0]!.health; + expect(usePartyStore.getState().members[0]!.health / member.maxHealth).toBeLessThan(0.3); + expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 1_000)) + .toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 3_000); + + const state = useCombatStore.getState(); + const aura = state.auras.find((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID + && entry.definition.id === "rom-priest-saints-blessing-healing"); + expect(aura).toMatchObject({ expiresAt: 13_000 }); + expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth); + expect(state.combatEvents.some((event) => event.kind === "healing" + && event.abilityId === "rom-priest-primary-2" + && event.targetActorId === member.id + && event.rawAmount > 30)).toBe(true); + }); +}); diff --git a/src/game/romPriestTooltips.ts b/src/game/romPriestTooltips.ts index f6f82bb8..f1ca3aa4 100644 --- a/src/game/romPriestTooltips.ts +++ b/src/game/romPriestTooltips.ts @@ -20,6 +20,11 @@ const PRIEST_DESCRIPTIONS: Readonly> = { 491176: "Revives a dead friendly target outside combat with reduced death debt.", 491177: "Revives a dead friendly target outside combat with greatly reduced death debt.", 491147: "Quickly restores health to a friendly target.", + // The 1.5-second channel and 5-second cooldown are supplied by the current + // CoA Wiki entry for the matching skill ID 498770; healing and threshold + // buffs remain based on this repository's RuneWaker snapshot. CoA's added + // area-damage rider is intentionally not imported into this older payload. + 498770: "Channels for 1.5 seconds, healing a friendly target four times. If the target finishes above 40% health, it gains 2% Physical and Magical Defense for 10 seconds. Below 40% health, the defense effect can gain two stacks per cast, up to eight stacks. Cooldown: 5 seconds.", 490256: "Sends a torrent at the target, dealing Water damage.", 490269: "Restores a friendly target's health every 2 seconds for 20 seconds.", 490300: "Makes you immune to damage for 5 seconds.", diff --git a/src/game/romPriestWarlockOverrides.ts b/src/game/romPriestWarlockOverrides.ts new file mode 100644 index 00000000..406fbc4b --- /dev/null +++ b/src/game/romPriestWarlockOverrides.ts @@ -0,0 +1,33 @@ +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; + +/** Select local Prayer of Tribulation buff stacks from its tooltip HP gates. */ +export function romPriestWarlockCastEffects( + ability: AbilityDefinition, + effects: readonly AbilityEffect[], + friendlyTargetHealthPercent: number, +): readonly AbilityEffect[] { + if (ability.source !== "runewaker" + || ability.classId !== "rom-priest" + || ability.secondaryClassId !== "rom-warlock" + || ability.dbcSpellId !== 498770) return effects; + + const buff = effects.find((effect): effect is Extract => ( + effect.kind === "apply-aura" + )); + if (!buff || friendlyTargetHealthPercent === 0.4) return effects.filter((effect) => effect !== buff); + + const stacks = friendlyTargetHealthPercent < 0.4 ? 2 : 1; + const stackedBuff: Extract = { + ...buff, + aura: { + ...buff.aura, + maxStacks: 8, + stackBehavior: "add", + modifiers: buff.aura.modifiers?.map((modifier) => ({ ...modifier, perStack: true })), + }, + }; + return [ + ...effects.filter((effect) => effect !== buff), + ...Array.from({ length: stacks }, () => stackedBuff), + ]; +} diff --git a/src/game/romPriestWarlockRuntime.test.ts b/src/game/romPriestWarlockRuntime.test.ts new file mode 100644 index 00000000..0df7ce2f --- /dev/null +++ b/src/game/romPriestWarlockRuntime.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts"; +import { useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; +import { romEffectsForCast } from "./romCombatMechanics"; + +const prayer = abilityById("rom-priest-rom-warlock-elite-25")!; + +function prayerEffects(targetHealthPercent: number) { + return romEffectsForCast(prayer, { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 1_000, + targetHealthPercent: 1, + friendlyTargetHealthPercent: targetHealthPercent, + }); +} + +describe("RoM Priest/Warlock Prayer of Tribulation", () => { + it("channels four source-authored friendly heals and fills missing timing from CoA", () => { + expect(prayer).toMatchObject({ + dbcSpellId: 498770, + target: "friendly", + castMode: "channel", + castTimeMs: 1_500, + cooldownMs: 5_000, + channelPulses: 4, + }); + const heals = prayer.effects.filter((effect) => effect.kind === "heal"); + expect(heals).toHaveLength(4); + expect(prayer.effects.some((effect) => effect.kind === "damage")).toBe(false); + expect(channelPulseCount(prayer)).toBe(4); + expect(channelAmountEffects(prayer, 4).filter(({ effect }) => effect.kind === "heal")) + .toEqual(heals.map((effect) => ({ effect, multiplier: 0.25 }))); + }); + + it("applies one +2% defense stack above 40%, two below, and none at 40%", () => { + const buffStacks = (effects: ReturnType) => effects.filter((effect) => ( + effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => ( + modifier.kind === "stat" && modifier.stat === "armor" + )) + )); + expect(buffStacks(prayerEffects(0.41))).toHaveLength(1); + expect(buffStacks(prayerEffects(0.4))).toHaveLength(0); + expect(buffStacks(prayerEffects(0.39))).toHaveLength(2); + for (const effect of buffStacks(prayerEffects(0.39))) { + expect(effect).toMatchObject({ + kind: "apply-aura", + aura: { durationMs: 10_000, maxStacks: 8, stackBehavior: "add" }, + }); + if (effect.kind === "apply-aura") { + expect(effect.aura.modifiers?.some((modifier) => ( + modifier.kind === "stat" && modifier.stat === "armor" && modifier.value === 0.02 && modifier.perStack + ))).toBe(true); + expect(effect.aura.modifiers?.some((modifier) => ( + modifier.kind === "stat" && modifier.stat === "magical-defense" && modifier.value === 0.02 && modifier.perStack + ))).toBe(true); + } + } + }); + + it("heals the selected ally over four pulses and applies two stacks below 40%", () => { + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", + secondaryClassId: "rom-warlock", + raceId: "rom-dwarf", + level: 30, + role: "healer", + }); + usePartyStore.getState().initializeParty("rom-priest-warlock-prayer", 30, useCombatStore.getState().health, "healer", 2, "rom"); + const member = usePartyStore.getState().members[0]!; + usePartyStore.getState().damageMember(member.id, member.maxHealth - 1); + // Keep the target below 40% through the four small heals so this cast + // exercises the tooltip's low-health double-stack branch at resolve time. + usePartyStore.setState((state) => ({ + members: state.members.map((partyMember) => partyMember.id === member.id + ? { ...partyMember, maxHealth: 10_000 } + : partyMember), + })); + registerPartyRuntimePosition(member.id, [3, 0, 0]); + usePartyStore.getState().selectMember(member.id); + const healthAtCast = usePartyStore.getState().members[0]!.health; + + expect(useCombatStore.getState().castAbility(prayer.id, undefined, 1_000)).toMatchObject({ ok: true }); + const healthByPulse: number[] = []; + let previousAt = 1_000; + for (const now of [1_375, 1_750, 2_125, 2_500]) { + useCombatStore.getState().tick((now - previousAt) / 1_000, now); + healthByPulse.push(usePartyStore.getState().members[0]!.health); + previousAt = now; + } + expect(healthByPulse[0]).toBeGreaterThan(healthAtCast); + expect(healthByPulse[1]).toBeGreaterThan(healthByPulse[0]!); + expect(healthByPulse[2]).toBeGreaterThan(healthByPulse[1]!); + expect(healthByPulse[3]).toBeGreaterThan(healthByPulse[2]!); + + const defenseAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === member.id && aura.definition.name.includes("Prayer of Tribulation") + )); + expect(defenseAura?.stacks).toBe(2); + expect(defenseAura?.definition.modifiers?.some((modifier) => ( + modifier.kind === "stat" && modifier.stat === "armor" && modifier.perStack + ))).toBe(true); + }); +}); diff --git a/src/game/romRogueMechanics.test.ts b/src/game/romRogueMechanics.test.ts new file mode 100644 index 00000000..9904b08c --- /dev/null +++ b/src/game/romRogueMechanics.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, getMobRuntimeTransform, setMobPosition } from "./mobRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { LOOT_CATALOG } from "./lootCatalog"; +import { abilityRequiresOutOfCombat } from "./abilityRules"; +import { romEffectsForCast } from "./romCombatMechanics"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial"; +import { resolvedEquipmentLoadout } from "./heirloomEquipment"; +import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment"; +import { + ROM_ROGUE_DAGGER_MASTERY_TAG, + ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID, + ROM_ROGUE_TREASURE_HUNTER_TAG, + romRogueDaggerDamageMultiplier, + romRogueShadowWalkerAdjustment, + romRogueTreasureHunterLootChance, +} from "./romRogueMechanics"; + +function effectAuras(abilityId: string) { + return abilityById(abilityId)!.effects.filter((effect) => effect.kind === "apply-aura"); +} + +function testWeapon(weaponType: string, instanceId: string, stats = { ...useCombatStore.getState().gearStats }) { + return { + instanceId, + itemId: 1, + itemKey: instanceId, + name: `Test ${weaponType}`, + quality: "uncommon" as const, + inventorySlot: weaponType === "crossbow" ? "two-hand" : "one-hand", + armorType: null, + weaponType, + itemLevel: 1, + sourceItemLevel: 1, + stats, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; +} + +describe("RoM Rogue source mechanics", () => { + afterEach(() => resetCombatSpatialProvider()); + + beforeEach(() => { + resetCombatSpatialProvider(); + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 }); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + registerCombatSpatialProvider({ + actor: (id) => { + if (id === PLAYER_AGGRO_ID) return { id, position: useCombatStore.getState().playerPosition, yaw: 0 }; + const transform = getMobRuntimeTransform(id); + return transform ? { id, ...transform } : null; + }, + hasLineOfSight: () => true, + projectGroundPoint: (point) => point, + }); + }); + + it("keeps Dagger Mastery, Treasure Hunter, and Off Hand Mastery as scoped passive tags", () => { + const daggerMastery = effectAuras("rom-rogue-primary-3")[0]; + const treasureHunter = effectAuras("rom-rogue-primary-10")[0]; + expect(daggerMastery?.kind === "apply-aura" && daggerMastery.aura.utilityTags) + .toContain(ROM_ROGUE_DAGGER_MASTERY_TAG); + expect(treasureHunter?.kind === "apply-aura" && treasureHunter.aura.utilityTags) + .toContain(ROM_ROGUE_TREASURE_HUNTER_TAG); + expect(abilityById("rom-rogue-primary-12")!.effects).toEqual([]); + }); + + it("applies Dagger Mastery only to the Rogue player's physical melee hits with a main-hand dagger", () => { + const context = { + classId: "rom-rogue", + sourceActorId: "player", + playerActorId: "player", + attackKind: "melee", + school: "physical", + mainHandDagger: true, + passiveTags: [ROM_ROGUE_DAGGER_MASTERY_TAG], + }; + expect(romRogueDaggerDamageMultiplier(context)).toBe(1.1); + expect(romRogueDaggerDamageMultiplier({ ...context, mainHandDagger: false })).toBe(1); + expect(romRogueDaggerDamageMultiplier({ ...context, classId: "rom-scout" })).toBe(1); + expect(romRogueDaggerDamageMultiplier({ ...context, attackKind: "spell" })).toBe(1); + expect(romRogueDaggerDamageMultiplier({ ...context, passiveTags: [] })).toBe(1); + }); + + it("adds Treasure Hunter only to the supported bonus loot chance and clamps at one", () => { + expect(romRogueTreasureHunterLootChance(0.2, false)).toBe(0.2); + expect(romRogueTreasureHunterLootChance(0.2, true)).toBeCloseTo(0.25); + expect(romRogueTreasureHunterLootChance(undefined, true)).toBeCloseTo(0.05); + expect(romRogueTreasureHunterLootChance(0.98, true)).toBe(1); + }); + + it("limits Shadow Walker's in-combat, cost, and cooldown rider to Rogue/Priest Sneak Attack", () => { + const sneakAttack = abilityById("rom-rogue-primary-9")!; + expect(romRogueShadowWalkerAdjustment( + "rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId, + [ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID], + )).toEqual({ allowsCombatUse: true, energyCostReduction: 10, cooldownIncreaseMs: 10_000 }); + expect(romRogueShadowWalkerAdjustment( + "rom-rogue", "rom-scout", sneakAttack.source, sneakAttack.dbcSpellId, + [ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID], + )).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 }); + expect(romRogueShadowWalkerAdjustment( + "rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId, [], + )).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 }); + expect(romRogueShadowWalkerAdjustment( + "rom-rogue", "rom-priest", sneakAttack.source, 490306, + [ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID], + )).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 }); + }); + + it("adds one bonus boss loot roll only while Treasure Hunter is active", () => { + const bossKey = LOOT_CATALOG.bosses[0]!.bossKey; + const lootCount = (passiveActive: boolean) => { + useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 }); + if (!passiveActive) { + useCombatStore.setState((state) => ({ + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_TREASURE_HUNTER_TAG)), + })); + } + useCombatStore.getState().registerMob("rogue-treasure-target", { + name: "Treasure Target", boss: true, maxHealth: 10, lootSourceId: bossKey, + }); + const random = vi.spyOn(Math, "random").mockReturnValue(0); + try { + useCombatStore.getState().damageMob("rogue-treasure-target", 10, 1_000); + } finally { + random.mockRestore(); + } + return useCombatStore.getState().mobs["rogue-treasure-target"]!.pendingLoot.length; + }; + const ordinaryDrops = lootCount(false); + const treasureHunterDrops = lootCount(true); + expect(treasureHunterDrops).toBe(ordinaryDrops + 1); + }); + + it("keeps Soul Stab's unconditional first hit and gates only the second hit on Weakened", () => { + const ability = abilityById("rom-rogue-rom-warlock-elite-25")!; + const context = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 2_000, + targetHealthPercent: 1, + }; + const ordinary = romEffectsForCast(ability, context).filter((effect) => effect.kind === "damage"); + const weakened = romEffectsForCast(ability, { ...context, targetTags: ["rom-weakened"] }) + .filter((effect) => effect.kind === "damage"); + expect(ordinary).toHaveLength(1); + expect(weakened).toHaveLength(2); + }); + + it("marks Blind Spot and Sneak Attack as rear attacks and preserves Sneak Attack's combat gate", () => { + const blindSpot = abilityById("rom-rogue-primary-4")!; + const sneakAttack = abilityById("rom-rogue-primary-9")!; + expect(blindSpot.targeting?.requiresBehindTarget).toBe(true); + expect(sneakAttack.targeting?.requiresBehindTarget).toBe(true); + expect(abilityRequiresOutOfCombat(sneakAttack)).toBe(true); + }); + + it("rejects Blind Spot from the front and allows Sneak Attack from behind out of combat", () => { + const dagger = testWeapon("dagger", "rogue-position-dagger"); + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", raceId: "rom-human", level: 80, + inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } }, + }); + useCombatStore.getState().registerMob("rogue-rear-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("rogue-rear-target", [0, 0, 0]); + useCombatStore.getState().selectMob("rogue-rear-target"); + useCombatStore.setState((state) => ({ + playerPosition: [0, 0, 2], + resource: 100, + resourcePools: { ...state.resourcePools, energy: 100 }, + })); + expect(useCombatStore.getState().castAbility("rom-rogue-primary-4", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "must-be-behind" }); + + useCombatStore.setState((state) => ({ playerPosition: [0, 0, -2], resource: 100, + resourcePools: { ...state.resourcePools, energy: 100 } })); + expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 2_000).ok).toBe(true); + }); + + it("rejects Sneak Attack while an enemy is already engaged", () => { + const dagger = testWeapon("dagger", "rogue-combat-dagger"); + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", raceId: "rom-human", level: 80, + inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } }, + }); + useCombatStore.getState().registerMob("rogue-engaged-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("rogue-engaged-target", [0, 0, 0]); + useCombatStore.getState().selectMob("rogue-engaged-target"); + useCombatStore.setState((state) => ({ + playerPosition: [0, 0, -2], + resource: 100, + resourcePools: { ...state.resourcePools, energy: 100 }, + mobs: { ...state.mobs, "rogue-engaged-target": { ...state.mobs["rogue-engaged-target"]!, engaged: true } }, + })); + expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + }); + + it("applies Shadow Walker to combat Sneak Attack with the exact Energy and cooldown rider", () => { + const dagger = testWeapon("dagger", "rogue-shadow-walker-dagger"); + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80, + inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } }, + }); + expect(useCombatStore.getState().abilities.some((candidate) => ( + candidate.dbcSpellId === ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID && candidate.passive + ))).toBe(true); + useCombatStore.getState().registerMob("rogue-shadow-walker-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("rogue-shadow-walker-target", [0, 0, 0]); + useCombatStore.getState().selectMob("rogue-shadow-walker-target"); + useCombatStore.setState((state) => ({ + playerPosition: [0, 0, -2], + resource: 100, + resourcePools: { ...state.resourcePools, energy: 100 }, + mobs: { ...state.mobs, "rogue-shadow-walker-target": { ...state.mobs["rogue-shadow-walker-target"]!, engaged: true } }, + })); + + expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.energy).toBe(80); + expect(useCombatStore.getState().cooldowns["rom-rogue-primary-9"]).toBe(11_000); + }); + + it("enforces Energy Conservation's strict below-75% health threshold", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-warlock", raceId: "rom-human", level: 80, + }); + const maximum = useCombatStore.getState().maxHealth; + const thresholdHealth = Math.ceil(maximum * 0.75); + useCombatStore.setState((state) => ({ + health: thresholdHealth, + resource: 100, + resourcePools: { ...state.resourcePools, energy: 100 }, + })); + expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + useCombatStore.setState((state) => ({ + health: thresholdHealth - 1, + resource: 100, + resourcePools: { ...state.resourcePools, energy: 100 }, + })); + expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000).ok).toBe(true); + }); + + it("requires the Rogue/Knight Lion's Protection aura for Power of the Lion", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80, + }); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, energy: 100, mana: 100 }, + })); + expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-25", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-491552"))) + .toBe(true); + expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 2_600).ok).toBe(true); + }); + + it("increases Shadowstab damage only with Dagger Mastery and a main-hand dagger", () => { + const baselineStats = { ...useCombatStore.getState().gearStats }; + const hitWith = (weaponType: string, passiveActive: boolean, instanceId: string) => { + const weapon = testWeapon(weaponType, instanceId, baselineStats); + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", raceId: "rom-human", level: 80, + inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } }, + }); + const equipped = resolvedEquipmentLoadout(useCombatStore.getState().equipment, useCombatStore.getState().inventory, { + ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: "rom-rogue", + specializationId: null, role: "damage", level: 80, + }); + expect(equipped["main-hand"]?.weaponType).toBe(weaponType); + if (!passiveActive) { + useCombatStore.setState((state) => ({ + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)), + })); + } + useCombatStore.getState().registerMob("rogue-dagger-mastery-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("rogue-dagger-mastery-target", [0, 0, 3]); + useCombatStore.getState().selectMob("rogue-dagger-mastery-target"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, energy: 100 }, + })); + const before = useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health; + const cast = useCombatStore.getState().castAbility("rom-rogue-general-0", undefined, 1_000); + if (!cast.ok) throw new Error(JSON.stringify(cast)); + return before - useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health; + }; + const baseDaggerDamage = hitWith("dagger", false, "rogue-mastery-dagger-without-passive"); + const daggerDamage = hitWith("dagger", true, "rogue-mastery-dagger-with-passive"); + const baseNonDaggerDamage = hitWith("crossbow", false, "rogue-mastery-crossbow-without-passive"); + const nonDaggerDamage = hitWith("crossbow", true, "rogue-mastery-crossbow-with-passive"); + expect(daggerDamage).toBeGreaterThan(baseDaggerDamage); + expect(daggerDamage / baseDaggerDamage).toBeCloseTo(1.1, 1); + expect(nonDaggerDamage).toBe(baseNonDaggerDamage); + }); +}); diff --git a/src/game/romRogueMechanics.ts b/src/game/romRogueMechanics.ts new file mode 100644 index 00000000..3733ea31 --- /dev/null +++ b/src/game/romRogueMechanics.ts @@ -0,0 +1,65 @@ +export const ROM_ROGUE_DAGGER_MASTERY_TAG = "rom-dagger-mastery"; +export const ROM_ROGUE_TREASURE_HUNTER_TAG = "rom-treasure-hunter"; +export const ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID = 491537; + +export interface RomRogueAbilityAdjustment { + readonly allowsCombatUse: boolean; + readonly energyCostReduction: number; + readonly cooldownIncreaseMs: number; +} + +const NO_SHADOW_WALKER_ADJUSTMENT: RomRogueAbilityAdjustment = Object.freeze({ + allowsCombatUse: false, + energyCostReduction: 0, + cooldownIncreaseMs: 0, +}); + +/** Shadow Walker's exact Rogue/Priest payload applies only to Sneak Attack. */ +export function romRogueShadowWalkerAdjustment( + classId: string, + secondaryClassId: string | null | undefined, + abilitySource: string | undefined, + abilitySpellId: number, + passiveSpellIds: readonly number[], +): RomRogueAbilityAdjustment { + if (classId !== "rom-rogue" + || secondaryClassId !== "rom-priest" + || abilitySource !== "runewaker" + || abilitySpellId !== 490337 + || !passiveSpellIds.includes(ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID)) { + return NO_SHADOW_WALKER_ADJUSTMENT; + } + return { + allowsCombatUse: true, + energyCostReduction: 10, + cooldownIncreaseMs: 10_000, + }; +} + +export interface RomRogueDaggerDamageContext { + readonly classId: string; + readonly sourceActorId: string; + readonly playerActorId: string; + readonly attackKind: string; + readonly school: string; + readonly mainHandDagger: boolean; + readonly passiveTags: readonly string[]; +} + +/** The RoM passive modifies only the player's physical melee hits while a dagger is equipped in the main hand. */ +export function romRogueDaggerDamageMultiplier(context: RomRogueDaggerDamageContext): number { + return context.classId === "rom-rogue" + && context.sourceActorId === context.playerActorId + && context.attackKind === "melee" + && context.school === "physical" + && context.mainHandDagger + && context.passiveTags.includes(ROM_ROGUE_DAGGER_MASTERY_TAG) + ? 1.1 + : 1; +} + +/** Treasure Hunter adds one five percentage point bonus roll to the supported boss-loot path. */ +export function romRogueTreasureHunterLootChance(baseChance: number | undefined, passiveActive: boolean): number { + const base = Number.isFinite(baseChance) ? Math.max(0, Math.min(1, baseChance!)) : 0; + return passiveActive ? Math.min(1, base + 0.05) : base; +} diff --git a/src/game/romScoutMechanics.test.ts b/src/game/romScoutMechanics.test.ts new file mode 100644 index 00000000..bcbafd8a --- /dev/null +++ b/src/game/romScoutMechanics.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { EMPTY_ITEM_STATS } from "./itemStats"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { resolveDamageValue, resolveStatValue } from "./combatAuras"; +import { useCombatStore } from "./combatStore"; + +describe("RoM Scout ranged weapon masteries", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + }); + + it.each(["bow", "crossbow"] as const)( + "applies Ranged Weapon Mastery and Speed Shooting Mastery once with an equipped %s", + (weaponType) => { + const rangedWeapon = { + instanceId: `scout-mastery-${weaponType}`, + itemId: 1, + itemKey: `scout-mastery-${weaponType}`, + name: `Test ${weaponType}`, + quality: "uncommon" as const, + inventorySlot: "ranged", + armorType: null, + weaponType, + itemLevel: 1, + sourceItemLevel: 1, + stats: EMPTY_ITEM_STATS, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "test", + acquiredAt: 1, + }; + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", + raceId: "rom-human", + level: 50, + inventory: [rangedWeapon], + equipment: { player: { ranged: rangedWeapon.instanceId } }, + }); + + const state = useCombatStore.getState(); + expect(state.equipment.player?.ranged).toBe(rangedWeapon.instanceId); + const playerAuras = state.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID); + const rangedDamageAuras = playerAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => ( + modifier.kind === "damage" + && modifier.direction === "dealt" + && modifier.attackKind === "ranged" + && modifier.operation === "percent" + ))); + + expect(rangedDamageAuras).toHaveLength(1); + expect(resolveDamageValue(100, playerAuras, { direction: "dealt", school: "physical", attackKind: "ranged" })) + .toBeCloseTo(108); + expect(resolveStatValue(0, playerAuras, "ranged-hit")).toBe(8); + expect(resolveStatValue(0, playerAuras, "physical-hit")).toBe(0); + expect(resolveStatValue(0, playerAuras, "ranged-haste")).toBe(11); + + const masteryDamageModifiers = abilityById("rom-scout-primary-2")!.effects.flatMap((effect) => ( + effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : [] + )).filter((modifier) => modifier.kind === "damage" + && modifier.direction === "dealt" + && modifier.attackKind === "ranged" + && modifier.operation === "percent"); + const speedMasteryModifiers = abilityById("rom-scout-general-8")!.effects.flatMap((effect) => ( + effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : [] + )).filter((modifier) => modifier.kind === "stat" && modifier.stat === "ranged-haste"); + expect(masteryDamageModifiers).toHaveLength(1); + expect(speedMasteryModifiers).toHaveLength(1); + }, + ); + + it("Cripple Joint roots on Joint Blow hits, not on other Scout attacks", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", + raceId: "rom-human", + level: 50, + }); + for (const id of ["shot-target", "joint-target"]) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 }); + setMobPosition(id, [3, 0, 0]); + } + + // Make the proc deterministic here; its catalog regression separately checks the sourced 55% chance. + useCombatStore.setState((state) => ({ + auras: state.auras.map((aura) => aura.definition.utilityTags?.includes("rom-cripple-joint") + ? { + ...aura, + definition: { + ...aura.definition, + procs: aura.definition.procs?.map((proc) => ({ ...proc, chance: 1 })), + }, + } + : aura), + })); + + useCombatStore.getState().selectMob("shot-target"); + expect(useCombatStore.getState().castAbility("rom-scout-general-0", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs["shot-target"]?.statuses.some((status) => status.kind === "root")).toBe(false); + + useCombatStore.getState().selectMob("joint-target"); + expect(useCombatStore.getState().castAbility("rom-scout-general-3", undefined, 2_600)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs["joint-target"]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "root", endsAt: 7_600, sourceAbilityId: "rom-scout-general-3" }), + ])); + }); +}); diff --git a/src/game/romScoutRogueOverrides.ts b/src/game/romScoutRogueOverrides.ts index 73c3ebf9..94b6b9e6 100644 --- a/src/game/romScoutRogueOverrides.ts +++ b/src/game/romScoutRogueOverrides.ts @@ -1,6 +1,10 @@ import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; import type { AuraDefinition, AuraModifier, AuraProcDefinition } from "./combatAuras"; import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; +import { + ROM_ROGUE_DAGGER_MASTERY_TAG, + ROM_ROGUE_TREASURE_HUNTER_TAG, +} from "./romRogueMechanics"; function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] { return effects.filter((effect) => effect.kind === "damage").slice(0, 1); @@ -30,6 +34,24 @@ function taggedPassive( }]; } +function taggedRoguePassive(sourceId: number, name: string, tag: string): AbilityEffect[] { + return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: `rom-aura:${sourceId}:rogue-passive`, + name, + disposition: "buff", + durationMs: null, + utilityTags: [tag], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: sourceId, + }]; +} + function customProc( sourceId: number, trigger: "hit" | "crit", @@ -78,6 +100,35 @@ export function overrideRomScoutRogueEffects( translated: readonly AbilityEffect[], ): readonly AbilityEffect[] | null { switch (row.sourceSkillId) { + case 490504: + return taggedRoguePassive(490504, "Dagger Mastery", ROM_ROGUE_DAGGER_MASTERY_TAG); + case 490316: + return taggedRoguePassive(490316, "Treasure Hunter", ROM_ROGUE_TREASURE_HUNTER_TAG); + case 490143: + // The source applies damage and accuracy only to off-hand attacks; there + // is no distinct RoM off-hand hit yet, so do not turn either value into a + // generic player melee modifier. + return []; + case 491552: + if (row.classId !== "rom-rogue") return null; + return translated.map((effect) => effect.kind === "apply-aura" ? { + ...effect, + aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-491552"] }, + } : effect); + case 490445: // Bow/crossbow accuracy must not grant melee hit; keep its range scope explicit. + return translated.filter((effect) => effect.kind !== "apply-aura" || effect.sourceAuraType !== 54) + .map((effect) => effect.kind === "apply-aura" ? { + ...effect, + aura: { + ...effect.aura, + modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "stat" + && modifier.stat === "physical-hit" + ? { ...modifier, stat: "ranged-hit" } + : modifier), + }, + } : effect); + case 490505: // Bow and crossbow each carry an equivalent +11% attack-speed modifier in the source. + return translated.filter((effect) => effect.kind !== "apply-aura" || effect.sourceAuraType !== 70); case 490400: // Piercing Arrow: one projectile, then a line hit behind its target. return firstDamage(translated); case 490424: { // Combo Shot: three impacts of the same source shot. @@ -88,6 +139,8 @@ export function overrideRomScoutRogueEffects( const hit = firstDamage(translated)[0]; return hit?.kind === "damage" ? [{ ...hit, falloffPerJump: 0.5 }] : translated; } + case 498803: // Soul Stab's first hit is unconditional; its second hit requires Weakened. + return translated.filter((effect) => effect.kind === "damage"); case 490354: // Vanish is an aggro/stealth transition, not a self-heal. return translated.filter((effect) => effect.kind !== "heal").map((effect) => effect.kind === "apply-aura" ? { ...effect, aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] } } @@ -108,6 +161,9 @@ export function overrideRomScoutRogueEffects( case 492916: return taggedPassive(492916, "Exploiting Shot", "rom-exploiting-shot", customProc(492916, "crit", "rom-exploiting-shot", { internalCooldownMs: 20_000 })); + case 493006: + return taggedPassive(493006, "Accurate Targeting", "rom-accurate-targeting", + customProc(493006, "hit", "rom-accurate-targeting", { internalCooldownMs: 30_000 })); case 493007: return taggedPassive(493007, "Deadly Counterattack", "rom-deadly-counterattack", customProc(493007, "hit", "rom-deadly-counterattack", { internalCooldownMs: 20_000 })); @@ -193,6 +249,15 @@ export function romScoutRogueCastEffects( falloffPerJump: passives.has("rom-poisonous-spit") ? 1 : effect.falloffPerJump, } : effect); } + if (ability.dbcSpellId === 498803) { + let seenDamage = 0; + const weakened = target.has("rom-weakened"); + return effects.filter((effect) => { + if (effect.kind !== "damage") return true; + seenDamage += 1; + return seenDamage === 1 || weakened; + }); + } if (ability.dbcSpellId === 490451 && passives.has("rom-meridian-attack")) { // 499537/623061 replaces Wrist Attack: -30 physical and magical damage for 10s. return effects.map((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster" @@ -298,9 +363,24 @@ export type RomScoutRogueProcActionSpec = export function romScoutRogueProcActionSpec( actionId: string, triggeringSourceSkillId: number, - _casterPoisonous: boolean, + casterPoisonous: boolean, ): RomScoutRogueProcActionSpec | null { switch (actionId) { + case "rom-accurate-targeting": { + if (triggeringSourceSkillId !== 490423) return null; + return { + kind: "aura", + recipient: "caster", + aura: { + id: "rom-scout-accurate-targeting", + name: "Accurate Targeting", + disposition: "buff", + durationMs: 15_000, // Source component 503255. + modifiers: [{ kind: "stat", stat: "physical-attack", operation: "flat", value: 50 }], + }, + provenance: "source", + }; + } case "rom-exploiting-shot": if (triggeringSourceSkillId !== 490423) return null; return { @@ -332,7 +412,7 @@ export function romScoutRogueProcActionSpec( provenance: "hybrid-estimate", }; case "rom-poisonous-spit": - if (![490457, 490424].includes(triggeringSourceSkillId)) return null; + if (!casterPoisonous || ![490457, 490424].includes(triggeringSourceSkillId)) return null; return { kind: "dot", recipient: "target", @@ -346,7 +426,7 @@ export function romScoutRogueProcActionSpec( intervalMs: 2_000, tag: "rom-poisonous-spit", }, - provenance: "source", // 499535/503522. Poison Powder is supplied without inventory gating. + provenance: "source", // 503522; the live Poison Powder aura gates the rider. }; case "rom-reclaim-energy": if (triggeringSourceSkillId === 490306) return { diff --git a/src/game/romScoutRoguePairValidation.test.ts b/src/game/romScoutRoguePairValidation.test.ts index 2c336adc..3de504f6 100644 --- a/src/game/romScoutRoguePairValidation.test.ts +++ b/src/game/romScoutRoguePairValidation.test.ts @@ -1,10 +1,14 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { abilitiesForCharacter, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog"; +import { abilitiesForCharacter, abilitiesForClass, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog"; +import { applyAura } from "./combatAuras"; +import { PLAYER_AGGRO_ID } from "./aggro"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { romScoutRogueCastEffects, romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides"; const scoutRogue = abilitiesForCharacter("rom-scout", "rom-rogue"); +const scoutWarrior = abilitiesForCharacter("rom-scout", "rom-warrior"); +const roguePrimary = abilitiesForClass("rom-rogue"); const rogueWarrior = abilitiesForCharacter("rom-rogue", "rom-warrior"); function skill(abilities: readonly AbilityDefinition[], id: string): AbilityDefinition { @@ -37,6 +41,20 @@ function prime(primary: "rom-scout" | "rom-rogue", level: number): void { useCombatStore.getState().selectMob("target"); } +function primeScoutWarrior(level: number): void { + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", + secondaryClassId: "rom-warrior", + raceId: "rom-elf", + level, + }); + for (const [id, x, z] of [["target", 3, 0], ["behind", 6, 0], ["nearby", 3, 3]] as const) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 }); + setMobPosition(id, [x, 0, z]); + } + useCombatStore.getState().selectMob("target"); +} + describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => { beforeEach(() => clearMobRuntimeRegistry()); @@ -159,9 +177,10 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => { expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000); }); - it("proc resolver poisons Reflected or Combo Shot with unlimited Poison Powder", () => { + it("Poisonous Spit only adds its source poison while Poison Powder is active", () => { for (const sourceId of [490457, 490424]) { - expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toMatchObject({ + expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toBeNull(); + expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, true)).toMatchObject({ kind: "dot", recipient: "target", provenance: "source", effect: { kind: "dot", ticks: 4, intervalMs: 2_000, basePoints: 7.1, pointsPerLevel: 2.1 }, }); @@ -171,14 +190,57 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => { expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000); }); - it("executes the Scout/Rogue poison rider on a successful reflected hit", () => { + it("does not execute Poisonous Spit without Poison Powder, then poisons after it is applied", () => { prime("rom-scout", 60); - expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 2_600)) + expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-poisonous-spit" + && effect.targetId === "target")).toBe(false); + + const poisonPowder = roguePrimary.find((ability) => ability.dbcSpellId === 490329); + if (!poisonPowder) throw new Error("Missing Poison Powder"); + const powderAura = poisonPowder.effects.find((effect) => effect.kind === "apply-aura"); + if (powderAura?.kind !== "apply-aura") throw new Error("Poison Powder has no aura"); + const applied = applyAura(useCombatStore.getState().auras, { + definition: powderAura.aura, + sourceId: PLAYER_AGGRO_ID, + targetId: PLAYER_AGGRO_ID, + now: 3_000, + }); + useCombatStore.setState({ auras: applied.auras }); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-poisonous"))) + .toBe(true); + expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 22_000)) .toMatchObject({ ok: true }); expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-poisonous-spit" && effect.targetId === "target")).toBe(true); }); + it("executes Accurate Targeting on a successful Shot for its sourced duration and modifier", () => { + primeScoutWarrior(60); + const passive = scoutWarrior.find((ability) => ability.dbcSpellId === 493006); + if (!passive) throw new Error("Missing Accurate Targeting"); + const proc = passive.effects.find((effect) => effect.kind === "apply-aura"); + expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0] : null).toMatchObject({ + internalCooldownMs: 30_000, + action: { kind: "custom", id: "rom-accurate-targeting" }, + }); + expect(romScoutRogueProcActionSpec("rom-accurate-targeting", 490457, false)).toBeNull(); + expect(romScoutRogueProcActionSpec("rom-accurate-targeting", 490423, false)).toMatchObject({ + kind: "aura", recipient: "caster", provenance: "source", aura: { + durationMs: 15_000, + modifiers: [{ kind: "stat", stat: "physical-attack", operation: "flat", value: 50 }], + }, + }); + + const shot = scoutRogue.find((ability) => ability.dbcSpellId === 490423); + if (!shot) throw new Error("Missing Shot"); + expect(useCombatStore.getState().castAbility(shot.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-scout-accurate-targeting" + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "physical-attack" && modifier.value === 50))).toBe(true); + }); + it("proc resolver routes Reclaim Energy by trigger skill", () => { expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 490306, false)).toMatchObject({ kind: "resource", resource: "energy", amount: 15, provenance: "hybrid-estimate", diff --git a/src/game/romSmashRuntime.test.ts b/src/game/romSmashRuntime.test.ts new file mode 100644 index 00000000..ba7142ef --- /dev/null +++ b/src/game/romSmashRuntime.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; +import { EMPTY_ITEM_STATS } from "./itemStats"; + +const SMASH_ID = "rom-knight-rom-rogue-elite-20"; + +function registerTarget(): void { + useCombatStore.getState().registerMob("smash-target", { + name: "Smash target", + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition("smash-target", [2, 0, 0]); + useCombatStore.getState().selectMob("smash-target"); +} + +function playerDamageEvent(critical: boolean, id: number, sourceActorId = PLAYER_AGGRO_ID) { + return { + id, + occurredAt: id * 1_000, + kind: "damage" as const, + sourceActorId, + targetActorId: "smash-target", + abilityId: "rom-knight-primary-1", + school: "holy" as const, + rawAmount: 100, + effectiveAmount: 100, + overheal: 0, + absorbed: 0, + critical, + outcome: critical ? "crit" : "hit", + }; +} + +describe("RuneWaker Smash prerequisite", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", + secondaryClassId: "rom-rogue", + raceId: "rom-human", + level: 80, + xp: 0, + }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("rom-smash-test", 80, useCombatStore.getState().health); + registerTarget(); + }); + + it("rejects Smash without a preceding player critical damage event without spending health or cooldown", () => { + const before = useCombatStore.getState(); + const result = before.castAbility(SMASH_ID, undefined, 1_000); + + expect(result).toMatchObject({ ok: false, reason: "condition-not-met", abilityId: SMASH_ID }); + expect(useCombatStore.getState().health).toBe(before.health); + expect(useCombatStore.getState().cooldowns[SMASH_ID] ?? 0).toBe(0); + expect(useCombatStore.getState().mobs["smash-target"]?.health).toBe(1_000_000); + }); + + it("allows Smash after a real player critical hit and ignores the later cast event", () => { + useCombatStore.setState({ + gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 1_000_000, hitRating: 1_000_000 }, + }); + const holySeal = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-knight-primary-1"); + expect(holySeal).toBeDefined(); + expect(useCombatStore.getState().castAbility(holySeal!.id, undefined, 2_000).ok).toBe(true); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" + && event.sourceActorId === PLAYER_AGGRO_ID + && event.critical + && event.effectiveAmount > 0 + ))).toBe(true); + + const result = useCombatStore.getState().castAbility(SMASH_ID, undefined, 4_000); + expect(result).toMatchObject({ ok: true, abilityId: SMASH_ID }); + expect(useCombatStore.getState().cooldowns[SMASH_ID]).toBe(9_000); + }); + + it("requires the latest player damage event to be a landed crit", () => { + useCombatStore.setState({ combatEvents: [ + playerDamageEvent(true, 1), + playerDamageEvent(false, 2), + ] }); + expect(useCombatStore.getState().castAbility(SMASH_ID, undefined, 3_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + + useCombatStore.setState({ combatEvents: [playerDamageEvent(true, 1, "party-member")] }); + expect(useCombatStore.getState().castAbility(SMASH_ID, undefined, 3_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + }); +}); diff --git a/src/game/romWardenOverrides.ts b/src/game/romWardenOverrides.ts new file mode 100644 index 00000000..48334755 --- /dev/null +++ b/src/game/romWardenOverrides.ts @@ -0,0 +1,142 @@ +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; + +export const ROM_WARDEN_WILL_ATTACK_TAG = "rom-will-attack"; +export const ROM_WARDEN_DAMAGE_TRANSFER_TAG = "rom-warden-damage-transfer"; +export const ROM_WARDEN_DAMAGE_TRANSFER_FRACTION = 0.3; + +const classPair = (ability: AbilityDefinition, primary: string, secondary: string): boolean => ( + ability.classId === primary && ability.secondaryClassId === secondary +); + +function casterAura( + ability: AbilityDefinition, + id: string, + name: string, + durationMs: number | null, + options: Pick["aura"], "controlImmunities" | "utilityTags">, +): Extract { + return { + kind: "apply-aura", + recipient: "caster", + aura: { + id, + name, + disposition: "buff", + durationMs, + ...options, + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: ability.dbcSpellId, + }; +} + +/** Select source-described Warden and Warden-linked class payloads. */ +export function romWardenCastEffects( + ability: AbilityDefinition, + effects: readonly AbilityEffect[], + naturePower: number, + passiveTags: readonly string[] = [], +): readonly AbilityEffect[] { + if (ability.source !== "runewaker") return effects; + + if (ability.dbcSpellId === 493391 && ability.classId === "rom-warden") { + // The source row includes Will Attack's component, but it belongs on the + // target only when the Warden has learned that passive. + const reduction = effects.find((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173); + const baseEffects = effects.filter((effect) => !(effect.kind === "apply-aura" && effect.sourceAuraType === 173)); + if (!passiveTags.includes(ROM_WARDEN_WILL_ATTACK_TAG) || !reduction || reduction.kind !== "apply-aura") { + return baseEffects; + } + return [...baseEffects, { + ...reduction, + recipient: "ability-target", + aura: { + ...reduction.aura, + id: "rom-warden-will-attack", + name: "Will Attack", + disposition: "debuff", + utilityTags: [...(reduction.aura.utilityTags ?? []), ROM_WARDEN_WILL_ATTACK_TAG], + }, + }]; + } + + if (ability.dbcSpellId === 493399 && ability.classId === "rom-warden") { + return effects.map((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "damage" + && modifier.direction === "taken" + && modifier.operation === "percent" + && modifier.value === -ROM_WARDEN_DAMAGE_TRANSFER_FRACTION) + ? { + ...effect, + aura: { + ...effect.aura, + utilityTags: [...(effect.aura.utilityTags ?? []), ROM_WARDEN_DAMAGE_TRANSFER_TAG], + }, + } + : effect); + } + + if (ability.dbcSpellId === 494043 && classPair(ability, "rom-rogue", "rom-warden")) { + // The source describes a chance on the second hit but contains no proc rate. + // Keep the guaranteed base strike and do not turn an unknown chance into 100%. + let keptBaseHit = false; + return effects.filter((effect) => { + if (effect.kind !== "damage") return true; + if (keptBaseHit) return false; + keptBaseHit = true; + return true; + }); + } + + if (ability.dbcSpellId === 499495 && classPair(ability, "rom-warrior", "rom-warden")) { + return [ + casterAura(ability, "rom-warden-awakening-root-slow-immunity", ability.name, 10_000, { + controlImmunities: ["root", "slow"], + utilityTags: ["rom-awakening-of-the-wild-immunity"], + }), + casterAura(ability, "rom-warden-awakening-next-critical", ability.name, null, { + utilityTags: ["next-offensive-critical", "rom-awakening-of-the-wild-next-critical"], + }), + ]; + } + + if (ability.dbcSpellId === 494323 && classPair(ability, "rom-druid", "rom-warden")) { + const heals = effects.filter((effect): effect is Extract => effect.kind === "heal"); + if (!heals.length) return effects; + if (naturePower < 1) return [heals[0]!]; + return [ + ...heals.slice(0, 2), + { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }, + ]; + } + + if (ability.dbcSpellId === 499936 && classPair(ability, "rom-druid", "rom-warden")) { + const points = Math.max(0, Math.floor(naturePower)); + if (points < 1) return []; + const heal = effects.find((effect): effect is Extract => effect.kind === "heal"); + if (!heal) return effects; + return [ + { + ...heal, + coefficient: 0, + percentMaxHealth: Math.min(1, points * 0.1), + basePoints: 0, + dieSides: 0, + pointsPerLevel: 0, + coefficientPerLevel: 0, + bonusPowerCoefficient: 0, + }, + { kind: "resource-drain", amount: points, burn: false, resource: "nature-power" }, + ]; + } + + if (ability.dbcSpellId === 494372 && classPair(ability, "rom-druid", "rom-warden")) { + return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.length + ? { ...effect, scope: "party" } + : effect); + } + + return effects; +} diff --git a/src/game/romWardenRuntime.test.ts b/src/game/romWardenRuntime.test.ts new file mode 100644 index 00000000..54037056 --- /dev/null +++ b/src/game/romWardenRuntime.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { ROM_ALL_ABILITIES, romTriggeredAbilityBySpellId } from "./romAbilityCatalog"; +import { applyAura, absorbDamage } from "./combatAuras"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; +import { usePartyStore } from "./partyStore"; +import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics"; +import { + ROM_WARDEN_DAMAGE_TRANSFER_FRACTION, + ROM_WARDEN_DAMAGE_TRANSFER_TAG, + ROM_WARDEN_WILL_ATTACK_TAG, +} from "./romWardenOverrides"; + +const emptyContext: RomCastMechanics = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 2_000, + targetHealthPercent: 1, +}; + +function effectsFor(spellId: number, context: Partial = {}) { + const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === spellId)!; + expect(ability).toBeDefined(); + return romEffectsForCast(ability, { ...emptyContext, ...context }); +} + +function initializeRomCharacter( + classId: "rom-warden" | "rom-warrior" | "rom-druid", + secondaryClassId?: "rom-warden" | "rom-warrior", +) { + useCombatStore.getState().initializeCharacter({ + classId, + ...(secondaryClassId ? { secondaryClassId } : {}), + raceId: classId === "rom-warrior" ? "rom-human" : "rom-elf", + level: 80, + }); + useCombatStore.setState((state) => ({ + resource: 1_000, + maxResource: 1_000, + resourcePools: { ...state.resourcePools, mana: 1_000, rage: 1_000, "nature-power": 0 }, + maxResourcePools: { ...state.maxResourcePools, mana: 1_000, rage: 1_000, "nature-power": 100 }, + })); +} + +describe("RoM Warden and Warden-linked source behaviors", () => { + beforeEach(() => { + clearPartyRuntimeRegistry(); + clearMobRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + it("gates Will Attack's 10% damage reduction on the passive and places it on Frantic Briar's target", () => { + const passive = effectsFor(494610).find((effect) => effect.kind === "apply-aura"); + expect(passive?.kind).toBe("apply-aura"); + if (passive?.kind === "apply-aura") { + expect(passive.aura.utilityTags).toContain(ROM_WARDEN_WILL_ATTACK_TAG); + expect(passive.aura.modifiers ?? []).toHaveLength(0); + } + + const withoutPassive = effectsFor(493391); + expect(withoutPassive.some((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173)).toBe(false); + expect(withoutPassive.some((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 24)).toBe(true); + expect(withoutPassive.filter((effect) => effect.kind === "damage")).toHaveLength(2); + + const withPassive = effectsFor(493391, { passiveTags: [ROM_WARDEN_WILL_ATTACK_TAG] }); + expect(withPassive.filter((effect) => effect.kind === "damage")).toHaveLength(2); + expect(withPassive.filter((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173)).toHaveLength(1); + expect(withPassive.find((effect) => effect.kind === "apply-aura" && effect.aura.id === "rom-warden-will-attack")) + .toMatchObject({ + kind: "apply-aura", + recipient: "ability-target", + aura: { + name: "Will Attack", + disposition: "debuff", + durationMs: 10_000, + modifiers: [{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "percent", value: -0.1 }], + }, + }); + }); + + it("reduces a Frantic Briar target's melee hit only while Will Attack is learned", () => { + const incomingDamage = (withWillAttack: boolean) => { + clearMobRuntimeRegistry(); + initializeRomCharacter("rom-warden", "rom-warrior"); + if (!withWillAttack) { + useCombatStore.setState((state) => ({ + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG)), + })); + } + const mobId = "will-attack-target"; + useCombatStore.getState().registerMob(mobId, { + name: "Will Attack target", + maxHealth: 100_000, + attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "will-attack-swing", initialCooldownMs: [0, 0] }], + }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().selectMob(mobId); + + const hasPassive = useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG) + )); + expect(hasPassive).toBe(withWillAttack); + expect(useCombatStore.getState().castAbility("rom-warden-primary-11", undefined, 1_000)) + .toMatchObject({ ok: true }); + const targetDebuff = useCombatStore.getState().auras.some((aura) => ( + aura.targetId === mobId && aura.definition.id === "rom-warden-will-attack" + )); + expect(targetDebuff).toBe(withWillAttack); + + useCombatStore.getState().damageMob(mobId, 1, 1_000); + return useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]); + }; + + const normalDamage = incomingDamage(false); + const reducedDamage = incomingDamage(true); + expect(normalDamage).toBeGreaterThan(0); + expect(reducedDamage).toBeCloseTo(normalDamage * 0.9, 0); + }); + + it("keeps Nature Crystal active and makes its owner immune while blocking attack skills", () => { + initializeRomCharacter("rom-warden"); + const now = Date.now(); + expect(useCombatStore.getState().castAbility("rom-warden-primary-6", undefined, now)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(4, now + 4_000); + const crystal = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102325)!; + expect(crystal.expiresAt).toBeGreaterThan(now + 60_000); + const health = useCombatStore.getState().health; + expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(0); + expect(useCombatStore.getState().health).toBe(health); + + useCombatStore.getState().registerMob("warden-crystal-target", { name: "Target", maxHealth: 10_000 }); + useCombatStore.getState().selectMob("warden-crystal-target"); + expect(useCombatStore.getState().castAbility("rom-warden-primary-0", undefined, now + 6_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + }); + + it("scales Crystalline Protection to 10% of each recipient's own maximum health", () => { + initializeRomCharacter("rom-warden"); + usePartyStore.getState().initializeParty("warden-crystalline-protection", 80, useCombatStore.getState().health, "healer", 3, "rom"); + const [first, second] = usePartyStore.getState().members; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + const members = usePartyStore.getState().members.map((member, index) => ({ + ...member, + maxHealth: index === 0 ? 2_000 : 5_000, + health: index === 0 ? 1_000 : 2_500, + })); + usePartyStore.setState({ members }); + members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0])); + + const playerMaxHealthBeforeCast = useCombatStore.getState().maxHealth; + expect(useCombatStore.getState().castAbility("rom-warden-primary-23", undefined, Date.now())).toMatchObject({ ok: true }); + const auras = useCombatStore.getState().auras; + const absorbFor = (targetId: string) => auras.find((aura) => aura.targetId === targetId + && aura.definition.name === "Crystalline Protection")?.absorbs[0]?.remaining; + expect(absorbFor(PLAYER_AURA_ENTITY_ID)).toBe(Math.round(playerMaxHealthBeforeCast * 0.1)); + expect(absorbFor(members[0]!.id)).toBe(200); + expect(absorbFor(members[1]!.id)).toBe(500); + }); + + it("absorbs exactly two Heart of the Oak attack packets and leaves ordinary point shields unchanged", () => { + initializeRomCharacter("rom-warden"); + expect(useCombatStore.getState().castAbility("rom-warden-primary-10", undefined, Date.now())).toMatchObject({ ok: true }); + expect(useCombatStore.getState().damagePlayer(100, "physical")).toBe(0); + expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(0); + expect(useCombatStore.getState().damagePlayer(100, "physical")).toBeGreaterThan(0); + + const flatShield = applyAura([], { + definition: { + id: "test:flat-absorb", + name: "Flat absorb", + disposition: "buff", + durationMs: 10_000, + absorbs: [{ id: "pool", amount: 5 }], + }, + sourceId: "test-source", + targetId: "target", + now: 1, + }).auras; + const result = absorbDamage(flatShield, { + targetId: "target", + amount: 10, + school: "physical", + now: 2, + countsAsAttack: true, + }); + expect(result).toMatchObject({ absorbed: 5, remainingDamage: 5 }); + }); + + it("transfers 30% of incoming damage to a nearby Warden pet and disables the reduction without one", () => { + const incomingHealthLoss = (withPet: boolean, petDistance = 0) => { + initializeRomCharacter("rom-warden"); + const now = Date.now(); + if (withPet) { + const summonAbility = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493333)!; + expect(useCombatStore.getState().castAbility(summonAbility.id, undefined, now)).toMatchObject({ ok: true }); + const summonCast = useCombatStore.getState().activeCast; + if (summonCast) useCombatStore.getState().tick(0.1, summonCast.completesAt); + useCombatStore.setState((state) => ({ + summons: state.summons.map((summon) => summon.creatureId === 102297 + ? { ...summon, expiresAt: now + 20_000 } + : summon), + })); + } + const petBefore = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102297); + if (petBefore && petDistance > 0) { + useCombatStore.setState((state) => ({ + summons: state.summons.map((summon) => summon.id === petBefore.id + ? { ...summon, position: [petDistance, 0, 0] } + : summon), + })); + } + const transferAbility = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493399)!; + expect(useCombatStore.getState().castAbility(transferAbility.id, undefined, now + 6_000)).toMatchObject({ ok: true }); + const playerHealthBefore = useCombatStore.getState().health; + const petHealthBefore = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102297)?.health ?? 0; + useCombatStore.getState().damagePlayer(500, "physical", 80, now + 7_000); + const state = useCombatStore.getState(); + const petHealthAfter = state.summons.find((summon) => summon.creatureId === 102297)?.health ?? 0; + return { + playerDamage: playerHealthBefore - state.health, + petDamage: petHealthBefore - petHealthAfter, + }; + }; + + const unshared = incomingHealthLoss(false); + const unsharedDamage = unshared.playerDamage; + const outOfRange = incomingHealthLoss(true, 301); + expect(unshared.petDamage).toBe(0); + expect(outOfRange.playerDamage).toBe(unsharedDamage); + expect(outOfRange.petDamage).toBe(0); + + const shared = incomingHealthLoss(true); + expect(shared.petDamage).toBeGreaterThan(0); + expect(shared.playerDamage + shared.petDamage).toBeCloseTo(unsharedDamage, -1); + expect(shared.petDamage / (shared.playerDamage + shared.petDamage)) + .toBeCloseTo(ROM_WARDEN_DAMAGE_TRANSFER_FRACTION, 1); + }); + + it("removes the unquantified Weak Point Strike bonus and models Awakening immunity and next critical", () => { + const weakPoint = effectsFor(494043); + expect(weakPoint.filter((effect) => effect.kind === "damage")).toHaveLength(1); + + const awakening = effectsFor(499495); + const auras = awakening.filter((effect) => effect.kind === "apply-aura"); + expect(auras).toHaveLength(2); + expect(auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + recipient: "caster", + aura: expect.objectContaining({ durationMs: 10_000, controlImmunities: ["root", "slow"] }), + }), + expect.objectContaining({ + recipient: "caster", + aura: expect.objectContaining({ + durationMs: null, + utilityTags: expect.arrayContaining(["next-offensive-critical"]), + }), + }), + ])); + expect(auras.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false); + }); + + it("spends one Nature Power for Healing Ripple's additional heal", () => { + const baseline = effectsFor(494323, { naturePower: 0 }); + const empowered = effectsFor(494323, { naturePower: 1 }); + expect(baseline.filter((effect) => effect.kind === "heal")).toHaveLength(1); + expect(baseline.some((effect) => effect.kind === "resource-drain")).toBe(false); + expect(empowered.filter((effect) => effect.kind === "heal")).toHaveLength(2); + expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }); + }); + + it("scales Gift Pulse by spent Nature Power and targets nearby party members", () => { + expect(effectsFor(499936, { naturePower: 0 })).toEqual([]); + const empowered = effectsFor(499936, { naturePower: 3 }); + const empoweredHeal = empowered.find((effect) => effect.kind === "heal"); + expect(empoweredHeal?.kind).toBe("heal"); + if (empoweredHeal?.kind === "heal") expect(empoweredHeal.percentMaxHealth).toBeCloseTo(0.3); + expect(empowered.find((effect) => effect.kind === "resource-drain")) + .toEqual({ kind: "resource-drain", amount: 3, burn: false, resource: "nature-power" }); + expect(effectsFor(499936, { naturePower: 20 }).find((effect) => effect.kind === "heal")) + .toMatchObject({ percentMaxHealth: 1 }); + expect(ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499936)) + .toMatchObject({ targeting: { shape: "circle", radius: 10 } }); + }); + + it("puts Mysterious Grace modifier auras on the party, and leaves Revival's absent state unresolved", () => { + const grace = effectsFor(494372); + const modifierAuras = grace.filter((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.length); + expect(modifierAuras.length).toBeGreaterThan(0); + expect(modifierAuras.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true); + expect(grace.filter((effect) => effect.kind === "apply-aura" && !effect.aura.modifiers?.length) + .every((effect) => effect.kind === "apply-aura" && effect.scope === undefined)).toBe(true); + expect(romTriggeredAbilityBySpellId(499544)).toBeNull(); + }); +}); diff --git a/src/game/romWarlockOverrides.ts b/src/game/romWarlockOverrides.ts new file mode 100644 index 00000000..16ef3a52 --- /dev/null +++ b/src/game/romWarlockOverrides.ts @@ -0,0 +1,195 @@ +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; + +export const ROM_WARLOCK_STATE_TAGS = Object.freeze({ + heartFence: "rom-warlock-state-501577", + perceptionExtraction: "rom-warlock-state-497960", + soulPain: "rom-warlock-state-497964", + soulPoisonedFang: "rom-warlock-state-498670", +} as const); + +const CRIME_AND_PUNISHMENT_STATE_TAGS = [ + ROM_WARLOCK_STATE_TAGS.perceptionExtraction, + ROM_WARLOCK_STATE_TAGS.soulPain, + ROM_WARLOCK_STATE_TAGS.soulPoisonedFang, +] as const; + +function appendAuraTag(effect: AbilityEffect, tag: string): AbilityEffect { + if (effect.kind !== "apply-aura") return effect; + return { + ...effect, + aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), tag] }, + }; +} + +/** Source-specific state markers and recipient mapping for the Warlock's paired skills. */ +export function overrideRomWarlockEffects( + row: RomGeneratedAbilityLike, + translated: readonly AbilityEffect[], +): readonly AbilityEffect[] | null { + if (row.classId !== "rom-warlock") return null; + + if (row.sourceSkillId === 498549) { + // Defense Net's active component supplies the 5-second magic mitigation; + // the source tooltip also describes immunity to Helpless, Stun, Root, and + // Slow. In this runtime, Helpless maps to fear control. + return translated.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 142 + ? { + ...effect, + aura: { + ...effect.aura, + controlImmunities: ["fear", "stun", "root", "slow"], + }, + } + : effect); + } + + if (row.sourceSkillId === 498710 || row.sourceSkillId === 498662) { + const targetAbilityName = row.sourceSkillId === 498710 ? "Flaming Heart Strike" : "Rising Tide"; + return [ + ...translated.filter((effect) => effect.kind !== "cooldown-reset"), + { kind: "cooldown-reset", mode: "reset", abilityName: targetAbilityName }, + ]; + } + + if ([498634, 498648, 498668].includes(row.sourceSkillId ?? 0)) { + // These passive tooltips describe event-gated resets. Keep their cooldown + // riders out of the generic cast path until their source events are wired. + return translated.filter((effect) => effect.kind !== "cooldown-reset"); + } + + if (row.sourceSkillId === 498695) { + let selectedTargetThreatAura = false; + const selectedTargetEffects: AbilityEffect[] = translated.flatMap((effect): AbilityEffect[] => { + if (effect.kind !== "apply-aura") return [effect]; + const threatModifiers = effect.aura.modifiers?.filter((modifier) => ( + modifier.kind === "stat" && modifier.stat === "threat" + )) ?? []; + const threatModifier = threatModifiers[0]; + if (!threatModifier || threatModifier.kind !== "stat") return [effect]; + + if (threatModifier.value > 0) { + // 621654 repeats the +5% modifier on a hidden script component; the tooltip + // describes one buff on the selected friendly target. + if (selectedTargetThreatAura) return []; + selectedTargetThreatAura = true; + return [{ ...effect, recipient: "ability-target" }]; + } + if (threatModifier.value < 0) { + return [{ ...effect, recipient: "caster", scope: "party" }]; + } + return [effect]; + }); + const reductionComponent = row.source?.components?.find((component) => component.id === 621433); + const reductionModifier = reductionComponent?.abilityModifiers?.find((modifier) => modifier.type === 138); + if (!reductionComponent || !reductionModifier) return selectedTargetEffects; + const partyReductionAura: AbilityEffect = { + kind: "apply-aura", + aura: { + id: "rom-warlock-liquidation-party-threat-reduction", + name: `${row.name}: party threat reduction`, + disposition: "buff", + durationMs: Math.max(0, Math.round((reductionComponent.effectTime ?? 10) * 1_000)), + modifiers: [{ + kind: "stat", + stat: "threat", + operation: "percent", + value: reductionModifier.value / 100, + }], + }, + recipient: "caster", + scope: "party", + sourceEffectType: -1, + sourceAuraType: 138, + miscValue: reductionModifier.value, + triggerSpellId: row.sourceSkillId ?? 0, + }; + return [...selectedTargetEffects, partyReductionAura]; + } + + const stateTag = row.sourceSkillId === 497960 + ? ROM_WARLOCK_STATE_TAGS.perceptionExtraction + : row.sourceSkillId === 497964 + ? ROM_WARLOCK_STATE_TAGS.soulPain + : row.sourceSkillId === 498670 + ? ROM_WARLOCK_STATE_TAGS.soulPoisonedFang + : null; + if (stateTag) { + return translated.map((effect) => effect.kind === "dot" + ? { ...effect, tag: stateTag } + : appendAuraTag(effect, stateTag)); + } + + if (row.source?.components?.some((component) => component.id === 501577)) { + return translated.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 226 + ? appendAuraTag(effect, ROM_WARLOCK_STATE_TAGS.heartFence) + : effect); + } + return null; +} + +function scaleDamage(effect: AbilityEffect, factor: number): AbilityEffect { + if (effect.kind !== "damage") return effect; + return { + ...effect, + coefficient: effect.coefficient * factor, + ...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }), + ...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }), + ...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }), + ...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }), + ...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * factor }), + }; +} + +/** Add only the source-described conditional riders for the Warlock primary build. */ +export function romWarlockCastEffects( + ability: AbilityDefinition, + effects: readonly AbilityEffect[], + targetTags: readonly string[], + learnedPassiveSpellIds: readonly number[] = [], +): readonly AbilityEffect[] { + if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return effects; + + if (ability.dbcSpellId === 498549 + && learnedPassiveSpellIds.includes(498648)) { + // Mind Shield (498648) reduces Defense Net's sourced five-second aura by one second. + return effects.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 142 + ? { + ...effect, + aura: { ...effect.aura, durationMs: Math.max(0, (effect.aura.durationMs ?? 0) - 1_000) }, + } + : effect); + } + + if (ability.dbcSpellId === 498646 && ability.secondaryClassId === "rom-warrior" + && targetTags.includes(ROM_WARLOCK_STATE_TAGS.heartFence)) { + return [...effects, { kind: "resource", amount: 100, percentage: true, resource: "rage" }]; + } + + if (ability.dbcSpellId === 498700 && ability.secondaryClassId === "rom-rogue") { + const matchedStates = CRIME_AND_PUNISHMENT_STATE_TAGS.filter((tag) => targetTags.includes(tag)); + const additionalStates = Math.max(0, matchedStates.length - 1); + return additionalStates > 0 + ? effects.map((effect) => scaleDamage(effect, 1 + additionalStates * 0.25)) + : effects; + } + + return effects; +} + +/** Return state tags whose source durations are refreshed by this successful cast. */ +export function romWarlockStateRefreshTags( + ability: AbilityDefinition, + targetTags: readonly string[], +): readonly string[] { + if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return []; + if (ability.dbcSpellId === 498646 && ability.secondaryClassId === "rom-warrior") { + return targetTags.includes(ROM_WARLOCK_STATE_TAGS.heartFence) + ? [ROM_WARLOCK_STATE_TAGS.heartFence] + : []; + } + if (ability.dbcSpellId === 498700 && ability.secondaryClassId === "rom-rogue") { + return CRIME_AND_PUNISHMENT_STATE_TAGS.filter((tag) => targetTags.includes(tag)); + } + return []; +} diff --git a/src/game/romWarlockRuntime.test.ts b/src/game/romWarlockRuntime.test.ts new file mode 100644 index 00000000..a95469f5 --- /dev/null +++ b/src/game/romWarlockRuntime.test.ts @@ -0,0 +1,324 @@ +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { AbilityEffect } from "./abilityCatalog"; +import { abilitiesForCharacter, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { cooldownResetCandidates } from "./cooldownReset"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { romEffectsForCast } from "./romCombatMechanics"; +import { + ROM_WARLOCK_STATE_TAGS, +} from "./romWarlockOverrides"; + +const TARGET_ID = "rom-warlock-runtime-target"; + +function ability(spellId: number) { + const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId); + expect(result, `RoM Warlock spell ${spellId}`).toBeDefined(); + return result!; +} + +function finishCast(now: number) { + const cast = useCombatStore.getState().activeCast; + if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt); +} + +function prepareWarlock(secondaryClassId: "rom-warrior" | "rom-rogue" | "rom-priest") { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warlock", + secondaryClassId, + raceId: "rom-human", + level: 80, + }); + useCombatStore.setState((state) => ({ + resource: 100, + maxResource: 100, + resourcePools: { + ...state.resourcePools, + focus: 100, + rage: 0, + energy: 100, + psi: 0, + }, + maxResourcePools: { + ...state.maxResourcePools, + focus: 100, + rage: 100, + energy: 100, + psi: 6, + }, + })); +} + +function registerTarget() { + useCombatStore.getState().registerMob(TARGET_ID, { name: "Warlock Test Target", maxHealth: 100_000 }); + setMobPosition(TARGET_ID, [5, 0, 0]); +} + +describe("RuneWaker Warlock runtime", () => { + beforeAll(async () => { + await preloadAbilityCatalogsForClasses(["rom-warlock"]); + }); + + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + it("converts the RoM Warlock/Warrior Rage pool to psi at 35:1", () => { + prepareWarlock("rom-warrior"); + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, rage: 70, psi: 1 }, + })); + + const mindTransference = ability(498642); + expect(mindTransference.costs).toEqual([]); + expect(useCombatStore.getState().castAbility(mindTransference.id, { now: 1_000 }).ok).toBe(true); + + expect(useCombatStore.getState().resourcePools.rage).toBe(0); + expect(useCombatStore.getState().resourcePools.psi).toBe(3); + }); + + it("consumes all Rage for Heart Fence and extends its duration by one second per 20 Rage", () => { + prepareWarlock("rom-warrior"); + usePartyStore.getState().initializeParty( + "rom-warlock-heart-fence", 80, useCombatStore.getState().health, "damage", 3, "rom", + ); + const member = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(member.id, [4, 0, 0]); + useCombatStore.getState().synchronizeActors(); + useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 45 } })); + + const heartFence = ability(498645); + expect(useCombatStore.getState().castAbility(heartFence.id, { + targetActorId: member.id, + now: 1_000, + }).ok).toBe(true); + + expect(useCombatStore.getState().resourcePools.rage).toBe(0); + const defenseAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id + && aura.definition.name.startsWith("Heart Fence") + && aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken")); + expect(defenseAuras).toHaveLength(2); + expect(defenseAuras.every((aura) => aura.expiresAt === 5_000)).toBe(true); + }); + + it("only refreshes state 501577 and restores Rage when Consistent Heart Strike finds it", () => { + prepareWarlock("rom-warrior"); + registerTarget(); + const heartStrike = ability(498646); + useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 25 } })); + + expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 500 }).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(25); + + const soulTrauma = ability(498550); + const traumaCast = useCombatStore.getState().castAbility(soulTrauma.id, { targetActorId: TARGET_ID, now: 2_000 }); + expect(traumaCast, JSON.stringify(traumaCast)).toMatchObject({ ok: true }); + const heartStatesAfterTrauma = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID + && aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence)); + expect(heartStatesAfterTrauma.some((aura) => aura.expiresAt === 17_000)).toBe(true); + + useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 20 } })); + expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 5_000 }).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(100); + const heartStatesAfterStrike = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID + && aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence)); + expect(heartStatesAfterStrike.length).toBeGreaterThan(0); + expect(heartStatesAfterStrike.every((aura) => aura.expiresAt === 20_000)).toBe(true); + }); + + it("scales Crime and Punishment by 25% per state after the first", () => { + const crimeAndPunishment = ability(498700); + const baseDamage = crimeAndPunishment.effects.find((effect) => effect.kind === "damage"); + if (baseDamage?.kind !== "damage") throw new Error("Missing Crime and Punishment damage effect"); + const effectsFor = (targetTags: readonly string[]) => romEffectsForCast(crimeAndPunishment, { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 10_000, + targetHealthPercent: 1, + targetTags, + }); + + const oneState = effectsFor([ + ROM_WARLOCK_STATE_TAGS.perceptionExtraction, + ]).find((effect) => effect.kind === "damage"); + const twoStates = effectsFor([ + ROM_WARLOCK_STATE_TAGS.perceptionExtraction, + ROM_WARLOCK_STATE_TAGS.soulPain, + ]).find((effect) => effect.kind === "damage"); + const threeStates = effectsFor([ + ROM_WARLOCK_STATE_TAGS.perceptionExtraction, + ROM_WARLOCK_STATE_TAGS.soulPain, + ROM_WARLOCK_STATE_TAGS.soulPoisonedFang, + ]).find((effect) => effect.kind === "damage"); + expect(oneState).toEqual(baseDamage); + expect(twoStates).toMatchObject({ + coefficient: baseDamage.coefficient * 1.25, + basePoints: baseDamage.basePoints! * 1.25, + }); + expect(threeStates).toMatchObject({ + coefficient: baseDamage.coefficient * 1.5, + basePoints: baseDamage.basePoints! * 1.5, + }); + }); + + it("refreshes the active three Warlock state effects after Crime and Punishment", () => { + prepareWarlock("rom-rogue"); + registerTarget(); + for (const spellId of [497960, 497964, 498670]) { + const stateSpell = ability(spellId); + const castAt = spellId === 497960 ? 1_000 : spellId === 497964 ? 2_500 : 4_000; + expect(useCombatStore.getState().castAbility(stateSpell.id, { targetActorId: TARGET_ID, now: castAt }).ok).toBe(true); + finishCast(castAt); + } + useCombatStore.getState().tick(2.1, 6_100); + const before = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot" + && effect.targetId === TARGET_ID + && ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`)); + expect(before.map((effect) => effect.remainingTicks).every((ticks) => ticks > 0)).toBe(true); + expect(before).toHaveLength(3); + expect(before.every((effect) => { + const sourceDot = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId) + ?.effects.find((entry): entry is Extract => ( + entry.kind === "dot" && entry.tag === effect.tag + )); + if (sourceDot?.kind !== "dot") return false; + return effect.remainingTicks < sourceDot.ticks; + })).toBe(true); + + const crimeAndPunishment = ability(498700); + expect(useCombatStore.getState().castAbility(crimeAndPunishment.id, { targetActorId: TARGET_ID, now: 6_200 }).ok).toBe(true); + const after = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot" + && effect.targetId === TARGET_ID + && ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`)); + expect(after).toHaveLength(before.length); + expect(after.every((effect) => effect.nextTickAt > 6_200)).toBe(true); + expect(after.map((effect) => effect.remainingTicks)).toEqual(before.map((effect) => { + const source = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId); + return source?.effects.find((entry): entry is Extract => ( + entry.kind === "dot" && entry.tag === effect.tag + ))?.ticks; + })); + }); + + it("applies Liquidation Suffering to its selected ally and the rest of the party", () => { + prepareWarlock("rom-rogue"); + usePartyStore.getState().initializeParty( + "rom-warlock-liquidation-suffering", 80, useCombatStore.getState().health, "damage", 3, "rom", + ); + const [selectedAlly, otherAlly] = usePartyStore.getState().members; + expect(selectedAlly).toBeDefined(); + expect(otherAlly).toBeDefined(); + registerPartyRuntimePosition(selectedAlly!.id, [4, 0, 0]); + registerPartyRuntimePosition(otherAlly!.id, [6, 0, 0]); + useCombatStore.getState().synchronizeActors(); + + const liquidation = ability(498695); + expect(useCombatStore.getState().castAbility(liquidation.id, { + targetActorId: selectedAlly!.id, + now: 1_000, + }).ok).toBe(true); + + const threatAuras = useCombatStore.getState().auras.filter((aura) => ( + aura.definition.name.startsWith("Liquidation Suffering") + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat") + )); + const modifierFor = (targetId: string) => threatAuras.find((aura) => aura.targetId === targetId) + ?.definition.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat"); + expect(modifierFor(selectedAlly!.id)).toMatchObject({ value: 0.05 }); + expect(modifierFor(PLAYER_AURA_ENTITY_ID)).toMatchObject({ value: -0.05 }); + expect(modifierFor(otherAlly!.id)).toMatchObject({ value: -0.05 }); + expect(threatAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => ( + modifier.kind === "stat" && modifier.stat === "threat" && modifier.value > 0 + )))).toHaveLength(1); + expect(threatAuras.some((aura) => aura.targetId === selectedAlly!.id + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "threat" && modifier.value < 0))).toBe(false); + }); + + it("grants Defense Net's source-described control immunity to its friendly target", () => { + prepareWarlock("rom-warrior"); + usePartyStore.getState().initializeParty( + "rom-warlock-defense-net", 80, useCombatStore.getState().health, "damage", 3, "rom", + ); + const member = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(member.id, [4, 0, 0]); + usePartyStore.setState((party) => ({ + members: party.members.map((candidate) => candidate.id === member.id + ? { ...candidate, controlMechanic: "stun" as const, controlledUntil: 5_000 } + : candidate), + })); + useCombatStore.getState().synchronizeActors(); + + const defenseNet = ability(498549); + expect(useCombatStore.getState().castAbility(defenseNet.id, { + targetActorId: member.id, + now: 1_000, + }).ok).toBe(true); + finishCast(1_000); + + const updatedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id); + expect(updatedMember).toMatchObject({ controlMechanic: null, controlledUntil: 0 }); + const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id + && candidate.definition.name.startsWith("Defense Net")); + expect(aura).toMatchObject({ + expiresAt: 6_000, + definition: { + controlImmunities: ["fear", "stun", "root", "slow"], + modifiers: expect.arrayContaining([expect.objectContaining({ direction: "taken", value: -0.5 })]), + }, + }); + }); + + it("reduces Defense Net to four seconds only for the Warlock/Priest Mind Shield pair", () => { + prepareWarlock("rom-priest"); + usePartyStore.getState().initializeParty( + "rom-warlock-mind-shield", 80, useCombatStore.getState().health, "damage", 3, "rom", + ); + const member = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(member.id, [4, 0, 0]); + useCombatStore.getState().synchronizeActors(); + + const defenseNet = ability(498549); + expect(useCombatStore.getState().castAbility(defenseNet.id, { + targetActorId: member.id, + now: 1_000, + }).ok).toBe(true); + finishCast(1_000); + + expect(useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id + && candidate.definition.name.startsWith("Defense Net"))?.expiresAt).toBe(5_000); + }); + + it("resolves Warlock paired named resets to only their source-named spells", () => { + const expectations = new Map([ + [498744, [491156]], // Static Resonance -> Electrostatic Charge + [498758, [490225, 490251]], // Deep Inspiration -> Intensification and Energy Well + [498662, [490256]], // Touch of Faith -> Rising Tide + [498710, [498697]], // Soul Brand Sting -> Flaming Heart Strike + ]); + for (const [sourceSpellId, expectedTargets] of expectations) { + const source = ability(sourceSpellId); + const activePair = abilitiesForCharacter(source.classId, source.secondaryClassId); + const targets = source.effects.flatMap((effect) => effect.kind === "cooldown-reset" + ? cooldownResetCandidates(activePair, source.id, effect.abilityName).map((candidate) => candidate.dbcSpellId) + : []); + expect(targets.sort((left, right) => left - right), `source ${sourceSpellId}`).toEqual( + [...expectedTargets].sort((left, right) => left - right), + ); + } + for (const sourceSpellId of [498634, 498648, 498668]) { + expect(ability(sourceSpellId).effects.some((effect) => effect.kind === "cooldown-reset"), `source ${sourceSpellId}`) + .toBe(false); + } + expect(ROM_ALL_ABILITIES.filter((candidate) => candidate.classId === "rom-warlock") + .flatMap((candidate) => candidate.effects.filter((effect) => effect.kind === "cooldown-reset" + && effect.abilityName === undefined).map((effect) => candidate.dbcSpellId))).toEqual([]); + }); +}); diff --git a/src/game/romWarriorOverrides.test.ts b/src/game/romWarriorOverrides.test.ts new file mode 100644 index 00000000..d9d37986 --- /dev/null +++ b/src/game/romWarriorOverrides.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import type { AbilityEffect } from "./abilityCatalog"; +import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { overrideRomWarriorEffects } from "./romWarriorOverrides"; +import { useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; + +describe("RoM Warrior/Priest Defender's Roar party scope", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + it("marks only Defender's Roar aura effects as party scoped for the exact class pair", () => { + const maxHealthAura: AbilityEffect = { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "test-aura", + name: "test", + disposition: "buff", + durationMs: 120_000, + modifiers: [{ kind: "stat", stat: "maximum-health", operation: "flat", value: 300 }], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + }; + const unrelatedAura: AbilityEffect = { + kind: "apply-aura", + recipient: "caster", + aura: { id: "test-proc", name: "proc", disposition: "buff", durationMs: null }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 123, + }; + const unrelated: AbilityEffect = { kind: "damage", coefficient: 1, school: "physical" }; + const row = { + sourceSkillId: 491466, + name: "Defender's Roar", + description: "your and your party members' maximum HP", + target: "self" as const, + classId: "rom-warrior", + secondaryClassId: "rom-priest", + }; + + const overridden = overrideRomWarriorEffects(row, [maxHealthAura, unrelatedAura, unrelated]); + + expect(overridden?.[0]).toMatchObject({ kind: "apply-aura", scope: "party" }); + expect(overridden?.[1]).toBe(unrelatedAura); + expect(overridden?.[2]).toBe(unrelated); + expect(overrideRomWarriorEffects({ ...row, classId: "rom-priest" }, [maxHealthAura])).toBeNull(); + expect(overrideRomWarriorEffects({ ...row, secondaryClassId: "rom-scout" }, [maxHealthAura])).toBeNull(); + expect(overrideRomWarriorEffects({ ...row, sourceSkillId: 490122 }, [maxHealthAura])).toBeNull(); + }); + + it("fans both source-backed maximum-health auras out to party members", () => { + const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 491466)!; + expect(ability).toMatchObject({ + id: "rom-warrior-rom-priest-elite-15", + classId: "rom-warrior", + secondaryClassId: "rom-priest", + }); + const partyAuras = ability.effects.filter((effect) => effect.kind === "apply-aura"); + const maximumHealthAuras = partyAuras.filter((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "maximum-health")); + expect(maximumHealthAuras).toHaveLength(2); + expect(maximumHealthAuras.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true); + expect(partyAuras.some((effect) => effect.kind === "apply-aura" && !effect.aura.modifiers?.length + && effect.scope !== "party")).toBe(true); + expect(maximumHealthAuras.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : [])) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "stat", stat: "maximum-health", operation: "flat", value: 300 }), + expect.objectContaining({ kind: "stat", stat: "maximum-health", operation: "percent", value: 0.01 }), + ])); + const rankMaximumHealthAuras = ability.ranks?.[0]?.effects.filter((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "maximum-health")); + expect(rankMaximumHealthAuras).toHaveLength(2); + expect(rankMaximumHealthAuras?.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", + secondaryClassId: "rom-priest", + raceId: "rom-human", + level: 30, + }); + usePartyStore.getState().initializeParty( + "warrior-defenders-roar-party-scope", + 30, + useCombatStore.getState().health, + "damage", + 2, + "rom", + ); + const member = usePartyStore.getState().members[0]!; + + expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true }); + + const appliedAuras = useCombatStore.getState().auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID + && aura.definition.name.startsWith("Defender's Roar:")); + expect(appliedAuras).toHaveLength(4); + expect(appliedAuras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)).toHaveLength(2); + expect(appliedAuras.filter((aura) => aura.targetId === member.id)).toHaveLength(2); + expect(appliedAuras.every((aura) => aura.definition.modifiers?.some((modifier) => ( + modifier.kind === "stat" && modifier.stat === "maximum-health" + )))).toBe(true); + }); +}); diff --git a/src/game/romWarriorOverrides.ts b/src/game/romWarriorOverrides.ts new file mode 100644 index 00000000..9eba1207 --- /dev/null +++ b/src/game/romWarriorOverrides.ts @@ -0,0 +1,23 @@ +import type { AbilityEffect } from "./abilityCatalog"; +import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; + +type RomWarriorGeneratedRow = RomGeneratedAbilityLike & { + readonly classId?: string; + readonly secondaryClassId?: string; +}; + +/** Give the source-defined Warrior/Priest Defender's Roar buffs their party scope. */ +export function overrideRomWarriorEffects( + row: RomWarriorGeneratedRow, + translated: readonly AbilityEffect[], +): readonly AbilityEffect[] | null { + if (row.classId !== "rom-warrior" + || row.secondaryClassId !== "rom-priest" + || row.sourceSkillId !== 491466) return null; + + return translated.map((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "maximum-health") + ? { ...effect, scope: "party" } + : effect); +} diff --git a/src/game/talentModifiers.test.ts b/src/game/talentModifiers.test.ts index 5f34cf6e..7bf78813 100644 --- a/src/game/talentModifiers.test.ts +++ b/src/game/talentModifiers.test.ts @@ -67,6 +67,49 @@ describe("talent runtime modifiers", () => { ])); }); + it("scopes Improved Rejuvenation's effect bonus to Rejuvenation healing", () => { + const improvedRejuvenation = TALENT_NODES.find((node) => node.id === "talent-830")!; + const modifiers = talentModifiersForRanks({ [improvedRejuvenation.id]: 3 }, "druid"); + + expect(modifiers).toContainEqual(expect.objectContaining({ + kind: "healing-percent", + amount: 20, + sourceTalentId: "talent-830", + rank: 3, + })); + expect(applyTalentModifierValue(100, modifiers, "healing-percent", { + abilityName: "Rejuvenation", + tags: ["periodic", "healing", "nature"], + })).toBe(120); + expect(applyTalentModifierValue(100, modifiers, "healing-percent", { + abilityName: "Regrowth", + tags: ["periodic", "healing", "nature"], + })).toBe(100); + }); + + it("keeps Warrior named-ability damage and cooldown bonuses on their source abilities", () => { + const improvedExecute = TALENT_NODES.find((node) => node.name === "Improved Execute")!; + const executeModifiers = talentModifiersForRanks({ [improvedExecute.id]: 1 }, "warrior"); + expect(applyTalentModifierValue(100, executeModifiers, "damage-percent", { abilityName: "Execute" })).toBe(108); + expect(applyTalentModifierValue(100, executeModifiers, "damage-percent", { abilityName: "Heroic Strike" })).toBe(100); + expect(applyTalentModifierValue(15, executeModifiers, "resource-cost-percent", { abilityName: "Heroic Strike" })).toBe(15); + + const unrelentingAssault = TALENT_NODES.find((node) => node.name === "Unrelenting Assault")!; + const assaultModifiers = talentModifiersForRanks({ [unrelentingAssault.id]: 1 }, "warrior"); + for (const abilityName of ["Overpower", "Revenge"]) { + expect(applyTalentModifierValue(100, assaultModifiers, "damage-percent", { abilityName })).toBeCloseTo(110); + expect(applyTalentModifierValue(10_000, assaultModifiers, "cooldown-flat-ms", { abilityName })).toBe(9_500); + } + expect(applyTalentModifierValue(100, assaultModifiers, "damage-percent", { abilityName: "Bloodthirst" })).toBe(100); + expect(applyTalentModifierValue(10_000, assaultModifiers, "cooldown-flat-ms", { abilityName: "Bloodthirst" })).toBe(10_000); + + const improvedDisciplines = TALENT_NODES.find((node) => node.name === "Improved Disciplines")!; + const disciplineModifiers = talentModifiersForRanks({ [improvedDisciplines.id]: 1 }, "warrior"); + expect(applyTalentModifierValue(300_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Shield Wall" })).toBe(290_000); + expect(applyTalentModifierValue(30_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Retaliation" })).toBe(20_000); + expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000); + }); + it("keeps critical damage bonuses out of ordinary hit damage", () => { const modifiers = parseTalentModifiers("Increases the critical damage of your spells by 150%.") .map((modifier) => ({ ...modifier, sourceTalentId: "test", sourceTalentName: "Thunderous", rank: 1, description: "" })); @@ -88,4 +131,39 @@ describe("talent runtime modifiers", () => { tags: ["cast-time", "holy"], })).toBe(20); }); + + it("keeps Paladin activation and aura text out of persistent modifiers", () => { + for (const name of ["Aura Mastery", "Divine Favor"]) { + const talent = TALENT_NODES.find((node) => node.name === name); + expect(talent, name).toBeDefined(); + expect(talentModifiersForRanks({ [talent!.id]: 1 }, "paladin"), name).toEqual([]); + } + }); + + it("scopes Improved Flash Heal critical chance and keeps its Inner Focus proc out of static cooldowns", () => { + const improvedFlashHeal = TALENT_NODES.find((node) => node.name === "Improved Flash Heal")!; + const modifiers = talentModifiersForRanks({ [improvedFlashHeal.id]: 3 }, "priest"); + + expect(applyTalentModifierValue(5, modifiers, "critical-chance-percent", { abilityName: "Flash Heal" })).toBe(20); + expect(applyTalentModifierValue(5, modifiers, "critical-chance-percent", { abilityName: "Greater Heal" })).toBe(5); + expect(modifiers.filter((modifier) => modifier.kind === "cooldown-flat-ms")).toEqual([]); + }); + + it("applies Black Ice to either Frost or Shadow damage", () => { + const blackIce = TALENT_NODES.find((node) => node.name === "Black Ice")!; + const modifiers = talentModifiersForRanks({ [blackIce.id]: blackIce.maxRank }, "death-knight"); + + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: "Icy Touch", + tags: ["damage", "frost"], + })).toBeCloseTo(110); + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: "Death Coil", + tags: ["damage", "shadow"], + })).toBeCloseTo(110); + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: "Plague Strike", + tags: ["damage", "physical"], + })).toBe(100); + }); }); diff --git a/src/game/talentModifiers.ts b/src/game/talentModifiers.ts index 747758cf..d37a446a 100644 --- a/src/game/talentModifiers.ts +++ b/src/game/talentModifiers.ts @@ -43,12 +43,36 @@ interface ParsedModifier extends Omit { + if (active.talentName === "Backlash") { + return [{ + kind: "critical-chance-percent" as const, + amount: active.rank, + unit: "percent" as const, + scope: "your spells and abilities", + sourceTalentId: active.talentId, + sourceTalentName: active.talentName, + rank: active.rank, + description: active.description, + }]; + } if (talentUsesExplicitActivation(active.talentName)) return []; + // Its healing and damage bonuses only apply below a target-health threshold; + // the scalar parser cannot encode that condition. CombatStore applies the + // rank-aware bonus at the individual target instead. + if (active.talentName === "Test of Faith") return []; const override = talentBalanceOverride(active.talentId); const scale = (override?.effectScalePercent ?? 100) / 100; - const parsed = parseTalentModifiers(active.description).map((modifier) => ({ - ...modifier, - amount: modifier.amount * scale, + const parsed = parseTalentModifiers(active.description) + // Improved Shadow Bolt's 5% spell crit applies to the debuffed target; + // only its named Shadow Bolt damage bonus is a persistent caster modifier. + .filter((modifier) => active.talentName !== "Improved Shadow Bolt" || modifier.kind !== "critical-chance-percent") + .filter((modifier) => !(classId === "warrior" && active.talentName === "Unrelenting Assault" + && ["damage-percent", "cooldown-percent"].includes(modifier.kind))) + .flatMap((modifier) => { + if (classId === "warrior" && active.talentName === "Improved Execute") { + // This tooltip combines a flat Rage reduction, damage percent, and a + // conditional capstone crit. The broad parser merges the first two + // clauses, so rebuild only the source-backed damage component here; + // combatStore applies the flat Rage reduction when casting Execute. + if (["resource-cost-percent", "damage-percent", "critical-chance-percent"].includes(modifier.kind)) { + if (modifier.kind !== "damage-percent") return []; + const increase = active.description.match(/increases its damage by (\d+(?:\.\d+)?)%/i); + if (!increase) return []; + return [{ + ...modifier, + amount: Number(increase[1]) * 2 * scale, + scope: "damage of your Execute ability", + sourceTalentId: active.talentId, + sourceTalentName: active.talentName, + rank: active.rank, + description: active.description, + }]; + } + } + return [{ + ...modifier, + amount: modifier.amount * scale, + sourceTalentId: active.talentId, + sourceTalentName: active.talentName, + rank: active.rank, + description: active.description, + }]; + }); + const unrelentingAssaultDamage = classId === "warrior" && active.talentName === "Unrelenting Assault" + ? active.description.match(/increases the damage done by both abilities by (\d+(?:\.\d+)?)%/i) + : null; + const explicitScoped = unrelentingAssaultDamage ? [{ + kind: "damage-percent" as const, + amount: Number(unrelentingAssaultDamage[1]) * scale, + unit: "percent" as const, + scope: "damage of your Overpower and Revenge abilities", sourceTalentId: active.talentId, sourceTalentName: active.talentName, rank: active.rank, description: active.description, - })); + }] : []; const supplemental = Object.entries(override?.modifiersPerRank ?? {}).flatMap(([kind, amount]) => { if (!Number.isFinite(amount) || amount === 0) return []; return [{ @@ -167,7 +255,7 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): description: active.description, }]; }); - return [...parsed, ...supplemental]; + return [...parsed, ...explicitScoped, ...supplemental]; }); } @@ -181,8 +269,12 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase())); if (scope.includes("instant") && !tags.has("instant")) return false; if (scope.includes("periodic") && !tags.has("periodic")) return false; - for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) { - if (scope.includes(school) && !tags.has(school)) return false; + const schools = (["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) + .filter((school) => scope.includes(school)); + if (schools.length > 1) { + if (!schools.some((school) => tags.has(school))) return false; + } else if (schools.length === 1 && !tags.has(schools[0])) { + return false; } if (context.abilityName && /\b(?:spell|ability)\b/.test(scope)) { const normalizedName = context.abilityName.toLowerCase(); @@ -190,10 +282,17 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex if (namedScope && !namedScope.includes("your") && !scope.includes(normalizedName)) return false; } if (context.abilityName) { - const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time).*?\b(?:by|of|with) your\b/.test(scope); + const normalizedName = context.abilityName.toLowerCase(); + const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time|effect|critical (?:effect |strike )?chance|chance to critically).*?\b(?:by|of|with) your\b/.test(scope); const genericScope = /\byour (?:all |instant |periodic |healing |damage |helpful |harmful )*(?:spells?|abilities?|attacks?)\b/.test(scope) || /\byour (?:arcane|fire|frost|holy|nature|shadow) spells?\b/.test(scope); - if (namedScope && !genericScope && !scope.includes(context.abilityName.toLowerCase())) return false; + const namedAbilityList = genericScope + ? undefined + : scope.match(/\byour\s+(.+?)\s+abilit(?:y|ies)\b/)?.[1] + ?.split(/\s*(?:,|\band\b|\bor\b)\s*/) + .map((name) => name.trim()); + if (namedAbilityList?.length && !namedAbilityList.includes(normalizedName)) return false; + if (namedScope && !genericScope && !scope.includes(normalizedName)) return false; } return true; } diff --git a/src/game/wow335AbilityCatalog.test.ts b/src/game/wow335AbilityCatalog.test.ts index 7b4743e9..beda8720 100644 --- a/src/game/wow335AbilityCatalog.test.ts +++ b/src/game/wow335AbilityCatalog.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog"; import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules"; -import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { + WOW335_ABILITIES_BY_CLASS, + WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID, +} from "./wow335AbilityCatalog"; import generatedData from "./wow335AbilityData.generated.json"; import { talentNodeById } from "./talentCatalog"; import { runtimeTalentNodeById } from "./talentRuntimeCatalog"; @@ -39,6 +42,99 @@ describe("WoW 3.3.5 executable effect mapping", () => { expect(byId("wow335-priest-power-word-shield").gcdMs).toBe(1_500); }); + it("keeps Bloodthirst's player cast, next-hit aura, and triggered heal on their source rows", () => { + const bloodthirst = abilityAtLevel(byId("wow335-warrior-bloodthirst"), 80); + expect(bloodthirst).toMatchObject({ + dbcSpellId: 23881, + target: "hostile", + range: { min: 0, max: 5 }, + cost: { resource: "rage", amount: 20 }, + cooldownMs: 4_000, + }); + expect(effectsOfKind(bloodthirst, "damage")).toHaveLength(1); + const bloodthirstAura = effectsOfKind(bloodthirst, "apply-aura") + .find((effect) => effect.sourceAuraType === 42); + expect(bloodthirstAura).toMatchObject({ + recipient: "caster", + aura: { + durationMs: 8_000, + procs: [expect.objectContaining({ + triggers: ["hit", "crit"], + attackKinds: ["melee"], + charges: 3, + action: { kind: "custom", id: "wow335-trigger-spell", data: { spellId: 23880 } }, + })], + }, + }); + expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[23880]?.effects).toEqual([ + expect.objectContaining({ kind: "heal", percentMaxHealth: 0.01 }), + ]); + }); + + it("selects Sweeping Strikes' player self-buff row instead of its damage child", () => { + const source = byId("wow335-warrior-sweeping-strikes"); + const sweepingStrikes = abilityAtLevel(source, 80); + expect(source).toMatchObject({ unlockLevel: 30, sourceUnlockLevel: 30 }); + expect(sweepingStrikes).toMatchObject({ + dbcSpellId: 12328, + target: "self", + range: { min: 0, max: 0 }, + cost: { resource: "rage", amount: 30 }, + cooldownMs: 30_000, + }); + expect(effectsOfKind(sweepingStrikes, "damage")).toEqual([]); + expect(effectsOfKind(sweepingStrikes, "apply-aura")).toEqual([ + expect.objectContaining({ + recipient: "caster", + aura: expect.objectContaining({ + durationMs: 30_000, + procs: [expect.objectContaining({ + triggers: ["hit", "crit"], + attackKinds: ["melee"], + charges: 5, + action: { kind: "custom", id: "wow335-sweeping-strikes-secondary-hit" }, + })], + }), + }), + ]); + }); + + it("maps Battle Shout and Commanding Shout to nearby party-wide buffs", () => { + for (const [abilityId, auraCount] of [ + ["wow335-warrior-battle-shout", 2], + ["wow335-warrior-commanding-shout", 1], + ] as const) { + const ability = abilityAtLevel(byId(abilityId), 80); + const auras = effectsOfKind(ability, "apply-aura"); + expect(ability).toMatchObject({ target: "self", radius: 30 }); + expect(targetingRulesForAbility(ability)).toMatchObject({ shape: "circle", radius: 30 }); + expect(auras).toHaveLength(auraCount); + expect(auras.every((effect) => effect.scope === "party")).toBe(true); + } + }); + + it("maps Nature's Swiftness to one next-Nature-spell cast-time aura and its shared cooldown", () => { + const natureSwiftness = abilityAtLevel(byId("wow335-shaman-nature-s-swiftness"), 80); + const druidNatureSwiftness = abilityAtLevel(byId("wow335-druid-nature-s-swiftness"), 80); + const elementalMastery = abilityAtLevel(byId("wow335-shaman-elemental-mastery"), 80); + + expect(natureSwiftness.cooldownGroup).toBe("shaman-nature-swiftness"); + expect(elementalMastery.cooldownGroup).toBe("shaman-nature-swiftness"); + expect(natureSwiftness.cooldownMs).toBe(180_000); + expect(druidNatureSwiftness.cooldownMs).toBe(180_000); + for (const ability of [natureSwiftness, druidNatureSwiftness]) { + const auraEffect = effectsOfKind(ability, "apply-aura")[0]; + expect(auraEffect).toMatchObject({ + recipient: "caster", + aura: { + durationMs: null, + utilityTags: ["wow335-next-nature-instant"], + }, + }); + if (auraEffect?.kind === "apply-aura") expect(auraEffect.aura.modifiers).toBeUndefined(); + } + }); + it("combines each Holy Nova damage rank with its matching healing payload", () => { const nova = byId("wow335-priest-holy-nova"); expect(nova.ranks).toHaveLength(9); @@ -55,6 +151,47 @@ describe("WoW 3.3.5 executable effect mapping", () => { }); }); + it("keeps Holy Shock's cast wrapper while combining damage and healing ranks", () => { + const shock = byId("wow335-paladin-holy-shock"); + expect(shock.ranks).toHaveLength(7); + for (const rank of shock.ranks ?? []) { + expect(effectsOfKind(rank, "damage")).toHaveLength(1); + expect(effectsOfKind(rank, "heal")).toHaveLength(1); + expect(rank.cost.amount).toBeGreaterThan(0); + expect(rank.cooldownMs).toBe(6_000); + } + + expect(abilityAtLevel(shock, 80)).toMatchObject({ + dbcSpellId: 48825, + target: "any", + range: { min: 0, max: 40 }, + castMode: "instant", + gcdMs: 1_500, + cooldownMs: 6_000, + }); + }); + + it("models Life Tap as a health cost and mana restoration, not a heal", () => { + const lifeTap = abilityAtLevel(byId("wow335-warlock-life-tap"), 80); + expect(lifeTap).toMatchObject({ + dbcSpellId: 57946, + target: "self", + cost: { resource: "health", amount: 1_490 }, + }); + expect(effectsOfKind(lifeTap, "resource")).toContainEqual( + { kind: "resource", resource: "mana", amount: 1_490 }, + ); + expect(effectsOfKind(lifeTap, "heal")).toEqual([]); + + for (const id of [ + "wow335-warlock-create-healthstone", + "wow335-warlock-create-soulstone", + "wow335-warlock-demonic-empowerment", + ]) { + expect(effectsOfKind(abilityAtLevel(byId(id), 80), "heal"), id).toEqual([]); + } + }); + it("maps Envenom to Deadly Poison dose damage and its poison-frequency buff", () => { const envenom = abilityAtLevel(byId("wow335-rogue-envenom"), 80); expect(effectsOfKind(envenom, "finisher-damage")).toEqual([ @@ -143,7 +280,7 @@ describe("WoW 3.3.5 executable effect mapping", () => { expect(byId("wow335-priest-divine-hymn").ranks?.map((rank) => rank.spellId)).toEqual([64843]); expect(byId("wow335-death-knight-death-coil").ranks?.map((rank) => rank.spellId)).not.toContain(47632); expect(byId("wow335-death-knight-death-coil")).toMatchObject({ - target: "hostile", + target: "any", effects: expect.arrayContaining([expect.objectContaining({ kind: "damage" })]), }); }); @@ -185,15 +322,16 @@ describe("WoW 3.3.5 executable effect mapping", () => { it("provides mechanics for previously silent class scripts and hidden proc spells", () => { expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]); expect(effectsOfKind(byId("wow335-warrior-bloodthirst"), "damage")).toContainEqual( - expect.objectContaining({ coefficient: 1.2 }), + expect.objectContaining({ coefficient: 1, basePoints: 50, bonusPowerCoefficient: 1 }), ); expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "resource")).toEqual([ { kind: "resource", resource: "rage", amount: 20 }, ]); expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "heal")).toEqual([]); - expect(effectsOfKind(byId("wow335-warrior-execute"), "execute")).toContainEqual( - expect.objectContaining({ coefficient: 1.8 }), + expect(effectsOfKind(abilityAtLevel(byId("wow335-warrior-execute"), 80), "damage")).toContainEqual( + expect.objectContaining({ basePoints: 1_456, coefficient: 1 }), ); + expect(effectsOfKind(byId("wow335-warrior-execute"), "execute")).toEqual([]); expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]); expect(effectsOfKind(byId("wow335-warrior-bladestorm"), "dot")).toEqual([ expect.objectContaining({ coefficient: 0.6, ticks: 6, intervalMs: 1_000 }), @@ -209,13 +347,25 @@ describe("WoW 3.3.5 executable effect mapping", () => { expect.objectContaining({ coefficient: 2 }), ); expect(effectsOfKind(byId("wow335-rogue-mutilate"), "combo")).not.toEqual([]); - expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "dot")).toContainEqual( - expect.objectContaining({ coefficient: 0.5, ticks: 5 }), + expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "dot")).toEqual([]); + expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "utility")).toContainEqual( + expect.objectContaining({ action: "class-script", durationMs: 2_000 }), ); + expect(byId("wow335-rogue-killing-spree").target).toBe("hostile"); expect(effectsOfKind(byId("wow335-warlock-conflagrate"), "damage")).toEqual([ - expect.objectContaining({ coefficient: 1.4 }), + expect.objectContaining({ coefficient: 0, school: "fire" }), ]); expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]); + expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "taunt")).toEqual([ + { kind: "taunt", durationMs: 3_000 }, + ]); + expect(effectsOfKind(byId("wow335-death-knight-chains-of-ice"), "status")).toEqual([ + { kind: "status", status: "slow", durationMs: 10_000, magnitude: 0.95 }, + ]); + expect(effectsOfKind(byId("wow335-death-knight-pestilence"), "damage")).toEqual([]); + expect(effectsOfKind(byId("wow335-death-knight-pestilence"), "utility")).toContainEqual( + expect.objectContaining({ action: "class-script", effectType: 77 }), + ); expect(effectsOfKind(byId("wow335-shaman-windfury-weapon"), "apply-aura")[0]?.aura.procs).not.toEqual([]); expect(effectsOfKind(byId("wow335-shaman-lightning-shield"), "apply-aura")[0]?.aura.procs).toEqual(expect.arrayContaining([ expect.objectContaining({ triggers: "damage-taken", charges: 3 }), @@ -249,6 +399,92 @@ describe("WoW 3.3.5 executable effect mapping", () => { expect(hurricane.cost.amount).toBeGreaterThan(0); }); + it("makes Empower Rune Weapon activate all six runes from the player spell", () => { + const empowerRuneWeapon = abilityAtLevel(byId("wow335-death-knight-empower-rune-weapon"), 80); + expect(empowerRuneWeapon.dbcSpellId).toBe(47568); + expect(effectsOfKind(empowerRuneWeapon, "rune")).toEqual([ + { kind: "rune", action: "activate", count: 6 }, + ]); + expect(effectsOfKind(empowerRuneWeapon, "resource")).toEqual([ + { kind: "resource", resource: "runic-power", amount: 25 }, + ]); + }); + + it("models Death Knight source healing, rune, charge, and magic-shell effects", () => { + const deathCoil = abilityAtLevel(byId("wow335-death-knight-death-coil"), 80); + expect(deathCoil.target).toBe("any"); + expect(effectsOfKind(deathCoil, "damage")).not.toEqual([]); + expect(effectsOfKind(deathCoil, "heal")).not.toEqual([]); + + const deathStrike = abilityAtLevel(byId("wow335-death-knight-death-strike"), 80); + expect(effectsOfKind(deathStrike, "heal")).toEqual([ + expect.objectContaining({ coefficient: 0, percentMaxHealth: 0.05 }), + ]); + + const antiMagicShell = abilityAtLevel(byId("wow335-death-knight-anti-magic-shell"), 80); + expect(effectsOfKind(antiMagicShell, "apply-aura")).toEqual([ + expect.objectContaining({ + recipient: "caster", + aura: expect.objectContaining({ + durationMs: 5_000, + absorbs: [expect.objectContaining({ + amount: 0.5, + fraction: 0.75, + schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], + })], + }), + }), + ]); + + const antiMagicZone = abilityAtLevel(byId("wow335-death-knight-anti-magic-zone"), 80); + expect(antiMagicZone).toMatchObject({ + target: "self", + description: expect.stringContaining("75% of incoming magic damage"), + targeting: { shape: "ground", radius: 10, groundRange: 30 }, + }); + expect(effectsOfKind(antiMagicZone, "summon")).toEqual([ + expect.objectContaining({ + creatureId: 28306, + durationMs: 10_000, + antiMagicZone: { + radius: 10, + capacityBase: 10_000, + capacityPerAttackPower: 2, + absorbFraction: 0.75, + intervalMs: 250, + }, + }), + ]); + + const boneShield = abilityAtLevel(byId("wow335-death-knight-bone-shield"), 80); + expect(effectsOfKind(boneShield, "apply-aura")).toEqual([ + expect.objectContaining({ aura: expect.objectContaining({ + durationMs: 300_000, + initialStacks: 4, + maxStacks: 4, + modifiers: expect.arrayContaining([ + expect.objectContaining({ kind: "damage", direction: "taken", value: -0.2, perStack: false }), + expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.02, perStack: false }), + ]), + procs: [expect.objectContaining({ + triggers: "damage-taken", + charges: 4, + action: { kind: "custom", id: "wow335-death-knight-bone-shield-charge" }, + })], + }) }), + ]); + + expect(effectsOfKind(abilityAtLevel(byId("wow335-death-knight-blood-tap"), 80), "rune")).toEqual([ + { kind: "rune", action: "activate", count: 1, runeType: "blood", convertToDeathMs: 20_000 }, + ]); + expect(effectsOfKind(abilityAtLevel(byId("wow335-death-knight-deathchill"), 80), "apply-aura")).toEqual([ + expect.objectContaining({ + recipient: "caster", + aura: expect.objectContaining({ utilityTags: expect.arrayContaining(["wow335-deathchill-next-critical"]) }), + }), + ]); + }); + it("keeps every rank of imported channels in channel mode", () => { const mismatches = Object.values(WOW335_ABILITIES_BY_CLASS).flat() .filter((ability) => ability.castMode === "channel" && ability.ranks?.some((rank) => rank.castMode !== "channel")) @@ -321,6 +557,14 @@ describe("WoW 3.3.5 executable effect mapping", () => { .toEqual(expect.arrayContaining([expect.objectContaining({ stat: "incoming-ranged-hit", value: -25 })])); }); + it("keeps Blink's root and stun breaks without inventing target-relative teleport movement", () => { + const blink = byId("wow335-mage-blink"); + expect(blink.dbcSpellId).toBe(1953); + expect(effectsOfKind(blink, "movement")).toEqual([]); + expect(effectsOfKind(blink, "apply-aura").map((effect) => effect.aura.controlImmunities)) + .toEqual([["stun"], ["root"]]); + }); + it("keeps triggered shield damage children out of learned Shaman ranks", () => { const lightningShield = abilityAtLevel(byId("wow335-shaman-lightning-shield"), 80); expect(lightningShield.dbcSpellId).toBe(49281); @@ -427,6 +671,36 @@ describe("WoW 3.3.5 executable effect mapping", () => { }); }); + it("maps Paladin revive, healing riders, class-group blessings, and aura mechanics", () => { + const redemption = byId("wow335-paladin-redemption"); + expect(redemption.target).toBe("friendly"); + expect(effectsOfKind(redemption, "resurrection")).toContainEqual(expect.objectContaining({ + flatHealth: 65, + })); + + const divineStorm = byId("wow335-paladin-divine-storm"); + expect(divineStorm.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "damage", coefficient: 1.1, school: "physical" }), + expect.objectContaining({ kind: "heal", percentOfDamage: 0.25, scope: "party", maximumTargets: 3 }), + ])); + + const beacon = abilityAtLevel(byId("wow335-paladin-beacon-of-light"), 80); + expect(effectsOfKind(beacon, "apply-aura")[0]?.aura.utilityTags).toContain("beacon-of-light"); + + const greaterBlessing = byId("wow335-paladin-greater-blessing-of-kings"); + expect(effectsOfKind(greaterBlessing, "apply-aura")[0]?.scope).toBe("party"); + const avengersShield = byId("wow335-paladin-avenger-s-shield"); + expect(avengersShield.targeting).toMatchObject({ shape: "chain", maximumTargets: 3, jumpRange: 10 }); + + const auraMastery = byId("wow335-paladin-aura-mastery"); + expect(effectsOfKind(auraMastery, "apply-aura").every((effect) => ( + effect.scope === "party" && effect.aura.utilityTags?.includes("wow335-aura-mastery-silence-interrupt") + ))).toBe(true); + const divineFavor = byId("wow335-paladin-divine-favor"); + expect(effectsOfKind(divineFavor, "apply-aura")[0]?.aura.utilityTags) + .toContain("wow335-divine-favor-next-holy-heal"); + }); + it("keeps periodic damage and healing auras out of the generic HUD strip", () => { const pain = byId("wow335-priest-shadow-word-pain"); const painAura = effectsOfKind(pain, "apply-aura") @@ -491,4 +765,23 @@ describe("WoW 3.3.5 executable effect mapping", () => { aura: { disposition: "debuff", dispelCategory: "magic" }, }); }); + + it("maps Soulshatter to its source 50-yard self-centered threat reduction", () => { + const soulshatter = byId("wow335-warlock-soulshatter"); + const source = generatedData.classes.warlock.find((chain) => chain.name === "Soulshatter")!; + + expect(source.ranks[0]).toMatchObject({ + spellId: 29858, + description: expect.stringContaining("Reduces threat by 50%"), + effects: [expect.objectContaining({ effectType: 3, basePoints: -50, radius: 50 })], + }); + expect(soulshatter).toMatchObject({ + dbcSpellId: 29858, + target: "self", + radius: 50, + }); + expect(effectsOfKind(soulshatter, "threat")).toEqual([ + { kind: "threat", mode: "percent", amount: -50 }, + ]); + }); }); diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index a63fc1c7..c2d856cd 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -220,7 +220,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] { case 55: case 186: case 199: return percentStat("spell-hit"); - case 57: + case 57: return flatStat("spell-critical-strike"); case 71: case 179: return percentStat("spell-critical-strike"); case 59: return [{ kind: "damage", direction: "dealt", operation: "flat", value: effect.basePoints }]; @@ -287,7 +287,10 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] { case 240: return flatStat("expertise"); case 230: return flatStat("maximum-health"); case 271: return [{ kind: "damage", direction: "taken", operation: "percent", value: percentValue(effect) }]; - case 280: return percentStat("armor-penetration"); + // DBC MOD_ARMOR_PENETRATION_PERCENT stores percentage points here (for + // example Battle Stance's 10 means ignore 10% armor). This stat is already + // expressed as a percent in CharacterStats, so keep it additive. + case 280: return flatStat("armor-penetration"); case 248: return percentStat("combat-result"); case 251: return percentStat("enemy-dodge"); case 253: return percentStat("block-critical-strike"); @@ -441,7 +444,7 @@ function namedAuraForEffect( ): AuraDefinition { const durationMs = rank.durationMs > 0 ? rank.durationMs : null; const hiddenProcOverrides = new Set([ - "Seal of Justice", "Lightning Shield", "Water Shield", "Frost Armor", "Ice Armor", "Molten Armor", "Nature's Grasp", + "Seal of Justice", "Lightning Shield", "Water Shield", "Shamanistic Rage", "Frost Armor", "Ice Armor", "Molten Armor", "Nature's Grasp", ]); const proc = hiddenProcOverrides.has(abilityName) ? undefined : procForAura(effect, rank, rank.description); const school = schoolFromMask(effect.miscValue); @@ -461,6 +464,10 @@ function namedAuraForEffect( { kind: "stat", stat: "stamina", operation: "percent", value: 0.1 }, ); } + if (abilityName === "Aura Mastery") { + if (effect.miscValue === 3) modifiers.push({ kind: "stat", stat: "silence-immunity", operation: "flat", value: 1 }); + if (effect.miscValue === 12) modifiers.push({ kind: "stat", stat: "interrupt-immunity", operation: "flat", value: 1 }); + } if (abilityName === "Ice Block" && effect.auraType === 39) { modifiers.push({ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -1 }); } @@ -574,6 +581,19 @@ function namedAuraForEffect( action: { kind: "resource", resource: "mana", amount: Math.max(1, effect.basePoints), target: "aura-target" }, }); } + if (classId === "shaman" && abilityName === "Shamanistic Rage" && effectIndex === 0) { + specialProcs.push({ + id: `${auraId}:shamanistic-rage-mana`, + triggers: ["hit", "crit"], + attackKinds: ["melee"], + chance: 1, + action: { + kind: "custom", + id: "wow335-shamanistic-rage-mana", + data: { attackPowerPercent: Math.max(0, effect.basePoints) }, + }, + }); + } if (abilityName === "Earth Shield" && effectIndex === 0) { specialProcs.push({ id: `${auraId}:earth-shield`, triggers: "damage-taken", chance: 1, @@ -621,8 +641,14 @@ function namedAuraForEffect( } const utilityTags = [ ...utilityTagsForAura(effect.auraType, abilityName), - ...(abilityName === "Light's Beacon" ? ["beacon-of-light"] : []), + ...(abilityName === "Light's Beacon" || abilityName === "Beacon of Light" ? ["beacon-of-light"] : []), + ...(classId === "paladin" && abilityName === "Divine Sacrifice" ? ["wow335-paladin-divine-sacrifice"] : []), + ...(abilityName === "Divine Favor" ? ["wow335-divine-favor-next-holy-heal"] : []), + ...(abilityName === "Aura Mastery" ? ["wow335-aura-mastery-silence-interrupt", "wow335-aura-mastery-aura-effectiveness"] : []), ]; + const controlImmunities = classId === "paladin" && abilityName === "Aura Mastery" && effect.miscValue === 3 + ? ["silence"] as const + : controlImmunitiesForAura(effect.auraType, effect.miscValue); return { id: auraId, name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`, @@ -638,9 +664,7 @@ function namedAuraForEffect( ? { exclusiveGroup: abilityName === "Light's Beacon" ? "paladin:beacon" : exclusiveGroupForAbility(classId, abilityName) } : {}), ...(utilityTags.length ? { utilityTags } : {}), - ...(controlImmunitiesForAura(effect.auraType, effect.miscValue) - ? { controlImmunities: controlImmunitiesForAura(effect.auraType, effect.miscValue) } - : {}), + ...(controlImmunities ? { controlImmunities } : {}), ...(effect.auraType === 28 ? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) } : {}), @@ -650,6 +674,11 @@ function namedAuraForEffect( ...(effect.auraType === 81 ? { redirectDamagePercent: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) } : {}), + ...(classId === "paladin" && abilityName === "Divine Sacrifice" && effect.auraType === 81 + ? { + redirectDamageCapPercent: Math.max(0, rank.effects.find((row) => row.effectType === 0)?.basePoints ?? 0) / 100, + } + : {}), ...(dispelCategoryForAura(abilityName, rank.description, rank.durationMs) !== undefined ? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) } : {}), @@ -724,6 +753,18 @@ function utilityAction(effectType: number): Extract> = { + 5394: 6, + 6375: 8, + 6377: 10, + 10462: 12, + 10463: 14, + 25567: 18, + 58755: 20, + 58756: 23, + 58757: 25, +}; + function classScriptEffects( classId: BaseClassId, abilityName: string, @@ -754,17 +795,89 @@ function classScriptEffects( triggerSpellId: 0, }); switch (`${classId}:${abilityName}`) { + case "paladin:Redemption": return [{ + kind: "resurrection", + percentMaxHealth: 0, + flatHealth: Math.max(1, Math.abs(rank.effects.find((effect) => effect.effectType === 113)?.basePoints ?? 1)), + }]; + case "paladin:Divine Storm": return [ + { kind: "damage", coefficient: 1.1, school: "physical" }, + { + kind: "heal", coefficient: 0, percentOfDamage: 0.25, scope: "party", + radius: 40, maximumTargets: 3, + }, + ]; case "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }]; - // The player spell delegates its weapon hit to a triggered child spell; - // retain the imported self-heal aura and restore the missing attack. - case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }]; + // Bloodthirst's player row (23881) owns the hit and cost. The 8-second + // child aura (23885) owns its three next-hit self-heals through 23880. + case "warrior:Bloodthirst": { + const child = data.classes.warrior.flatMap((chain) => chain.ranks) + .find((candidate) => candidate.spellId === 23885); + const effect = child?.effects.find((candidate) => candidate.effectType === 6 && candidate.auraType === 42); + if (!child || !effect) return []; + const definition = namedAuraForEffect(classId, effect, child, abilityName, 0); + return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + ...definition, + procs: definition.procs?.map((proc) => ({ + ...proc, + triggers: ["hit", "crit"], + attackKinds: ["melee"], + charges: 3, + })), + }, + sourceEffectType: effect.effectType, + sourceAuraType: effect.auraType, + miscValue: effect.miscValue, + triggerSpellId: effect.triggerSpellId, + }]; + } + // The active Wrath talent grants five melee hits an extra nearby strike. + case "warrior:Sweeping Strikes": return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "wow335:warrior:sweeping-strikes", + name: abilityName, + disposition: "buff", + durationMs: rank.durationMs, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-sweeping-strikes"], + procs: [{ + id: "wow335:warrior:sweeping-strikes-secondary-hit", + triggers: ["hit", "crit"], + attackKinds: ["melee"], + chance: 1, + charges: 5, + action: { kind: "custom", id: "wow335-sweeping-strikes-secondary-hit" }, + }], + }, + sourceEffectType: 6, + sourceAuraType: 4, + miscValue: 0, + triggerSpellId: 0, + }]; // The player-facing aura delegates each weapon strike to server-only child // spells. Model the six one-second swings as a periodic damage payload. case "warrior:Bladestorm": return [{ kind: "dot", coefficient: 0.6, ticks: 6, intervalMs: 1_000, tag: "bladestorm", }]; case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }]; - case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }]; + case "warrior:Execute": { + const source = rank.effects.find((effect) => effect.effectType === 3); + return source ? [{ + kind: "damage", + coefficient: source.coefficient, + basePoints: source.basePoints, + dieSides: source.dieSides, + pointsPerLevel: source.pointsPerLevel, + sourceLevel: rank.sourceLevel, + maxScalingLevel: rank.maxScalingLevel, + }] : []; + } case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }]; case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }]; case "warrior:Devastate": return [ @@ -825,9 +938,80 @@ function classScriptEffects( kind: "utility", action: "class-script", effectType: 77, auraType: 0, miscValue: 0, triggerSpellId: 0, durationMs: 0, }]; case "hunter:Disengage": return [{ kind: "movement", movement: "leap", toward: "away" }]; - case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }]; + case "hunter:Intimidation": return [{ + kind: "apply-aura", + recipient: "pet", + aura: { + id: `wow335:${rank.spellId}:intimidation`, + name: abilityName, + description: "Your pet's next successful melee attack stuns its target for 3 seconds and adds 580 threat.", + disposition: "buff", + durationMs: rank.durationMs, + maxStacks: 1, + stackBehavior: "replace", + procs: [{ + id: `wow335:${rank.spellId}:intimidation-next-melee`, + triggers: "hit", + attackKinds: ["melee"], + chance: 1, + charges: 1, + action: { kind: "custom", id: "wow335-hunter-intimidation-hit" }, + }], + }, + sourceEffectType: 6, + sourceAuraType: 42, + miscValue: 0, + triggerSpellId: 24394, + }]; + case "hunter:Kill Command": return [{ + kind: "apply-aura", + recipient: "pet", + aura: { + id: `wow335:${rank.spellId}:kill-command`, + name: abilityName, + description: "Increases your pet's special attack damage by 1% per remaining charge for 30 seconds. Each successful special attack reduces the bonus by 1%.", + disposition: "buff", + durationMs: rank.durationMs, + maxStacks: 60, + initialStacks: 60, + stackBehavior: "replace", + utilityTags: ["wow335-hunter-kill-command"], + // The source applies this only to pet special attacks. The current + // summon scheduler emits generic melee swings, so do not boost those. + }, + sourceEffectType: 6, + sourceAuraType: 4, + miscValue: 0, + triggerSpellId: 58914, + }]; + case "hunter:Freezing Arrow": return [{ + kind: "utility", + action: "class-script", + effectType: 32, + auraType: 0, + miscValue: 0, + triggerSpellId: 60202, + durationMs: 30_000, + }]; + case "hunter:Explosive Trap": { + const child = HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK[rank.spellId]; + return child ? [{ + kind: "dot", + coefficient: 0, + basePoints: child.periodicBasePoints, + dieSides: child.periodicDieSides, + sourceLevel: Math.max(1, rank.sourceLevel || rank.level), + ...(rank.maxScalingLevel > 0 ? { maxScalingLevel: rank.maxScalingLevel } : {}), + school: "fire", + ticks: 10, + intervalMs: 2_000, + tag: "hunter-explosive-trap", + }] : []; + } case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }]; - case "hunter:Readiness": return [{ kind: "cooldown-reset", mode: "reset" }]; + case "hunter:Readiness": return [{ + kind: "cooldown-reset", mode: "reset", excludeAbilityNames: ["Bestial Wrath"], + }]; case "hunter:Chimera Shot": return [{ kind: "damage", coefficient: 1.25 }]; case "hunter:Kill Shot": return [{ kind: "damage", coefficient: 2, @@ -849,7 +1033,8 @@ function classScriptEffects( } case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }, { kind: "combo", amount: 1 }]; case "rogue:Killing Spree": return [{ - kind: "dot", coefficient: 0.5, ticks: 5, intervalMs: 500, tag: "killing-spree", + kind: "utility", action: "class-script", effectType: 0, auraType: 0, + miscValue: 0, triggerSpellId: 0, durationMs: Math.max(500, rank.durationMs), }]; // Mutilate triggers separate main-hand and off-hand child attacks that are // not player-facing SkillLine records. The DBC combo award remains intact. @@ -874,6 +1059,32 @@ function classScriptEffects( triggerSpellId: 0, }]; case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }]; + case "rogue:Shadow Dance": return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: `wow335:${rank.spellId}:shadow-dance`, + name: abilityName, + description: "Allows Sap, Garrote, Ambush, Cheap Shot, Premeditation, Pick Pocket, and Disarm Trap without stealth.", + disposition: "buff", + durationMs: rank.durationMs || 6_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-rogue-shadow-dance"], + }, + sourceEffectType: 6, + sourceAuraType: 275, + miscValue: 0, + triggerSpellId: 0, + }]; + case "rogue:Tricks of the Trade": return [{ + kind: "utility", action: "class-script", effectType: 0, auraType: 0, + miscValue: 0, triggerSpellId: 0, durationMs: Math.max(6_000, rank.durationMs), + }]; + case "rogue:Shadowstep": return [{ + kind: "utility", action: "class-script", effectType: 0, auraType: 0, + miscValue: 0, triggerSpellId: 0, durationMs: Math.max(3_000, rank.durationMs), + }]; case "rogue:Cold Blood": return [{ kind: "apply-aura", aura: { @@ -920,9 +1131,37 @@ function classScriptEffects( triggerSpellId: 0, }, ]; + case "rogue:Preparation": return [ + "Evasion", + "Sprint", + "Vanish", + "Cold Blood", + "Shadowstep", + ].map((abilityName) => ({ + kind: "cooldown-reset" as const, + mode: "reset" as const, + abilityName, + })); case "rogue:Vanish": return [ { kind: "threat", mode: "percent", amount: -100 }, - { kind: "movement", movement: "leap", toward: "away" }, + { + kind: "apply-aura", + recipient: "caster", + aura: { + id: `wow335:${rank.spellId}:vanish-stealth`, + name: abilityName, + description: "Enters stealth and breaks movement impairing effects.", + disposition: "buff", + durationMs: null, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["stealth"], + }, + sourceEffectType: 6, + sourceAuraType: 16, + miscValue: 0, + triggerSpellId: 0, + }, ]; // Both spells place a channel aura whose actual pulses live in non-player // trigger spells. Keep the imported cast/cost/rank data and supply only the @@ -938,6 +1177,18 @@ function classScriptEffects( case "priest:Shadowfiend": return [ { kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }, ]; + // The client rank is a generic summon row. Its description owns the + // Lightwell duration; the click limit is enforced by the summon runtime. + // The child heal payload is absent from the imported spell rows. + case "priest:Lightwell": return [{ + kind: "summon", + creatureId: rank.effects.find((effect) => effect.effectType === 28)?.miscValue ?? 0, + coefficient: 0, + durationMs: rank.durationMs || 180_000, + }]; + case "priest:Pain Suppression": return [{ + kind: "threat", mode: "percent", amount: -40, recipient: "ability-target", + }]; case "priest:Inner Focus": return [{ kind: "apply-aura", recipient: "caster", @@ -955,37 +1206,206 @@ function classScriptEffects( miscValue: 0, triggerSpellId: 0, }]; - case "priest:Prayer of Mending": return [{ + case "shaman:Nature's Swiftness": + case "druid:Nature's Swiftness": return [{ kind: "apply-aura", + recipient: "caster", aura: { - id: `wow335:${rank.spellId}:prayer-of-mending`, + id: `wow335:${classId}:natures-swiftness`, name: abilityName, + description: "Your next Nature spell with a base cast time under 10 seconds becomes instant.", disposition: "buff", - durationMs: 30_000, + durationMs: null, maxStacks: 1, - stackBehavior: "refresh", - procs: [{ - id: `wow335:${rank.spellId}:prayer-of-mending-proc`, - triggers: "damage-taken", - chance: 1, - charges: 5, - action: { kind: "heal", amount: Math.max(1, Math.round(power * 0.8)), target: "aura-target" }, - }], + stackBehavior: "replace", + utilityTags: ["wow335-next-nature-instant"], }, - sourceEffectType: 64, - sourceAuraType: 42, - miscValue: 0, + sourceEffectType: 6, + sourceAuraType: 108, + miscValue: 10, triggerSpellId: 0, }]; - case "death-knight:Death Grip": return [{ kind: "pull", toward: "caster" }]; - case "death-knight:Blood Tap": return [{ kind: "rune", action: "activate", count: 1 }]; + // The player spell summons a 5-health totem. Its hidden child aura pulses + // a rank-specific group heal every two seconds in a 10-yard radius. + case "shaman:Healing Stream Totem": { + const amount = SHAMAN_HEALING_STREAM_AMOUNT_BY_RANK[rank.spellId]; + const creatureId = rank.effects.find((effect) => effect.effectType === 28)?.miscValue ?? 0; + return amount === undefined || creatureId <= 0 ? [] : [{ + kind: "summon", + creatureId, + coefficient: 0, + durationMs: rank.durationMs, + healingPulse: { amount, intervalMs: 2_000, radius: 10 }, + }]; + } + // Tidal Force applies its 60% healing-crit window as three 20% charges. + // The runtime consumes one charge for each critical heal and scopes the + // bonus to Healing Wave, Lesser Healing Wave, and Chain Heal. + case "shaman:Tidal Force": return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "wow335:shaman:tidal-force", + name: abilityName, + description: "Adds 20% healing critical chance per remaining charge for 2 seconds.", + disposition: "buff", + durationMs: rank.durationMs, + maxStacks: 3, + initialStacks: 3, + stackBehavior: "replace", + utilityTags: ["wow335-tidal-force"], + }, + sourceEffectType: 6, + sourceAuraType: 4, + miscValue: 7, + triggerSpellId: 0, + }]; + case "priest:Prayer of Mending": { + const healAmount = rank.effects.find((effect) => effect.effectType === 142)?.basePoints ?? 0; + return healAmount > 0 ? [{ + kind: "apply-aura", + aura: { + id: `wow335:${rank.spellId}:prayer-of-mending`, + name: abilityName, + disposition: "buff", + description: "Heals its target for this rank's listed amount the next time they take damage, then jumps to an injured party member within 40 yards. It has five damage-triggered activations total.", + durationMs: 30_000, + maxStacks: 1, + stackBehavior: "refresh", + procs: [{ + id: `wow335:${rank.spellId}:prayer-of-mending-proc`, + triggers: "damage-taken", + chance: 1, + charges: 5, + action: { + kind: "custom", + id: "wow335-prayer-of-mending", + data: { healAmount, maxRange: rank.rangeMax || 40 }, + }, + }], + }, + sourceEffectType: 64, + sourceAuraType: 42, + miscValue: 0, + triggerSpellId: 0, + }] : []; + } + case "death-knight:Death Grip": return [ + { kind: "pull", toward: "caster" }, + { kind: "taunt", durationMs: 3_000 }, + ]; + case "death-knight:Death Coil": return [{ kind: "heal", coefficient: 1 }]; + case "death-knight:Death Strike": return [{ + kind: "heal", + coefficient: 0, + percentMaxHealth: 0.05, + }]; + case "death-knight:Anti-Magic Shell": return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: `wow335:${rank.spellId}:anti-magic-shell`, + name: abilityName, + disposition: "buff", + durationMs: rank.durationMs, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: ["wow335-death-knight-anti-magic-shell"], + // Amount is multiplied by max health when the aura is applied. + absorbs: [{ + id: `wow335:${rank.spellId}:anti-magic-shell-absorb`, + amount: 0.5, + schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], + fraction: 0.75, + }], + }, + sourceEffectType: 6, + sourceAuraType: 69, + miscValue: 126, + triggerSpellId: 0, + }]; + case "death-knight:Anti-Magic Zone": return [{ + kind: "summon", + creatureId: rank.effects.find((effect) => effect.effectType === 28)?.miscValue ?? 28306, + coefficient: 0, + durationMs: rank.durationMs, + antiMagicZone: { + radius: 10, + capacityBase: 10_000, + capacityPerAttackPower: 2, + absorbFraction: 0.75, + intervalMs: 250, + }, + }]; + case "death-knight:Bone Shield": return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: `wow335:${rank.spellId}:bone-shield`, + name: abilityName, + disposition: "buff", + durationMs: rank.durationMs, + maxStacks: 4, + initialStacks: 4, + stackBehavior: "replace", + modifiers: [ + { kind: "damage", direction: "taken", operation: "percent", value: -0.2, perStack: false }, + { kind: "damage", direction: "dealt", operation: "percent", value: 0.02, perStack: false }, + ], + utilityTags: ["wow335-death-knight-bone-shield"], + procs: [{ + id: "wow335-death-knight-bone-shield-charge", + triggers: "damage-taken", + chance: 1, + charges: 4, + action: { kind: "custom", id: "wow335-death-knight-bone-shield-charge" }, + }], + }, + sourceEffectType: 6, + sourceAuraType: 87, + miscValue: 127, + triggerSpellId: 0, + }]; + case "death-knight:Deathchill": return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: `wow335:${rank.spellId}:deathchill`, + name: abilityName, + disposition: "buff", + durationMs: rank.durationMs, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: ["next-offensive-critical", "wow335-deathchill-next-critical"], + }, + sourceEffectType: 6, + sourceAuraType: 107, + miscValue: 7, + triggerSpellId: 0, + }]; + case "death-knight:Blood Tap": return [{ + kind: "rune", action: "activate", count: 1, runeType: "blood", convertToDeathMs: rank.durationMs, + }]; + // The DBC stores two rune activations with a count of two each, but the + // player-facing spell restores every rune. Replace those rows below. + case "death-knight:Empower Rune Weapon": return [ + { kind: "rune", action: "activate", count: 6 }, + { kind: "resource", resource: "runic-power", amount: 25 }, + ]; case "death-knight:Chains of Ice": return [ - { kind: "status", status: "root", durationMs: 3_000 }, { kind: "status", status: "slow", durationMs: 10_000, magnitude: 0.95 }, ]; case "death-knight:Icy Touch": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "frost-fever" }]; case "death-knight:Plague Strike": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "blood-plague" }]; - case "death-knight:Pestilence": return [{ kind: "damage", coefficient: 0.35 }]; + case "death-knight:Pestilence": return [{ + kind: "utility", + action: "class-script", + effectType: 77, + auraType: 0, + miscValue: 0, + triggerSpellId: 0, + durationMs: 0, + }]; case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }]; case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({ kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000, @@ -1008,15 +1428,66 @@ function classScriptEffects( case "shaman:Feral Spirit": return Array.from({ length: 2 }, () => ({ kind: "summon" as const, creatureId: 29264, coefficient: 0.35, durationMs: 45_000, })); - case "warlock:Soulshatter": return [{ kind: "threat", mode: "percent", amount: -90 }]; - // Conflagrate's imported parent row contains a one-point server marker; - // model the consumed-DoT burst rather than treating that marker as damage. - case "warlock:Conflagrate": return [{ kind: "damage", coefficient: 1.4 }]; + case "warlock:Soulshatter": return [{ + kind: "threat", + mode: "percent", + amount: rank.effects.find((effect) => effect.effectType === 3)?.basePoints ?? -50, + }]; + case "warlock:Life Tap": return [{ + kind: "resource", + resource: "mana", + amount: Math.max(1, Math.abs(rank.effects.find((effect) => effect.effectType === 3)?.basePoints ?? 1)), + }]; + // The parent row's one-point marker is not its damage. The runtime derives + // Conflagrate's payload from the consumed Immolate or Shadowflame effect. + case "warlock:Conflagrate": return [{ kind: "damage", coefficient: 0, school: "fire" }]; + // The generated player chain omits Shadowflame's periodic child spells. + // These children are 8-second Fire DoTs with the imported per-tick bonus. + case "warlock:Shadowflame": return [ + { kind: "dot", coefficient: 0.0667, ticks: 4, intervalMs: 2_000, school: "fire", tag: "wow335-warlock-shadowflame" }, + ]; case "warlock:Demonic Circle: Summon": return [{ kind: "summon", creatureId: 19175, coefficient: 0, durationMs: 360_000 }]; case "warlock:Demonic Circle: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }]; case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }]; - case "rogue:Tricks of the Trade": return [{ kind: "threat", mode: "redirect", amount: 100 }]; - case "mage:Blink": return [{ kind: "movement", movement: "leap", toward: "away" }]; + case "mage:Presence of Mind": return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "wow335:mage:presence-of-mind", + name: abilityName, + description: "Your next Mage spell with a cast time under 10 seconds becomes instant.", + disposition: "buff", + durationMs: rank.durationMs > 0 ? rank.durationMs : null, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["wow335-next-mage-instant"], + }, + sourceEffectType: 6, + sourceAuraType: 108, + miscValue: 10, + triggerSpellId: 0, + }]; + case "mage:Arcane Power": { + const powerPercent = Math.abs(rank.effects.find((effect) => effect.auraType === 108)?.basePoints ?? 20) / 100; + return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: `wow335:${rank.spellId}:arcane-power`, + name: abilityName, + disposition: "buff", + durationMs: rank.durationMs, + modifiers: [ + { kind: "damage", direction: "dealt", operation: "percent", value: powerPercent }, + { kind: "resource", resource: "mana", property: "cost", operation: "percent", value: powerPercent }, + ], + }, + sourceEffectType: 6, + sourceAuraType: 108, + miscValue: 0, + triggerSpellId: 0, + }]; + } case "mage:Combustion": return [{ kind: "apply-aura", recipient: "caster", aura: { @@ -1040,20 +1511,39 @@ function classScriptEffects( aura: { id: "wow335:arcane-blast-stack", name: "Arcane Blast", - disposition: "debuff", - durationMs: 6_000, - maxStacks: 4, + disposition: "buff", + description: "Each Arcane Blast adds 15% Arcane spell damage and 200% Arcane Blast mana cost for 10 seconds, stacking up to 3 times.", + durationMs: 10_000, + maxStacks: 3, stackBehavior: "add", - modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 0.15 }], + utilityTags: ["wow335-arcane-blast-stack"], + modifiers: [ + { kind: "damage", direction: "dealt", school: "arcane", operation: "percent", value: 0.15, perStack: true }, + { kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 2, perStack: true }, + ], }, sourceEffectType: 64, sourceAuraType: 108, miscValue: 0, triggerSpellId: 0, }]; + // Cold Snap is a DBC dummy effect whose gameplay operation is a school + // cooldown reset. Resolve the Frost spell names explicitly because the + // shared cooldown-reset effect currently selects candidates by name. + case "mage:Cold Snap": return [ + "Frost Nova", + "Frost Ward", + "Fire Ward", // Frost Ward shares its cooldown with Fire Ward. + "Icy Veins", + "Cone of Cold", + "Ice Block", + "Ice Barrier", + "Summon Water Elemental", + "Deep Freeze", + ].map((abilityName) => ({ kind: "cooldown-reset" as const, mode: "reset" as const, abilityName })); case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }]; - // Swiftmend's DBC row exposes only the one-point consume marker; supply the - // direct heal while leaving HoT consumption out until aura choice is modeled. + // Swiftmend's DBC row exposes only a one-point marker. The cast runtime + // supplies its direct heal and resolves the consumed Rejuvenation/Regrowth. case "druid:Swiftmend": return [{ kind: "heal", coefficient: 2.2 }]; case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }]; default: return []; @@ -1067,6 +1557,39 @@ const COMBO_FINISHERS = new Set([ const DIRECT_COMBO_FINISHERS = new Set(["Eviscerate", "Envenom", "Deadly Throw", "Ferocious Bite"]); +const HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK: Readonly> = { + 13813: { directBasePoints: 100, directDieSides: 31, periodicBasePoints: 15, periodicDieSides: 1, sourceLevel: 34, maxScalingLevel: 40 }, + 14316: { directBasePoints: 139, directDieSides: 49, periodicBasePoints: 24, periodicDieSides: 1, sourceLevel: 44, maxScalingLevel: 50 }, + 14317: { directBasePoints: 201, directDieSides: 57, periodicBasePoints: 33, periodicDieSides: 1, sourceLevel: 54, maxScalingLevel: 60 }, + 27025: { directBasePoints: 263, directDieSides: 75, periodicBasePoints: 45, periodicDieSides: 1, sourceLevel: 61, maxScalingLevel: 67 }, + 49066: { directBasePoints: 434, directDieSides: 123, periodicBasePoints: 74, periodicDieSides: 1, sourceLevel: 71, maxScalingLevel: 75 }, + 49067: { directBasePoints: 523, directDieSides: 149, periodicBasePoints: 90, periodicDieSides: 1, sourceLevel: 77, maxScalingLevel: 81 }, +}; + +export function hunterExplosiveTrapDirectScaling( + spellId: number, + sourceLevel = 0, + maxScalingLevel = 0, +): AbilityAmountScaling & { readonly coefficient: number } | null { + const child = HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK[spellId]; + if (!child) return null; + return { + coefficient: 0, + basePoints: child.directBasePoints, + dieSides: child.directDieSides, + attackPowerCoefficient: 0.1, + sourceLevel: sourceLevel > 0 ? sourceLevel : child.sourceLevel, + maxScalingLevel: maxScalingLevel > 0 ? maxScalingLevel : child.maxScalingLevel, + }; +} + function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] { const effects: AbilityEffect[] = []; for (const [effectIndex, effect] of rank.effects.entries()) { @@ -1077,8 +1600,21 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str effects.push({ kind: "apply-aura", aura, + ...(classId === "hunter" && abilityName === "Bestial Wrath" && effect.implicitTargetA === 5 + ? { recipient: "pet" as const } + : {}), ...(effect.implicitTargetA === 1 ? { recipient: "caster" as const } : {}), - ...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}), + ...([35, 65].includes(effect.effectType) + || (classId === "warrior" + && ["Battle Shout", "Commanding Shout"].includes(abilityName) + && effect.implicitTargetA === 56) + || (classId === "paladin" && abilityName.startsWith("Greater Blessing of ")) + || (classId === "paladin" && abilityName === "Aura Mastery") + || (classId === "priest" + && ["Prayer of Fortitude", "Prayer of Shadow Protection", "Prayer of Spirit"].includes(abilityName) + && effect.implicitTargetA === 56) + ? { scope: "party" as const } + : {}), ...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}), sourceEffectType: effect.effectType, sourceAuraType: effect.auraType, @@ -1338,7 +1874,8 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str && !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) { if (classId === "death-knight" && abilityName === "Death Coil") { effects.unshift({ kind: "damage", coefficient: 1 }); - } else if (/\b(?:heals?|restore|mend|rejuvenat)\w*\b/i.test(rank.description)) { + } else if (!/^Create\b/i.test(abilityName) + && /\b(?:heal(?:s|ed|ing)?|restore(?:s|d)?|mend(?:s|ed)?|rejuvenat\w*)\b/i.test(rank.description)) { effects.unshift({ kind: "heal", coefficient: 1 }); } else if (/\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) { effects.unshift({ kind: "damage", coefficient: 1 }); @@ -1348,20 +1885,38 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str const executable = effects .filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported") .filter((effect) => !( - (classId === "warlock" && abilityName === "Conflagrate" && effect.kind === "damage") + (classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection") + || (classId === "paladin" && abilityName === "Divine Storm" + && (effect.kind === "damage" || effect.kind === "heal")) + || (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage") + || (classId === "warlock" && abilityName === "Conflagrate" && effect.kind === "damage") || (classId === "warrior" && abilityName === "Execute" && (effect.kind === "damage" || effect.kind === "heal")) || (classId === "priest" && abilityName === "Penance" && (effect.kind === "damage" || effect.kind === "heal")) || (classId === "priest" && abilityName === "Divine Hymn" && effect.kind === "heal") + || (classId === "priest" && abilityName === "Prayer of Mending" + && (effect.kind === "heal" || effect.kind === "trigger-spell")) + || (classId === "rogue" && abilityName === "Killing Spree" + && ["damage", "dot", "apply-aura", "trigger-spell"].includes(effect.kind)) + || (classId === "rogue" && abilityName === "Tricks of the Trade" + && ["threat", "apply-aura"].includes(effect.kind)) || (classId === "warrior" && ["Devastate", "Last Stand"].includes(abilityName) && (effect.kind === "damage" || effect.kind === "heal")) || (classId === "hunter" && abilityName === "Chimera Shot" && effect.kind === "damage") || (classId === "hunter" && abilityName === "Kill Shot" && effect.kind === "damage") + || (classId === "hunter" && abilityName === "Intimidation" && effect.kind === "apply-aura") + || (classId === "hunter" && abilityName === "Kill Command" && effect.kind === "apply-aura") + || (classId === "hunter" && abilityName === "Freezing Arrow" + && (effect.kind === "trigger-spell" || effect.kind === "status")) + || (classId === "hunter" && abilityName === "Explosive Trap" && effect.kind === "summon") || (classId === "priest" && abilityName === "Shadowfiend" && (effect.kind === "summon" || effect.kind === "trigger-spell")) + || (classId === "priest" && abilityName === "Lightwell" && effect.kind === "summon") || (classId === "mage" && abilityName === "Combustion" && (effect.kind === "damage" || effect.kind === "apply-aura" || effect.kind === "trigger-spell")) + || (classId === "mage" && abilityName === "Arcane Power" && effect.kind === "apply-aura") + || (classId === "mage" && abilityName === "Presence of Mind" && effect.kind === "apply-aura") || (classId === "mage" && abilityName === "Mirror Image" && (effect.kind === "summon" || effect.kind === "trigger-spell")) || (classId === "rogue" && abilityName === "Cold Blood" && effect.kind === "apply-aura") @@ -1371,10 +1926,23 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str || (classId === "druid" && abilityName === "Swiftmend" && effect.kind === "heal") || (classId === "warrior" && abilityName === "Bloodrage" && (effect.kind === "resource" || effect.kind === "heal")) + || (classId === "warrior" && abilityName === "Sweeping Strikes" && effect.kind === "apply-aura") || (classId === "priest" && abilityName === "Inner Focus" && effect.kind === "apply-aura") + || (classId === "shaman" && abilityName === "Nature's Swiftness" && effect.kind === "apply-aura") + || (classId === "druid" && abilityName === "Nature's Swiftness" && effect.kind === "apply-aura") + || (classId === "shaman" && abilityName === "Tidal Force" && effect.kind === "apply-aura") + || (classId === "shaman" && abilityName === "Healing Stream Totem" && effect.kind === "summon") || (classId === "hunter" && abilityName === "Volley" && effect.kind === "apply-aura") || (classId === "rogue" && abilityName === "Envenom" && effect.kind === "apply-aura") || (classId === "rogue" && abilityName === "Vanish" && effect.kind === "movement") + || (classId === "death-knight" && abilityName === "Empower Rune Weapon" && effect.kind === "rune") + || (classId === "death-knight" && abilityName === "Blood Tap" && effect.kind === "rune") + || (classId === "death-knight" && abilityName === "Anti-Magic Zone" && effect.kind === "summon") + || (classId === "death-knight" && ["Anti-Magic Shell", "Bone Shield", "Deathchill"].includes(abilityName) + && effect.kind === "apply-aura") + // Blink's DBC teleport is forward, but the combat state has no player + // facing vector. Do not approximate it with a target-relative movement. + || (classId === "mage" && abilityName === "Blink" && effect.kind === "movement") )) .map((effect) => effect.kind === "utility" ? effect : effect); const result = scriptedFallbacks.length @@ -1428,6 +1996,7 @@ function targetForRank(rank: Wow335Rank, effects: readonly AbilityEffect[]): Abi const targets = rank.effects.flatMap((effect) => [effect.implicitTargetA, effect.implicitTargetB]); if (targets.some((target) => HOSTILE_TARGETS.has(target))) return "hostile"; if (targets.some((target) => FRIENDLY_TARGETS.has(target))) return "friendly"; + if (effects.some((effect) => effect.kind === "resurrection")) return "friendly"; if (effects.some((effect) => ["damage", "dot", "taunt", "interrupt", "pull"].includes(effect.kind))) return "hostile"; if (effects.some((effect) => effect.kind === "dispel" && effect.relationship === "hostile")) return "hostile"; if (effects.some((effect) => effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) return "hostile"; @@ -1487,11 +2056,16 @@ function rankDefinition( .sort((left, right) => left - right)[0]; const castTimeMs = channeling ? Math.max(rank.castTimeMs, channelDurationMs) : rank.castTimeMs; const rankResource = resourceForRank(resource, rank); + const lifeTapCost = classId === "warlock" && abilityName === "Life Tap" + ? Math.max(1, Math.abs(rank.effects.find((effect) => effect.effectType === 3)?.basePoints ?? 1)) + : null; return { rank: rank.rank || index + 1, spellId: rank.spellId, level: gameplaySourceLevel(classId, rank.level), - description: rank.description, + description: classId === "death-knight" && abilityName === "Anti-Magic Zone" + ? "Creates a stationary 10-yard zone for 10 seconds. Party members inside it absorb 75% of incoming magic damage, up to 10000 plus twice your attack power." + : rank.description, castMode: channeling ? "channel" : rank.castTimeMs > 0 ? "cast" : "instant", castTimeMs, ...(channeling && channelTickMs ? { channelTickMs } : {}), @@ -1499,11 +2073,13 @@ function rankDefinition( ? { channelPulses: Math.max(1, Math.round(castTimeMs / channelTickMs) + (penance ? 1 : 0)) } : {}), ...(penance ? { channelStartsWithPulse: true } : {}), - cooldownMs: rank.cooldownMs, + cooldownMs: classId === "shaman" && abilityName === "Nature's Swiftness" + ? Math.max(180_000, rank.cooldownMs) + : rank.cooldownMs, gcdMs: rank.gcdMs, cost: { resource: rankResource, - amount: costForRank(classId, rankResource, rank), + amount: lifeTapCost ?? costForRank(classId, rankResource, rank), ...(rank.powerCostPercentage > 0 && rankResource !== "mana" ? { percentage: true } : {}), }, effects, @@ -1526,9 +2102,19 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef // variants after the actual learnable ranks (Execute's damage child, // Divine Hymn's tick, Death Coil's split target spells, polymorph models). // They are executable sub-spells, not a higher player rank. - const sourceRanks = chain.ranks.some((rank) => rank.rank > 0) - ? chain.ranks.filter((rank) => rank.rank > 0) - : chain.ranks; + const sourceRanks = classId === "warlock" && chain.name === "Seed of Corruption" + ? chain.ranks.filter((rank) => [27243, 47835, 47836].includes(rank.spellId)) + : classId === "shaman" && chain.name === "Shamanistic Rage" + ? chain.ranks.filter((rank) => rank.spellId === 30823) + : classId === "shaman" && chain.name === "Stormstrike" + ? chain.ranks.filter((rank) => rank.spellId === 17364) + : classId === "warrior" && chain.name === "Bloodthirst" + ? chain.ranks.filter((rank) => rank.spellId === 23881) + : classId === "warrior" && chain.name === "Sweeping Strikes" + ? chain.ranks.filter((rank) => rank.spellId === 12328) + : chain.ranks.some((rank) => rank.rank > 0) + ? chain.ranks.filter((rank) => rank.rank > 0) + : chain.ranks; const channelRanks = sourceRanks[0]?.channelDurationMs ? sourceRanks.filter((rank) => rank.channelDurationMs > 0) : sourceRanks; @@ -1553,10 +2139,22 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef : effect), }] : channelRanks; - // Holy Nova's DBC chain stores damage and healing as separate same-rank - // spells. The damage spell is the cast (and pays mana); its healing partner - // is a second payload of that same cast, not another selectable rank. - const castRanks = classId === "priest" && chain.name === "Holy Nova" + // Holy Nova and Holy Shock store damage and healing as separate same-rank + // spells. Holy Shock also has a player-cast wrapper that owns its cost, + // cooldown, and range; retain that wrapper as the selectable spell while + // combining both payloads into its executable rank. + const castRanks = classId === "paladin" && chain.name === "Holy Shock" + ? playerRanks.filter((rank) => rank.effects.some((effect) => effect.effectType === 3)) + .map((rank) => { + const damage = sourceRanks.find((candidate) => candidate.rank === rank.rank + && candidate.effects.some((effect) => effect.effectType === 2)); + const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank + && candidate.effects.some((effect) => effect.effectType === 10)); + return damage && healing + ? { ...rank, effects: [...damage.effects, ...healing.effects] } + : rank; + }) + : classId === "priest" && chain.name === "Holy Nova" ? playerRanks.filter((rank) => rank.effects.some((effect) => effect.effectType === 2)) .map((rank) => { const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank @@ -1568,7 +2166,10 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef const first = ranks[0]; const firstRaw = castRanks[0]; if (!first || !firstRaw) return null; - const target = abilityNameTarget(chain.name) ?? targetForRank(firstRaw, first.effects); + const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes" + ? Math.max(1, firstRaw.sourceLevel || firstRaw.level) + : chain.sourceUnlockLevel; + const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects); const radius = Math.max( abilityNameRadius(chain.name), ...firstRaw.effects.map((effect) => effect.radius), @@ -1584,14 +2185,32 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef classId, dbcSpellId: first.spellId, name: chain.name, - unlockLevel: starterUnlockLevel(classId, chain.sourceUnlockLevel, chain.name), - sourceUnlockLevel: chain.sourceUnlockLevel, + unlockLevel: starterUnlockLevel(classId, effectiveSourceUnlockLevel, chain.name), + sourceUnlockLevel: effectiveSourceUnlockLevel, icon: chain.iconPath ?? `/assets/ui/spells/${chain.iconBasename}.png`, sigil: sigil(chain.name), description: first.description, target, range, ...(radius > 0 ? { radius } : {}), + ...(classId === "paladin" && chain.name === "Avenger's Shield" + ? { targeting: { shape: "chain" as const, maximumTargets: 3, jumpRange: 10 } } + : {}), + ...(classId === "hunter" && ["Freezing Arrow", "Explosive Trap"].includes(chain.name) + ? { + targeting: { + shape: "ground" as const, + radius: 2, + groundRange: chain.name === "Freezing Arrow" ? Math.max(40, firstRaw.rangeMax) : 0, + }, + } + : {}), + ...(classId === "death-knight" && chain.name === "Anti-Magic Zone" + ? { targeting: { shape: "ground" as const, radius: 10, groundRange: 30 } } + : {}), + ...(classId === "death-knight" && chain.name === "Anti-Magic Zone" + ? { description: "Creates a stationary 10-yard zone for 10 seconds. Party members inside it absorb 75% of incoming magic damage, up to 10000 plus twice your attack power." } + : {}), castMode: first.castMode, castTimeMs: first.castTimeMs, channelTickMs: first.channelTickMs, @@ -1619,7 +2238,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef } function abilityNameRadius(abilityName: string): number { - if (abilityName === "Bladestorm") return 8; + if (["Bladestorm", "Divine Storm"].includes(abilityName)) return 8; if (abilityName === "Hellfire") return 10; if (abilityName === "Mind Sear") return 10; if (abilityName === "Pestilence") return 10; @@ -1627,17 +2246,26 @@ function abilityNameRadius(abilityName: string): number { return 0; } -function abilityNameTarget(abilityName: string): AbilityTarget | undefined { - if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead", "Fan of Knives"].includes(abilityName)) return "self"; +function abilityNameTarget(classId: BaseClassId, abilityName: string): AbilityTarget | undefined { + if (abilityName === "Redemption" || abilityName.startsWith("Greater Blessing of ")) return "friendly"; + if (classId === "rogue" && abilityName === "Tricks of the Trade") return "friendly"; + if (classId === "rogue" && abilityName === "Killing Spree") return "hostile"; + if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead", "Fan of Knives", "Anti-Magic Zone"].includes(abilityName)) return "self"; + if (abilityName === "Soulshatter") return "self"; + if (abilityName === "Desperate Prayer") return "self"; + if (abilityName === "Lightwell") return "self"; + if (abilityName === "Dispel Magic") return "any"; + if (abilityName === "Holy Shock") return "any"; if (abilityName === "Volley") return "hostile"; if (abilityName === "Penance") return "any"; - if (["Last Stand", "Enraged Regeneration", "Molten Armor", "Combustion"].includes(abilityName)) return "self"; - if (abilityName === "Death Coil") return "hostile"; + if (["Last Stand", "Enraged Regeneration", "Molten Armor", "Combustion", "Life Tap", "Bone Shield"].includes(abilityName)) return "self"; + if (abilityName === "Death Coil") return classId === "death-knight" ? "any" : "hostile"; return undefined; } function classicCooldownGroup(classId: BaseClassId, abilityName: string): string | undefined { if (classId === "shaman" && / Shock$/i.test(abilityName)) return "shaman-shocks"; + if (classId === "shaman" && ["Nature's Swiftness", "Elemental Mastery"].includes(abilityName)) return "shaman-nature-swiftness"; if (classId === "hunter" && / Trap$/i.test(abilityName)) return "hunter-traps"; if (classId === "mage" && /^(?:Fire|Frost) Ward$/i.test(abilityName)) return "mage-wards"; return undefined; @@ -1656,8 +2284,7 @@ export const WOW335_ABILITIES_BY_CLASS: Readonly> = - Object.freeze(Object.fromEntries(BASE_CLASS_IDS.flatMap((classId) => data.classes[classId] +const wow335ChannelTriggeredAbilitiesBySpellId = Object.fromEntries(BASE_CLASS_IDS.flatMap((classId) => data.classes[classId] .filter((chain) => GROUND_CHANNEL_CHILD_NAMES.has(chain.name)) .flatMap((chain) => { const ranksById = new Map(chain.ranks.map((rank) => [rank.spellId, rank])); @@ -1689,4 +2316,122 @@ export const WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID: Readonly { + const chain = data.classes.warlock.find((candidate) => candidate.name === "Seed of Corruption"); + const rank = chain?.ranks.find((candidate) => candidate.spellId === spellId); + if (!rank) return []; + const effects = effectsForRank("warlock", rank, "Seed of Corruption") + .filter((effect) => effect.kind === "damage"); + if (!effects.length) return []; + return [[spellId, { + id: `wow335-trigger-${spellId}`, + classId: "warlock" as const, + dbcSpellId: spellId, + name: "Seed of Corruption explosion", + unlockLevel: 1, + icon: chain?.iconPath ?? "/assets/ui/spells/warlock_seedofcorruption.png", + sigil: "SC", + description: "Deals the rank-specific Shadow damage when Seed of Corruption detonates.", + target: "hostile" as const, + range: { min: 0, max: 15 }, + castMode: "instant" as const, + castTimeMs: 0, + cooldownMs: 0, + gcdMs: 0, + cost: { resource: "mana" as const, amount: 0 }, + effects, + } satisfies AbilityDefinition]]; + }), +); + +const bloodthirstHealTriggeredAbility: AbilityDefinition = { + id: "wow335-trigger-23880", + classId: "warrior", + dbcSpellId: 23880, + name: "Bloodthirst heal", + unlockLevel: 1, + icon: "/assets/ui/spells/ability_warrior_bloodthirst.png", + sigil: "BH", + description: "A successful melee hit heals the warrior for 1% of maximum health.", + target: "self", + range: { min: 0, max: 0 }, + castMode: "instant", + castTimeMs: 0, + cooldownMs: 0, + gcdMs: 0, + cost: { resource: "rage", amount: 0 }, + effects: [{ kind: "heal", coefficient: 0, percentMaxHealth: 0.01 }], +}; + +const rogueShadowstepPositionChild: AbilityDefinition = { + id: "wow335-trigger-36563", + classId: "rogue", + dbcSpellId: 36563, + name: "Shadowstep behind target", + unlockLevel: 1, + icon: "/assets/ui/spells/ability_rogue_shadowstep.png", + sigil: "SS", + description: "Reappear behind the selected enemy.", + target: "hostile", + range: { min: 0, max: 25 }, + castMode: "instant", + castTimeMs: 0, + cooldownMs: 0, + gcdMs: 0, + cost: { resource: "energy", amount: 0 }, + effects: [], +}; + +const rogueShadowstepRiderChild: AbilityDefinition = { + id: "wow335-trigger-44373", + classId: "rogue", + dbcSpellId: 44373, + name: "Shadowstep next-ability rider", + unlockLevel: 1, + icon: "/assets/ui/spells/ability_rogue_shadowstep.png", + sigil: "SS", + description: "Increases damage by 1% and reduces threat by 1% for 3 sec.", + target: "self", + range: { min: 0, max: 0 }, + castMode: "instant", + castTimeMs: 0, + cooldownMs: 0, + gcdMs: 0, + cost: { resource: "energy", amount: 0 }, + effects: [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "wow335:rogue:shadowstep-rider", + name: "Shadowstep", + disposition: "buff", + durationMs: 3_000, + maxStacks: 1, + stackBehavior: "refresh", + modifiers: [ + { kind: "damage", direction: "dealt", operation: "percent", value: 0.01 }, + { kind: "stat", stat: "threat", operation: "percent", value: -0.01 }, + ], + utilityTags: ["wow335-rogue-shadowstep-rider"], + }, + sourceEffectType: 6, + sourceAuraType: 4, + miscValue: 0, + triggerSpellId: 0, + }], +}; + +export const WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID: Readonly> = + Object.freeze({ + ...wow335ChannelTriggeredAbilitiesBySpellId, + ...wow335SeedExplosionAbilitiesBySpellId, + 36563: rogueShadowstepPositionChild, + 44373: rogueShadowstepRiderChild, + 23880: bloodthirstHealTriggeredAbility, + }); + +/** @deprecated Use WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID. */ +export const WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID = WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID; diff --git a/src/game/wow335DruidProcRuntime.test.ts b/src/game/wow335DruidProcRuntime.test.ts new file mode 100644 index 00000000..63a2033e --- /dev/null +++ b/src/game/wow335DruidProcRuntime.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { applyAura, resolveStatValue } from "./combatAuras"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; + +const TARGET_ID = "druid-proc-target"; + +function unit(seed: string): number { + let hash = 2166136261; + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function abilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function findDruidProcStart(name: string, startAt: number, chance: number, procTag: string): number { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + for (let start = startAt; start < startAt + 100_000; start += 100) { + const impactAt = start + ability!.castTimeMs; + const directSeed = `${PLAYER_AGGRO_ID}:${ability!.id}:${TARGET_ID}:${impactAt}:direct`; + if (unit(`${directSeed}:outcome`) > 0.1 && unit(`${directSeed}:${procTag}`) < chance) return start; + } + throw new Error(`No deterministic ${name} proc seed found.`); +} + +function castAndFinish(name: string, startedAt: number) { + const id = abilityId(name); + const result = useCombatStore.getState().castAbility(id, { targetActorId: TARGET_ID, now: startedAt }); + expect(result.ok, JSON.stringify(result)).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + if (activeCast?.abilityId === id) { + useCombatStore.getState().tick(Math.max(0.1, (activeCast.completesAt - startedAt) / 1_000), activeCast.completesAt); + } + return useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.abilityId === id + )).at(-1); +} + +describe("WoW Druid proc runtime", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["druid"]); + useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-druid-proc-tests", 1, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + })); + useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", maxHealth: 1_000_000 }); + setMobPosition(TARGET_ID, [4, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); + }); + + it("procs Solar Eclipse from Starfire and gives Wrath its source 30% damage bonus", () => { + const baseline = castAndFinish("Wrath", 1_000); + expect(baseline?.kind).toBe("damage"); + + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1924": 3 } })); + castAndFinish("Starfire", 5_000); + const solar = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-eclipse-solar") + )); + expect(solar?.expiresAt).toBe(23_500); + expect(useCombatStore.getState().cooldowns["wow335-druid-eclipse-solar"]).toBe(38_500); + + const buffed = castAndFinish("Wrath", 10_000); + expect(buffed?.kind).toBe("damage"); + expect(buffed?.rawAmount).toBe(Math.round(baseline!.rawAmount * 1.3)); + }); + + it("procs Lunar Eclipse from Wrath and adds 30 percentage points to Starfire crit chance", () => { + const baselineEvent = castAndFinish("Starfire", 1_000); + expect(baselineEvent?.kind).toBe("damage"); + const baselineCritChance = useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent; + expect(baselineCritChance).toBeDefined(); + + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1924": 3 } })); + const wrathStart = findDruidProcStart("Wrath", 6_000, 0.6, "druid-eclipse"); + castAndFinish("Wrath", wrathStart); + const lunar = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-eclipse-lunar") + )); + expect(lunar).toBeDefined(); + + castAndFinish("Starfire", wrathStart + 2_100); + expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent) + .toBe(baselineCritChance! + 30); + }); + + it("grants Nature's Grace after a direct healing critical and applies 20% cast haste for four seconds", () => { + const healingTouch = abilityId("Healing Touch"); + let startedAt = 1_000; + while (unit(`${PLAYER_AGGRO_ID}:${healingTouch}:${PLAYER_AURA_ENTITY_ID}:${startedAt + 3_000}:heal:druid-omen-of-clarity`) >= 0.06) { + startedAt += 100; + } + useCombatStore.setState((state) => ({ + health: Math.round(state.maxHealth / 2), + talentRanks: { ...state.talentRanks, "talent-789": 3, "talent-827": 3 }, + auras: [...applyAura(state.auras, { + definition: { + id: "test-druid-guaranteed-spell-crit", + name: "Test Critical Chance", + disposition: "buff", + durationMs: null, + modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 100 }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: startedAt, + }).auras], + })); + + expect(useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: startedAt }).ok).toBe(true); + const firstCast = useCombatStore.getState().activeCast; + expect(firstCast?.abilityId).toBe(healingTouch); + useCombatStore.getState().tick(0.1, firstCast!.completesAt); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "healing" && event.abilityId === healingTouch && event.critical + ))).toBe(true); + + const natureGrace = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-natures-grace") + )); + expect(natureGrace?.expiresAt).toBe(firstCast!.completesAt + 4_000); + expect(resolveStatValue(0, [natureGrace!], "haste")).toBe(20); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-clearcasting") + ))).toBe(true); + const manaAfterFirstHeal = useCombatStore.getState().resource; + + const nextStart = firstCast!.completesAt + 1_500; + useCombatStore.setState((state) => ({ + lastResourceSpentAt: { ...state.lastResourceSpentAt, mana: nextStart }, + })); + expect(useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: nextStart }).ok).toBe(true); + expect(useCombatStore.getState().resource).toBe(manaAfterFirstHeal); + const hastedCast = useCombatStore.getState().activeCast; + expect(hastedCast?.originalDurationMs).toBe(Math.round(3_000 / 1.2)); + useCombatStore.getState().tick(0.1, hastedCast!.completesAt); + const overhealClearcasting = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-clearcasting") + )); + expect(overhealClearcasting?.expiresAt).toBe(hastedCast!.completesAt + 15_000); + expect(useCombatStore.getState().cooldowns["wow335-druid-omen-of-clarity"]) + .toBe(hastedCast!.completesAt + 2_000); + }); + + it("procs Omen of Clarity from damage, makes the next eligible cast free, and respects its two-second ICD", () => { + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-827": 3 } })); + const moonfire = useCombatStore.getState().abilities.find((ability) => ability.name === "Moonfire"); + expect(moonfire).toBeDefined(); + const start = (() => { + for (let now = 1_000; now < 100_000; now += 1) { + const seed = `${PLAYER_AGGRO_ID}:${moonfire!.id}:${TARGET_ID}:${now}:direct`; + if (unit(`${seed}:outcome`) > 0.1 && unit(`${seed}:druid-omen-of-clarity`) < 0.06) return now; + } + throw new Error("No deterministic Omen of Clarity proc seed found."); + })(); + + const first = useCombatStore.getState().castAbility(moonfire!.id, { targetActorId: TARGET_ID, now: start }); + expect(first.ok, JSON.stringify(first)).toBe(true); + const manaAfterFirst = useCombatStore.getState().resource; + const clearcasting = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-clearcasting") + )); + expect(clearcasting?.expiresAt).toBe(start + 15_000); + expect(useCombatStore.getState().cooldowns["wow335-druid-omen-of-clarity"]).toBe(start + 2_000); + + const second = useCombatStore.getState().castAbility(moonfire!.id, { + targetActorId: TARGET_ID, + now: start + 1_500, + }); + expect(second.ok, JSON.stringify(second)).toBe(true); + expect(useCombatStore.getState().resource).toBe(manaAfterFirst); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-clearcasting") + ))).toBe(false); + }); + + it("allows a successful healing spell that fully overheals to proc Omen of Clarity", () => { + const healingTouch = abilityId("Healing Touch"); + useCombatStore.setState((state) => ({ + health: state.maxHealth, + talentRanks: { ...state.talentRanks, "talent-827": 3 }, + })); + let startedAt = 1_000; + while (unit(`${PLAYER_AGGRO_ID}:${healingTouch}:${PLAYER_AURA_ENTITY_ID}:${startedAt + 3_000}:heal:druid-omen-of-clarity`) >= 0.06) { + startedAt += 100; + } + + const result = useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: startedAt }); + expect(result.ok, JSON.stringify(result)).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + expect(activeCast?.abilityId).toBe(healingTouch); + useCombatStore.getState().tick(0.1, activeCast!.completesAt); + + const healingEvent = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "healing" && event.abilityId === healingTouch && event.occurredAt === activeCast!.completesAt + )); + expect(healingEvent).toMatchObject({ rawAmount: expect.any(Number), effectiveAmount: 0 }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-clearcasting") + ))).toBe(true); + }); + + it("allows landed player auto attacks to trigger Omen of Clarity", () => { + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-827": 3 } })); + let now = 1_000; + let seed = `druid-auto-attack:${now}`; + while (unit(`${seed}:druid-omen-of-clarity`) >= 0.06) { + now += 1; + seed = `druid-auto-attack:${now}`; + } + + const result = useCombatStore.getState().damageMob(TARGET_ID, 100, now, undefined, "physical", { + attackKind: "melee", + seed, + rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false, canCrit: false }, + }); + expect(result.amount).toBeGreaterThan(0); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-clearcasting") + ))).toBe(true); + }); +}); diff --git a/src/game/wow335DruidRuntime.test.ts b/src/game/wow335DruidRuntime.test.ts new file mode 100644 index 00000000..b43a0487 --- /dev/null +++ b/src/game/wow335DruidRuntime.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { abilityTalentNodeId, type AbilityDefinition } from "./abilityCatalog"; +import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { useCombatStore, type TimedCombatEffect } from "./combatStore"; + +function prepareSwiftmend(): AbilityDefinition { + useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 }); + useCombatStore.setState((state) => ({ health: 1_000, resource: state.maxResource })); + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Swiftmend"); + if (!ability) throw new Error("Druid Swiftmend is missing from the learned ability catalog."); + const talentId = abilityTalentNodeId(ability); + if (!talentId) throw new Error("Druid Swiftmend has no active talent node."); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [talentId]: 1 } })); + return ability; +} + +function hot( + sourceName: "Rejuvenation" | "Regrowth", + sourceActorId: string, + tickHealing: number, + remainingTicks: number, + nextTickAt: number, + id: string, + fixedAmount?: number, +): TimedCombatEffect { + const sourceAbility = useCombatStore.getState().abilities.find((ability) => ability.name === sourceName); + if (!sourceAbility) throw new Error(`Druid ${sourceName} is missing from the learned ability catalog.`); + return { + id, + kind: "hot", + sourceAbilityId: sourceAbility.id, + sourceActorId, + targetId: null, + coefficient: 0, + basePoints: tickHealing, + dieSides: 1, + pointsPerLevel: 0, + sourceLevel: 80, + ...(fixedAmount === undefined ? {} : { fixedAmount }), + remainingTicks, + intervalMs: 3_000, + nextTickAt, + }; +} + +function castSwiftmend(ability: AbilityDefinition, now = 1_000) { + const result = useCombatStore.getState().castAbility(ability.id, { targetActorId: null, now }); + expect(result.ok).toBe(true); + const event = useCombatStore.getState().combatEvents.filter((candidate) => ( + candidate.kind === "healing" && candidate.abilityId === ability.id + )).at(-1); + if (!event || event.kind !== "healing") throw new Error("Swiftmend did not record its healing event."); + return event; +} + +function expectCriticalAdjustedRawHealing(rawAmount: number, critical: boolean, baseAmount: number): void { + expect(rawAmount).toBe(baseAmount * (critical ? 1.5 : 1)); +} + +describe("WoW Druid active talent runtime", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + }); + + it.each([ + ["Rejuvenation", 100, 5, 400], + ["Regrowth", 150, 7, 900], + ] as const)("Swiftmend uses its source 12/18-second amount for %s", (sourceName, tickHealing, remainingTicks, expected) => { + const swiftmend = prepareSwiftmend(); + const sourceHot = hot(sourceName, PLAYER_AGGRO_ID, tickHealing, remainingTicks, 4_000, "swiftmend-source-hot"); + useCombatStore.setState({ effects: [sourceHot] }); + + const event = castSwiftmend(swiftmend); + + expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, expected); + expect(useCombatStore.getState().effects).toEqual([]); + }); + + it("prefers its own Hot over a shorter foreign Hot", () => { + const swiftmend = prepareSwiftmend(); + const foreignShort = hot("Rejuvenation", "party-druid-short", 25, 1, 1_500, "swiftmend-foreign-short", 25); + const casterHot = hot("Regrowth", PLAYER_AGGRO_ID, 100, 7, 16_000, "swiftmend-caster-long"); + useCombatStore.setState({ effects: [foreignShort, casterHot] }); + + const event = castSwiftmend(swiftmend); + + expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 600); + expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([foreignShort.id]); + }); + + it("uses the shortest foreign Rejuvenation or Regrowth when no caster Hot exists", () => { + const swiftmend = prepareSwiftmend(); + const longerRegrowth = hot("Regrowth", "party-druid-long", 50, 7, 4_000, "swiftmend-foreign-long", 50); + const shorterRejuvenation = hot("Rejuvenation", "party-druid-short", 100, 5, 4_000, "swiftmend-foreign-short", 100); + useCombatStore.setState({ effects: [longerRegrowth, shorterRejuvenation] }); + + const event = castSwiftmend(swiftmend); + + expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400); + expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([longerRegrowth.id]); + }); +}); diff --git a/src/game/wow335HunterMechanics.test.ts b/src/game/wow335HunterMechanics.test.ts new file mode 100644 index 00000000..24627cf3 --- /dev/null +++ b/src/game/wow335HunterMechanics.test.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { cooldownResetCandidates } from "./cooldownReset"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { usePartyStore } from "./partyStore"; + +describe("WoW 3.3.5 Hunter Readiness", () => { + it("preserves Readiness's Bestial Wrath cooldown exception", () => { + const hunterAbilities = WOW335_ABILITIES_BY_CLASS.hunter; + const readiness = hunterAbilities.find((ability) => ability.name === "Readiness"); + expect(readiness).toBeDefined(); + + const reset = readiness!.effects.find((effect) => effect.kind === "cooldown-reset"); + expect(reset).toMatchObject({ + kind: "cooldown-reset", + mode: "reset", + excludeAbilityNames: ["Bestial Wrath"], + }); + if (!reset || reset.kind !== "cooldown-reset") return; + + const candidates = cooldownResetCandidates( + hunterAbilities, + readiness!.id, + reset.abilityName, + reset.excludeAbilityNames, + ); + expect(candidates.some((ability) => ability.name === "Bestial Wrath")).toBe(false); + expect(candidates.some((ability) => ability.name === "Readiness")).toBe(false); + expect(candidates.some((ability) => ability.name === "Rapid Fire")).toBe(true); + }); + + it("does not turn The Beast Within's conditional tooltip into a permanent modifier", () => { + expect(talentModifiersForRanks({ "talent-1803": 1 }, "hunter")).toEqual([]); + expect(talentModifiersForRanks({ "talent-1803": 2 }, "hunter")).toEqual([]); + }); +}); + +function hunterAbilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function unlockHunterTalent(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + const nodeId = abilityTalentNodeId(ability!); + expect(nodeId, name).toBeTruthy(); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [nodeId!]: 1 }, + })); + return ability!.id; +} + +function hunterProcRoll(seed: string): number { + let hash = 2_166_136_261; + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function hunterProcTime(abilityId: string, petId: string, shouldProc: boolean, after: number): number { + for (let now = after; now < after + 60_000; now += 1) { + const rolls = hunterProcRoll(`${PLAYER_AGGRO_ID}:${abilityId}:${petId}:${now}:the-beast-within`); + if ((rolls < 0.5) === shouldProc) return now; + } + throw new Error("Could not find a deterministic rank-one proc test time."); +} + +function registerHunterMob(id: string, position: readonly [number, number, number], maxHealth = 100_000): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 }); + setMobPosition(id, position); + useCombatStore.getState().selectMob(id); +} + +describe("WoW 3.3.5 Hunter pet mechanics", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["hunter"]); + useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty( + "wow335-hunter-mechanics-tests", + 1, + useCombatStore.getState().health, + ); + }); + + it("resets Hunter cooldowns while leaving Bestial Wrath's cooldown untouched", () => { + const readinessId = unlockHunterTalent("Readiness"); + const bestialWrathId = unlockHunterTalent("Bestial Wrath"); + const rapidFireId = hunterAbilityId("Rapid Fire"); + useCombatStore.setState((state) => ({ + cooldowns: { + ...state.cooldowns, + [readinessId]: 0, + [bestialWrathId]: 90_000, + [rapidFireId]: 30_000, + }, + })); + + const result = useCombatStore.getState().castAbility(readinessId, undefined, 1_000); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().cooldowns[bestialWrathId]).toBe(90_000); + expect(useCombatStore.getState().cooldowns[rapidFireId]).toBe(0); + expect(useCombatStore.getState().cooldowns[readinessId]).toBeGreaterThan(1_000); + }); + + it("rejects Bestial Wrath without an active pet and does not proc The Beast Within", () => { + const bestialWrathId = unlockHunterTalent("Bestial Wrath"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1803": 2 }, + })); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false); + + const startingMana = useCombatStore.getState().resourcePools.mana; + const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 1_000); + + expect(result).toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana); + expect(useCombatStore.getState().cooldowns[bestialWrathId] ?? 0).toBe(0); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false); + }); + + it("requires a live pet before accepting pet-command and Mend Pet casts", () => { + registerHunterMob("petless-target", [0, 0, 3]); + const petDependentCasts = [ + { id: unlockHunterTalent("Bestial Wrath"), targetActorId: undefined }, + { id: unlockHunterTalent("Intimidation"), targetActorId: "petless-target" }, + { id: hunterAbilityId("Kill Command"), targetActorId: "petless-target" }, + { id: hunterAbilityId("Mend Pet"), targetActorId: undefined }, + ]; + const startingMana = useCombatStore.getState().resourcePools.mana; + + for (const [index, cast] of petDependentCasts.entries()) { + const result = useCombatStore.getState().castAbility(cast.id, { + ...(cast.targetActorId ? { targetActorId: cast.targetActorId } : {}), + now: 1_000 + index * 2_000, + }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana); + expect(useCombatStore.getState().cooldowns[cast.id] ?? 0).toBe(0); + } + }); + + it("places Bestial Wrath on the pet and applies the rank-two Beast Within payload to the hunter", () => { + const callPetId = hunterAbilityId("Call Pet"); + const bestialWrathId = unlockHunterTalent("Bestial Wrath"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1803": 2 }, + })); + + const callResult = useCombatStore.getState().castAbility(callPetId, undefined, 1_000); + expect(callResult, JSON.stringify(callResult)).toMatchObject({ ok: true }); + const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); + expect(pet).toBeDefined(); + + const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 3_000); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + + const auras = useCombatStore.getState().auras; + const petAuras = auras.filter((aura) => aura.targetId === pet!.id && aura.definition.id.includes(":19574:")); + expect(petAuras).toHaveLength(3); + expect(auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.id.includes(":19574:"))).toBe(false); + + const beastWithinAuras = auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.id.includes(":34471:")); + expect(beastWithinAuras).toHaveLength(3); + expect(beastWithinAuras.every((aura) => aura.expiresAt === 21_000)).toBe(true); + expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.2 }), + expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.1 }), + ])); + }); + + it("uses The Beast Within's 50% rank-one proc chance", () => { + const callPetId = hunterAbilityId("Call Pet"); + const bestialWrathId = unlockHunterTalent("Bestial Wrath"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1803": 1 }, + })); + expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true); + const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); + expect(pet).toBeDefined(); + + const noProcAt = hunterProcTime(bestialWrathId, pet!.id, false, 3_000); + expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, noProcAt).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.id.includes(":34471:"))).toBe(false); + + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [bestialWrathId]: 0 }, + globalCooldownEndsAt: 0, + })); + const procAt = hunterProcTime(bestialWrathId, pet!.id, true, noProcAt + 5_000); + expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, procAt).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.id.includes(":34471:"))).toBe(true); + }); + + it("stores Intimidation on the pet and spends it on the pet's next melee hit", () => { + registerHunterMob("intimidation-target", [0, 0, 3]); + const callPetId = hunterAbilityId("Call Pet"); + const intimidationId = unlockHunterTalent("Intimidation"); + expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true); + const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); + expect(pet).toBeDefined(); + + const cast = useCombatStore.getState().castAbility(intimidationId, { targetActorId: "intimidation-target", now: 3_000 }); + expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); + const intimidationAura = useCombatStore.getState().auras.find((aura) => ( + aura.definition.id.includes(":19577:") + )); + expect(intimidationAura?.targetId).toBe(pet!.id); + expect(useCombatStore.getState().mobs["intimidation-target"]?.statuses).toHaveLength(0); + + useCombatStore.getState().tick(1, 4_000); + + const target = useCombatStore.getState().mobs["intimidation-target"]; + expect(target?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "stun", sourceActorId: pet!.id, endsAt: 7_000 }), + ])); + expect(target?.threatByActor[pet!.id]).toBe(580); + expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === intimidationAura?.instanceId)).toBe(false); + }); + + it("only enables Counterattack after a parry and consumes that readiness on use", () => { + const counterattackId = unlockHunterTalent("Counterattack"); + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, parryRating: 100_000 }, + })); + useCombatStore.getState().registerMob("counterattack-target", { + name: "counterattack-target", + maxHealth: 100_000, + xpReward: 0, + attacks: [DEFAULT_ENEMY_ATTACK], + }); + setMobPosition("counterattack-target", [2, 0, 0]); + + const unavailable = useCombatStore.getState().castAbility(counterattackId, { + targetActorId: "counterattack-target", now: 1_000, + }); + expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" }); + + useCombatStore.getState().damageMob("counterattack-target", 1, 1_100); + let parryAt: number | null = null; + for (let now = 1_450; now < 20_000; now += 100) { + useCombatStore.getState().advanceMobCombat(now); + if (useCombatStore.getState().lastCombatResult?.parried) { + parryAt = now; + break; + } + } + expect(parryAt).not.toBeNull(); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready") + ))).toBe(true); + + const available = useCombatStore.getState().castAbility(counterattackId, { + targetActorId: "counterattack-target", now: parryAt! + 100, + }); + expect(available, JSON.stringify(available)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready") + ))).toBe(false); + }); + + it("does not consume Kill Command's special-attack charges on a generic pet melee swing", () => { + registerHunterMob("kill-command-target", [0, 0, 3]); + const callPetId = hunterAbilityId("Call Pet"); + const killCommandId = hunterAbilityId("Kill Command"); + expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true); + const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); + expect(pet).toBeDefined(); + + const cast = useCombatStore.getState().castAbility(killCommandId, { targetActorId: "kill-command-target", now: 3_000 }); + expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); + const aura = useCombatStore.getState().auras.find((entry) => ( + entry.definition.utilityTags?.includes("wow335-hunter-kill-command") + )); + expect(aura?.targetId).toBe(pet!.id); + expect(aura?.stacks).toBe(60); + expect(aura?.definition.modifiers?.some((modifier) => modifier.kind === "damage") ?? false).toBe(false); + + useCombatStore.getState().tick(1, 4_000); + + expect(useCombatStore.getState().auras.find((entry) => entry.instanceId === aura?.instanceId)?.stacks).toBe(60); + }); + + it("arms Freezing Arrow at its ground point and freezes only an approaching target", () => { + registerHunterMob("freezing-target", [0, 0, 10]); + useCombatStore.getState().registerMob("freezing-later", { name: "freezing-later", maxHealth: 100_000, xpReward: 0 }); + setMobPosition("freezing-later", [0, 0, 12]); + const freezingArrowId = hunterAbilityId("Freezing Arrow"); + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === freezingArrowId)!; + expect(ability.targeting).toMatchObject({ shape: "ground", groundRange: 40, radius: 2 }); + + const cast = useCombatStore.getState().castAbility(freezingArrowId, { + origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000, + }); + expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0); + expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("freezing"); + + setMobPosition("freezing-target", [0, 0, 1]); + useCombatStore.getState().tick(1, 2_000); + + expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "freeze", endsAt: 22_000, breakOnDamage: true }), + ])); + expect(useCombatStore.getState().mobs["freezing-later"]?.statuses).toHaveLength(0); + expect(useCombatStore.getState().areaEffects.some((area) => area.hunterTrap?.kind === "freezing")).toBe(false); + + useCombatStore.getState().damageMob("freezing-target", 1, 2_001); + expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0); + }); + + it("arms Explosive Trap, then deals its rank fire impact and periodic AoE ticks", () => { + registerHunterMob("explosive-center", [0, 0, 0]); + useCombatStore.getState().registerMob("explosive-edge", { name: "explosive-edge", maxHealth: 100_000, xpReward: 0 }); + setMobPosition("explosive-edge", [0, 0, 8]); + useCombatStore.getState().registerMob("explosive-outside", { name: "explosive-outside", maxHealth: 100_000, xpReward: 0 }); + setMobPosition("explosive-outside", [0, 0, 11]); + const abilityId = hunterAbilityId("Explosive Trap"); + const cast = useCombatStore.getState().castAbility(abilityId, { + origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000, + }); + expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); + const beforeCenter = useCombatStore.getState().mobs["explosive-center"]!.health; + const beforeEdge = useCombatStore.getState().mobs["explosive-edge"]!.health; + expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("explosive"); + + useCombatStore.getState().tick(1, 2_000); + + expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(beforeCenter); + expect(useCombatStore.getState().mobs["explosive-edge"]!.health).toBeLessThan(beforeEdge); + expect(useCombatStore.getState().mobs["explosive-outside"]!.health).toBe(100_000); + expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + occurredAt: 2_000, + kind: "damage", + abilityId, + targetActorId: "explosive-center", + school: "fire", + }), + ])); + expect(useCombatStore.getState().areaEffects[0]).toMatchObject({ radius: 10, tickIntervalMs: 2_000 }); + const afterImpact = useCombatStore.getState().mobs["explosive-center"]!.health; + + useCombatStore.getState().tick(1, 4_000); + + expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(afterImpact); + expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + occurredAt: 4_000, + kind: "damage", + abilityId, + targetActorId: "explosive-center", + school: "fire", + }), + ])); + }); +}); diff --git a/src/game/wow335MageImpactRuntime.test.ts b/src/game/wow335MageImpactRuntime.test.ts new file mode 100644 index 00000000..6afa8db6 --- /dev/null +++ b/src/game/wow335MageImpactRuntime.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { WOW335_MAGE_IMPACT_TAG } from "./wow335MageTalentRuntime"; + +const TARGET_ID = "mage-impact-target"; +const PROC_SEED = "mage-impact-seed-0"; +const NO_PROC_SEED = "mage-impact-no-proc-0"; + +function abilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function setImpactRank(rank: number): void { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-30": rank }, + })); +} + +function impactAura() { + return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_IMPACT_TAG)); +} + +function damageSpell(name: string, now: number, seed: string, rules = {}) { + const spell = useCombatStore.getState().abilities.find((ability) => ability.name === name); + expect(spell, name).toBeDefined(); + return useCombatStore.getState().damageMob(TARGET_ID, 1_000, now, undefined, + name === "Frostbolt" ? "frost" : "fire", { + attackKind: "spell", + seed, + abilityId: spell!.id, + rules: { + canMiss: false, + canCrit: false, + canDodge: false, + canParry: false, + canBlock: false, + ...rules, + }, + }); +} + +describe("WoW 3.3.5 Mage Impact runtime", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["mage"]); + useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-mage-impact-tests", 1, useCombatStore.getState().health); + useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", maxHealth: 100_000 }); + useCombatStore.getState().selectMob(TARGET_ID); + }); + + it("uses the active rank-three 4% proc on direct damaging spells without requiring a critical", () => { + setImpactRank(3); + const result = damageSpell("Fireball", 1_000, PROC_SEED); + expect(result.combatResult?.landed).toBe(true); + expect(result.combatResult?.critical).toBe(false); + expect(impactAura()?.expiresAt).toBe(11_000); + + useCombatStore.getState().consumeActorAuraTag(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_IMPACT_TAG); + damageSpell("Fireball", 2_000, NO_PROC_SEED); + expect(impactAura()).toBeUndefined(); + }); + + it("does not activate the child stun proc before rank three", () => { + for (const rank of [1, 2]) { + setImpactRank(rank); + damageSpell("Fireball", 1_000 + rank, PROC_SEED); + expect(impactAura()).toBeUndefined(); + } + }); + + it("preserves the proc through a missed Fire Blast, then consumes it on a landed Fire Blast", () => { + setImpactRank(3); + damageSpell("Fireball", 1_000, PROC_SEED); + expect(impactAura()).toBeDefined(); + + const miss = damageSpell("Fire Blast", 2_000, NO_PROC_SEED, { + canMiss: true, + baseMissChancePercent: 100, + }); + expect(miss.combatResult?.landed).toBe(false); + expect(impactAura()).toBeDefined(); + expect(useCombatStore.getState().mobs[TARGET_ID]?.statuses.some((status) => status.kind === "stun")) + .toBe(false); + + const hit = damageSpell("Fire Blast", 3_000, NO_PROC_SEED); + expect(hit.combatResult?.landed).toBe(true); + expect(impactAura()).toBeUndefined(); + expect(useCombatStore.getState().mobs[TARGET_ID]?.statuses).toContainEqual(expect.objectContaining({ + kind: "stun", + sourceAbilityId: abilityId("Fire Blast"), + endsAt: 6_000, + })); + }); + + it("consumes the Fire Blast proc charge against a stun-immune target", () => { + setImpactRank(3); + useCombatStore.getState().registerMob("mage-impact-immune", { + name: "Stun immune target", + maxHealth: 100_000, + mechanicImmunities: ["stun"], + }); + damageSpell("Fireball", 1_000, PROC_SEED); + + useCombatStore.getState().damageMob("mage-impact-immune", 1_000, 2_000, undefined, "fire", { + attackKind: "spell", + seed: NO_PROC_SEED, + abilityId: abilityId("Fire Blast"), + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }); + + expect(impactAura()).toBeUndefined(); + expect(useCombatStore.getState().mobs["mage-impact-immune"]?.statuses).toEqual([]); + }); +}); diff --git a/src/game/wow335MageRuntime.test.ts b/src/game/wow335MageRuntime.test.ts new file mode 100644 index 00000000..18ba4ed3 --- /dev/null +++ b/src/game/wow335MageRuntime.test.ts @@ -0,0 +1,490 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, resolvedAbilityCosts, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { abilityAtLevel, abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { resolveDamageValue, resolveStatValue } from "./combatAuras"; +import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { + WOW335_MAGE_FINGERS_OF_FROST_TAG, + WOW335_MAGE_FOCUS_MAGIC_TAG, + WOW335_MAGE_HOT_STREAK_TAG, + WOW335_MAGE_IMPROVED_SCORCH_TAG, +} from "./wow335MageTalentRuntime"; + +function runtimeAbilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function unlockTalentAbility(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + const nodeId = abilityTalentNodeId(ability!); + expect(nodeId, name).toBeTruthy(); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } })); + return ability!.id; +} + +describe("WoW 3.3.5 Mage runtime mappings", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["mage"]); + useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-mage-runtime-tests", 1, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + })); + useCombatStore.getState().registerMob("cold-snap-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("cold-snap-target", [4, 0, 0]); + useCombatStore.getState().selectMob("cold-snap-target"); + }); + + it("resets Mage Frost spell cooldowns and leaves Fire and Arcane cooldowns intact", () => { + const coldSnap = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Cold Snap"); + expect(coldSnap?.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Frost Nova" }), + expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Frost Ward" }), + expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Fire Ward" }), + expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Ice Block" }), + expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Deep Freeze" }), + ])); + + const cast = useCombatStore.getState().castAbility; + for (const [name, now] of [ + ["Frost Ward", 1_000], + ["Fire Blast", 3_000], + ["Arcane Power", 5_000], + ["Frost Nova", 7_000], + ] as const) { + if (name === "Arcane Power") unlockTalentAbility(name); + const result = cast(runtimeAbilityId(name), undefined, now); + expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true); + } + + expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Frost Ward")]).toBe(31_000); + expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Fire Ward")]).toBe(31_000); + expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Frost Nova")]).toBe(32_000); + + const coldSnapResult = cast(unlockTalentAbility("Cold Snap"), undefined, 9_000); + expect(coldSnapResult.ok, JSON.stringify(coldSnapResult)).toBe(true); + + const cooldowns = useCombatStore.getState().cooldowns; + expect(cooldowns[runtimeAbilityId("Frost Ward")]).toBe(0); + expect(cooldowns[runtimeAbilityId("Fire Ward")]).toBe(0); + expect(cooldowns[runtimeAbilityId("Frost Nova")]).toBe(0); + expect(cooldowns[runtimeAbilityId("Ice Block")]).toBe(0); + expect(cooldowns[runtimeAbilityId("Deep Freeze")]).toBe(0); + expect(cooldowns[runtimeAbilityId("Fire Blast")]).toBe(11_000); + expect(cooldowns[runtimeAbilityId("Arcane Power")]).toBe(125_000); + expect(cooldowns[runtimeAbilityId("Cold Snap")]).toBe(489_000); + }); + + it("applies Arcane Power's source 20% magic damage and mana cost modifiers", () => { + const arcanePower = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Arcane Power"); + const modeledAura = arcanePower?.effects.filter((effect) => effect.kind === "apply-aura" + && effect.aura.id.endsWith(":arcane-power")); + expect(modeledAura).toHaveLength(1); + expect(modeledAura?.[0]).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + aura: { + durationMs: 15_000, + modifiers: [ + { kind: "damage", direction: "dealt", value: 0.2 }, + { kind: "resource", resource: "mana", property: "cost", value: 0.2 }, + ], + }, + }); + + const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!; + const baseManaCost = resolvedAbilityCosts( + fireBlast, + useCombatStore.getState().talentModifiers, + useCombatStore.getState(), + )[0]!.amount; + const arcanePowerId = unlockTalentAbility("Arcane Power"); + const result = useCombatStore.getState().castAbility(arcanePowerId, undefined, 1_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + + const state = useCombatStore.getState(); + const aura = state.auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID + && candidate.definition.id.endsWith(":arcane-power")); + expect(aura).toBeDefined(); + for (const school of ["fire", "frost", "arcane"] as const) { + expect(resolveDamageValue(100, [aura!], { direction: "dealt", school, attackKind: "spell" })).toBe(120); + } + const buffedManaCost = resolvedAbilityCosts(fireBlast, state.talentModifiers, state)[0]!.amount; + expect(buffedManaCost).toBe(Math.round(baseManaCost * 1.2)); + }); + + it("applies Focus Magic to an ally and grants the Mage 3% spell crit for 10 seconds when the ally spell-crits", () => { + const focusMagic = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Focus Magic"); + const targetBuff = focusMagic?.effects.find((effect) => effect.kind === "apply-aura"); + expect(targetBuff).toMatchObject({ + kind: "apply-aura", + aura: { + modifiers: [expect.objectContaining({ + kind: "stat", + stat: "spell-critical-strike", + operation: "flat", + value: 3, + })], + }, + }); + + const ally = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(ally.id, [2, 0, 0]); + const focusMagicId = unlockTalentAbility("Focus Magic"); + const focusMagicCast = useCombatStore.getState().castAbility(focusMagicId, { + targetActorId: ally.id, + now: 1_000, + }); + expect(focusMagicCast, JSON.stringify(focusMagicCast)).toMatchObject({ ok: true }); + const allyAura = useCombatStore.getState().auras.find((aura) => aura.targetId === ally.id + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "spell-critical-strike")); + expect(allyAura, JSON.stringify(useCombatStore.getState().auras.map((aura) => ({ + targetId: aura.targetId, + name: aura.definition.name, + modifiers: aura.definition.modifiers, + procs: aura.definition.procs, + })))).toBeDefined(); + expect(allyAura?.definition.procs ?? []).toContainEqual(expect.objectContaining({ + triggers: "crit", + attackKinds: ["spell"], + action: { kind: "custom", id: "wow335-mage-focus-magic-proc" }, + })); + + const fireballId = runtimeAbilityId("Fireball"); + const allyCritical = useCombatStore.getState().damageMob("cold-snap-target", 100, 2_000, { + actorId: ally.id, + role: ally.role, + abilityId: fireballId, + threatScale: 1, + }, "fire", { + attackKind: "spell", + seed: "mage-focus-magic-ally-crit", + abilityId: fireballId, + criticalChanceBonusPercent: 1_000, + rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false }, + }); + expect(allyCritical.combatResult?.critical).toBe(true); + + const procAura = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_FOCUS_MAGIC_TAG)); + expect(procAura?.expiresAt).toBe(12_000); + expect(resolveStatValue(0, [procAura!], "spell-critical-strike")).toBe(3); + }); + + it("ticks rank-five Ignite for the DBC 40% burn total on two fixed Fire damage ticks", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-34": 5 }, + })); + const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!; + const critical = useCombatStore.getState().damageMob("cold-snap-target", 100, 1_000, undefined, "fire", { + attackKind: "spell", + seed: "mage-ignite-rank-five-crit", + abilityId: fireball.id, + criticalChanceBonusPercent: 1_000, + rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false }, + }); + expect(critical.combatResult?.critical).toBe(true); + + // Spell effect ranks 11119 through 12848 encode an 8%-per-rank burn. The + // generated talent tooltip's 30%/healing-reduction wording conflicts with + // those rows; no healing modifier is inferred from the spell data. + const igniteTicks = useCombatStore.getState().effects.filter((effect) => ( + effect.tag === "wow335-mage-ignite" && effect.targetId === "cold-snap-target" + )); + expect(igniteTicks).toHaveLength(2); + expect(igniteTicks.map((effect) => effect.nextTickAt)).toEqual([3_000, 5_000]); + expect(igniteTicks.reduce((sum, effect) => sum + (effect.fixedAmount ?? 0), 0)) + .toBe(Math.round(critical.amount * 0.4)); + + useCombatStore.getState().tick(0.1, 3_000); + expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-mage-ignite")) + .toHaveLength(1); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === fireball.id && event.occurredAt === 3_000 + ))).toBe(true); + useCombatStore.getState().tick(0.1, 5_000); + expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-mage-ignite")) + .toHaveLength(0); + }); + + it("maps Presence of Mind to a non-expiring next cast-time Mage spell aura", () => { + const presenceOfMind = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Presence of Mind"); + expect(presenceOfMind?.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ + durationMs: null, + utilityTags: ["wow335-next-mage-instant"], + }), + }), + ])); + }); + + it("keeps Presence of Mind through instant and channel casts, then makes the next eligible Mage cast instant", () => { + const presenceOfMindId = unlockTalentAbility("Presence of Mind"); + expect(useCombatStore.getState().castAbility(presenceOfMindId, undefined, 1_000)).toMatchObject({ ok: true }); + + const hasPresenceOfMind = () => useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-next-mage-instant") + )); + expect(hasPresenceOfMind()).toBe(true); + + expect(useCombatStore.getState().castAbility(runtimeAbilityId("Fire Blast"), undefined, 2_000)) + .toMatchObject({ ok: true }); + expect(hasPresenceOfMind()).toBe(true); + + const missiles = runtimeAbilityId("Arcane Missiles"); + expect(useCombatStore.getState().castAbility(missiles, undefined, 4_000)).toMatchObject({ ok: true }); + const channel = useCombatStore.getState().activeCast; + expect(channel?.mode).toBe("channel"); + expect(hasPresenceOfMind()).toBe(true); + useCombatStore.getState().tick(0.1, channel!.completesAt); + expect(hasPresenceOfMind()).toBe(true); + + const fireball = useCombatStore.getState().castAbility(runtimeAbilityId("Fireball"), undefined, 10_000); + expect(fireball.ok, JSON.stringify(fireball)).toBe(true); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(hasPresenceOfMind()).toBe(false); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === runtimeAbilityId("Fireball") + ))).toBe(true); + }); + + it("uses Arcane Blast child 36032 stacking values and consumes its aura after another Arcane damage cast", () => { + const arcaneBlastDefinition = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Arcane Blast"); + const arcaneBlastAura = arcaneBlastDefinition?.effects.find((effect) => ( + effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("wow335-arcane-blast-stack") + )); + expect(arcaneBlastAura).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + aura: { + disposition: "buff", + durationMs: 10_000, + maxStacks: 3, + stackBehavior: "add", + modifiers: [ + { kind: "damage", direction: "dealt", school: "arcane", operation: "percent", value: 0.15, perStack: true }, + { kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 2, perStack: true }, + ], + }, + }); + + useCombatStore.setState((state) => ({ + resource: 100_000, + maxResource: 100_000, + resourcePools: { ...state.resourcePools, mana: 100_000 }, + maxResourcePools: { ...state.maxResourcePools, mana: 100_000 }, + })); + const arcaneBlastId = runtimeAbilityId("Arcane Blast"); + const arcaneBlast = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneBlastId)!; + const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!; + const baseArcaneBlastCost = resolvedAbilityCosts(arcaneBlast, [], useCombatStore.getState())[0]!.amount; + const baseFireballCost = resolvedAbilityCosts(fireball, [], useCombatStore.getState())[0]!.amount; + + const castArcaneBlastAndFinish = (startedAt: number) => { + const result = useCombatStore.getState().castAbility(arcaneBlastId, undefined, startedAt); + expect(result.ok, JSON.stringify(result)).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + expect(activeCast?.abilityId).toBe(arcaneBlastId); + useCombatStore.getState().tick(0.1, activeCast!.completesAt); + }; + for (const startedAt of [1_000, 4_000, 7_000, 10_000]) castArcaneBlastAndFinish(startedAt); + + const currentAura = () => useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-arcane-blast-stack") + )); + expect(currentAura()?.stacks).toBe(3); + expect(resolveDamageValue(100, [currentAura()!], { + direction: "dealt", school: "arcane", attackKind: "spell", + })).toBe(145); + expect(resolveDamageValue(100, [currentAura()!], { + direction: "dealt", school: "fire", attackKind: "spell", + })).toBe(100); + expect(resolvedAbilityCosts(arcaneBlast, [], useCombatStore.getState())[0]!.amount) + .toBe(Math.round(baseArcaneBlastCost * 7)); + expect(resolvedAbilityCosts(fireball, [], useCombatStore.getState())[0]!.amount).toBe(baseFireballCost); + + const fireballResult = useCombatStore.getState().castAbility(fireball.id, undefined, 13_000); + expect(fireballResult.ok, JSON.stringify(fireballResult)).toBe(true); + const fireballCast = useCombatStore.getState().activeCast; + expect(fireballCast?.abilityId).toBe(fireball.id); + useCombatStore.getState().tick(0.1, fireballCast!.completesAt); + expect(currentAura()?.stacks).toBe(3); + + const arcaneBarrageId = unlockTalentAbility("Arcane Barrage"); + const barrageResult = useCombatStore.getState().castAbility(arcaneBarrageId, undefined, 17_000); + expect(barrageResult.ok, JSON.stringify(barrageResult)).toBe(true); + expect(currentAura()).toBeUndefined(); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === arcaneBarrageId + ))).toBe(true); + }); + + it("applies Improved Scorch's rank-three 30-second crit-vulnerability debuff", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-25": 3 }, + })); + const scorch = useCombatStore.getState().abilities.find((ability) => ability.name === "Scorch")!; + useCombatStore.getState().damageMob("cold-snap-target", 100, 5_000, undefined, "fire", { + attackKind: "spell", + seed: "mage-scorch-vulnerability", + abilityId: scorch.id, + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }); + + const vulnerability = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === "cold-snap-target" + && aura.definition.utilityTags?.includes(WOW335_MAGE_IMPROVED_SCORCH_TAG) + )); + expect(vulnerability?.expiresAt).toBe(35_000); + }); + + it("grants two 15-second Fingers of Frost charges and consumes one per successful Mage spell", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1853": 2 }, + })); + const deepFreezeId = unlockTalentAbility("Deep Freeze"); + expect(useCombatStore.getState().castAbility(deepFreezeId, undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + + const frostbolt = useCombatStore.getState().abilities.find((ability) => ability.name === "Frostbolt")!; + let procAt = 0; + for (let index = 0; index < 100; index += 1) { + procAt = 2_000 + index; + useCombatStore.getState().damageMob("cold-snap-target", 100, procAt, undefined, "frost", { + attackKind: "spell", + seed: `mage-fingers-of-frost-${index}`, + abilityId: frostbolt.id, + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }); + if (useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG) + ))) break; + } + const fingers = () => useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG) + )); + expect(fingers()?.stacks).toBe(2); + expect(fingers()?.expiresAt).toBe(procAt + 15_000); + + const fireballId = runtimeAbilityId("Fireball"); + expect(useCombatStore.getState().castAbility(fireballId, undefined, procAt + 1_000)) + .toMatchObject({ ok: true }); + const fireballCast = useCombatStore.getState().activeCast; + expect(fireballCast).not.toBeNull(); + useCombatStore.getState().tick(0.1, fireballCast!.completesAt); + expect(fingers()?.stacks).toBe(1); + + expect(useCombatStore.getState().castAbility(deepFreezeId, undefined, fireballCast!.completesAt + 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "stun")) + .toBe(true); + expect(fingers()).toBeUndefined(); + }); + + it("procs Hot Streak from two consecutive eligible fire spell crits and empowers one Pyroblast", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1850": 3 }, + })); + const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!; + const pyroblastId = unlockTalentAbility("Pyroblast"); + const pyroblast = useCombatStore.getState().abilities.find((ability) => ability.id === pyroblastId)!; + const rankedPyroblast = abilityAtLevel(pyroblast, useCombatStore.getState().level, 1); + const baseCost = resolvedAbilityCosts(rankedPyroblast, [], useCombatStore.getState())[0]!.amount; + const forceCrit = (now: number) => useCombatStore.getState().damageMob( + "cold-snap-target", + 100, + now, + { + actorId: PLAYER_AURA_ENTITY_ID, + role: usePartyStore.getState().playerRole, + abilityId: fireball.id, + threatScale: 1, + }, + "fire", + { + attackKind: "spell", + seed: `mage-hot-streak-${now}`, + abilityId: fireball.id, + criticalChanceBonusPercent: 1_000, + rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false }, + }, + ); + expect(forceCrit(1_000).combatResult?.critical).toBe(true); + expect(forceCrit(2_000).combatResult?.critical).toBe(true); + + const stateWithHotStreak = useCombatStore.getState(); + const hotStreak = stateWithHotStreak.auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG) + )); + expect(hotStreak?.expiresAt).toBe(12_000); + expect(resolvedAbilityCosts(rankedPyroblast, stateWithHotStreak.talentModifiers, stateWithHotStreak)[0]!.amount) + .toBe(Math.round(baseCost * 0.5)); + + const manaBefore = stateWithHotStreak.resourcePools.mana!; + const result = stateWithHotStreak.castAbility(pyroblast.id, undefined, 3_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(manaBefore - useCombatStore.getState().resourcePools.mana!).toBe(Math.round(baseCost * 0.5)); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG) + ))).toBe(false); + }); + + it("lets Mage Frostbite freeze a target after landed Frost spell damage at its rank chance", () => { + const frostbolt = useCombatStore.getState().abilities.find((ability) => ability.name === "Frostbolt")!; + const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!; + const applySpellDamage = (abilityId: string, school: "fire" | "frost", now: number) => ( + useCombatStore.getState().damageMob( + "cold-snap-target", + 100, + now, + undefined, + school, + { attackKind: "spell", seed: `mage-frostbite-${abilityId}-${now}`, abilityId, periodic: false }, + ) + ); + + applySpellDamage(frostbolt.id, "frost", 1_000); + expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze")) + .toBe(false); + + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-38": 3 } })); + for (let index = 0; index < 20; index += 1) { + applySpellDamage(fireball.id, "fire", 2_000 + index); + } + expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze")) + .toBe(false); + + let procAt = 0; + for (let index = 0; index < 200; index += 1) { + procAt = 3_000 + index; + applySpellDamage(frostbolt.id, "frost", procAt); + if (useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze")) break; + } + const freeze = useCombatStore.getState().mobs["cold-snap-target"]?.statuses.find((status) => ( + status.kind === "freeze" && status.sourceAbilityId === frostbolt.id + )); + expect(freeze?.endsAt).toBe(procAt + 4_000); + }); +}); diff --git a/src/game/wow335MageTalentRuntime.ts b/src/game/wow335MageTalentRuntime.ts new file mode 100644 index 00000000..a44ab855 --- /dev/null +++ b/src/game/wow335MageTalentRuntime.ts @@ -0,0 +1,65 @@ +import type { AuraDefinition } from "./combatAuras"; + +export const WOW335_MAGE_HOT_STREAK_TAG = "wow335-mage-hot-streak"; +export const WOW335_MAGE_FINGERS_OF_FROST_TAG = "wow335-mage-fingers-of-frost"; +export const WOW335_MAGE_IMPROVED_SCORCH_TAG = "wow335-mage-improved-scorch"; +export const WOW335_MAGE_IMPACT_TAG = "wow335-mage-impact-next-fire-blast"; +export const WOW335_MAGE_FOCUS_MAGIC_TAG = "wow335-mage-focus-magic-proc"; + +export const WOW335_MAGE_HOT_STREAK_AURA: AuraDefinition = { + id: "wow335:mage:hot-streak", + name: "Hot Streak", + description: "Your next Pyroblast within 10 seconds is instant, deals 40% more damage, and costs 50% less mana.", + disposition: "buff", + durationMs: 10_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [WOW335_MAGE_HOT_STREAK_TAG], +}; + +export const WOW335_MAGE_FINGERS_OF_FROST_AURA: AuraDefinition = { + id: "wow335:mage:fingers-of-frost", + name: "Fingers of Frost", + description: "Your next two Mage spells treat their target as Frozen for 15 seconds.", + disposition: "buff", + durationMs: 15_000, + maxStacks: 2, + initialStacks: 2, + stackBehavior: "replace", + utilityTags: [WOW335_MAGE_FINGERS_OF_FROST_TAG], +}; + +export const WOW335_MAGE_IMPROVED_SCORCH_AURA: AuraDefinition = { + id: "wow335:mage:improved-scorch", + name: "Improved Scorch", + description: "Increases spell critical strike chance against this target by 5% for 30 seconds.", + disposition: "debuff", + durationMs: 30_000, + maxStacks: 1, + stackBehavior: "refresh", + uniqueBySource: false, + utilityTags: [WOW335_MAGE_IMPROVED_SCORCH_TAG], +}; + +export const WOW335_MAGE_IMPACT_AURA: AuraDefinition = { + id: "wow335:mage:impact", + name: "Impact", + description: "Your next Fire Blast stuns its target for 3 seconds.", + disposition: "buff", + durationMs: 10_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [WOW335_MAGE_IMPACT_TAG], +}; + +export const WOW335_MAGE_FOCUS_MAGIC_PROC_AURA: AuraDefinition = { + id: "wow335:mage:focus-magic-proc", + name: "Focus Magic", + description: "Your spell critical strike chance is increased by 3% for 10 seconds.", + disposition: "buff", + durationMs: 10_000, + maxStacks: 1, + stackBehavior: "refresh", + modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 3 }], + utilityTags: [WOW335_MAGE_FOCUS_MAGIC_TAG], +}; diff --git a/src/game/wow335PaladinRuntime.test.ts b/src/game/wow335PaladinRuntime.test.ts new file mode 100644 index 00000000..d246995b --- /dev/null +++ b/src/game/wow335PaladinRuntime.test.ts @@ -0,0 +1,552 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, updatePartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { applyAura } from "./combatAuras"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +function abilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function unlockTalentAbility(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + const nodeId = abilityTalentNodeId(ability!); + expect(nodeId, name).toBeTruthy(); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } })); + return ability!.id; +} + +function setTalentRank(name: string, rank: number): void { + const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name); + expect(talent, name).toBeDefined(); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [talent!.id]: rank }, + })); +} + +describe("WoW 3.3.5 Paladin source-backed runtime mechanics", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["paladin"]); + useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-paladin-runtime-tests", 80, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + })); + }); + + it("blocks silence with Aura Mastery for its sourced 10-second window, then allows silence after expiry", () => { + const mobId = "paladin-aura-mastery-silence"; + useCombatStore.getState().registerMob(mobId, { + name: "Silence attacker", + maxHealth: 10_000, + attacks: [{ + id: "silence", + name: "Silence", + delivery: "area", + target: "primary", + school: "shadow", + animation: "cast", + castTimeMs: 0, + range: 30, + cooldownMs: 10_000, + initialCooldownMs: [0, 0], + repeatCooldownMs: [10_000, 10_000], + damageMultiplier: 0, + effect: { kind: "control", mechanic: "silence", durationMs: 3_000 }, + }], + }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().selectMob(mobId); + useCombatStore.getState().damageMob(mobId, 1, 1_000); + + const activate = useCombatStore.getState().castAbility(unlockTalentAbility("Aura Mastery"), undefined, 1_000); + expect(activate).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.controlImmunities?.includes("silence") + && aura.expiresAt === 11_000 + ))).toBe(true); + const partyMember = usePartyStore.getState().members[0]!; + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === partyMember.id + && aura.definition.controlImmunities?.includes("silence") + && aura.expiresAt === 11_000 + ))).toBe(true); + + useCombatStore.getState().advanceMobCombat(1_450); + expect(useCombatStore.getState().controlMechanic).toBeNull(); + expect(useCombatStore.getState().schoolLockedUntil).toEqual({}); + + useCombatStore.getState().tick(0.1, 11_001); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.controlImmunities?.includes("silence") + ))).toBe(false); + useCombatStore.getState().advanceMobCombat(11_450); + expect(useCombatStore.getState().controlMechanic).toBe("silence"); + expect(useCombatStore.getState().controlledUntil).toBe(14_450); + }); + + it("lets Redemption target and revive a defeated party member at the rank's source health", () => { + const member = usePartyStore.getState().members[0]!; + usePartyStore.setState((party) => ({ + members: party.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: 0, status: "down" as const } + : candidate), + })); + updatePartyRuntimePosition(member.id, 4, 0, 0); + usePartyStore.getState().selectMember(member.id); + + const cast = useCombatStore.getState().castAbility(abilityId("Redemption"), undefined, 1_000); + expect(cast).toMatchObject({ ok: true }); + const activeCast = useCombatStore.getState().activeCast; + expect(activeCast?.targetId).toBe(member.id); + useCombatStore.getState().tick(0.1, activeCast!.completesAt); + + expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(1_760); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "resurrection" && event.targetActorId === member.id + ))).toBe(true); + }); + + it("blocks Redemption while any living enemy is engaged", () => { + const member = usePartyStore.getState().members[0]!; + usePartyStore.setState((party) => ({ + members: party.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: 0, status: "down" as const } + : candidate), + })); + updatePartyRuntimePosition(member.id, 4, 0, 0); + usePartyStore.getState().selectMember(member.id); + + const targetId = "paladin-redemption-combat-gate"; + useCombatStore.getState().registerMob(targetId, { name: "Engaged enemy", maxHealth: 10_000 }); + setMobPosition(targetId, [4, 0, 0]); + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, engaged: true } }, + })); + + const result = useCombatStore.getState().castAbility(abilityId("Redemption"), undefined, 1_000); + expect(result).toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(0); + }); + + it("enforces Hammer of Wrath's 20% execute gate", () => { + const targetId = "paladin-hammer-gate-target"; + useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 10_000 }); + setMobPosition(targetId, [4, 0, 0]); + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, health: 2_001 } }, + selectedTargetId: targetId, + })); + + const result = useCombatStore.getState().castAbility(abilityId("Hammer of Wrath"), undefined, 1_000); + expect(result).toMatchObject({ ok: false, reason: "condition-not-met" }); + }); + + it("requires a shield before Holy Shield can be activated", () => { + useCombatStore.setState({ equipment: {}, inventory: [] }); + expect(useCombatStore.getState().abilities.find((candidate) => candidate.name === "Holy Shield")?.target).toBe("self"); + const result = useCombatStore.getState().castAbility(unlockTalentAbility("Holy Shield"), undefined, 1_000); + expect(result).toMatchObject({ ok: false, reason: "condition-not-met" }); + }); + + it("procs three 8-second Reckoning charges from melee hits and adds one landed autoattack per charge", () => { + setTalentRank("Reckoning", 5); + const mobId = "paladin-reckoning-trigger"; + useCombatStore.getState().registerMob(mobId, { + name: "Reckoning attacker", + level: 80, + maxHealth: 100_000, + attacks: [{ + id: "reckoning-melee", + name: "Melee attack", + delivery: "melee", + target: "primary", + school: "physical", + animation: "attack", + castTimeMs: 0, + range: 8, + cooldownMs: 500, + initialCooldownMs: [0, 0], + repeatCooldownMs: [500, 500], + damageMultiplier: 0.1, + }], + }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().damageMob(mobId, 1, 1_000); + const ensureMeleeHit = applyAura(useCombatStore.getState().auras, { + definition: { + id: "test:ensure-reckoning-melee-hit", + name: "Ensure incoming melee hit", + disposition: "buff", + durationMs: null, + modifiers: [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 100 }], + }, + sourceId: mobId, + targetId: PLAYER_AURA_ENTITY_ID, + now: 1_000, + }).auras; + useCombatStore.setState((state) => ({ auras: [...ensureMeleeHit] })); + + let procAt: number | null = null; + for (let now = 1_450; now <= 30_000; now += 250) { + useCombatStore.getState().advanceMobCombat(now); + if (useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-paladin-reckoning") + ))) { + procAt = now; + break; + } + } + expect(procAt).not.toBeNull(); + const reckoning = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-paladin-reckoning") + )); + expect(reckoning).toMatchObject({ stacks: 3, expiresAt: procAt! + 8_000 }); + + useCombatStore.setState((state) => ({ + autoAttackTargetId: mobId, + nextAutoAttackAt: procAt! + 100, + gearStats: { ...state.gearStats, hitRating: 1_000_000, expertiseRating: 1_000_000 }, + })); + let autoAttackAt = procAt! + 100; + for (let charge = 2; charge >= 0; charge -= 1) { + useCombatStore.getState().tick(0.1, autoAttackAt); + const swingEvents = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" + && event.sourceActorId === PLAYER_AURA_ENTITY_ID + && event.targetActorId === mobId + && event.occurredAt === autoAttackAt + )); + expect(swingEvents).toHaveLength(2); + const remaining = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-paladin-reckoning") + )); + if (charge > 0) expect(remaining?.stacks).toBe(charge); + else expect(remaining).toBeUndefined(); + autoAttackAt = useCombatStore.getState().nextAutoAttackAt; + } + }); + + it("reflects the sourced rank percentage of incoming critical damage with Eye for an Eye", () => { + const mobId = "paladin-eye-for-an-eye-crit"; + useCombatStore.getState().registerMob(mobId, { + name: "Critical attacker", + level: 80, + maxHealth: 100_000, + damageMultiplier: 1, + attacks: [{ + id: "eye-crit-18", + name: "Critical attack", + delivery: "area", + target: "primary", + school: "fire", + animation: "cast", + castTimeMs: 0, + range: 30, + cooldownMs: 10_000, + initialCooldownMs: [0, 0], + repeatCooldownMs: [10_000, 10_000], + damageMultiplier: 1, + }], + }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().selectMob(mobId); + useCombatStore.getState().damageMob(mobId, 1, 1_000); + + const ensureSpellHit = applyAura([], { + definition: { + id: "test:ensure-incoming-spell-hit", + name: "Ensure incoming spell hit", + disposition: "buff", + durationMs: null, + modifiers: [{ kind: "stat", stat: "incoming-spell-hit", operation: "flat", value: 100 }], + }, + sourceId: mobId, + targetId: PLAYER_AURA_ENTITY_ID, + now: 1_000, + }).auras; + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1632": 3 }, + auras: [...state.auras, ...ensureSpellHit], + })); + + // This seeded instant spell attack rolls inside the crit slice of the + // equal-level attack table, so the test does not rely on chance. + useCombatStore.getState().advanceMobCombat(1_450); + useCombatStore.getState().advanceMobCombat(3_100); + const events = useCombatStore.getState().combatEvents; + const incoming = events.find((event) => event.kind === "damage" + && event.sourceActorId === mobId && event.targetActorId === PLAYER_AURA_ENTITY_ID); + expect(incoming?.critical, JSON.stringify(incoming)).toBe(true); + const reflected = events.find((event) => event.kind === "damage" + && event.targetActorId === mobId && event.abilityId === "talent-1632"); + expect(reflected?.rawAmount).toBe(Math.round((incoming?.effectiveAmount ?? 0) * 0.15)); + }); + + it("bounces Avenger's Shield to three total targets without damage falloff", () => { + const shieldId = unlockTalentAbility("Avenger's Shield"); + const targetIds = [ + "paladin-avenger-target", + "paladin-avenger-bounce-a", + "paladin-avenger-bounce-b", + "paladin-avenger-bounce-c", + "paladin-avenger-out-of-range", + ]; + for (const id of targetIds) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 }); + } + setMobPosition(targetIds[0]!, [4, 0, 0]); + setMobPosition(targetIds[1]!, [10, 0, 0]); + setMobPosition(targetIds[2]!, [16, 0, 0]); + setMobPosition(targetIds[3]!, [22, 0, 0]); + setMobPosition(targetIds[4]!, [10, 0, 12]); + useCombatStore.getState().selectMob(targetIds[0]!); + + const result = useCombatStore.getState().castAbility(shieldId, undefined, 1_000); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + expect(result.affectedMobIds).toEqual(targetIds.slice(0, 3)); + expect(result.affectedMobIds).not.toContain(targetIds[3]); + expect(result.affectedMobIds).not.toContain(targetIds[4]); + + const damageEvents = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.abilityId === shieldId + && result.affectedMobIds.includes(event.targetActorId) + )); + expect(damageEvents).toHaveLength(3); + const normalizedRawDamage = damageEvents.map((event) => Math.round(event.rawAmount / (event.critical ? 2 : 1))); + expect(new Set(normalizedRawDamage).size).toBe(1); + }); + + it("applies a Greater Blessing to the selected ally's class group", () => { + const members = usePartyStore.getState().members; + const target = members[0]!; + usePartyStore.setState((party) => ({ + members: party.members.map((candidate, index) => ({ + ...candidate, + classId: index < 2 ? "mage" : "priest", + })), + })); + for (const member of members) updatePartyRuntimePosition(member.id, 1, 0, 0); + usePartyStore.getState().selectMember(target.id); + + const result = useCombatStore.getState().castAbility(abilityId("Greater Blessing of Kings"), undefined, 1_000); + expect(result).toMatchObject({ ok: true }); + + const blessedIds = new Set(useCombatStore.getState().auras + .filter((aura) => aura.definition.name.includes("Greater Blessing of Kings")) + .map((aura) => aura.targetId)); + expect(blessedIds).toEqual(new Set([target.id, members[1]!.id])); + expect(blessedIds.has(PLAYER_AURA_ENTITY_ID)).toBe(false); + }); + + it("heals at most three party actors from Divine Storm damage", () => { + const members = usePartyStore.getState().members; + usePartyStore.setState((party) => ({ + members: party.members.map((member) => ({ ...member, health: 1 })), + })); + members.forEach((member, index) => updatePartyRuntimePosition(member.id, index + 1, 0, 0)); + useCombatStore.setState((state) => ({ health: Math.max(1, Math.round(state.maxHealth * 0.1)) })); + + const targetId = "paladin-divine-storm-target"; + const nearbyTargetId = "paladin-divine-storm-nearby-target"; + const distantTargetId = "paladin-divine-storm-distant-target"; + useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 100_000 }); + useCombatStore.getState().registerMob(nearbyTargetId, { name: "Nearby Target", maxHealth: 100_000 }); + useCombatStore.getState().registerMob(distantTargetId, { name: "Distant Target", maxHealth: 100_000 }); + setMobPosition(targetId, [4, 0, 0]); + setMobPosition(nearbyTargetId, [7, 0, 0]); + setMobPosition(distantTargetId, [13, 0, 0]); + useCombatStore.setState((state) => ({ selectedTargetId: targetId })); + const divineStorm = unlockTalentAbility("Divine Storm"); + const result = useCombatStore.getState().castAbility(divineStorm, { targetActorId: targetId, now: 1_000 }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + expect(result.affectedMobIds).toEqual(expect.arrayContaining([targetId, nearbyTargetId])); + expect(result.affectedMobIds).not.toContain(distantTargetId); + + const healEvents = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "healing" && event.abilityId === divineStorm && event.effectiveAmount > 0 + )); + expect(healEvents.length).toBeGreaterThan(0); + expect(healEvents.length).toBeLessThanOrEqual(3); + expect(healEvents.every((event) => event.rawAmount > 0)).toBe(true); + }); + + it("transfers direct healing to the active Beacon of Light target", () => { + const member = usePartyStore.getState().members[0]!; + updatePartyRuntimePosition(member.id, 4, 0, 0); + usePartyStore.setState((party) => ({ + members: party.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: 100 } + : candidate), + })); + usePartyStore.getState().selectMember(member.id); + + const beaconResult = useCombatStore.getState().castAbility(unlockTalentAbility("Beacon of Light"), undefined, 1_000); + expect(beaconResult).toMatchObject({ ok: true }); + const beaconCast = useCombatStore.getState().activeCast; + if (beaconCast) useCombatStore.getState().tick(0.1, beaconCast.completesAt); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === member.id && aura.definition.utilityTags?.includes("beacon-of-light") + ))).toBe(true); + + useCombatStore.getState().damagePlayer(5_000); + const beforeTransfer = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health; + const heal = useCombatStore.getState().castAbility(abilityId("Flash of Light"), undefined, 3_000); + expect(heal).toMatchObject({ ok: true }); + const healCast = useCombatStore.getState().activeCast; + if (healCast) useCombatStore.getState().tick(0.1, healCast.completesAt); + + const afterTransfer = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health; + expect(afterTransfer).toBeGreaterThan(beforeTransfer); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id + && aura.definition.utilityTags?.includes("beacon-of-light"))).toBe(true); + }); + + it("limits Divine Sacrifice to the remaining portion of its shared damage cap", () => { + const member = usePartyStore.getState().members[0]!; + updatePartyRuntimePosition(member.id, 0, 0, 0); + usePartyStore.setState((party) => ({ + members: party.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: candidate.maxHealth } + : candidate), + })); + usePartyStore.getState().selectMember(member.id); + const sacrifice = unlockTalentAbility("Divine Sacrifice"); + expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true); + + const cap = Math.round(useCombatStore.getState().maxHealth * 1.5); + const anchor = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice") + )); + expect(anchor?.definition.redirectDamageCapPercent).toBe(1.5); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === member.id && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice") + ))).toBe(true); + useCombatStore.setState((state) => ({ + auras: state.auras.map((aura) => aura.instanceId === anchor!.instanceId + ? { ...aura, redirectedDamage: cap - 20 } + : aura), + })); + + const mobId = "paladin-divine-sacrifice-partial"; + useCombatStore.getState().registerMob(mobId, { name: "Cap test attacker", maxHealth: 100_000 }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().damageMob(mobId, 1, 1_000, { actorId: member.id, role: "tank" }); + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } }, + })); + const playerBefore = useCombatStore.getState().health; + const memberBefore = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health; + useCombatStore.getState().advanceMobCombat(1_450); + useCombatStore.getState().advanceMobCombat(3_350); + + expect(playerBefore - useCombatStore.getState().health).toBe(20); + expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health).toBeLessThan(memberBefore); + expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(cap); + }); + + it("stops Divine Sacrifice redirects after the shared damage cap is exhausted", () => { + const member = usePartyStore.getState().members[0]!; + updatePartyRuntimePosition(member.id, 0, 0, 0); + usePartyStore.setState((party) => ({ + members: party.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: candidate.maxHealth } + : candidate), + })); + usePartyStore.getState().selectMember(member.id); + const sacrifice = unlockTalentAbility("Divine Sacrifice"); + expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true); + const anchor = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice") + )); + expect(anchor).toBeDefined(); + const cap = Math.round(useCombatStore.getState().maxHealth * 1.5); + useCombatStore.setState((state) => ({ + auras: state.auras.map((aura) => aura.instanceId === anchor!.instanceId + ? { ...aura, redirectedDamage: cap } + : aura), + })); + + const mobId = "paladin-divine-sacrifice-exhausted"; + useCombatStore.getState().registerMob(mobId, { name: "Cap test attacker", maxHealth: 100_000 }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().damageMob(mobId, 1, 1_000, { actorId: member.id, role: "tank" }); + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } }, + })); + const playerBefore = useCombatStore.getState().health; + const memberBefore = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health; + useCombatStore.getState().advanceMobCombat(1_450); + const resolvedDamage = useCombatStore.getState().advanceMobCombat(3_350); + expect(resolvedDamage, JSON.stringify(useCombatStore.getState().mobs[mobId])).toBeGreaterThan(0); + + expect(useCombatStore.getState().health).toBe(playerBefore); + expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health).toBeLessThan(memberBefore); + expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(cap); + }); + + it("does not redirect damage from an attack that targets the Paladin", () => { + const sacrifice = unlockTalentAbility("Divine Sacrifice"); + expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true); + const anchor = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice") + )); + expect(anchor).toBeDefined(); + + const mobId = "paladin-divine-sacrifice-self-hit"; + useCombatStore.getState().registerMob(mobId, { name: "Self hit attacker", maxHealth: 100_000 }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().damageMob(mobId, 1, 1_000); + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } }, + })); + const playerBefore = useCombatStore.getState().health; + expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0); + + expect(useCombatStore.getState().health).toBeLessThan(playerBefore); + expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(0); + }); + + it("makes the next eligible Divine Favor heal critical and consumes its aura", () => { + const maxHealth = useCombatStore.getState().maxHealth; + useCombatStore.getState().damagePlayer(Math.round(maxHealth * 0.4)); + + const activate = useCombatStore.getState().castAbility(unlockTalentAbility("Divine Favor"), undefined, 1_000); + expect(activate).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal") + ))).toBe(true); + + const flashOfLight = abilityId("Flash of Light"); + const heal = useCombatStore.getState().castAbility(flashOfLight, undefined, 3_000); + expect(heal).toMatchObject({ ok: true }); + const activeCast = useCombatStore.getState().activeCast; + if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt); + + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "healing" && event.abilityId === flashOfLight && event.critical + ))).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal") + ))).toBe(false); + }); +}); diff --git a/src/game/wow335PriestMechanics.test.ts b/src/game/wow335PriestMechanics.test.ts new file mode 100644 index 00000000..bc21fe88 --- /dev/null +++ b/src/game/wow335PriestMechanics.test.ts @@ -0,0 +1,672 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityAtLevel, abilitiesForClass, abilityTalentNodeId } from "./abilityCatalog"; +import { resolvedAbilityCosts, useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { TALENT_NODES } from "./talentCatalog"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; + +function priestAbility(name: string) { + const ability = abilitiesForClass("priest").find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!; +} + +function unlock(name: string): void { + const nodeId = abilityTalentNodeId(priestAbility(name)); + expect(nodeId, `${name} talent node`).toBeTruthy(); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } })); +} + +function setPriestTalentRanks(ranks: Record, criticalChance?: number): void { + const modifiers = talentModifiersForRanks(ranks, "priest"); + useCombatStore.setState({ + talentRanks: ranks, + talentModifiers: criticalChance === undefined ? modifiers : [...modifiers, { + kind: "critical-chance-percent", + amount: criticalChance, + unit: "percent", + scope: "your spells", + sourceTalentId: "test:priest-critical-chance", + sourceTalentName: "Test critical chance", + rank: 1, + description: "Test-only critical chance modifier.", + }], + }); +} + +function setMemberHealth(memberId: string, health: number, maxHealth: number): void { + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === memberId + ? { ...member, health, maxHealth } + : member), + })); + useCombatStore.setState((state) => ({ + actors: { + ...state.actors, + [memberId]: { ...state.actors[memberId]!, health, maxHealth, alive: health > 0 }, + }, + })); +} + +function castFriendly(name: string, targetId: string, now: number) { + useCombatStore.getState().selectFriendlyActor(targetId); + const result = useCombatStore.getState().castAbility(priestAbility(name).id, { targetActorId: targetId, now }); + const activeCast = useCombatStore.getState().activeCast; + if (result.ok && activeCast?.abilityId === priestAbility(name).id) { + useCombatStore.getState().tick((activeCast.completesAt - now) / 1_000, activeCast.completesAt); + } + return result; +} + +function castHostile(name: string, targetId: string, now: number) { + useCombatStore.getState().selectMob(targetId); + const result = useCombatStore.getState().castAbility(priestAbility(name).id, { targetActorId: targetId, now }); + const activeCast = useCombatStore.getState().activeCast; + if (result.ok && activeCast?.abilityId === priestAbility(name).id) { + useCombatStore.getState().tick((activeCast.completesAt - now) / 1_000, activeCast.completesAt); + } + return result; +} + +describe("WoW Priest mechanics", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-priest-mechanics", 80, useCombatStore.getState().health); + usePartyStore.getState().selectMember(null); + useCombatStore.getState().synchronizeActors(); + usePartyStore.getState().members.forEach((member, index) => { + registerPartyRuntimePosition(member.id, [index + 2, 0, 0]); + }); + }); + + it("uses self targeting for Desperate Prayer and dual relationship for Dispel Magic", () => { + const desperatePrayer = priestAbility("Desperate Prayer"); + const dispelMagic = priestAbility("Dispel Magic"); + const rank988 = abilityAtLevel(dispelMagic, 38); + expect(desperatePrayer.target).toBe("self"); + expect(dispelMagic.target).toBe("any"); + expect(rank988.dbcSpellId).toBe(988); + expect(rank988.effects).toContainEqual(expect.objectContaining({ + kind: "dispel", mode: "cleanse", relationship: "target", categories: ["magic"], maxCount: 2, + })); + }); + + it("lets Dispel Magic rank 988 cleanse a party member's magic debuff", () => { + const member = usePartyStore.getState().members[0]!; + const carrier = priestAbility("Prayer of Fortitude"); + expect(useCombatStore.getState().applyActorAura(PLAYER_AGGRO_ID, member.id, carrier, { + kind: "apply-aura", + aura: { + id: "test:priest-magic-debuff", name: "Test magic debuff", disposition: "debuff", + dispelCategory: "magic", durationMs: 30_000, maxStacks: 1, stackBehavior: "refresh", + }, + sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0, + }, 1_000)).toBe(true); + expect(useCombatStore.getState().selectFriendlyActor(member.id)).toBe(true); + const result = useCombatStore.getState().castAbility(priestAbility("Dispel Magic").id, { + targetActorId: member.id, now: 2_000, + }); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "test:priest-magic-debuff")).toBe(false); + }); + + it("casts Desperate Prayer on the priest and applies Pain Suppression's threat reduction to its ally", () => { + unlock("Desperate Prayer"); + useCombatStore.getState().damagePlayer(500); + const playerBefore = useCombatStore.getState().health; + const desperatePrayer = useCombatStore.getState().castAbility(priestAbility("Desperate Prayer").id, [0, 0, 0], 1_000); + expect(desperatePrayer.ok, JSON.stringify(desperatePrayer)).toBe(true); + expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore); + + const member = usePartyStore.getState().members[0]!; + unlock("Pain Suppression"); + useCombatStore.getState().registerMob("priest-threat-test", { name: "Threat test", maxHealth: 1_000, xpReward: 0 }); + setMobPosition("priest-threat-test", [4, 0, 0]); + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, "priest-threat-test": { + ...state.mobs["priest-threat-test"]!, engaged: true, + threatByActor: { [member.id]: 100 }, targetActorId: member.id, + } }, + })); + useCombatStore.getState().selectFriendlyActor(member.id); + const result = useCombatStore.getState().castAbility(priestAbility("Pain Suppression").id, { + targetActorId: member.id, now: 3_000, + }); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().mobs["priest-threat-test"]?.threatByActor[member.id]).toBe(60); + }); + + it("applies Prayer of Fortitude to the priest and all party members", () => { + const result = useCombatStore.getState().castAbility(priestAbility("Prayer of Fortitude").id, [0, 0, 0], 1_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + const recipients = useCombatStore.getState().auras + .filter((aura) => aura.definition.id.includes(":48162:")) + .map((aura) => aura.targetId); + expect(recipients).toContain(PLAYER_AURA_ENTITY_ID); + for (const member of usePartyStore.getState().members) expect(recipients).toContain(member.id); + }); + + it("reduces only magic damage while an eligible Test of Faith cast is active", () => { + const talent = TALENT_NODES.find((node) => node.name === "Test of Faith"); + expect(talent).toBeDefined(); + const ranks = { [talent!.id]: 1 }; + let castId = 0; + const damageTaken = (rank: number, school: "physical" | "shadow", abilityName: string, expired = false): number => { + const now = Date.now(); + const maxHealth = useCombatStore.getState().maxHealth; + const selectedRanks = rank > 0 ? { [talent!.id]: rank } : {}; + const completesAt = expired ? now - 1 : now + 2_000; + useCombatStore.setState({ + health: maxHealth, + shield: 0, + auras: [], + talentRanks: selectedRanks, + talentModifiers: talentModifiersForRanks(selectedRanks, "priest"), + activeCast: { + id: ++castId, + abilityId: priestAbility(abilityName).id, + targetId: null, + mode: "cast", + startedAt: now - 500, + completesAt, + originalDurationMs: 2_500, + pushbackHits: 0, + school: "holy", + canCastWhileMoving: false, + origin: [0, 0, 0], + groundPosition: null, + totalTicks: 1, + ticksCompleted: 0, + nextTickAt: completesAt, + }, + }); + useCombatStore.getState().damagePlayer(1_000, school); + return maxHealth - useCombatStore.getState().health; + }; + + const baselineMagic = damageTaken(0, "shadow", "Flash Heal"); + expect(damageTaken(1, "shadow", "Flash Heal")).toBe(Math.round(baselineMagic * 0.9)); + expect(damageTaken(1, "physical", "Flash Heal")).toBe(damageTaken(0, "physical", "Flash Heal")); + expect(damageTaken(1, "shadow", "Prayer of Fortitude")).toBe(baselineMagic); + expect(damageTaken(1, "shadow", "Flash Heal", true)).toBe(baselineMagic); + expect(ranks[talent!.id]).toBe(1); + }); + + it("applies Test of Faith to magic hits resolved through enemy combat", () => { + const talent = TALENT_NODES.find((node) => node.name === "Test of Faith"); + expect(talent).toBeDefined(); + const incomingHit = (rank: number): number => { + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + const selectedRanks = rank > 0 ? { [talent!.id]: rank } : {}; + useCombatStore.setState({ + talentRanks: selectedRanks, + talentModifiers: talentModifiersForRanks(selectedRanks, "priest"), + activeCast: { + id: 1, + abilityId: priestAbility("Flash Heal").id, + targetId: null, + mode: "cast", + startedAt: 1_000, + completesAt: 8_000, + originalDurationMs: 7_000, + pushbackHits: 0, + school: "holy", + canCastWhileMoving: false, + origin: [0, 0, 0], + groundPosition: null, + totalTicks: 1, + ticksCompleted: 0, + nextTickAt: 8_000, + }, + }); + const mobId = "priest-test-faith-magic-hit"; + useCombatStore.getState().registerMob(mobId, { + name: "Test of Faith magic hit", + maxHealth: 100_000, + xpReward: 0, + attacks: [{ + ...DEFAULT_ENEMY_ATTACK, + id: "priest-test-shadow-cast", + name: "Shadow cast", + delivery: "projectile", + animation: "cast", + school: "shadow", + range: 30, + castTimeMs: 0, + }], + }); + setMobPosition(mobId, [8, 0, 0]); + useCombatStore.getState().damageMob(mobId, 1, 1_000); + const before = useCombatStore.getState().health; + useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]); + return before - useCombatStore.getState().health; + }; + + const baseline = incomingHit(0); + expect(baseline).toBeGreaterThan(0); + expect(incomingHit(1)).toBe(Math.round(baseline * 0.9)); + }); + + it("applies Test of Faith only to spells whose target is at or below half health", () => { + const talent = TALENT_NODES.find((node) => node.name === "Test of Faith"); + expect(talent).toBeDefined(); + const ranks = { [talent!.id]: 3 }; + expect(talentModifiersForRanks(ranks, "priest")).not.toContainEqual(expect.objectContaining({ + kind: "healing-percent", sourceTalentName: "Test of Faith", + })); + expect(talentModifiersForRanks(ranks, "priest")).not.toContainEqual(expect.objectContaining({ + kind: "damage-percent", sourceTalentName: "Test of Faith", + })); + + const member = usePartyStore.getState().members[0]!; + const prepareTarget = (health: number) => { + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health, maxHealth: 20_000 } + : candidate), + })); + useCombatStore.setState((state) => ({ + actors: { + ...state.actors, + [member.id]: { ...state.actors[member.id]!, health, maxHealth: 20_000, alive: true }, + }, + })); + }; + const castFlashHeal = (health: number, now: number, withTalent: boolean) => { + prepareTarget(health); + useCombatStore.setState({ + talentRanks: withTalent ? ranks : {}, + talentModifiers: talentModifiersForRanks(withTalent ? ranks : {}, "priest"), + }); + useCombatStore.getState().selectFriendlyActor(member.id); + const result = useCombatStore.getState().castAbility(priestAbility("Flash Heal").id, { + targetActorId: member.id, now, + }); + expect(result.ok, JSON.stringify(result)).toBe(true); + const completesAt = useCombatStore.getState().activeCast?.completesAt; + if (completesAt) useCombatStore.getState().tick((completesAt - now) / 1_000, completesAt); + const event = useCombatStore.getState().combatEvents.filter((candidate) => ( + candidate.kind === "healing" + && candidate.abilityId === priestAbility("Flash Heal").id + && candidate.targetActorId === member.id + )).at(-1); + expect(event).toBeDefined(); + return event!.rawAmount / (event!.critical ? 1.5 : 1); + }; + + const base = castFlashHeal(9_000, 1_000, false); + const boosted = castFlashHeal(9_000, 4_000, true); + const aboveThreshold = castFlashHeal(11_000, 7_000, true); + expect(boosted).toBeCloseTo(base * 1.12, 0); + expect(aboveThreshold).toBeCloseTo(base, 0); + + const smite = priestAbility("Smite"); + useCombatStore.getState().registerMob("priest-test-faith-damage", { + name: "Test of Faith target", maxHealth: 20_000, xpReward: 0, + }); + setMobPosition("priest-test-faith-damage", [4, 0, 0]); + const castSmite = (health: number, now: number, withTalent: boolean) => { + useCombatStore.setState((state) => ({ + talentRanks: withTalent ? ranks : {}, + talentModifiers: talentModifiersForRanks(withTalent ? ranks : {}, "priest"), + mobs: { + ...state.mobs, + "priest-test-faith-damage": { + ...state.mobs["priest-test-faith-damage"]!, health, maxHealth: 20_000, dead: false, + }, + }, + })); + useCombatStore.getState().selectMob("priest-test-faith-damage"); + const result = useCombatStore.getState().castAbility(smite.id, [0, 0, 0], now); + expect(result.ok, JSON.stringify(result)).toBe(true); + const completesAt = useCombatStore.getState().activeCast?.completesAt; + if (completesAt) useCombatStore.getState().tick((completesAt - now) / 1_000, completesAt); + const event = useCombatStore.getState().combatEvents.filter((candidate) => ( + candidate.kind === "damage" + && candidate.abilityId === smite.id + && candidate.targetActorId === "priest-test-faith-damage" + )).at(-1); + expect(event).toBeDefined(); + return event!.rawAmount / (event!.critical ? 1.5 : 1); + }; + const baseDamage = castSmite(9_000, 10_000, false); + const lowHealthDamage = castSmite(9_000, 13_000, true); + const healthyDamage = castSmite(11_000, 16_000, true); + expect(lowHealthDamage).toBeCloseTo(baseDamage * 1.12, 0); + expect(healthyDamage).toBeCloseTo(baseDamage, 0); + }); + + it("keeps proc-based Priest talent values out of passive talent modifiers", () => { + const procTalentNames = ["Holy Concentration", "Serendipity", "Shadow Weaving"]; + const ranks = Object.fromEntries(procTalentNames.map((name) => { + const talent = TALENT_NODES.find((node) => node.name === name); + expect(talent, name).toBeDefined(); + return [talent!.id, talent!.maxRank]; + })); + const modifiers = talentModifiersForRanks(ranks, "priest"); + for (const name of procTalentNames) { + expect(modifiers.filter((modifier) => modifier.sourceTalentName === name), name).toEqual([]); + } + }); + + it("limits Inner Focus to its three named spells and empowers Greater Heal", () => { + const talent = TALENT_NODES.find((node) => node.name === "Inner Focus"); + expect(talent).toBeDefined(); + setPriestTalentRanks({ [talent!.id]: 1 }, 75); + useCombatStore.setState((state) => ({ + resource: 0, + resourcePools: { ...state.resourcePools, mana: 0 }, + })); + useCombatStore.getState().registerMob("priest-inner-focus-damage", { + name: "Inner Focus target", maxHealth: 100_000, xpReward: 0, + }); + setMobPosition("priest-inner-focus-damage", [4, 0, 0]); + expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().castAbility(priestAbility("Smite").id, { + targetActorId: "priest-inner-focus-damage", now: 1_001, + })).toMatchObject({ ok: false, reason: "not-enough-resource" }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("next-heal-free-critical") + ))).toBe(true); + + const manaSpentAt = useCombatStore.getState().lastResourceSpentAt.mana; + const member = usePartyStore.getState().members[0]!; + setMemberHealth(member.id, 1, member.maxHealth); + useCombatStore.getState().selectFriendlyActor(member.id); + const greaterHeal = priestAbility("Greater Heal"); + const cast = useCombatStore.getState().castAbility(greaterHeal.id, { + targetActorId: member.id, now: 1_002, + }); + expect(cast.ok, JSON.stringify(cast)).toBe(true); + const activeCast = useCombatStore.getState().activeCast!; + expect(activeCast.completesAt - activeCast.startedAt).toBe(Math.round(greaterHeal.castTimeMs * 0.5)); + useCombatStore.getState().tick((activeCast.completesAt - activeCast.startedAt) / 1_000, activeCast.completesAt); + expect(useCombatStore.getState().lastResourceSpentAt.mana).toBe(manaSpentAt); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("next-heal-free-critical") + ))).toBe(false); + const heal = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "healing" && event.abilityId === greaterHeal.id && event.targetActorId === member.id + )).at(-1)!; + expect(heal.critical).toBe(true); + }); + + it("gives Inner Focus's Mind Blast rider and reduces Power Word: Shield's Weakened Soul", () => { + const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!; + setPriestTalentRanks({ [talent.id]: 1 }); + useCombatStore.setState((state) => ({ + resource: 0, + resourcePools: { ...state.resourcePools, mana: 0 }, + })); + useCombatStore.getState().registerMob("priest-inner-focus-mind-blast", { + name: "Inner Focus Mind Blast target", maxHealth: 100_000, xpReward: 0, + }); + setMobPosition("priest-inner-focus-mind-blast", [4, 0, 0]); + + expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 1_000).ok).toBe(true); + const mindBlast = priestAbility("Mind Blast"); + expect(useCombatStore.getState().castAbility(mindBlast.id, { + targetActorId: "priest-inner-focus-mind-blast", now: 1_001, + }).ok).toBe(true); + expect(useCombatStore.getState().activeCast?.completesAt).toBe(1_001 + mindBlast.castTimeMs / 2); + useCombatStore.getState().tick(mindBlast.castTimeMs / 2_000, 1_001 + mindBlast.castTimeMs / 2); + expect(useCombatStore.getState().mobs["priest-inner-focus-mind-blast"]?.statuses).toContainEqual(expect.objectContaining({ + kind: "slow", + magnitude: 0.7, + endsAt: 1_001 + mindBlast.castTimeMs / 2 + 5_000, + })); + + const member = usePartyStore.getState().members[0]!; + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [priestAbility("Inner Focus").id]: 0 }, + })); + expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 8_000).ok).toBe(true); + expect(castFriendly("Power Word: Shield", member.id, 8_001).ok).toBe(true); + const weakenedSoul = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === member.id && aura.definition.id === "wow335:priest:weakened-soul" + )); + expect(weakenedSoul?.expiresAt).toBe(28_001); + }); + + it.each([ + { rank: 1, percent: 0.04 }, + { rank: 2, percent: 0.07 }, + { rank: 3, percent: 0.1 }, + ])("grants Divine Aegis rank $rank from a critical Holy heal, including reduced overheal", ({ rank, percent }) => { + const talent = TALENT_NODES.find((node) => node.name === "Divine Aegis")!; + const ranks = { [talent.id]: rank }; + setPriestTalentRanks(ranks, 100); + const member = usePartyStore.getState().members[0]!; + setMemberHealth(member.id, 1, 20_000); + + const result = castFriendly("Flash Heal", member.id, 1_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + const heal = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "healing" && event.abilityId === priestAbility("Flash Heal").id && event.targetActorId === member.id + )).at(-1)!; + expect(heal.critical).toBe(true); + const expected = Math.min(2_000, Math.round((heal.effectiveAmount + heal.overheal * 0.25) * percent * 2)); + const aegis = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id + && aura.definition.utilityTags?.includes("wow335-priest-divine-aegis")); + expect(aegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBe(expected); + expect(aegis?.expiresAt).toBe(heal.occurredAt + 6_000); + }); + + it("caps a target's Divine Aegis absorb at ten percent maximum health", () => { + const talent = TALENT_NODES.find((node) => node.name === "Divine Aegis")!; + setPriestTalentRanks({ [talent.id]: 3 }, 100); + const member = usePartyStore.getState().members[0]!; + setMemberHealth(member.id, 1, 500); + expect(castFriendly("Flash Heal", member.id, 1_000).ok).toBe(true); + const aegis = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id + && aura.definition.utilityTags?.includes("wow335-priest-divine-aegis")); + expect(aegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeLessThanOrEqual(50); + }); + + it("makes Surge of Light's next Smite instant, free, stronger, and unable to crit", () => { + const talent = TALENT_NODES.find((node) => node.name === "Surge of Light")!; + useCombatStore.getState().registerMob("priest-surge-test", { name: "Surge target", maxHealth: 200_000, xpReward: 0 }); + setMobPosition("priest-surge-test", [4, 0, 0]); + setPriestTalentRanks({}, -100); + expect(castHostile("Smite", "priest-surge-test", 1_000).ok).toBe(true); + const baseline = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.abilityId === priestAbility("Smite").id && event.targetActorId === "priest-surge-test" + )).at(-1)!; + expect(baseline.critical).toBe(false); + + setPriestTalentRanks({ [talent.id]: 2 }, 100); + const member = usePartyStore.getState().members[0]!; + let procAt = 0; + for (let attempt = 0; attempt < 16; attempt += 1) { + const now = 6_000 + attempt * 4_000; + setMemberHealth(member.id, 1, 20_000); + expect(castFriendly("Flash Heal", member.id, now).ok).toBe(true); + if (useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))) { + procAt = now + priestAbility("Flash Heal").castTimeMs; + break; + } + } + expect(procAt).toBeGreaterThan(0); + + const manaBefore = useCombatStore.getState().resource; + expect(castHostile("Smite", "priest-surge-test", procAt + 4_000).ok).toBe(true); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(useCombatStore.getState().resource).toBe(manaBefore); + const surged = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.abilityId === priestAbility("Smite").id && event.targetActorId === "priest-surge-test" + )).at(-1)!; + expect(surged.critical).toBe(false); + expect(surged.rawAmount).toBe(Math.round(baseline.rawAmount * 1.6)); + }); + + it("also triggers Surge of Light from critical direct damage and spends it on Flash Heal", () => { + const talent = TALENT_NODES.find((node) => node.name === "Surge of Light")!; + useCombatStore.getState().registerMob("priest-surge-crit-test", { + name: "Surge critical target", maxHealth: 1_000_000, xpReward: 0, + }); + setMobPosition("priest-surge-crit-test", [4, 0, 0]); + setPriestTalentRanks({ [talent.id]: 2 }, 100); + let procAt = 0; + for (let attempt = 0; attempt < 16; attempt += 1) { + const now = 1_000 + attempt * 4_000; + expect(castHostile("Smite", "priest-surge-crit-test", now).ok).toBe(true); + if (useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))) { + procAt = now + priestAbility("Smite").castTimeMs; + break; + } + } + expect(procAt).toBeGreaterThan(0); + const member = usePartyStore.getState().members[0]!; + setMemberHealth(member.id, 1, 20_000); + setPriestTalentRanks({}, 100); + const manaBefore = useCombatStore.getState().resource; + expect(castFriendly("Flash Heal", member.id, procAt + 4_000).ok).toBe(true); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(useCombatStore.getState().resource).toBe(manaBefore); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))).toBe(false); + }); + + it("stacks Serendipity to three charges and consumes all charges on Greater Heal", () => { + const talent = TALENT_NODES.find((node) => node.name === "Serendipity")!; + const ranks = { [talent.id]: 3 }; + setPriestTalentRanks(ranks); + const member = usePartyStore.getState().members[0]!; + for (const now of [1_000, 5_000, 9_000]) { + setMemberHealth(member.id, 1, 20_000); + expect(castFriendly("Flash Heal", member.id, now).ok).toBe(true); + } + const procAura = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity")); + expect(procAura?.stacks).toBe(3); + + const greaterHeal = abilityAtLevel(priestAbility("Greater Heal"), 80); + useCombatStore.setState((state) => ({ + resource: 5_000, + maxResource: 5_000, + resourcePools: { ...state.resourcePools, mana: 5_000 }, + maxResourcePools: { ...state.maxResourcePools, mana: 5_000 }, + gearStats: { ...state.gearStats, spirit: 0, mp5: 0 }, + })); + const baseCost = resolvedAbilityCosts(greaterHeal, [], useCombatStore.getState()) + .find((cost) => cost.resource === "mana")!.amount; + const manaBefore = useCombatStore.getState().resource; + useCombatStore.getState().selectFriendlyActor(member.id); + const result = useCombatStore.getState().castAbility(greaterHeal.id, { targetActorId: member.id, now: 14_000 }); + expect(result.ok, JSON.stringify(result)).toBe(true); + const cast = useCombatStore.getState().activeCast!; + expect(cast.completesAt - cast.startedAt).toBe(Math.round(greaterHeal.castTimeMs * 0.55)); + expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"))?.stacks).toBe(3); + useCombatStore.getState().castAbility(greaterHeal.id, { + targetActorId: member.id, now: cast.completesAt, completingCast: cast, + }); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"))).toBe(false); + expect(manaBefore - useCombatStore.getState().resource).toBe(Math.round(baseCost * 0.55)); + }); + + it("adds Shadow Weaving on Shadow damage and increases later ticks up to five stacks", () => { + const talent = TALENT_NODES.find((node) => node.name === "Shadow Weaving")!; + useCombatStore.getState().registerMob("priest-shadow-weaving-test", { + name: "Shadow Weaving target", maxHealth: 200_000, xpReward: 0, + }); + setMobPosition("priest-shadow-weaving-test", [4, 0, 0]); + setPriestTalentRanks({ [talent.id]: 3 }, -100); + expect(castHostile("Shadow Word: Pain", "priest-shadow-weaving-test", 1_000).ok).toBe(true); + useCombatStore.getState().tick(18, 19_000); + const ticks = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.abilityId === priestAbility("Shadow Word: Pain").id + && event.targetActorId === "priest-shadow-weaving-test" + )); + expect(ticks.length).toBeGreaterThanOrEqual(2); + expect(ticks[1]!.rawAmount).toBe(Math.round(ticks[0]!.rawAmount * 1.02)); + expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-shadow-weaving"))?.stacks).toBe(5); + }); + + it("procs Holy Concentration on a critical heal and extends nearby Renew by at most six seconds", () => { + const talent = TALENT_NODES.find((node) => node.name === "Holy Concentration")!; + setPriestTalentRanks({ [talent.id]: 3 }, 100); + const [first, second] = usePartyStore.getState().members; + expect(castFriendly("Renew", first!.id, 1_000).ok).toBe(true); + expect(castFriendly("Renew", second!.id, 4_000).ok).toBe(true); + const beforeExtensions = new Map(useCombatStore.getState().effects.filter((effect) => effect.kind === "hot" + && effect.sourceAbilityId === priestAbility("Renew").id) + .map((effect) => [effect.targetId ?? PLAYER_AURA_ENTITY_ID, + effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs])); + + setMemberHealth(second!.id, 1, second!.maxHealth); + expect(castFriendly("Flash Heal", second!.id, 7_000).ok).toBe(true); + const concentration = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-holy-concentration")); + expect(concentration?.expiresAt).toBeGreaterThan(0); + expect(concentration?.definition.modifiers).toContainEqual(expect.objectContaining({ + kind: "stat", stat: "spirit", operation: "percent", value: 0.3, + })); + let renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot" + && effect.sourceAbilityId === priestAbility("Renew").id); + for (const effect of renewEffects) { + const target = effect.targetId ?? PLAYER_AURA_ENTITY_ID; + expect(effect.extensionTicks).toBe(1); + expect(effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs).toBe(beforeExtensions.get(target)! + 3_000); + } + + setMemberHealth(second!.id, 1, second!.maxHealth); + expect(castFriendly("Flash Heal", second!.id, 11_000).ok).toBe(true); + renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot" + && effect.sourceAbilityId === priestAbility("Renew").id); + for (const effect of renewEffects) expect(effect.extensionTicks).toBe(2); + setMemberHealth(second!.id, 1, second!.maxHealth); + expect(castFriendly("Flash Heal", second!.id, 15_000).ok).toBe(true); + renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot" + && effect.sourceAbilityId === priestAbility("Renew").id); + for (const effect of renewEffects) expect(effect.extensionTicks).toBe(2); + }); + + it("gives the three Prayer buffs party scope and Pain Suppression its target threat reduction", () => { + for (const name of ["Prayer of Fortitude", "Prayer of Shadow Protection", "Prayer of Spirit"]) { + const rank = abilityAtLevel(priestAbility(name), 80); + expect(rank.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"), name).toBe(true); + } + const painSuppression = abilityAtLevel(priestAbility("Pain Suppression"), 80); + expect(painSuppression.effects).toContainEqual({ + kind: "threat", mode: "percent", amount: -40, recipient: "ability-target", + }); + }); + + it("summons a nonattacking Lightwell and heals for six seconds per click", () => { + unlock("Lightwell"); + const ability = priestAbility("Lightwell"); + expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true); + const completion = useCombatStore.getState().activeCast?.completesAt; + expect(completion).toBeDefined(); + useCombatStore.getState().tick(1, completion!); + const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id); + expect(well).toMatchObject({ clickCharges: 10, expiresAt: completion! + 180_000, attackDamage: 0 }); + + useCombatStore.getState().damagePlayer(500); + const healthBefore = useCombatStore.getState().health; + const clickAmount = useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1); + expect(clickAmount).toBeGreaterThan(0); + expect(useCombatStore.getState().health).toBe(healthBefore); + expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ + kind: "hot", + sourceAbilityId: ability.id, + remainingTicks: 6, + intervalMs: 1_000, + fixedAmount: expect.any(Number), + nextTickAt: completion! + 1_001, + })); + useCombatStore.getState().tick(6, completion! + 6_001); + expect(useCombatStore.getState().health).toBeGreaterThan(healthBefore); + expect(useCombatStore.getState().summons.find((summon) => summon.id === well!.id)?.clickCharges).toBe(9); + + for (let click = 1; click < 10; click += 1) { + useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1 + click); + } + expect(useCombatStore.getState().summons.some((summon) => summon.id === well!.id)).toBe(false); + expect(useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 20)).toBe(0); + }); +}); diff --git a/src/game/wow335PriestPrayerOfMendingRuntime.test.ts b/src/game/wow335PriestPrayerOfMendingRuntime.test.ts new file mode 100644 index 00000000..4c6f7bce --- /dev/null +++ b/src/game/wow335PriestPrayerOfMendingRuntime.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { abilityAtLevel, abilitiesForClass } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; + +const PRAYER_RANKS = [ + { level: 68, spellId: 33076, healAmount: 800 }, + { level: 74, spellId: 48112, healAmount: 905 }, + { level: 79, spellId: 48113, healAmount: 1043 }, +] as const; + +function prayerOfMendingAbility() { + const ability = abilitiesForClass("priest").find((candidate) => candidate.name === "Prayer of Mending"); + expect(ability, "Prayer of Mending").toBeDefined(); + return ability!; +} + +function damageActor(actorId: string, now: number): number { + const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId); + for (let attempt = 0; attempt < 12; attempt += 1) { + const mobId = `pom-attacker-${actorId}-${now}-${attempt}`; + const combat = useCombatStore.getState(); + combat.registerMob(mobId, { name: "Prayer of Mending attacker", maxHealth: 1_000, xpReward: 0 }); + setMobPosition(mobId, [2, 0, 0]); + combat.damageMob(mobId, 1, now, { + actorId, + role: member?.role ?? "healer", + abilityId: "pom-test-threat", + }); + useCombatStore.setState((state) => ({ + mobs: state.mobs[mobId] + ? { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, nextAttackAt: now + 450 } } + : state.mobs, + })); + const damage = useCombatStore.getState().advanceMobCombat(now + 450); + useCombatStore.getState().resetMobForNavigation(mobId, true, now + 450); + if (damage > 0) return damage; + } + throw new Error(`No enemy attack landed on ${actorId} after 12 attempts`); +} + +describe("WoW Priest Prayer of Mending", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-prayer-of-mending", 80, useCombatStore.getState().health); + usePartyStore.getState().selectMember(null); + useCombatStore.getState().synchronizeActors(); + usePartyStore.getState().members.forEach((member, index) => { + registerPartyRuntimePosition(member.id, [index + 1, 0, 0]); + }); + }); + + it("maps every player-facing rank to its DBC base heal and five damage charges", () => { + const base = prayerOfMendingAbility(); + for (const expected of PRAYER_RANKS) { + const rank = abilityAtLevel(base, expected.level); + expect(rank.dbcSpellId).toBe(expected.spellId); + expect(rank.effects).toHaveLength(1); + const aura = rank.effects.find((effect) => effect.kind === "apply-aura"); + expect(aura?.kind).toBe("apply-aura"); + if (aura?.kind !== "apply-aura") continue; + expect(aura.aura.procs?.[0]).toMatchObject({ + triggers: "damage-taken", + charges: 5, + action: { kind: "custom", id: "wow335-prayer-of-mending", data: { healAmount: expected.healAmount } }, + }); + } + }); + + it("heals the aura holder and transfers through injured allies for five total activations", () => { + const combat = useCombatStore.getState(); + const members = usePartyStore.getState().members; + const initialTarget = members[0]!; + const memberHealth = [1_900, 1_800, 1_700, 1_600]; + const injuredMembers = members.map((member, index) => ({ + ...member, + maxHealth: 2_000, + health: memberHealth[index] ?? 1_500, + })); + usePartyStore.setState({ members: injuredMembers }); + useCombatStore.setState((state) => ({ + actors: { + ...state.actors, + ...Object.fromEntries(injuredMembers.flatMap((member) => { + const actor = state.actors[member.id]; + return actor ? [[member.id, { + ...actor, + health: member.health, + maxHealth: member.maxHealth, + alive: member.health > 0, + }]] : []; + })), + }, + })); + useCombatStore.setState((state) => ({ maxHealth: 2_000, health: 1_980 })); + const cast = combat.castAbility(prayerOfMendingAbility().id, { + targetActorId: initialTarget.id, + now: 1_000, + }); + expect(cast.ok, JSON.stringify(cast)).toBe(true); + expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === "wow335:48113:prayer-of-mending")) + .toMatchObject({ targetId: initialTarget.id, procStates: [{ remainingCharges: 5 }] }); + + let auraTarget = initialTarget.id; + let now = 2_000; + for (let activation = 1; activation <= 5; activation += 1) { + const previousTarget = auraTarget; + damageActor(previousTarget, now); + now += 1_000; + + const healingEvents = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "healing" && event.abilityId === prayerOfMendingAbility().id + )); + expect(healingEvents).toHaveLength(activation); + const active = useCombatStore.getState().auras.find((aura) => ( + aura.definition.id === "wow335:48113:prayer-of-mending" + )); + if (activation === 5) { + expect(active).toBeUndefined(); + break; + } + + expect(active).toBeDefined(); + expect(active?.targetId).not.toBe(previousTarget); + const combatState = useCombatStore.getState(); + const injured = [ + ...(combatState.health > 0 && combatState.health < combatState.maxHealth + ? [{ id: PLAYER_AGGRO_ID, health: combatState.health, maxHealth: combatState.maxHealth }] + : []), + ...usePartyStore.getState().members.flatMap((member) => { + const actor = combatState.actors[member.id]; + return actor && actor.health > 0 && actor.health < actor.maxHealth + ? [{ id: member.id, health: actor.health, maxHealth: actor.maxHealth }] + : []; + }), + ].filter((candidate) => candidate.id !== previousTarget) + .sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth); + expect(active?.targetId).toBe(injured[0]?.id); + auraTarget = active!.targetId; + } + }); + + it("does not spend a charge for unrelated outgoing damage", () => { + const member = usePartyStore.getState().members[0]!; + expect(useCombatStore.getState().castAbility(prayerOfMendingAbility().id, { + targetActorId: member.id, + now: 1_000, + }).ok).toBe(true); + useCombatStore.getState().registerMob("pom-unrelated", { name: "Unrelated target", maxHealth: 1_000 }); + setMobPosition("pom-unrelated", [4, 0, 0]); + useCombatStore.getState().damageMob("pom-unrelated", 1, 2_000); + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.definition.id === "wow335:48113:prayer-of-mending" + )); + expect(aura).toMatchObject({ targetId: member.id, procStates: [{ remainingCharges: 5 }] }); + }); +}); diff --git a/src/game/wow335RogueMechanics.test.ts b/src/game/wow335RogueMechanics.test.ts new file mode 100644 index 00000000..df718896 --- /dev/null +++ b/src/game/wow335RogueMechanics.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { abilityTalentNodeId } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; + +describe("WoW Rogue class mechanics", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-rogue-mechanics-tests", 1, + useCombatStore.getState().health); + }); + + const ability = (name: string) => { + const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(found, `missing Rogue ability ${name}`).toBeDefined(); + return found!; + }; + + const learnAbilityTalent = (name: string) => { + const nodeId = abilityTalentNodeId(ability(name)); + expect(nodeId, `${name} should have a source talent node`).toBeTruthy(); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [nodeId!]: 1 }, + })); + }; + + const addTarget = (id: string, position: readonly [number, number, number] = [3, 0, 0]) => { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); + setMobPosition(id, position); + useCombatStore.getState().selectMob(id); + }; + + it("gates stealth openers and allows Pick Pocket through Shadow Dance", () => { + addTarget("rogue-opener-target"); + const backstab = ability("Backstab"); + const pocket = ability("Pick Pocket"); + + expect(useCombatStore.getState().castAbility(backstab.id, undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().castAbility(pocket.id, undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + + learnAbilityTalent("Shadow Dance"); + const dance = ability("Shadow Dance"); + expect(useCombatStore.getState().castAbility(dance.id, undefined, 2_000).ok).toBe(true); + + const result = useCombatStore.getState().castAbility(pocket.id, undefined, 5_000); + expect(result).toMatchObject({ ok: true, abilityId: pocket.id }); + expect(useCombatStore.getState().feedback).toMatchObject({ kind: "loot", message: expect.stringContaining("succeeds") }); + }); + + it.each([["Ambush", 2], ["Garrote", 1], ["Cheap Shot", 2]] as const)( + "grants Initiative's source-ranked extra combo point on %s", + (openerName, baseComboPoints) => { + addTarget("rogue-initiative-target"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-245": 3 }, + })); + + expect(useCombatStore.getState().castAbility("wow335-rogue-vanish", undefined, 1_000).ok).toBe(true); + const result = useCombatStore.getState().castAbility(ability(openerName).id, undefined, 5_000); + + expect(result).toMatchObject({ ok: true }); + expect(useCombatStore.getState().comboPoints).toBe(baseComboPoints + 1); + expect(useCombatStore.getState().comboTargetId).toBe("rogue-initiative-target"); + }, + ); + + it("grants Ruthlessness's finisher point and observes its 9-second cooldown", () => { + addTarget("rogue-ruthlessness-target"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-273": 3 }, + comboPoints: 3, + comboTargetId: "rogue-ruthlessness-target", + })); + const eviscerate = ability("Eviscerate"); + + expect(useCombatStore.getState().castAbility(eviscerate.id, undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().comboPoints).toBe(1); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes( + "wow335-rogue-ruthlessness-cooldown", + ) && aura.expiresAt === 10_000)).toBe(true); + + useCombatStore.setState({ comboPoints: 2, comboTargetId: "rogue-ruthlessness-target" }); + expect(useCombatStore.getState().castAbility(eviscerate.id, undefined, 2_000).ok).toBe(true); + expect(useCombatStore.getState().comboPoints).toBe(0); + }); + + it("grants Honor Among Thieves' rank-3 direct-damage combo point no more than once every 3 seconds", () => { + addTarget("rogue-honor-among-thieves-target"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-2078": 3 }, + resource: 1_000, + maxResource: 1_000, + })); + const sinisterStrike = ability("Sinister Strike"); + + expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().comboPoints).toBe(2); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-rogue-honor-among-thieves-cooldown") + && aura.expiresAt === 4_000)).toBe(true); + + expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 2_000).ok).toBe(true); + expect(useCombatStore.getState().comboPoints).toBe(3); + + expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 4_000).ok).toBe(true); + expect(useCombatStore.getState().comboPoints).toBe(5); + }); + + it("redirects only threat generated after Tricks of the Trade is cast", () => { + addTarget("rogue-tricks-target", [8, 0, 0]); + const member = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(member.id, [2, 0, 0]); + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + "rogue-tricks-target": { + ...state.mobs["rogue-tricks-target"]!, + engaged: true, + threatByActor: { [PLAYER_AGGRO_ID]: 70 }, + }, + }, + })); + + const tricks = ability("Tricks of the Trade"); + const result = useCombatStore.getState().castAbility(tricks.id, { targetActorId: member.id, now: 1_000 }); + + expect(result).toMatchObject({ ok: true, abilityId: tricks.id }); + expect(useCombatStore.getState().mobs["rogue-tricks-target"]?.threatByActor) + .toEqual({ [PLAYER_AGGRO_ID]: 70 }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === member.id && aura.definition.utilityTags?.includes("wow335-rogue-tricks-damage-buff") + ))).toBe(true); + + useCombatStore.getState().damageMob("rogue-tricks-target", 100, 2_000); + const threat = useCombatStore.getState().mobs["rogue-tricks-target"]!.threatByActor; + expect(threat[PLAYER_AGGRO_ID]).toBe(70); + expect(threat[member.id]).toBeGreaterThan(0); + }); + + it("executes five two-weapon Killing Spree assaults against a single reachable target", () => { + addTarget("rogue-killing-spree-target", [3, 0, 0]); + learnAbilityTalent("Killing Spree"); + const spree = ability("Killing Spree"); + + const result = useCombatStore.getState().castAbility(spree.id, undefined, 1_000); + + expect(result).toMatchObject({ ok: true, abilityId: spree.id }); + const strikes = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.targetActorId === "rogue-killing-spree-target" && event.abilityId === spree.id + )); + expect(strikes).toHaveLength(10); + expect(useCombatStore.getState().mobs["rogue-killing-spree-target"]?.health).toBeLessThan(100_000); + expect(useCombatStore.getState().playerPosition).not.toEqual([0, 0, 0]); + }); + + it("Shadowsteps behind its target and applies the registered triggered rider", () => { + addTarget("rogue-shadowstep-target", [8, 0, 0]); + const shadowstep = ability("Shadowstep"); + + const result = useCombatStore.getState().castAbility(shadowstep.id, undefined, 1_000); + + expect(result).toMatchObject({ ok: true, abilityId: shadowstep.id }); + expect(useCombatStore.getState().playerPosition).toEqual([8, 0, -1.5]); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-rogue-shadowstep-rider"))).toBe(true); + }); + + it("keeps the Riposte cast gated until a parry grants its ready state", () => { + addTarget("rogue-riposte-target"); + learnAbilityTalent("Riposte"); + const riposte = ability("Riposte"); + + expect(useCombatStore.getState().castAbility(riposte.id, undefined, 1_000)) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-rogue-riposte-ready"))).toBe(false); + + useCombatStore.setState((state) => ({ + auras: [...state.auras, { + instanceId: "test-riposte-ready", + definition: { + id: "wow335:rogue:riposte-ready", + name: "Riposte Ready", + disposition: "buff" as const, + durationMs: 5_000, + maxStacks: 1, + stackBehavior: "refresh" as const, + utilityTags: ["wow335-rogue-riposte-ready"], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + appliedAt: 1_500, + expiresAt: 6_500, + stacks: 1, + absorbs: [], + procStates: [], + }], + })); + expect(useCombatStore.getState().castAbility(riposte.id, undefined, 2_000).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes( + "wow335-rogue-riposte-ready", + ))).toBe(false); + }); +}); diff --git a/src/game/wow335RogueVanishPreparationRuntime.test.ts b/src/game/wow335RogueVanishPreparationRuntime.test.ts new file mode 100644 index 00000000..ae4886bb --- /dev/null +++ b/src/game/wow335RogueVanishPreparationRuntime.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; + +describe("WoW Rogue Vanish and Preparation runtime", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-rogue-vanish-preparation-tests", 1, + useCombatStore.getState().health); + }); + + it.each(["root", "slow"] as const)("Vanish grants stealth, clears %s, and does not relocate the rogue", (mechanic) => { + useCombatStore.getState().registerMob("vanish-target", { name: "vanish-target", maxHealth: 100_000, xpReward: 0 }); + setMobPosition("vanish-target", [8, 0, 0]); + useCombatStore.getState().selectMob("vanish-target"); + useCombatStore.setState({ controlledUntil: 15_000, controlMechanic: mechanic }); + + const result = useCombatStore.getState().castAbility("wow335-rogue-vanish", undefined, 1_000); + + expect(result).toMatchObject({ ok: true, abilityId: "wow335-rogue-vanish" }); + const state = useCombatStore.getState(); + expect(state.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("stealth") + && aura.expiresAt === null)).toBe(true); + expect(state).toMatchObject({ controlledUntil: 0, controlMechanic: null, playerPosition: [0, 0, 0] }); + }); + + it("Preparation resets only its five source-listed cooldowns", () => { + const abilities = useCombatStore.getState().abilities; + const byName = (name: string) => { + const ability = abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!; + }; + const resetNames = ["Evasion", "Sprint", "Vanish", "Cold Blood", "Shadowstep"]; + const resetIds = resetNames.map((name) => byName(name).id); + const unrelatedId = byName("Kick").id; + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-284": 1 }, + cooldowns: { + ...state.cooldowns, + ...Object.fromEntries([...resetIds, unrelatedId].map((id) => [id, 60_000])), + }, + })); + + const result = useCombatStore.getState().castAbility("wow335-rogue-preparation", undefined, 1_000); + + expect(result).toMatchObject({ ok: true, abilityId: "wow335-rogue-preparation" }); + const cooldowns = useCombatStore.getState().cooldowns; + for (const id of resetIds) expect(cooldowns[id], id).toBe(0); + expect(cooldowns[unrelatedId]).toBe(60_000); + expect(cooldowns["wow335-rogue-preparation"]).toBe(601_000); + }); +}); diff --git a/src/game/wow335ShamanConditionalTalentsRuntime.test.ts b/src/game/wow335ShamanConditionalTalentsRuntime.test.ts new file mode 100644 index 00000000..47a895d4 --- /dev/null +++ b/src/game/wow335ShamanConditionalTalentsRuntime.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore, resolvedAbilityCosts } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; + +function shamanAbility(name: string) { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, `Shaman ${name}`).toBeDefined(); + return ability!; +} + +function setTalentRank(talentId: string, rank: number): void { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [talentId]: rank }, + })); +} + +function castAndFinish(abilityId: string, targetActorId: string | null | undefined, now: number): void { + const result = useCombatStore.getState().castAbility( + abilityId, + targetActorId !== undefined ? { targetActorId, now } : { now }, + ); + expect(result.ok, JSON.stringify(result)).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt); +} + +function setupShaman(): void { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-shaman-conditional-talent-tests", 80, useCombatStore.getState().health); + useCombatStore.getState().registerMob("shaman-conditional-target", { + name: "Shaman conditional talent target", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition("shaman-conditional-target", [8, 0, 0]); + useCombatStore.getState().selectMob("shaman-conditional-target"); +} + +describe("WoW Shaman conditional talent mechanics", () => { + beforeEach(setupShaman); + + it("grants two 30% mana-cost reductions after a critical elemental spell", () => { + setTalentRank("talent-574", 2); + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 }, + })); + const lightningBolt = shamanAbility("Lightning Bolt"); + castAndFinish(lightningBolt.id, undefined, 1_000); + + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === lightningBolt.id && event.critical + ))).toBe(true); + const clearcasting = () => useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-shaman-elemental-focus-clearcasting") + )); + expect(clearcasting()?.stacks).toBe(2); + + const healingWave = shamanAbility("Healing Wave"); + const before = useCombatStore.getState(); + const fullCost = resolvedAbilityCosts(healingWave, before.talentModifiers, { ...before, auras: [] }) + .find((cost) => cost.resource === "mana")?.amount; + expect(fullCost).toBeGreaterThan(0); + const discountedCost = Math.round(fullCost! * 0.7); + expect(resolvedAbilityCosts(healingWave, before.talentModifiers, before) + .find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost); + + castAndFinish(healingWave.id, null, 10_000); + expect(clearcasting()?.stacks).toBe(1); + const oneChargeState = useCombatStore.getState(); + expect(resolvedAbilityCosts(healingWave, oneChargeState.talentModifiers, oneChargeState) + .find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost); + castAndFinish(healingWave.id, null, 20_000); + expect(clearcasting()).toBeUndefined(); + }); + + it("uses Reverberation's rank chance and 20% bonus on the next different Shock", () => { + setTalentRank("talent-575", 3); + const earthShock = shamanAbility("Earth Shock"); + const frostShock = shamanAbility("Frost Shock"); + + castAndFinish(earthShock.id, undefined, 1_000); + const initialReverberation = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes("wow335-shaman-reverberation") + )); + expect(initialReverberation?.expiresAt).toBe(9_000); + expect(initialReverberation?.definition.utilityTags).toContain(`wow335-shaman-reverberation:from:${earthShock.id}`); + + castAndFinish(frostShock.id, undefined, 6_000); + const frostShockHit = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === frostShock.id + )); + expect(frostShockHit?.effectiveAmount).toBeGreaterThan(0); + const refreshedReverberation = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes("wow335-shaman-reverberation") + )); + expect(refreshedReverberation?.definition.utilityTags) + .toContain(`wow335-shaman-reverberation:from:${frostShock.id}`); + + const buffedRawDamage = frostShockHit?.rawAmount; + setupShaman(); + const baselineBolt = shamanAbility("Lightning Bolt"); + const baselineFrostShock = shamanAbility("Frost Shock"); + castAndFinish(baselineBolt.id, undefined, 1_000); + castAndFinish(baselineFrostShock.id, undefined, 6_000); + const baselineHit = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === baselineFrostShock.id + )); + expect(baselineHit?.rawAmount).toBeGreaterThan(0); + expect(buffedRawDamage).toBe(Math.round((baselineHit?.rawAmount ?? 0) * 1.2)); + }); +}); diff --git a/src/game/wow335ShamanHealingStreamRuntime.test.ts b/src/game/wow335ShamanHealingStreamRuntime.test.ts new file mode 100644 index 00000000..5e5c0226 --- /dev/null +++ b/src/game/wow335ShamanHealingStreamRuntime.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; + +const HEALING_STREAM_RANKS = [ + { level: 20, spellId: 5394, heal: 6 }, + { level: 30, spellId: 6375, heal: 8 }, + { level: 40, spellId: 6377, heal: 10 }, + { level: 50, spellId: 10462, heal: 12 }, + { level: 60, spellId: 10463, heal: 14 }, + { level: 69, spellId: 25567, heal: 18 }, + { level: 71, spellId: 58755, heal: 20 }, + { level: 76, spellId: 58756, heal: 23 }, + { level: 80, spellId: 58757, heal: 25 }, +] as const; + +function healingStreamAbility() { + const ability = abilitiesForClass("shaman").find((candidate) => candidate.name === "Healing Stream Totem"); + expect(ability, "Healing Stream Totem").toBeDefined(); + return ability!; +} + +describe("WoW Shaman Healing Stream Totem", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-healing-stream-test", 80, useCombatStore.getState().health); + }); + + it("uses each source rank’s 2-second heal amount", () => { + const base = healingStreamAbility(); + for (const expected of HEALING_STREAM_RANKS) { + const ranked = abilityAtLevel(base, expected.level); + const summon = ranked.effects.find((effect) => effect.kind === "summon"); + expect(ranked.dbcSpellId).toBe(expected.spellId); + expect(summon?.kind).toBe("summon"); + if (summon?.kind !== "summon") continue; + expect(summon.healingPulse).toEqual({ amount: expected.heal, intervalMs: 2_000, radius: 10 }); + expect(summon.durationMs).toBe(300_000); + } + }); + + it("heals every nearby party member and never attacks the selected enemy", () => { + const state = useCombatStore.getState(); + const members = usePartyStore.getState().members; + useCombatStore.setState((current) => ({ health: current.maxHealth - 25 })); + usePartyStore.setState((current) => ({ + members: current.members.map((member) => ({ ...member, health: member.maxHealth - 25 })), + })); + members.forEach((member, index) => { + registerPartyRuntimePosition(member.id, index === 0 ? [5, 0, 0] : index === 1 ? [9, 0, 0] : [10.1 + index, 0, 0]); + }); + useCombatStore.getState().registerMob("healing-stream-nearby-enemy", { name: "Nearby enemy", maxHealth: 10_000 }); + setMobPosition("healing-stream-nearby-enemy", [4, 0, 0]); + useCombatStore.getState().selectMob("healing-stream-nearby-enemy"); + + const cast = useCombatStore.getState().castAbility(healingStreamAbility().id, undefined, 1_000); + expect(cast.ok, JSON.stringify(cast)).toBe(true); + const totem = useCombatStore.getState().summons.find((summon) => ( + summon.sourceAbilityId === healingStreamAbility().id + )); + expect(totem).toBeDefined(); + expect(totem?.kind).toBe("totem"); + expect(totem?.maxHealth).toBe(5); + expect(totem?.attackDamage).toBe(0); + expect(totem?.expiresAt).toBe(301_000); + + useCombatStore.getState().tick(0, 3_000); + + const after = useCombatStore.getState(); + expect(after.health).toBe(after.maxHealth); + expect(usePartyStore.getState().members[0]?.health).toBe(usePartyStore.getState().members[0]?.maxHealth); + expect(usePartyStore.getState().members[1]?.health).toBe(usePartyStore.getState().members[1]?.maxHealth); + expect(usePartyStore.getState().members[2]?.health).toBe((usePartyStore.getState().members[2]?.maxHealth ?? 0) - 25); + expect(after.mobs["healing-stream-nearby-enemy"]?.health).toBe(10_000); + expect(after.combatEvents.filter((event) => event.kind === "healing" + && event.abilityId === healingStreamAbility().id)).toHaveLength(3); + }); +}); diff --git a/src/game/wow335ShamanProcRuntime.test.ts b/src/game/wow335ShamanProcRuntime.test.ts new file mode 100644 index 00000000..99c12630 --- /dev/null +++ b/src/game/wow335ShamanProcRuntime.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { usePartyStore } from "./partyStore"; + +function shamanAbility(name: string) { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, `Shaman ${name}`).toBeDefined(); + return ability!; +} + +function finishCast(abilityId: string, targetId: string | undefined, startedAt: number): void { + const requestTargetId = targetId === PLAYER_AURA_ENTITY_ID ? null : targetId; + const result = useCombatStore.getState().castAbility( + abilityId, + requestTargetId !== undefined + ? { targetActorId: requestTargetId, now: startedAt } + : { now: startedAt }, + ); + expect(result.ok, JSON.stringify(result)).toBe(true); + const cast = useCombatStore.getState().activeCast; + if (cast) useCombatStore.getState().tick(0.1, cast.completesAt); +} + +describe("WoW Shaman source-backed proc talents", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-shaman-proc-tests", 80, useCombatStore.getState().health); + for (const [index, member] of usePartyStore.getState().members.entries()) { + registerPartyRuntimePosition(member.id, [4 + index, 0, 0]); + } + }); + + it.each([ + [1, 500], + [2, 1_000], + [3, 1_500], + ])("reduces the shared Shock cooldown by %i rank(s) (%i ms) with Reverberation", (rank, reductionMs) => { + const flameShock = shamanAbility("Flame Shock"); + const shockAbilities = useCombatStore.getState().abilities.filter((ability) => ( + ability.cooldownGroup === "shaman-shock" + )); + expect(shockAbilities.length).toBeGreaterThan(1); + + const target = `reverberation-target-${rank}`; + useCombatStore.getState().registerMob(target, { + name: "Reverberation target", + level: 80, + maxHealth: 100_000, + xpReward: 0, + }); + setMobPosition(target, [8, 0, 0]); + useCombatStore.getState().selectMob(target); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-575": rank }, + })); + + const result = useCombatStore.getState().castAbility(flameShock.id, undefined, 1_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + const cast = useCombatStore.getState().activeCast; + if (cast) useCombatStore.getState().tick(0.1, cast.completesAt); + + const expectedReadyAt = 1_000 + 6_000 - reductionMs; + for (const ability of shockAbilities) { + expect(useCombatStore.getState().cooldowns[ability.id], ability.name).toBe(expectedReadyAt); + } + }); + + it("uses the ranked Ancestral Healing reduction and heals the lowest-health ally from Ancestral Awakening", () => { + const members = usePartyStore.getState().members; + const mostInjured = members[0]!; + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === mostInjured.id + ? { ...member, health: Math.round(member.maxHealth * 0.4) } + : member), + })); + useCombatStore.setState((state) => ({ + health: state.maxHealth - 1, + gearStats: { ...state.gearStats, criticalStrikeRating: 100_000 }, + talentRanks: { ...state.talentRanks, "talent-2061": 3, "talent-581": 3 }, + })); + + const heal = shamanAbility("Healing Wave"); + finishCast(heal.id, PLAYER_AURA_ENTITY_ID, 1_000); + + const events = useCombatStore.getState().combatEvents; + const directHeal = events.find((event) => event.kind === "healing" && event.abilityId === heal.id); + expect(directHeal?.critical).toBe(true); + const awakeningHeal = events.find((event) => ( + event.kind === "healing" && event.abilityId === "wow335-shaman-ancestral-awakening" + )); + expect(awakeningHeal).toMatchObject({ targetActorId: mostInjured.id, school: "nature" }); + expect(awakeningHeal?.rawAmount).toBe(Math.round((directHeal?.rawAmount ?? 0) * 0.3)); + expect(usePartyStore.getState().members.find((member) => member.id === mostInjured.id)?.health) + .toBeGreaterThan(Math.round(mostInjured.maxHealth * 0.4)); + + const ancestralHealing = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-shaman-ancestral-healing") + )); + expect(ancestralHealing).toMatchObject({ expiresAt: directHeal!.occurredAt + 15_000 }); + expect(ancestralHealing?.definition.modifiers).toContainEqual({ + kind: "damage", + direction: "taken", + school: "physical", + operation: "percent", + value: -0.1, + }); + }); + + it("reduces Elemental Mastery and Thunderstorm cooldowns on damaging Lightning Bolt hits", () => { + const state = useCombatStore.getState(); + const lightningBolt = shamanAbility("Lightning Bolt"); + const sourceCooldownAbilities = ["Elemental Mastery", "Thunderstorm"].map((name) => ( + WOW335_ABILITIES_BY_CLASS.shaman.find((ability) => ability.name === name) + )); + expect(sourceCooldownAbilities.every(Boolean)).toBe(true); + const cooldownAbilities = sourceCooldownAbilities as NonNullable[]; + const target = "shamanism-target"; + useCombatStore.getState().registerMob(target, { name: "Shamanism target", level: 80, maxHealth: 100_000, xpReward: 0 }); + setMobPosition(target, [8, 0, 0]); + useCombatStore.getState().selectMob(target); + useCombatStore.setState((current) => ({ + talentRanks: { ...current.talentRanks, "talent-2252": 3 }, + abilities: [...current.abilities, ...cooldownAbilities.filter((ability) => ( + !current.abilities.some((candidate) => candidate.id === ability.id) + ))], + cooldowns: { + ...current.cooldowns, + [cooldownAbilities[0]!.id]: 20_000, + [cooldownAbilities[1]!.id]: 20_000, + }, + })); + + const result = useCombatStore.getState().castAbility(lightningBolt.id, undefined, 1_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + const cast = useCombatStore.getState().activeCast; + if (cast) useCombatStore.getState().tick(0.1, cast.completesAt); + + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === lightningBolt.id && event.effectiveAmount > 0 + ))).toBe(true); + expect(useCombatStore.getState().cooldowns[cooldownAbilities[0]!.id]).toBe(19_000); + expect(useCombatStore.getState().cooldowns[cooldownAbilities[1]!.id]).toBe(19_000); + }); +}); diff --git a/src/game/wow335ShamanisticRageRuntime.test.ts b/src/game/wow335ShamanisticRageRuntime.test.ts new file mode 100644 index 00000000..a7ed9fb5 --- /dev/null +++ b/src/game/wow335ShamanisticRageRuntime.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityTalentNodeId } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; + +function unlockShamanTalent(abilityName: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === abilityName); + expect(ability, abilityName).toBeDefined(); + const nodeId = abilityTalentNodeId(ability!); + expect(nodeId, abilityName).toBeTruthy(); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } })); + return ability!.id; +} + +describe("WoW Shaman Shamanistic Rage source proc", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-shamanistic-rage-runtime-tests", 1, useCombatStore.getState().health); + useCombatStore.getState().registerMob("shamanistic-rage-target", { name: "Shamanistic Rage target", maxHealth: 100_000, xpReward: 0 }); + setMobPosition("shamanistic-rage-target", [2, 0, 0]); + useCombatStore.getState().selectMob("shamanistic-rage-target"); + }); + + it("restores 15% attack power as mana on landed melee hits for the 15-second aura window", () => { + const rageId = unlockShamanTalent("Shamanistic Rage"); + const rageAbility = useCombatStore.getState().abilities.find((candidate) => candidate.id === rageId); + expect(rageAbility?.dbcSpellId).toBe(30823); + useCombatStore.setState((state) => ({ + resource: 300, + resourcePools: { ...state.resourcePools, mana: 300 }, + })); + + const rageCast = useCombatStore.getState().castAbility(rageId, undefined, 1_000); + expect(rageCast.ok).toBe(true); + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.targetId === PLAYER_AURA_ENTITY_ID + && candidate.definition.procs?.some((proc) => proc.action.kind === "custom" + && proc.action.id === "wow335-shamanistic-rage-mana") + )); + expect(aura).toBeDefined(); + expect(aura?.expiresAt).toBe(16_000); + + for (let now = 3_000; now < 16_000; now += 2_500) { + useCombatStore.setState({ autoAttackTargetId: "shamanistic-rage-target", nextAutoAttackAt: now }); + useCombatStore.getState().tick(0.1, now); + if (useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "resource" && event.abilityId === rageId && event.effectiveAmount > 0 + ))) break; + } + const afterMelee = useCombatStore.getState(); + expect(afterMelee.resourcePools.mana).toBeGreaterThan(0); + expect(afterMelee.combatEvents.some((event) => ( + event.kind === "resource" + && event.abilityId === rageId + && event.effectiveAmount > 0 + ))).toBe(true); + + const rageRestoresBeforeExpiry = afterMelee.combatEvents.filter((event) => ( + event.kind === "resource" && event.abilityId === rageId + )).length; + useCombatStore.setState({ + autoAttackTargetId: "shamanistic-rage-target", + nextAutoAttackAt: 20_000, + }); + useCombatStore.getState().tick(0.1, 20_000); + expect(useCombatStore.getState().auras.some((entry) => entry.instanceId === aura?.instanceId)).toBe(false); + expect(useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "resource" && event.abilityId === rageId + ))).toHaveLength(rageRestoresBeforeExpiry); + }); +}); diff --git a/src/game/wow335WarlockMechanics.test.ts b/src/game/wow335WarlockMechanics.test.ts new file mode 100644 index 00000000..acf39327 --- /dev/null +++ b/src/game/wow335WarlockMechanics.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { applyAura } from "./combatAuras"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { usePartyStore } from "./partyStore"; +import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +function abilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function unlockTalentAbility(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + const nodeId = abilityTalentNodeId(ability!); + expect(nodeId, name).toBeTruthy(); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } })); + return ability!.id; +} + +function setTalentRank(name: string, rank: number): void { + const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name); + expect(talent, name).toBeDefined(); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [talent!.id]: rank }, + })); +} + +function castAndFinish(name: string, now: number): number { + const id = abilityId(name); + const result = useCombatStore.getState().castAbility(id, undefined, now); + expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + if (activeCast?.abilityId === id) { + useCombatStore.getState().tick(0.1, activeCast.completesAt); + return activeCast.completesAt; + } + return now; +} + +function addNearbyMob(id: string, x: number, maxHealth = 100_000): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth }); + setMobPosition(id, [x, 0, 0]); +} + +function deterministicUnit(seed: string): number { + let hash = 2166136261; + for (let index = 0; index < seed.length; index += 1) { + hash ^= seed.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +describe("WoW 3.3.5 Warlock mechanics", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["warlock"]); + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-warlock-mechanics-tests", 1, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + })); + addNearbyMob("warlock-target", 4); + useCombatStore.getState().selectMob("warlock-target"); + }); + + it("requires and consumes the caster's Immolate for Conflagrate", () => { + const target = useCombatStore.getState().mobs["warlock-target"]!; + const conflagrateId = unlockTalentAbility("Conflagrate"); + const unavailable = useCombatStore.getState().castAbility(conflagrateId, undefined, 1_000); + expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" }); + + const immolateCompleteAt = castAndFinish("Immolate", 3_000); + const immolateId = abilityId("Immolate"); + expect(useCombatStore.getState().effects.some((effect) => ( + effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id + ))).toBe(true); + + const conflagrate = useCombatStore.getState().castAbility(conflagrateId, undefined, immolateCompleteAt + 100); + expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true); + expect(conflagrate.damage).toBeGreaterThan(0); + expect(useCombatStore.getState().effects.some((effect) => ( + effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id + ))).toBe(false); + }); + + it("models Shadowflame's missing periodic child so Conflagrate can consume it", () => { + const shadowflameCompleteAt = castAndFinish("Shadowflame", 3_000); + const shadowflameId = abilityId("Shadowflame"); + const activeShadowflame = useCombatStore.getState().effects.find((effect) => ( + effect.kind === "dot" && effect.sourceAbilityId === shadowflameId && effect.targetId === "warlock-target" + )); + expect(activeShadowflame).toMatchObject({ kind: "dot", intervalMs: 2_000, remainingTicks: 4, damageSchool: "fire" }); + + const conflagrate = useCombatStore.getState().castAbility( + unlockTalentAbility("Conflagrate"), undefined, shadowflameCompleteAt + 2_000, + ); + expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true); + expect(conflagrate.damage).toBeGreaterThan(0); + expect(useCombatStore.getState().effects.some((effect) => ( + effect.kind === "dot" && effect.sourceAbilityId === shadowflameId + ))).toBe(false); + }); + + it("ramps Curse of Agony ticks through half, full, then one-and-a-half damage", () => { + const curseId = abilityId("Curse of Agony"); + const completeAt = castAndFinish("Curse of Agony", 1_000); + for (let tick = 1; tick <= 12; tick += 1) { + useCombatStore.getState().tick(0.1, completeAt + tick * 2_000); + } + const amounts = useCombatStore.getState().combatEvents + .filter((event) => event.kind === "damage" && event.abilityId === curseId && event.targetActorId === "warlock-target") + .map((event) => event.effectiveAmount); + expect(amounts).toHaveLength(12); + expect(new Set(amounts.slice(0, 4)).size).toBe(1); + expect(new Set(amounts.slice(4, 8)).size).toBe(1); + expect(new Set(amounts.slice(8, 12)).size).toBe(1); + expect(Math.abs(amounts[4]! - 2 * amounts[0]!)).toBeLessThanOrEqual(2); + expect(Math.abs(amounts[8]! - 3 * amounts[0]!)).toBeLessThanOrEqual(3); + }); + + it("uses Seed's DBC threshold and rank-matched area explosion on damage", () => { + const source = WOW335_ABILITIES_BY_CLASS.warlock.find((ability) => ability.name === "Seed of Corruption"); + expect(source?.ranks?.map((rank) => rank.spellId)).toEqual([27_243, 47_835, 47_836]); + + addNearbyMob("seed-neighbor", 8); + const completeAt = castAndFinish("Seed of Corruption", 1_000); + expect(useCombatStore.getState().effects.find((effect) => effect.kind === "dot" && effect.targetId === "warlock-target")) + .toMatchObject({ warlockSeed: { threshold: 1_518, explosionSpellId: 47_834, accumulatedDamage: 0 } }); + + const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health; + useCombatStore.getState().damageMob("warlock-target", 1_517, completeAt + 100, undefined, "shadow"); + expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBe(neighborHealth); + + useCombatStore.getState().damageMob("warlock-target", 1, completeAt + 200, undefined, "shadow"); + expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" && effect.targetId === "warlock-target")) + .toBe(false); + }); + + it("detonates Seed when its carrier dies before the damage threshold", () => { + addNearbyMob("seed-neighbor", 8); + addNearbyMob("seed-carrier", 6, 1_000); + useCombatStore.getState().selectMob("seed-carrier"); + const completeAt = castAndFinish("Seed of Corruption", 1_000); + const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health; + + useCombatStore.getState().damageMob("seed-carrier", 1_000, completeAt + 100, undefined, "shadow"); + + expect(useCombatStore.getState().mobs["seed-carrier"]?.dead).toBe(true); + expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth); + }); + + it("procs Backlash only from landed physical attacks, with its source duration and internal cooldown", () => { + setTalentRank("Backlash", 3); + const maxHealth = useCombatStore.getState().maxHealth; + const readyTag = "wow335-warlock-backlash-ready"; + const cooldownTag = "wow335-warlock-backlash-cooldown"; + + useCombatStore.getState().damagePlayer(1, "shadow", 80, 20_000); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(readyTag))).toBe(false); + + const procAt = Array.from({ length: 500 }, (_, offset) => 30_000 + offset) + .find((now) => deterministicUnit( + `${PLAYER_AGGRO_ID}:external:physical:${now}:${maxHealth}:wow335-warlock-backlash`, + ) < 0.25); + expect(procAt, "a deterministic source-rank Backlash proc seed").toBeDefined(); + useCombatStore.setState({ health: maxHealth }); + useCombatStore.getState().damagePlayer(1, "physical", 80, procAt!); + + const ready = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag)); + expect(ready?.expiresAt).toBe(procAt! + 12_000); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(cooldownTag) + && aura.expiresAt === procAt! + 8_000)).toBe(true); + + useCombatStore.setState({ health: maxHealth }); + useCombatStore.getState().damagePlayer(1, "physical", 80, procAt! + 1); + expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag))?.expiresAt) + .toBe(procAt! + 12_000); + }); + + it("makes Backlash's next Shadow Bolt instant, half-cost at rank 3, and consumes its proc", () => { + setTalentRank("Backlash", 3); + const playerId = PLAYER_AURA_ENTITY_ID; + const shadowBolt = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Shadow Bolt")!; + const manaBefore = useCombatStore.getState().resourcePools.mana!; + const normalCast = useCombatStore.getState().castAbility(shadowBolt.id, undefined, 1_000); + expect(normalCast.ok, JSON.stringify(normalCast)).toBe(true); + const normalActiveCast = useCombatStore.getState().activeCast; + expect(normalActiveCast?.abilityId).toBe(shadowBolt.id); + useCombatStore.getState().tick(0.1, normalActiveCast!.completesAt); + const normalManaSpent = manaBefore - useCombatStore.getState().resourcePools.mana!; + expect(normalManaSpent).toBeGreaterThan(0); + + const backlashCastAt = normalActiveCast!.completesAt + 1_000; + const readyAura = applyAura([], { + definition: { + id: "wow335:warlock:backlash-ready", + name: "Backlash", + disposition: "buff", + durationMs: 12_000, + utilityTags: ["wow335-warlock-backlash-ready"], + }, + sourceId: playerId, + targetId: playerId, + now: backlashCastAt, + }).auras; + const afterNormal = useCombatStore.getState(); + useCombatStore.setState({ + auras: readyAura, + activeCast: null, + globalCooldownEndsAt: 0, + resource: afterNormal.maxResource, + resourcePools: { ...afterNormal.resourcePools, mana: afterNormal.maxResourcePools.mana! }, + }); + const procManaBefore = useCombatStore.getState().resourcePools.mana!; + + const result = useCombatStore.getState().castAbility(shadowBolt.id, undefined, backlashCastAt); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(procManaBefore - useCombatStore.getState().resourcePools.mana!).toBe(Math.round(normalManaSpent * 0.5)); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes( + "wow335-warlock-backlash-ready", + ))).toBe(false); + expect(result.damage).toBeGreaterThan(0); + }); +}); diff --git a/src/game/wow335WarlockSoulshatterRuntime.test.ts b/src/game/wow335WarlockSoulshatterRuntime.test.ts new file mode 100644 index 00000000..74628974 --- /dev/null +++ b/src/game/wow335WarlockSoulshatterRuntime.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; + +function soulshatterId(): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Soulshatter"); + expect(ability, "Soulshatter").toBeDefined(); + return ability!.id; +} + +function setPlayerThreat(id: string, amount: number): void { + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + [id]: { + ...state.mobs[id], + threatByActor: { ...state.mobs[id].threatByActor, [PLAYER_AGGRO_ID]: amount }, + }, + }, + })); +} + +describe("WoW 3.3.5 Warlock Soulshatter runtime", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["warlock"]); + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-warlock-soulshatter-tests", 1, + useCombatStore.getState().health); + }); + + it("cuts threat in half inside the caster's 50-yard radius and leaves outside enemies alone", () => { + useCombatStore.getState().registerMob("soulshatter-inside", { name: "Inside", maxHealth: 10_000 }); + useCombatStore.getState().registerMob("soulshatter-outside", { name: "Outside", maxHealth: 10_000 }); + setMobPosition("soulshatter-inside", [20, 0, 0]); + setMobPosition("soulshatter-outside", [60, 0, 0]); + setPlayerThreat("soulshatter-inside", 100); + setPlayerThreat("soulshatter-outside", 100); + + const cast = useCombatStore.getState().castAbility(soulshatterId(), undefined, 1_000); + + expect(cast).toMatchObject({ ok: true, affectedMobIds: ["soulshatter-inside"] }); + expect(useCombatStore.getState().mobs["soulshatter-inside"].threatByActor[PLAYER_AGGRO_ID]).toBe(50); + expect(useCombatStore.getState().mobs["soulshatter-outside"].threatByActor[PLAYER_AGGRO_ID]).toBe(100); + }); + + it("does not reduce distant enemies when nobody is within the caster-centered radius", () => { + useCombatStore.getState().registerMob("soulshatter-far", { name: "Far", maxHealth: 10_000 }); + setMobPosition("soulshatter-far", [60, 0, 0]); + setPlayerThreat("soulshatter-far", 100); + + const cast = useCombatStore.getState().castAbility(soulshatterId(), undefined, 1_000); + + expect(cast).toMatchObject({ ok: true, affectedMobIds: [] }); + expect(useCombatStore.getState().mobs["soulshatter-far"].threatByActor[PLAYER_AGGRO_ID]).toBe(100); + }); +}); diff --git a/src/game/wow335WarlockTalentProcs.test.ts b/src/game/wow335WarlockTalentProcs.test.ts new file mode 100644 index 00000000..7fd715b7 --- /dev/null +++ b/src/game/wow335WarlockTalentProcs.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +function abilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function setTalentRank(name: string, rank: number): void { + const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name); + expect(talent, name).toBeDefined(); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [talent!.id]: rank }, + })); +} + +function addTarget(id = "warlock-proc-target"): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 }); + setMobPosition(id, [4, 0, 0]); + useCombatStore.getState().selectMob(id); +} + +function castAndFinish(name: string, now: number): number { + const id = abilityId(name); + const result = useCombatStore.getState().castAbility(id, undefined, now); + expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + if (activeCast?.abilityId === id) { + useCombatStore.getState().tick(0.1, activeCast.completesAt); + return activeCast.completesAt; + } + return now; +} + +describe("WoW 3.3.5 Warlock talent procs", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["warlock"]); + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-warlock-proc-tests", 1, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + })); + addTarget(); + }); + + it("heals the active pet for 15% of effective spell damage with Fel Synergy", () => { + setTalentRank("Fel Synergy", 2); + castAndFinish("Summon Imp", 1_000); + const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); + expect(pet).toBeDefined(); + useCombatStore.setState((state) => ({ + summons: state.summons.map((summon) => summon.id === pet!.id + ? { ...summon, health: 1, nextActionAt: Number.MAX_SAFE_INTEGER } + : summon), + })); + + const target = useCombatStore.getState().mobs["warlock-proc-target"]!; + const playerPet = useCombatStore.getState().summons.find((summon) => summon.id === pet!.id)!; + const completesAt = castAndFinish("Shadow Bolt", 12_000); + const damage = target.health - useCombatStore.getState().mobs[target.id]!.health; + const updatedPet = useCombatStore.getState().summons.find((summon) => summon.id === playerPet.id)!; + + expect(damage).toBeGreaterThan(0); + expect(updatedPet.health).toBe(Math.min(updatedPet.maxHealth, 1 + Math.round(damage * 0.15))); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "healing" && event.targetActorId === playerPet.id && event.occurredAt === completesAt + ))).toBe(true); + }); + + it("heals from eligible direct Destruction hits and observes Soul Leech's three-second cooldown", () => { + setTalentRank("Soul Leech", 3); + const maxHealth = useCombatStore.getState().maxHealth; + useCombatStore.setState((state) => ({ + health: state.maxHealth - 1_000, + })); + const target = useCombatStore.getState().mobs["warlock-proc-target"]!; + const completesAt = castAndFinish("Shadow Bolt", 1_000); + const effectiveDamage = target.health - useCombatStore.getState().mobs[target.id]!.health; + const expectedHeal = Math.min(Math.round(effectiveDamage * 0.15), Math.round(maxHealth * 0.15)); + expect(useCombatStore.getState().health).toBe(maxHealth - 1_000 + expectedHeal); + const initialSoulLeechEvents = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "healing" && event.abilityId === abilityId("Shadow Bolt") + )); + expect(initialSoulLeechEvents).toHaveLength(1); + expect(initialSoulLeechEvents[0]?.effectiveAmount).toBe(expectedHeal); + + castAndFinish("Searing Pain", completesAt + 100); + const followupSoulLeechEvents = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "healing" && event.abilityId === abilityId("Searing Pain") + )); + expect(followupSoulLeechEvents).toHaveLength(0); + }); + + it("applies Improved Shadow Bolt vulnerability only after a critical Shadow Bolt", () => { + setTalentRank("Improved Shadow Bolt", 5); + const firstCompleteAt = castAndFinish("Shadow Bolt", 1_000); + const shadowBoltId = abilityId("Shadow Bolt"); + const firstHit = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === shadowBoltId + )); + expect(firstHit?.critical).toBe(false); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === "warlock-proc-target" + && aura.definition.utilityTags?.includes("wow335-warlock-improved-shadow-bolt") + ))).toBe(false); + + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 }, + })); + const completesAt = castAndFinish("Shadow Bolt", firstCompleteAt + 100); + const criticalHit = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === shadowBoltId && event.occurredAt === completesAt + )); + expect(criticalHit?.critical).toBe(true); + const targetAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === "warlock-proc-target" + && aura.definition.utilityTags?.includes("wow335-warlock-improved-shadow-bolt") + )); + + expect(targetAura).toMatchObject({ expiresAt: completesAt + 30_000 }); + }); + + it("keeps conditional Warlock procs out of global scalar modifiers", () => { + const improvedShadowBolt = WOW335_TALENT_NODES.find((candidate) => candidate.name === "Improved Shadow Bolt")!; + const modifiers = talentModifiersForRanks({ + "talent-1002": 2, + "talent-1878": 3, + [improvedShadowBolt.id]: 5, + }, "warlock"); + + expect(modifiers.some((modifier) => modifier.sourceTalentName === "Nightfall")).toBe(false); + expect(modifiers.some((modifier) => modifier.sourceTalentName === "Eradication")).toBe(false); + expect(modifiers.filter((modifier) => modifier.sourceTalentName === "Improved Shadow Bolt")) + .toEqual([expect.objectContaining({ kind: "damage-percent", amount: 15 })]); + }); + + it("keeps Backlash's next-cast damage and cost conditional while applying its passive critical chance", () => { + const modifiers = talentModifiersForRanks({ "talent-1817": 3 }, "warlock"); + expect(modifiers.filter((modifier) => modifier.sourceTalentName === "Backlash")) + .toEqual([expect.objectContaining({ kind: "critical-chance-percent", amount: 3 })]); + }); + + it("procs rank-scaled Eradication from Corruption damage and applies haste to cast time", () => { + setTalentRank("Eradication", 3); + let nextCastAt = 1_000; + let procAt: number | null = null; + for (let application = 0; application < 3 && procAt === null; application += 1) { + const completesAt = castAndFinish("Corruption", nextCastAt); + const dot = useCombatStore.getState().effects.find((effect) => ( + effect.kind === "dot" && effect.sourceAbilityId === abilityId("Corruption") + )); + expect(dot, "Corruption periodic effect").toBeDefined(); + for (let tick = 1; tick <= dot!.remainingTicks && procAt === null; tick += 1) { + nextCastAt = completesAt + tick * dot!.intervalMs; + useCombatStore.getState().tick(0.1, nextCastAt); + if (useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("wow335-warlock-eradication") + && (aura.expiresAt === null || aura.expiresAt > nextCastAt) + ))) procAt = nextCastAt; + } + nextCastAt += 1_000; + } + expect(procAt).not.toBeNull(); + + const shadowBoltId = abilityId("Shadow Bolt"); + const shadowBolt = useCombatStore.getState().abilities.find((ability) => ability.id === shadowBoltId)!; + const sourceCastTimeMs = shadowBolt.ranks?.at(-1)?.castTimeMs ?? shadowBolt.castTimeMs; + const result = useCombatStore.getState().castAbility(shadowBoltId, undefined, procAt! + 100); + expect(result.ok).toBe(true); + expect(useCombatStore.getState().activeCast?.completesAt! - (procAt! + 100)) + .toBe(Math.round(sourceCastTimeMs / 1.2)); + }); +}); diff --git a/src/game/wow335WarriorRuntime.test.ts b/src/game/wow335WarriorRuntime.test.ts new file mode 100644 index 00000000..4470a02a --- /dev/null +++ b/src/game/wow335WarriorRuntime.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityTalentNodeId } from "./abilityCatalog"; +import { resolveStatValue } from "./combatAuras"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; + +function unlockWarriorTalent(abilityId: string): void { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === abilityId); + expect(ability, abilityId).toBeDefined(); + const nodeId = abilityTalentNodeId(ability!); + expect(nodeId, abilityId).toBeTruthy(); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [nodeId!]: 1 }, + })); +} + +function registerTarget(id: string, position: readonly [number, number, number], maxHealth = 100_000): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 }); + setMobPosition(id, position); +} + +describe("WoW 3.3.5 Warrior source-row mechanics", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-warrior-runtime-tests", 1, useCombatStore.getState().health); + }); + + it("casts Bloodthirst from 23881 and heals the warrior on the next landed melee hit", () => { + unlockWarriorTalent("wow335-warrior-bloodthirst"); + registerTarget("bloodthirst-target", [2, 0, 0]); + const state = useCombatStore.getState(); + const startingHealth = state.maxHealth - 500; + useCombatStore.setState((current) => ({ + health: startingHealth, + resource: 100, + resourcePools: { ...current.resourcePools, rage: 100 }, + })); + + expect(useCombatStore.getState().castAbility("wow335-warrior-bloodthirst", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(80); + expect(useCombatStore.getState().cooldowns["wow335-warrior-bloodthirst"]).toBe(5_000); + const healAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.procs?.some((proc) => proc.action.kind === "custom" + && proc.action.id === "wow335-trigger-spell" + && proc.action.data?.spellId === 23880) + )); + expect(healAura).toBeDefined(); + + const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000); + expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true }); + const afterMeleeHit = useCombatStore.getState(); + expect(afterMeleeHit.health).toBeGreaterThan(startingHealth); + expect(afterMeleeHit.auras.find((aura) => aura.instanceId === healAura?.instanceId)?.procStates[0]?.remainingCharges) + .toBe(2); + expect(afterMeleeHit.combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === "wow335-trigger-23880" + ))).toBe(false); + }); + + it("arms five Sweeping Strikes charges and copies each landed melee hit to the nearest nearby enemy", () => { + unlockWarriorTalent("wow335-warrior-sweeping-strikes"); + registerTarget("sweeping-primary", [2, 0, 0], 1_000_000); + registerTarget("sweeping-secondary", [3, 0, 0], 1_000_000); + registerTarget("sweeping-distant", [10, 0, 0], 1_000_000); + useCombatStore.getState().selectMob("sweeping-primary"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + })); + + expect(useCombatStore.getState().castAbility("wow335-warrior-sweeping-strikes", undefined, 1_000).ok).toBe(true); + const armedAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-sweeping-strikes") + )); + expect(armedAura?.procStates[0]?.remainingCharges).toBe(5); + expect(useCombatStore.getState().mobs["sweeping-primary"].health).toBe(1_000_000); + + const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000); + expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true }); + const afterHit = useCombatStore.getState(); + expect(afterHit.mobs["sweeping-primary"].health).toBeLessThan(1_000_000); + expect(afterHit.mobs["sweeping-secondary"].health).toBeLessThan(1_000_000); + expect(afterHit.mobs["sweeping-distant"].health).toBe(1_000_000); + expect(afterHit.auras.find((aura) => aura.instanceId === armedAura?.instanceId)?.procStates[0]?.remainingCharges) + .toBe(4); + }); + + it.each([ + [1, 1_000], + [2, 2_000], + ])("reduces Whirlwind cooldown by %i rank(s) (%i ms) with Improved Whirlwind", (rank, reductionMs) => { + registerTarget(`improved-whirlwind-target-${rank}`, [2, 0, 0]); + useCombatStore.getState().selectMob(`improved-whirlwind-target-${rank}`); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + talentRanks: { ...state.talentRanks, "talent-1655": rank }, + })); + + const whirlwind = useCombatStore.getState().abilities.find((ability) => ability.name === "Whirlwind"); + expect(whirlwind).toBeDefined(); + expect(whirlwind?.cooldownGroup).toBeUndefined(); + const mortalStrike = useCombatStore.getState().abilities.find((ability) => ability.name === "Mortal Strike"); + expect(mortalStrike).toBeDefined(); + unlockWarriorTalent(mortalStrike!.id); + const unrelatedResult = useCombatStore.getState().castAbility(mortalStrike!.id, undefined, 1_000); + expect(unrelatedResult.ok, JSON.stringify(unrelatedResult)).toBe(true); + expect(useCombatStore.getState().cooldowns[mortalStrike!.id]).toBe(7_000); + + const result = useCombatStore.getState().castAbility(whirlwind!.id, undefined, 3_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().cooldowns[whirlwind!.id]).toBe(3_000 + 10_000 - reductionMs); + }); + + it("requires Execute targets below 20% health and applies ranked damage instead of killing outright", () => { + registerTarget("execute-target", [2, 0, 0], 100_000); + useCombatStore.getState().selectMob("execute-target"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + })); + const targetAtThreshold = useCombatStore.getState().mobs["execute-target"]; + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, "execute-target": { ...targetAtThreshold, health: 20_000 } }, + })); + + expect(useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, 1_000)).toMatchObject({ + ok: false, + reason: "condition-not-met", + }); + expect(useCombatStore.getState().mobs["execute-target"].health).toBe(20_000); + + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, "execute-target": { ...state.mobs["execute-target"], health: 19_000 } }, + })); + expect(useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, 3_000).ok).toBe(true); + const afterExecute = useCombatStore.getState(); + expect(afterExecute.mobs["execute-target"].health).toBeGreaterThan(0); + expect(afterExecute.mobs["execute-target"].health).toBeLessThan(19_000); + expect(afterExecute.resourcePools.rage).toBe(85); + expect(afterExecute.combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === "wow335-warrior-execute" + ))).toBe(true); + }); + + it("stacks Improved Execute's rank-3 critical chance after casts below 20% health", () => { + registerTarget("execute-critical-target", [2, 0, 0], 1_000_000); + useCombatStore.getState().selectMob("execute-critical-target"); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + talentRanks: { ...state.talentRanks, "talent-1542": 3 }, + mobs: { + ...state.mobs, + "execute-critical-target": { + ...state.mobs["execute-critical-target"], + health: 190_000, + }, + }, + })); + + for (const [index, now] of [1_000, 3_000, 5_000, 7_000].entries()) { + const result = useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, now); + expect(result.ok, JSON.stringify(result)).toBe(true); + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.targetId === PLAYER_AURA_ENTITY_ID + && candidate.definition.id === "wow335:warrior:improved-execute-critical" + )); + expect(aura?.stacks).toBe(Math.min(index + 1, 3)); + expect(aura?.expiresAt).toBe(now + 5_000); + expect(resolveStatValue(0, aura ? [aura] : [], "critical-strike")).toBe(Math.min(index + 1, 3) * 10); + } + }); + + it("applies Battle Stance's source-row armor penetration to physical attacks", () => { + const runAttack = (withBattleStance: boolean): number => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-warrior-armor-penetration-tests", 1, useCombatStore.getState().health); + registerTarget("armor-penetration-target", [2, 0, 0], 1_000_000); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + mobs: { + ...state.mobs, + "armor-penetration-target": { ...state.mobs["armor-penetration-target"], armor: 6_000 }, + }, + })); + useCombatStore.getState().selectMob("armor-penetration-target"); + + if (withBattleStance) { + const stance = useCombatStore.getState().castAbility("wow335-warrior-battle-stance", undefined, 1_000); + expect(stance, JSON.stringify(stance)).toMatchObject({ ok: true }); + } + const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000); + expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true }); + return [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "damage" && event.abilityId === "warrior-heroic-strike" + ))?.effectiveAmount ?? 0; + }; + + const withoutStance = runAttack(false); + const withStance = runAttack(true); + expect(withoutStance).toBeGreaterThan(0); + expect(withStance).toBeGreaterThan(withoutStance); + }); + + it.each([ + ["wow335-warrior-battle-shout", "Battle Shout"], + ["wow335-warrior-commanding-shout", "Commanding Shout"], + ])("applies %s to party members inside its source 30-yard radius", (abilityId, abilityName) => { + const [nearbyMember, distantMember] = usePartyStore.getState().members; + expect(nearbyMember).toBeDefined(); + expect(distantMember).toBeDefined(); + registerPartyRuntimePosition(nearbyMember!.id, [5, 0, 0]); + registerPartyRuntimePosition(distantMember!.id, [40, 0, 0]); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + })); + + const result = useCombatStore.getState().castAbility(abilityId, undefined, 1_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + const auraTargets = useCombatStore.getState().auras + .filter((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID + && aura.definition.name.startsWith(`${abilityName} (DBC`)) + .map((aura) => aura.targetId); + expect(auraTargets).toContain(PLAYER_AURA_ENTITY_ID); + expect(auraTargets).toContain(nearbyMember!.id); + expect(auraTargets).not.toContain(distantMember!.id); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.name.startsWith(abilityName))).toBe(true); + }); + + it.each([ + ["warrior-heroic-strike", "talent-124", 1, 2, 15], + ["warrior-heroic-strike", "talent-124", 3, 6, 15], + ["wow335-warrior-execute", "talent-1542", 1, 2, 15], + ["wow335-warrior-execute", "talent-1542", 3, 6, 15], + ])("applies the rank-scaled flat Rage discount to %s", (abilityId, talentId, rank, reduction, baseCost) => { + registerTarget(`flat-rage-cost-${abilityId}-${rank}`, [2, 0, 0], 100_000); + useCombatStore.getState().selectMob(`flat-rage-cost-${abilityId}-${rank}`); + if (abilityId === "wow335-warrior-execute") { + const target = useCombatStore.getState().mobs[`flat-rage-cost-${abilityId}-${rank}`]; + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [target.id]: { ...target, health: 19_000 } }, + })); + } + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + talentRanks: { ...state.talentRanks, [talentId]: rank }, + })); + + const result = useCombatStore.getState().castAbility(abilityId, undefined, 1_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(100 - baseCost + reduction); + }); +}); diff --git a/src/game/wowDruidNatureSwiftnessRuntime.test.ts b/src/game/wowDruidNatureSwiftnessRuntime.test.ts new file mode 100644 index 00000000..aef4b597 --- /dev/null +++ b/src/game/wowDruidNatureSwiftnessRuntime.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; + +describe("WoW Druid Nature's Swiftness", () => { + beforeEach(() => { + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-druid-nature-swiftness-test", 80, useCombatStore.getState().health); + const member = usePartyStore.getState().members[0]!; + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: 1 } + : candidate), + })); + registerPartyRuntimePosition(member.id, [5, 0, 0]); + usePartyStore.getState().selectMember(member.id); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + talentRanks: { ...state.talentRanks, "talent-831": 1 }, + })); + }); + + it("makes the next eligible Druid Nature heal instant until it is successfully cast", () => { + const state = useCombatStore.getState(); + const swift = state.abilities.find((ability) => ability.name === "Nature's Swiftness")!; + const heal = state.abilities.find((ability) => ability.name === "Healing Touch")!; + expect(swift).toBeDefined(); + expect(heal).toBeDefined(); + + const activation = state.castAbility(swift.id, undefined, 1_000); + expect(activation.ok, JSON.stringify(activation)).toBe(true); + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.targetId === PLAYER_AGGRO_ID + && candidate.definition.utilityTags?.includes("wow335-next-nature-instant") + )); + expect(aura?.definition.durationMs).toBeNull(); + + const cast = useCombatStore.getState().castAbility(heal.id, [0, 0, 0], 1_001); + expect(cast.ok, JSON.stringify(cast)).toBe(true); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(useCombatStore.getState().auras.some((candidate) => ( + candidate.definition.utilityTags?.includes("wow335-next-nature-instant") + ))).toBe(false); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "healing" && event.abilityId === heal.id && event.sourceActorId === PLAYER_AGGRO_ID + ))).toBe(true); + }); +}); diff --git a/src/game/wowNatureSwiftnessRuntime.test.ts b/src/game/wowNatureSwiftnessRuntime.test.ts new file mode 100644 index 00000000..d6324692 --- /dev/null +++ b/src/game/wowNatureSwiftnessRuntime.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; + +const NATURE_SWIFTNESS_ID = "wow335-shaman-nature-s-swiftness"; +const ELEMENTAL_MASTERY_ID = "wow335-shaman-elemental-mastery"; +const NON_NATURE_SPELL_ID = "shaman-flame-shock"; +const HEALING_WAVE_ID = "shaman-healing-wave"; + +describe("WoW Shaman Nature's Swiftness runtime", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-nature-swiftness-test", 80, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + })); + }); + + it("makes the next cast-time Nature spell instant, preserves the proc through Flame Shock, and shares the 3-minute cooldown", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-591": 1, "talent-573": 1 }, + })); + const member = usePartyStore.getState().members[0]!; + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: 1 } + : candidate), + })); + usePartyStore.getState().selectMember(member.id); + registerPartyRuntimePosition(member.id, [5, 0, 0]); + useCombatStore.getState().registerMob("nature-swiftness-target", { name: "Target", maxHealth: 10_000 }); + setMobPosition("nature-swiftness-target", [10, 0, 0]); + useCombatStore.getState().selectMob("nature-swiftness-target"); + + const activation = useCombatStore.getState().castAbility(NATURE_SWIFTNESS_ID, undefined, 1_000); + expect(activation.ok, JSON.stringify(activation)).toBe(true); + expect(useCombatStore.getState().cooldowns[ELEMENTAL_MASTERY_ID]).toBe(181_000); + expect(useCombatStore.getState().castAbility(ELEMENTAL_MASTERY_ID, undefined, 2_000)) + .toMatchObject({ ok: false, reason: "cooldown" }); + + expect(useCombatStore.getState().castAbility(NON_NATURE_SPELL_ID, undefined, 2_000)).toMatchObject({ ok: true }); + const natureSwiftnessAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes("wow335-next-nature-instant") + )); + expect(natureSwiftnessAura).toBeDefined(); + expect(natureSwiftnessAura?.definition.durationMs).toBeNull(); + expect(natureSwiftnessAura?.definition.modifiers ?? []).not.toContainEqual( + expect.objectContaining({ kind: "stat", stat: "haste" }), + ); + + const healed = useCombatStore.getState().castAbility(HEALING_WAVE_ID, [0, 0, 0], 4_000); + expect(healed.ok, JSON.stringify(healed)).toBe(true); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes("wow335-next-nature-instant") + ))).toBe(false); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "healing" + && event.sourceActorId === PLAYER_AGGRO_ID + && event.abilityId === HEALING_WAVE_ID + && event.targetActorId === member.id + ))).toBe(true); + }); +}); diff --git a/src/game/wowTidalForceRuntime.test.ts b/src/game/wowTidalForceRuntime.test.ts new file mode 100644 index 00000000..48bc53fe --- /dev/null +++ b/src/game/wowTidalForceRuntime.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; + +function abilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +describe("WoW Shaman Tidal Force", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-tidal-force-test", 80, useCombatStore.getState().health); + const members = usePartyStore.getState().members; + usePartyStore.setState((state) => ({ + members: state.members.map((member) => ({ ...member, health: 1 })), + })); + members.forEach((member, index) => registerPartyRuntimePosition(member.id, [4 + index, 0, 0])); + useCombatStore.setState((state) => ({ + health: 1, + gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 }, + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + talentRanks: { ...state.talentRanks, "talent-582": 1, "talent-591": 1 }, + })); + useCombatStore.getState().registerMob("tidal-force-target", { name: "Target", maxHealth: 1_000_000 }); + setMobPosition("tidal-force-target", [8, 0, 0]); + useCombatStore.getState().selectMob("tidal-force-target"); + if (members[0]) usePartyStore.getState().selectMember(members[0].id); + }); + + it("adds 60% crit to its three healing spells and spends one charge for each critical heal", () => { + const tidalForceId = abilityId("Tidal Force"); + const natureSwiftnessId = abilityId("Nature's Swiftness"); + const flameShockId = abilityId("Flame Shock"); + const chainHealId = abilityId("Chain Heal"); + + expect(useCombatStore.getState().castAbility(tidalForceId, undefined, 1_000).ok).toBe(true); + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.targetId === PLAYER_AGGRO_ID + && candidate.definition.utilityTags?.includes("wow335-tidal-force") + )); + expect(aura?.stacks).toBe(3); + expect(aura?.definition.durationMs).toBe(2_000); + + expect(useCombatStore.getState().castAbility(flameShockId, undefined, 1_001).ok).toBe(true); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === flameShockId && event.critical + ))).toBe(true); + expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura?.instanceId)?.stacks) + .toBe(3); + + const now = 2_502; + expect(useCombatStore.getState().castAbility(natureSwiftnessId, undefined, now).ok).toBe(true); + const instantHeal = useCombatStore.getState().castAbility(chainHealId, undefined, now + 1); + expect(instantHeal.ok, JSON.stringify(instantHeal)).toBe(true); + expect(useCombatStore.getState().activeCast).toBeNull(); + + const criticalChainHeals = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "healing" && event.abilityId === chainHealId && event.critical + )); + expect(criticalChainHeals.length).toBeGreaterThanOrEqual(3); + expect(useCombatStore.getState().auras.some((candidate) => ( + candidate.definition.utilityTags?.includes("wow335-tidal-force") + ))).toBe(false); + }); +}); diff --git a/src/scene/CombatSummonPopulation.tsx b/src/scene/CombatSummonPopulation.tsx index 283958fb..ce307c50 100644 --- a/src/scene/CombatSummonPopulation.tsx +++ b/src/scene/CombatSummonPopulation.tsx @@ -79,10 +79,39 @@ function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon; ; } +function LightwellMember({ summon }: { readonly summon: CombatSummon }) { + const click = (event: { stopPropagation: () => void }) => { + event.stopPropagation(); + useCombatStore.getState().clickLightwell(summon.id); + }; + return + + + + + + + + + + + + + + + + + + ; +} + /** Player and companion combat summons live outside the static dungeon population. */ export function CombatSummonPopulation({ active }: { readonly active: boolean }) { const summons = useCombatStore((state) => state.summons); return + {summons.filter((summon) => summon.sourceAbilityId === "wow335-priest-lightwell" + && summon.clickCharges && summon.health > 0) + .map((summon) => )} {summons.filter((summon) => summon.creatureId === SHADOWFIEND_CREATURE_ID && summon.health > 0) .map((summon) => )} ; diff --git a/src/scene/GameScene.tsx b/src/scene/GameScene.tsx index 6144e988..a0cecf06 100644 --- a/src/scene/GameScene.tsx +++ b/src/scene/GameScene.tsx @@ -11,6 +11,7 @@ import { import { Canvas, useThree } from "@react-three/fiber"; import { BiteSizedEditorMarkers } from "./BiteSizedEditorMarkers"; import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore"; +import { focusBiteSizedEditorScene } from "../game/biteSizedEditorInput"; import { Physics } from "@react-three/rapier"; import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { spawnMatchesDifficulty } from "../game/mobPopulation"; @@ -321,6 +322,8 @@ export function GameScene() { return (
{ if (useBiteSizedLayoutsStore.getState().editorMode) focusBiteSizedEditorScene(); }} onPointerDown={beginMouseLook} onLostPointerCapture={endMouseLook} onContextMenu={(event) => event.preventDefault()} diff --git a/src/scene/OriginalCombatEffect.tsx b/src/scene/OriginalCombatEffect.tsx index 77be3fc3..2dd67140 100644 --- a/src/scene/OriginalCombatEffect.tsx +++ b/src/scene/OriginalCombatEffect.tsx @@ -833,7 +833,7 @@ function LoadedOriginalCombatEffect({ {graph.kind === "runewaker-ros-particle-archive" ? ( + playbackRef={staticMeshPlaybackRef} opacity={tuning.opacity} /> ) : graph.kind === "wow-m2-static-mesh" ? ( ; + readonly opacity: number; }) { const system = useRef({ particles: Array(budget).fill(null), debt: 0, births: 0, lastTime: 0 }); const geometry = useMemo(() => { @@ -261,7 +262,7 @@ const RuneEmitter = memo(function RuneEmitter({ particle.origin[1] + particle.velocity[1] * age + particle.acceleration[1] * ageSquared, particle.origin[2] + particle.velocity[2] * age + particle.acceleration[2] * ageSquared); colors.setXYZW(index, color[0], color[1], color[2], - Math.max(0, Math.min(1, alpha * color[3] * globalAlpha))); + Math.max(0, Math.min(1, alpha * color[3] * globalAlpha * opacity))); sizes.setXY(index, Math.max(0.001, scale * layer.sprite.quadScale[0]), Math.max(0.001, scale * layer.sprite.quadScale[1])); rotations.setX(index, layer.sprite.orientToTravel @@ -276,7 +277,7 @@ const RuneEmitter = memo(function RuneEmitter({ }); const RuneEntityEmitter = memo(function RuneEntityEmitter({ - graph, emitter, visualizer, texture, budget, active, eventId, timelineRef, + graph, emitter, visualizer, texture, budget, active, eventId, timelineRef, opacity, }: { readonly graph: RunewakerParticleGraph; readonly emitter: RuneEmitterController; @@ -286,6 +287,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({ readonly active: boolean; readonly eventId: number; readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; + readonly opacity: number; }) { const system = useRef({ particles: Array(budget).fill(null), debt: 0, births: 0, lastTime: 0 }); const pool = useMemo(() => { @@ -365,7 +367,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({ mesh.scale.setScalar(Math.max(0.001, scale)); mesh.rotation.y = next.orientation; mesh.material.color.setRGB(color[0], color[1], color[2]); - mesh.material.opacity = Math.max(0, Math.min(1, alpha * color[3])); + mesh.material.opacity = Math.max(0, Math.min(1, alpha * color[3] * opacity)); mesh.visible = scale > 0.001 && mesh.material.opacity > 0.002; } }); @@ -376,7 +378,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({ }); const RuneGroundDecal = memo(function RuneGroundDecal({ - decal, alpha, color, texture, active, timelineRef, + decal, alpha, color, texture, active, timelineRef, opacity, }: { readonly decal: RuneDecalController; readonly alpha: RuneGlobalAlphaController; @@ -384,6 +386,7 @@ const RuneGroundDecal = memo(function RuneGroundDecal({ readonly texture: Texture; readonly active: boolean; readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; + readonly opacity: number; }) { const meshRef = useRef>(null); const geometry = useMemo(() => new PlaneGeometry(2, 2), []); @@ -405,10 +408,10 @@ const RuneGroundDecal = memo(function RuneGroundDecal({ const time = timelineRef.current.elapsedMs / 1_000; const radius = runeSample(decal.tracks[2], time, 0); const rotation = runeSample(decal.tracks[1], time, 0); - const opacity = runeSample(alpha.alphaTrack, Math.max(0, time - alpha.startTime), 0); + const sourceOpacity = runeSample(alpha.alphaTrack, Math.max(0, time - alpha.startTime), 0); const tint = runeSample(color.tracks[0], time, [1, 1, 1, 1]); material.color.setRGB(tint[0], tint[1], tint[2]); - material.opacity = Math.max(0, Math.min(1, opacity * tint[3])); + material.opacity = Math.max(0, Math.min(1, sourceOpacity * tint[3] * opacity)); mesh.scale.set(radius, radius, 1); mesh.rotation.z = rotation; mesh.visible = time <= alpha.startTime + alpha.duration && material.opacity > 0.002; @@ -421,7 +424,7 @@ const RuneGroundDecal = memo(function RuneGroundDecal({ }); export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({ - graph, textures, phase, quality, active, eventId, playbackRef, + graph, textures, phase, quality, active, eventId, playbackRef, opacity = 1, }: { readonly graph: RunewakerParticleGraph; readonly textures: ReadonlyMap; @@ -430,6 +433,7 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({ readonly active: boolean; readonly eventId: number; readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; + readonly opacity?: number; }) { const layers = useMemo(() => runeLayers(graph, phase), [graph, phase]); const motion = useMemo(() => runeMotion(graph, phase), [graph, phase]); @@ -457,18 +461,18 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({ const budget = Math.max(1, Math.min(sourceMaximum, Math.floor(limit / Math.max(1, layers.length)))); return ; })} {graph.runeMesh && runeEntity && runeEmitter && textures.get(graph.runeMesh.textureAssetUrl) ? : null} {decal && decalAlpha && decalColor && textures.get(decal.textureAssetUrl) ? + texture={textures.get(decal.textureAssetUrl)!} active={active} opacity={opacity} timelineRef={playbackRef} /> : null} ; }); diff --git a/src/scene/combatEffectRuntime.test.ts b/src/scene/combatEffectRuntime.test.ts index ebe659ed..bcddc156 100644 --- a/src/scene/combatEffectRuntime.test.ts +++ b/src/scene/combatEffectRuntime.test.ts @@ -50,6 +50,22 @@ function enqueue( } describe("combat-effect runtime budget", () => { + it("softens Flash Heal's impact without shrinking its cast visual", () => { + for (const abilityId of ["priest-flash-heal", "wow335-priest-flash-heal"]) { + expect(combatEffectVisualTuning(event(1, { abilityId }))) + .toEqual({ scale: 0.38, opacity: 0.22 }); + expect(combatEffectVisualTuning(event(2, { abilityId, phase: "cast-start" }))) + .toEqual({ scale: 1, opacity: 1 }); + } + }); + + it("keeps Arcane Explosion's large cast sphere readable", () => { + expect(combatEffectVisualTuning(event(1, { abilityId: "wow335-mage-arcane-explosion", phase: "cast-start" }))) + .toEqual({ scale: 0.65, opacity: 0.5 }); + expect(combatEffectVisualTuning(event(2, { abilityId: "wow335-mage-arcane-explosion", phase: "impact" }))) + .toEqual({ scale: 1, opacity: 1 }); + }); + it("contains the Dispel Magic impact without shrinking Mass Dispel", () => { expect(combatEffectVisualTuning(event(1, { abilityId: "wow335-priest-dispel-magic" }))) .toEqual({ scale: 0.45, opacity: 0.4 }); @@ -59,6 +75,18 @@ describe("combat-effect runtime budget", () => { .toEqual({ scale: 1, opacity: 1 }); }); + it("softens the RoM Mage electric impacts without changing cast cues", () => { + for (const [sourceSpellId, expected] of [ + [491168, { scale: 0.45, opacity: 0.32 }], + [491169, { scale: 0.6, opacity: 0.45 }], + ] as const) { + expect(combatEffectVisualTuning(event(1, { source: "runewaker", sourceSpellId }))) + .toEqual(expected); + expect(combatEffectVisualTuning(event(2, { source: "runewaker", sourceSpellId, phase: "cast-start" }))) + .toEqual({ scale: 1, opacity: 1 }); + } + }); + it("delays source-backed target impacts until their projectiles arrive", () => { const release = event(1, { source: "runewaker", sourceSpellId: 490256, phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] }); diff --git a/src/scene/combatEffectRuntime.ts b/src/scene/combatEffectRuntime.ts index 19e0bf53..6f92f32d 100644 --- a/src/scene/combatEffectRuntime.ts +++ b/src/scene/combatEffectRuntime.ts @@ -17,13 +17,22 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly, + event: Pick, ): { readonly scale: number; readonly opacity: number } { if ((event.abilityId === "priest-flash-heal" || event.abilityId === "wow335-priest-flash-heal") && event.phase === "impact") { - return { scale: 0.6, opacity: 0.4 }; + return { scale: 0.38, opacity: 0.22 }; + } + if (event.abilityId === "wow335-mage-arcane-explosion" && event.phase === "cast-start") { + return { scale: 0.65, opacity: 0.5 }; + } + if (event.source === "runewaker" && event.phase === "impact" && event.sourceSpellId === 491168) { + return { scale: 0.45, opacity: 0.32 }; + } + if (event.source === "runewaker" && event.phase === "impact" && event.sourceSpellId === 491169) { + return { scale: 0.6, opacity: 0.45 }; } return event.abilityId === "wow335-priest-dispel-magic" && event.phase === "impact" ? { scale: 0.45, opacity: 0.4 } diff --git a/src/ui/BiteSizedEditor.tsx b/src/ui/BiteSizedEditor.tsx index e579bdeb..07d1f4dc 100644 --- a/src/ui/BiteSizedEditor.tsx +++ b/src/ui/BiteSizedEditor.tsx @@ -9,6 +9,7 @@ import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent"; import { validateChangedBiteSizedSpots } from "../game/biteSizedLayoutValidation"; import { FrontSurface } from "./FrontSurface"; import { releasePointerLock } from "../game/inputManager"; +import { focusBiteSizedEditorScene } from "../game/biteSizedEditorInput"; import { useDungeonRunStore } from "../game/dungeonRunStore"; import "./biteSizedEditor.css"; @@ -96,6 +97,7 @@ export function BiteSizedEditor({ inGame = false }: { readonly inGame?: boolean {!collapsed &&

{layouts.dirty ? "Unsaved draft" : `Published revision ${layouts.revision}`} · {layouts.editorMode === "test" ? `${testComplete ? "Test complete" : "Test run"} — rewards disabled` : inGame ? "Combat disabled · walk to place points" : "Choose a spot to visit"}

+ {inGame &&

Click the world or Walk in scene, then use WASD. Hold right mouse to look; Space jumps. Panel controls pause movement.

} {!inGame && } {spot && <>