diff --git a/docs/ACTIVE_CLASS_AUDIT.md b/docs/ACTIVE_CLASS_AUDIT.md index bdb41741..9be83b7d 100644 --- a/docs/ACTIVE_CLASS_AUDIT.md +++ b/docs/ACTIVE_CLASS_AUDIT.md @@ -1,6 +1,12 @@ # 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. +Updated 2026-09-30. 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. + +## Class-mechanics source precedence + +- For WoW, use the existing generated class, rank, and talent entries wherever they contain the relevant data. Consult [WotLKDB 3.3.5a](https://wotlkdb.com/) only to fill a missing tooltip or spell detail; do not replace a populated generated value with a conflicting database row. +- For RoM, keep populated RuneWaker generated entries and local source mappings authoritative. Consult the [Chronicles of Arcadia Wiki](https://chroniclesofarcadia.com/en/wiki/skills/list) only when tooltip or spell details are genuinely missing; do not import a conflicting later version into the local RuneWaker mechanic. +- This mechanics follow-up excludes visual and sound tuning. The visual findings below record work from the earlier visual pass. ## Reviewed scope @@ -47,10 +53,12 @@ Manifest coverage means an effect mapping exists; a graph means an original effe - 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 Death Knight Anti-Magic Shell now grants 2% Runic Power per point actually absorbed by its live magic-damage shield, respecting the source absorb cap and the Runic Power maximum; damage the shell does not absorb grants none. - 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 Warlock Death's Grasp now applies its rank 1/2/3% hit bonus to direct player ability attacks and its matching spell haste to spell casts; auto-attacks, periodic ticks, and other classes do not receive the hit bonus. - 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 Hunter Readiness 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 generated rank's 50%/100% chance and uses the generated tooltip's six seconds, +15% damage, and -50% mana cost; the populated generated talent entry takes precedence over the conflicting extracted spell row. - 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. @@ -60,41 +68,65 @@ Manifest coverage means an effect mapping exists; a graph means an original effe - 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 Mage/Druid Green Guardian now preserves the two RuneWaker HoT payloads, including its target-max-health percentage, and schedules both for five two-second ticks when either rank of Mother Earth's Protection expires. Player and party casters are supported, and healing goes to the protection recipient, including a party ally; no CoA Wiki fallback was needed. +- RoM Warrior/Scout Calm now grants 20 physical hit for 15 seconds only after its paired Skull Breaker fails; landing the attack, failing another ability, and using Warrior without Scout do not trigger it. - RoM Scout/Warden Thorn Arrow now requires the Briar Shield buff before it can be cast. +- RoM Warden Briar Shield (493642) now applies RuneWaker component 503958's 900-second self aura and +5% armor without dealing damage on cast. A landed physical melee or ranged attack reflects component 503959's level-scaled fraction of main-hand weapon DPS to its attacker within 50 range. The local `runewakerOrgPower(-0.01, characterLevel, 8)` conversion yields 7.4% at level 80 and 9.4% at level 105; no later Chronicles of Arcadia trigger or stat values were imported. - 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. +- Champion Rune Pulse's initial Rage-aura mapping was later corrected to its landed-hit behavior in the follow-up below. - 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 Focused Will preserves its generated +1/2/3 percentage points of spell critical chance; each landed incoming critical hit grants +3% healing received per stack for 10 seconds, up to three stacks, for player and party Priests. Its conditional healing clause is excluded from unconditional talent modifiers. Player and companion incoming-hit regressions pass. - 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. +- WoW Paladin Blessed Life's generated rank tooltips say that all attacks against the Paladin have a 4%/7%/10% chance (ranks 1/2/3) to cause half damage. Player and party enemy-hit paths use stable per-target/per-mob/per-attack/per-revision/per-resolution rolls only after a landed result, after `resolveEnemyDamageResult` and `romWardenWillAttackDamage`, and before mitigation and absorbs. Regressions cover both target paths, rank thresholds, misses, and unrelated party targets. +- 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 increase enemy attack crit chance. Divine Sacrifice tracks its source-defined 150%-of-caster-max-health redirect cap. Redemption enforces its out-of-combat gate, and Aura Mastery grants eligible recipients ten-second silence and interrupt immunities while doubling modifiers from the same Paladin's active aura on each recipient. For duration, the runtime follows local effect duration index 32 and the generated 31821 spell row (10 seconds); the generated talent-rank tooltip instead says 6 seconds, so that local source conflict remains explicit. Aura amplification applies only to the existing modifier model and does not supply missing Paladin aura mechanics. Redoubt follows the generated rank tooltip: shield-equipped block chance is 4/7/10%, each block has a 10% proc chance, and the rank-matched Strength/damage reduction lasts 10 seconds; WotLKDB supplied the missing child duration only. Vindication now uses generated rank chances (17/34/65%) and the local `4.5 × caster level − 3` reduction, with WotLKDB child 67 filling only its missing ten-second duration. Its source-owned debuff reduces the attack-power-derived part of modeled enemy melee damage. The capstone AP gain remains unresolved because its amount/operation is unstated. Focused Paladin tests cover these changes. +- WoW Mage wires Improved Scorch, Fingers of Frost, Hot Streak, and Arcane Concentration from rank-specific data. Clearcasting procs at 5/10/15%, lasts 15 seconds, and makes the next successful active Mage ability free of Mana, Energy, and Rage cost; the missing duration comes from WotLKDB. Interrupted timed casts retain the proc, while channels consume it at their upfront-cost start. Empowered Fire restores 2% of the local level-based Mage base Mana on each positive Ignite tick, capped at the caster's Mana maximum; rank 3 also allows that caster's Living Bomb periodic damage to critically strike. Its generated 7/14/20% rider now increases only the spell-power coefficient of Fireball, Pyroblast, and Living Bomb, leaving DBC base points and die ranges unchanged for player and party Mages. +- RoM Warlock Ruthless Judgment (498544) preserves its RuneWaker damage payload and grants the caster the sourced 20-second, +5% spell-damage aura only when the target is below 20% health. The target does not receive the aura; player and companion casts are covered. Its focused runtime suite passed 12 tests. +- WoW Shaman now wires Ancestral Awakening, Ancestral Healing, Shamanism, rank-scaled Reverberation, Improved Water Shield, Tidal Waves, and Riptide's target-scoped Chain Heal rider for player and party casters. Improved Water Shield restores the local Water Shield orb mana on qualifying critical Nature heals and doubles its chance for Healing Wave. Tidal Waves uses generated ranks for charges, Healing Wave cast-time reduction, Lesser Healing Wave crit chance, and its 4/8/12% spell-power coefficient; WotLKDB child 53390 fills only the missing 15-second duration. Riptide's 25% Chain Heal boost consumes only the caster's matching HoT on the primary target. Party Shaman heal events now use their actual Nature school for proc checks. Combined Shaman/player/party focused suites passed 54 tests. - 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. +- WoW Hunter's Intimidation proc is attributed to the pet and consumes on its successful melee hit. Kill Command retains its generated 30-second pet aura, but the 1%-per-special-attack rider remains unsupported because the generated tooltip omits the starting bonus and the simulator emits no distinct pet-special event. Ordinary melee hits do not consume it; the fabricated 60-stack placeholder was removed. The focused Hunter suite passed 12 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. +- 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. Weakening Seed and Withering Seed select their RuneWaker base or empowered modifier component instead of dropping or applying both branches. 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; the Seed and caster-resource follow-up passed 86 tests across five files. +- 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. Tough Oak (494207) also reduces Warden/Mage Punch and Sepal Stab cooldowns by one second each. Briar Shield (493642) applies its sourced 900-second +5% armor aura and reflects local formula 503959's level-scaled main-hand DPS fraction on landed physical melee/ranged attacks within 50 range. Its focused runtime suite passed 15 tests. +- RoM Knight Divine Punishment stores its passive as a marker and applies the extracted 1% bonus only to Punishment damage/DoT components. Knight/Priest Holy Light's Fury (491635) now reduces Holy Seal cooldown by three seconds; its additional damage rider has no numeric payload. The current CoA patch changes the same skill identity to an 8% healing effect, so local RuneWaker values remain authoritative here. Knight periodic/mechanics suites passed 9 tests in this follow-up. 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. It also applies the generated −2 attack-power debuff to the hit target for 10 seconds; modeled enemy melee damage now reduces only its attack-power-derived portion while this aura is active. 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 42 tests across four files. +- WoW Warlock Metamorphosis now applies the generated 30-second form and its +130% armor contribution, +15% damage, −6 melee critical chance, and −25% stun/snare duration modifiers. It gates Fire/Shadow or shapeshift-safe abilities and adds Shadow Cleave, Challenging Howl, Demon Charge, and Immolation Aura; Immolation uses its local AP-reduction formula. The PvP-only +10% damage rider has no modeled player target. Four focused Warlock suites passed 39 tests. - 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. +- 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. Impact's 3/6/9% rank damage bonus is scoped to Fire Blast and Meteor, not all Fire spells. The focused Mage/Impact suites passed 14 tests across two files. The local legacy DBC differs on proc chance and stun duration. +- WoW Priest Lightwell now maps each summoned rank to its Renew child and applies its source-backed three ticks at two-second intervals over six seconds, including rank-specific base healing and the five ranks with local spell-power coefficient rows. Its generated rank tooltip's 30%-of-max-health cancellation rule now accumulates effective HP damage separately per recipient and removes that recipient's HoT at threshold; mitigated and absorbed damage do not count. The existing 180-second duration and ten-click limit remain. +- RoM Priest/Warlock Soul Cleansing Ceremony now gates Cleanse's curse-removal category; without the passive, Cleanse remains limited to magic, poison, and disease. Its integration regression verifies that an ally curse is removed with the pair passive. +- WoW Warlock Death's Grasp now gives 1/2/3% hit to direct player ability hits and 1/2/3% spell haste to spell casts; auto-attacks and periodic ticks do not receive the hit bonus. +- WoW Death Knight Anti-Magic Shell now converts actual consumed magic absorb into Runic Power (2% of absorbed damage in the modeled resource scale), respecting shell capacity and resource caps. - 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 Rogue/Mage Day of Rain (491183) now loses one second of cooldown whenever Throw (490309) is cast and uses a 50% miss chance while Enchanted Throw (491178) is active. Four focused combat/catalog suites passed 110 tests, including cooldown, active-buff tagging, and resolved hit/miss coverage. - 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. +- RoM Champion Rune Pulse (498529) now restores its source-translated 5 Rage on a landed damage hit instead of granting a cast-duration Rage regeneration aura. Its source component is 622327; Champion runtime tests now cover the effect. +- RoM Mage/Warlock Elemental Extraction (499814) now accepts critical hits from only the RuneWaker-listed triggers 499812 and Electric Explosion (491170); Flame (491150) no longer procs its target defense debuff. Four paired/class suites passed 117 tests. +- RoM Mage Elemental Weakness rank 1 (490247) now applies its sourced 30-second −30 all-resistance debuff to each of the five resistance fields used by combat damage; rank 2 keeps its separate magical-defense effect. +- RoM Mage/Priest Grand Dispel (492928) now makes Mage/Priest Purify remove modeled harmful magic, curse, poison, or disease effects while preserving its one-target cleanse behavior. +- RoM Priest/Warlock Soul Cleansing Ceremony (498771) now lets its pair-scoped passive expand Cleanse (491175) to curses; without the passive, Cleanse remains limited to magic, poison, and disease. Eight Priest/catalog suites passed 119 tests. +- WoW Priest Lightwell now selects the matching Renew child per summoned rank and heals for three ticks at two-second intervals over six seconds, with source-backed per-tick base healing and the five sourced spell-power coefficients. Its generated tooltip's 30%-of-max-health damage cancellation now accumulates effective HP damage per recipient; fully mitigated/absorbed hits do not count. Its existing 180-second duration and ten-click limit remain. - 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. +- WoW Druid Savage Fury now scopes its generated 4/8/12% damage bonus to all six named abilities; its dedicated regression verifies those scopes at all three ranks and excludes unrelated attacks. +- WoW Druid Living Seed now stores 30% of an eligible critical heal at talent ranks' 33/66/100% proc chances, lasts 15 seconds, and blooms on the target's next landed direct hit. The runtime covers the player and party targets; the two tooltip-listed heals absent from the local WoW Druid ability catalog cannot trigger. - 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. +- WoW Paladin Holy Shield now spends its eight successful-block charges, matching `procCharges=8` on local Spell.dbc ranks 20925, 20927, and 20928. The focused Paladin runtime suite passed 17 tests. +- WoW Mage Flame Throwing adds its generated 2/4/6 yards to hostile damaging-spell range for players and companions; healing range and area-effect radius stay unchanged. The active hostile-cast range paths, including ground targeting, use the bonus. +- WoW Warrior Anger Management grants its generated 2/4/6 Rage every three seconds. Charge grants its local rank's 9/12/15 base Rage plus the talent's 5/10/15 bonus; rank-three Charge also applies the generated 30% damage reduction for four seconds. Charge's generated out-of-combat requirement is enforced for player and companion casts. +- RoM Priest/Warrior Battle Monk Stance lasts the local RuneWaker 900 seconds and gates skills 491362, 491363, 491364, 492635, and 493038. Player casts and companion readiness/target validation require an active, unexpired stance aura. ## Confirmed remaining behavior gaps @@ -102,29 +134,32 @@ These are specific, source-backed gaps that still need implementation or a gamep ### 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. +- **Hunter:** Freezing Arrow, Freezing Trap, Frost Trap, Immolation Trap, and Explosive Trap now have armed-trap runtime behavior for players and companions. Local DBC create-trap rows supply placement lifetime and trigger radius; Freezing Trap uses its local child-spell freeze duration by rank (10/15/20 seconds), and damage breaks the freeze. Companion self-placed traps are recorded at the Hunter's current position and retain their owner and learned rank; Frost Trap uses that owner's Entrapment rank, and Immolation/Explosive Trap damage uses owner level, gear, and talent values captured when the trap triggers. Frost Trap's local aura row supplies a 10-yard, 50% slow zone lasting 30 seconds with a two-second pulse; Entrapment uses generated rank slow bonuses (4/10/15%), local rank proc values (2/4/6%), and its three-second root child. Immolation Trap uses its local child-rank periodic values, five fire ticks every three seconds, and the missing numeric 2% ranged-attack-power-per-tick coefficient from [WotLKDB](https://wotlkdb.com/). Intimidation stores and consumes its next-melee proc on the pet. Beast Within follows the generated rank tooltip (50/100% proc on Bestial Wrath, six-second damage/mana riders) instead of conflicting extracted child amounts. Kill Command retains its generated 30-second pet aura, but its starting damage percentage remains unavailable: the generated tooltip says “listed amount” and the [WotLKDB row](https://wotlkdb.com/?spell=34026) exposes only a server-side dummy effect. The source's 1%-per-special-attack decay also awaits a pet-special event path. Ordinary melee hits do not consume that aura; no 60-stack or damage amount is invented. +- **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, Test of Faith, Lightwell's rank-specific six-second click heal, and Pain and Suffering have focused runtime behavior. Pain and Suffering uses its generated 33/66/100% proc ranks after Mind Flay, Mind Sear, or Mind Blast and refreshes only the caster's Shadow Word: Pain on that target; the player and companion cast paths preserve the DoT payload. Spell Warding now reduces direct and periodic magic damage by its generated 1/2/3% ranks and leaves physical hits unchanged. Lightwell's generated 30%-of-max-health damage cancellation is implemented per recipient using effective HP damage; its visual remains unreviewed in live gameplay. The rank-6 Renew child (48085) has no local spell-power coefficient row. WotLKDB sibling Renew rows (including [spell 28276](https://wotlkdb.com/index.php?spell=28276)) list 62.6% per tick, while the local/AzerothCore coefficient used for the first five ranks is 33.33%; the exact rank-6 WotLKDB row was unavailable, so no sibling coefficient was inferred. +- **Death Knight:** Death Coil's friendly Undead heal, Death Strike disease-count self-heal, Anti-Magic Shell and Zone absorbs, Anti-Magic Shell Runic Power from actual absorbed magic damage, Bone Shield charges, Blood Tap rune restriction, Deathchill, Improved Rune Tap, Butchery's eligible-kill Runic Power, Rime's generated 5/10/15% Obliterate proc (Howling Blast cooldown reset and one persistent, one-use rune-free Howling Blast), Mark of Blood's generated 4%-max-health heal on landed damage (20 charges, 20-second duration), Scent of Blood, and Sudden Doom's generated 5/10/15% chance after landed Blood Strike/Heart Strike to immediately dispatch one free Death Coil at the same target have focused fixes/tests. Scent of Blood uses generated 15% proc chance after dodge, parry, or direct damage; ranks 1/2/3 grant that many melee-hit charges, each restoring 10 Runic Power. Player and party incoming-event/landed-melee paths are covered, one actual hit consumes only one charge even when the combat event has multiple damage packets, Runic Power respects its cap, and child 50421 expires after 20 seconds. Its trigger rank page [49509 on WotLKDB](https://wotlkdb.com/?spell=49509) shows the 15% proc links but no duration; the missing child duration comes from the matching 3.3.5 [spell 50421 row at WoWRoad](https://wowroad.info/?spell=50421), corroborated by [DVG's 80-level WLK record](https://80.dvg.cn/?spell=50421). Sudden Doom is covered for player and party Death Knights; the triggered player cast preserves the GCD and waives Runic Power, while party effect execution skips a second ability-use commit. Blood-Caked Blade and other conditional/pet/disease talents remain outside this pass. Populated local Hysteria talent and class spell rows disagree on its physical damage bonus (15% vs. 20%), and Dancing Rune Weapon rows disagree on the reduction to its duplicate attacks (50% vs. 10%); these conflicts remain unresolved rather than choosing one value. 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, Unfair Advantage's rank 1/2 50/100% main-hand weapon-damage retaliation for player and party Rogues after dodge or parry, with a per-Rogue two-second limit, and future-only Tricks threat routing are covered. Initiative, Ruthlessness, and rank-3 Honor Among Thieves 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 now runs on distinct off-hand swings: ranks 1/2/3 use the local 33/66/100% chances for 5 Energy, and rank 3 reduces active Blade Flurry and Killing Spree cooldowns by one second per landed off-hand hit. Improved Kick's max-rank capstone grants 40 Energy and 35% attack speed for six seconds only after a successful interrupt, for player and party Rogues; the talent's movement-speed cap rider is not modeled because enemies have no movement-speed bonus state. The catalog has no Combat Readiness ability to receive the named reduction. 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, rank-scaled Reverberation, Maelstrom Weapon, Elemental Oath, Improved Water Shield, Tidal Waves, and Riptide's Chain Heal rider have focused player and party behavior. Convection now applies its generated 3/6/9/12/15% mana-cost reduction to each modeled listed spell through explicit ability scopes; the source-listed Earthquake has no local Shaman ability row. Improved Water Shield restores the rank-scaled Water Shield orb mana on eligible critical Nature heals (double chance for Healing Wave); the generated talent ranks remain authoritative. Tidal Waves grants three Chain Heal or two Riptide charges at the generated rank chance; charges reduce Healing Wave cast time or add Lesser Healing Wave critical chance, and its 4/8/12% spell-power coefficient applies to both heals. Riptide's 25% Chain Heal increase applies only to the caster's matching active Riptide HoT on Chain Heal's primary target, which is then consumed. WotLKDB child 53390 fills only Tidal Waves' missing 15-second duration. Elemental Oath (`talent-2049`) adds its generated 1/2/3 percentage points once to Shaman and party spell-damage and healing critical chance; melee and ranged critical chance are unchanged. Reverberation follows its generated rank tooltip: a successful Shock has the rank's 33/66/100% chance to arm an eight-second, 20% bonus for the next different Shock or Elemental Blast; linked child 965977 is missing, but the generated tooltip contains the implemented mechanic. Elemental Focus grants rank-scaled Clearcasting with two discounted casts and expires after 15 seconds, matching patch-D child spell 16246 and SpellDuration row 8; its focused runtime test covers expiry. Maelstrom Weapon follows its generated eligible-spell list, 20% cast-time reduction per stack, five-stack cap, and whole-bundle next-spell consumption. WotLKDB fills the absent 4/8/12 procs-per-minute rank rates and 30-second duration; the per-hit chance scales with the represented weapon speed. The combat path does not identify which hand hit for ordinary autoattacks or Stormstrike, so those use main-hand speed. Fireball/Frostbolt are absent from the Shaman catalog, and its 2%-per-stack 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's shield requirement and eight source-defined block charges, Divine Sacrifice redirect cap, aura parsing, Aura Master's 10-second silence and interrupt immunity, Avenger's Shield's three-target/no-falloff chain, Redoubt, and Vindication have targeted fixes or runtime coverage. Redoubt follows generated ranks and requires a shield; its 10% block proc grants rank-scaled Strength and damage reduction. Vindication uses generated 17/34/65% rank chances and the local `4.5 × caster level − 3` attack-power reduction; WotLKDB child 67 supplies only the missing 10-second duration. The source-owned nonstacking debuff reduces the attack-power-derived portion of modeled enemy melee damage. Its capstone attack-power gain remains unresolved because the generated tooltip does not specify its amount or exact operation. Aura Master's amplification and active/UI duration/scope discrepancy also remain open. +- **Mage:** Blink root/stun clearing, Improved Scorch, Fingers of Frost, Hot Streak, Arcane Concentration/Clearcasting, Brain Freeze, Missile Barrage, active-row Impact's next-Fire-Blast stun, and Impact's 3/6/9% damage scope on Fire Blast and Meteor have focused coverage. Clearcasting follows generated 5/10/15% proc ranks, spends on the next successful Mage ability, and expires after 15 seconds (duration filled from WotLKDB). Brain Freeze follows its generated trigger ranks and applies a 15-second one-use proc that makes the next Fireball or Frostfire Bolt instant, free of Mana, and 25% stronger; Frozen Orb is absent from the learnable local catalog. Missile Barrage reads per-spell chances from its generated rank descriptions, triggers on landed direct damage from available listed spells or a successfully applied Slow debuff, and makes the next Arcane Missiles channel half as long with half-spaced pulses and no Mana cost; missing child duration comes from WotLKDB's 15-second row. Empowered Fire restores 2% of the caster's local level-based base Mana per positive Ignite tick, respects that caster's Mana cap, and grants rank 3 Living Bomb periodic criticals; its generated 7/14/20% coefficient increase is applied only to Fireball, Pyroblast, and Living Bomb for player and party Mages. Flame Throwing adds the generated 2/4/6 yards to hostile damaging-spell range without expanding healing range or AoE radius. Torment the Weak applies its generated 3/6/9% bonus only to Fireball, Frostbolt, Frostfire Bolt, Pyroblast, Arcane Missiles, Arcane Blast, and Arcane Barrage against a target with an active movement- or attack-speed slow; player and party direct/channel paths are covered. Improved Counterspell applies the generated 4/7/10-second cooldown reduction only to Counterspell and adds its generated two-second silence only after it interrupts an active cast; player and party paths are covered. Frostbite uses its generated 5/10/15% proc chance and four-second freeze for player Frost damage and now also party direct Frost hits and generated Blizzard channel pulses, using actual landed positive damage. Non-player Frost timed/area damage events have an owner-aware hook, but no learnable pure-Frost Mage ground effect currently reaches the area-tick path; no synthetic spell was added. The player path remains on the existing shared damage hook. These mechanics use generated local ranks, so no WotLKDB lookup was needed. Molten Fury suppresses its generic always-on scalar and applies the generated 6%/12% Fire, Frost, or Arcane damage bonus only below 35% target health for player direct/periodic/area damage and party direct/periodic damage; party direct damage includes same-event Sunder bonuses before multiplication, and the threshold is reevaluated per hit or tick. Arcane Orb, Moonfire, Starfire, and Starsurge are tooltip-listed but absent from the Mage catalog. The active generated Impact row (4% proc, 3-second stun) takes precedence over the legacy DBC row. Focus Magic runtime coverage applies +3% spell critical strike chance to the recipient using the generated class spell row (54646) and grants the Mage +3% for 10 seconds after that recipient lands a spell critical; the generated talent-node tooltip instead says the recipient receives +1%, so the +3% recipient value remains a documented source/UI conflict. Forward Blink movement remains 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 and its generated 2-AP/10-second target debuff, Eradication, crit-gated Improved Shadow Bolt, Backlash's rank-based proc/cast rider, Death's Grasp's rank-scoped hit/haste effects, Nightfall, Metamorphosis, and RoM Ruthless Judgment's below-20% caster bonus now have focused coverage. The local talent row resolves child 900067's tooltip placeholders to −2 attack power for 10 seconds, although the child spell row itself is absent locally; WotLKDB's exact child URL is inaccessible and has no indexed search result. Its capstone text gives +30% crit after a physical hit while casting, but the generated ranks do not specify whether that rider is rank-gated or how the cast-scoped trigger is consumed, so it remains unresolved. Nightfall follows its generated rank chances, trigger list, next-ability scope, 25/50% mana discount, 20% Shadow Bolt damage rider, and five-second ICD; its missing 10-second aura duration comes from WotLKDB. Soul Link uses the populated talent tooltip rather than the conflicting dummy DBC row: it shares 6/8/10/12% of post-mitigation incoming damage with a controlled, living, in-range non-Imp demon. The transferred share bypasses absorbs and prevention, and damage beyond the demon's remaining health stays on the caster. Player, enemy-attack, and companion-target paths have tests. +- **Druid:** Eclipse, Omen of Clarity (including successful overheal events), Nature's Grace, Living Seed (33/66/100% rank proc, 30% effective-crit-heal storage, 15-second duration, and bloom on the target's next landed direct hit), Primal Fury's rank-scaled Bear/Dire Bear Rage and Cat combo-point procs, the rank-scaled Improved Rejuvenation healing bonus, Savage Fury's generated 4/8/12% damage increase to Claw, Rake, Shred, Mangle (Cat), Mangle (Bear), and Maul, and Feral Instinct have source-backed behavior. Feral Instinct applies its 10/20/30% damage bonus only to Swipe (Bear), Swipe (Cat), and Ravage; its 1/2/3% Tiger's Fury damage and Enrage damage-taken bonuses are timed to those active buffs, and positive landed damage with Swipe or Ravage reduces both cooldowns by one second. Primal Fury excludes Cat auto-attacks, periodic ticks, and non-combo abilities and caps combo points at five. Living Seed covers modeled player, party, online, and summon recipients; Flourish and Lesser Healing Wave are named in the local tooltip but absent from the WoW Druid ability catalog. 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, Improved Whirlwind's 1/2-second cooldown reduction, Blood Craze, Second Wind, and Anger Management have focused behavior tests. Anger Management grants 2/4/6 Rage per three seconds; Charge gains its 5/10/15 talent bonus atop local rank Rage and rank three applies four seconds of 30% damage reduction. Blood Craze and Second Wind follow their generated rank tooltips for proc triggers and base-health healing, using WotLKDB only for missing periodic cadence details. Unbridled Wrath remains unresolved: its generated rank descriptions say 2/4 additional Rage after landed Heroic Strike, Cleave, or auto-attack damage, but both generated rank-effect rows have `procChance: 0`; rank 1 triggers child 12964, whose [WotLKDB row](https://wotlkdb.com/?spell=12964) specifies only 1 Rage, while rank 2 references child 812964, for which no local or WotLKDB row is available. Thus both the trigger probability and the rank-2 payload are missing, and the rank-1 child amount conflicts with the generated tooltip. The proc remains unimplemented rather than guessed. 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. +- **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. Warrior/Scout Calm now adds +20 physical hit for 15 seconds after failed Skull Breaker, following the populated RuneWaker passive/component values. Druid/Warrior Briar Technique now gains up to five +3% Physical Attack/+3% Wisdom stacks for 15 seconds on landed Natural Attack or Heart Piercing hits while its passive is learned; the local child payload is duplicated on those two skills, so the implementation scopes the proc to those source-backed triggers. The [current CoA Druid/Warrior page](https://chroniclesofarcadia.com/en/wiki/skills/list/8-1) names Cross Thorn Blow (494018) as a third trigger, but the matching RuneWaker ability row is absent; its locally referenced child 499956 is also missing, so no third trigger has been inferred. The current CoA [Warrior/Knight page](https://chroniclesofarcadia.com/ja/wiki/skills/list/1-6) identifies local Heroic Surprise Attack (492661) as Heroic Charge Attack; Resist (493251) now references Blocking Stance 493287, while RuneWaker has active Blocking Stance 491480, and the Wiki omits Resist's Rage chance/amount. Recovery of Life (494377) is now Rage of Calmness with an unquantified rider on the [Warrior/Druid page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-8). The [Warrior/Rogue page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-3) gives Exhaustion (493248) a 30% chance to return 25 Energy and 50 Rage on current Probing Attack 493283, but RuneWaker maps the trigger to primary Warrior Probing Attack 491133 and describes player-only target-resource drain. The [Warrior/Warlock page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-9) now describes Speed Slash (499827) as increasing Slash damage and reducing its Rage cost by 10, and Excellent Combat Training (499806) as +20% Physical Damage; these conflict with RuneWaker's cooldown-reset references to missing rows 499828, 499825, and 499826. No conflicting Wiki payloads were imported pending identity/version confirmation. +- **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; and Scout/Priest Sniper reduces Snipe's cast time by one second. Scout/Warrior Enhanced Throat Attack and Rogue/Scout Slit Throat now silence a target for four seconds after a landed Throat Attack; Enhanced Throat Attack's extra range is unquantified in local data. The [current CoA Scout skills page](https://chroniclesofarcadia.com/wiki/skills/list/2-6?locale=en) confirms ranged-weapon requirements for Shot, Joint Blow, Vampire Arrows, Wind Arrows, and Autoshot; the combat validator now enforces those requirements, with player and companion rejection covered for Scout secondary skills without a ranged slot. Drums of War (493010) now triggers for the learned Scout/Priest build after a landed ranged Shot (490423), applying RuneWaker's +20 physical/magical critical-strike modifiers for 8 seconds to living party members within 50 yards. Each caster has a separate 15-second cooldown. RuneWaker describes the recipients as nearby party members but omits the radius; the [CoA Scout/Priest page](https://chroniclesofarcadia.com/en/wiki/skills/list/2-5) supplies range 50 and was used only for this missing detail. Player, party-caster, cooldown, and range behavior have focused regressions. The newer CoA critical-damage rework was not imported. 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, Rogue/Priest Shadow Walker, and Rogue/Mage Day of Rain's Throw cooldown and Enchanted Throw hit-rate interaction have targeted behavior. Rogue/Priest Sinister Methods now grants +20 physical critical strike for 10 seconds on landed Low Blow or Blind Spot hits, including critical hits, with a 20-second cooldown; the passive is only a marker until one of those hits lands. Instant Shadow (497206) now arms after a successful dodge and can be activated for the local child 502597's three-second stealth. Enchanted Throw (491178) now adds the referenced generated Throw (490309) damage on landed Rogue/Mage melee auto-attacks; the hidden 491185 child payload is absent, so the runtime reuses the local Throw damage type/calculation. Projectile ammunition is not modeled. Off Hand Mastery (490143) remains unsupported because RoM off-hand swings are not modeled; local component 620335 carries its damage/accuracy modifiers, while the [current CoA Rogue page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1) reports later level-55 values that were not copied into the RuneWaker model. +- RuneWaker Numbing Dagger (490347) supplies a 10-second creature sleep (500737) and a separate 3-second player sleep (503046). `applyStatus` now selects the local duration by defender kind for player casts, companion casts, and the lower-level control action; focused regressions cover NPC and character-defender targets. +- **Rogue/Champion Nerve Gear:** RuneWaker passive 498839 (child 621652) now remains a learned marker; each successful non-passive Rogue/Champion elite-skill cast grants +5% attack speed and +2 dodge for 5 seconds, stacking to five. Player and party hooks apply once per cast, including area casts. +- **Rogue Poison:** RuneWaker Poison (490329) component 500715 specifies a 50% normal-attack trigger to child 490357. Its local tooltip supplies a four-stack cap and a 2% reduction to target damage per stack. Player and party landed normal attacks now apply source-owned stacks when that Rogue has learned Poison and its self aura is active; the common enemy-hit consumer applies the debuff to melee, ranged, and spell damage. The hidden child has no local duration row, so only its missing 900-second duration is taken from the [current CoA Rogue skills page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1); its conflicting 8% reduction and attack-speed rider are excluded. +- **Priest:** Knight/Priest Holy Smite now heals and consumes Holy Seals correctly; Holy Protection applies its alternate threat payload as intended; Priest/Knight Holy Power applies RuneWaker's +60 Wisdom and +1% Wisdom for 20 seconds, ending after three successful casts of Heal; Priest/Mage Enlightenment now triggers when the Priest/Mage caster completes Heal (490261), grants +1% Wisdom for 8 seconds, and uses a per-caster 20-second internal cooldown in player and party paths; its generated passive aura is suppressed at startup. Lightning Rod requires the sourced Bone Chill target state; Priest/Scout Saint's Blessing uses its friendly-target health threshold; Priest/Warrior Battle Monk Stance gates five stance-dependent abilities for player and companion paths; Priest/Warlock Prayer of Tribulation has focused runtime coverage; and Soul Cleansing Ceremony conditionally extends Cleanse to curses. Blessed Spring Water (490298), Embrace of the Water Spirit (491323), and Shadow Fury (492932) now apply their source-described modifier auras to the party; Embrace's separate periodic proc remains caster-only. Infused Light Chain (498610) locally says it increases Chain of Light damage, removes its cooldown, and can add Rage on hits, but the generated passive has no source components: the trigger chance, Rage amount, and damage increase are all missing. Chain of Light (491164) itself has a local 6-second cooldown, and the existing override removes that cooldown when the passive is learned. The [current CoA Priest/Champion record](https://chroniclesofarcadia.com/en/wiki/skills/list/5-10) reuses ID 498610 for a differently named Rage Control passive that grants 20 Rage per second of spell channeling; because that is a conflicting later payload rather than a matching source for the local on-hit effect, no chance, Rage amount, or damage bonus was inferred from it. Protection Rune Seal (498613) now attaches a hidden monitor to Regenerate and uses its local 8% chance to reset Heart Rune Energy on a successful periodic tick, including the final tick before expiry; the healing-strength rider remains unquantified. Current CoA records also conflict with local RuneWaker IDs/names or payloads for Priest/Champion 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; Knight/Priest Holy Light's Fury applies the local 3-second Holy Seal cooldown reduction. Disregard Danger (493273) now redirects 40% of one ally hit for 10 seconds, consumes the redirect after the first hit, and gives the caster +20 Parry for 10 seconds; its integration test verifies the 40/60 split and that the next hit is unredirected. Master of Parry (492935) applies the generated +5,000 Parry modifier for 15 seconds and consumes it after two successful blocks; the hidden periodic trigger now shares the source 15-second lifetime. Its runtime test forces real block events and verifies the charge countdown/removal. Shield of Valor (490468) now reduces a party Knight's Holy Seal cooldown by 1 second on each parry while its local 15-second aura is active; the deterministic regression covers active-parry, expired/inactive-parry, and active non-parry controls. War Prayer (490078) schedules six personal Holy Seal increments at 3-second intervals over 18 seconds, and its regression test verifies Punishment consumes the earned stacks. Local child 490079 is absent; the current CoA [skill entry](https://chroniclesofarcadia.com/en/wiki/skills/list/6-1) and [fix patch](https://chroniclesofarcadia.com/patch/10-4-1-1000) corroborate the personal Seal mechanic. Deadly Whirlwind (491639) now applies its local 10-Rage/20-second reductions to Warrior Whirlwind (490133) in player and party casts; paired passive lookup is scoped to a learned Knight/Warrior build. Focused regressions verify both paths, including the party cast at 40 Rage against Whirlwind's 50-Rage base cost. Its 10-second healing-reduction rider is not inferred because its local trigger/child attribution is absent. God's Sword (491621) has an isolated helper and regression that scopes the local 40% critical-hit-rate value to Holy Strike (490148) and the Knight/Rogue pairing, but landed-damage integration remains unresolved: the local tooltip says only “increased by 40%” and the RuneWaker passive row has no extracted components or numeric modifier. The current combat resolver interprets added critical chance as percentage points; the local data does not establish whether this is +40 points or a 40% relative increase to Holy Strike's existing critical chance. The current [CoA 10.6.1 patch](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) describes a later active/offhand-DPS version of God's Sword that conflicts with the local passive tooltip and cannot resolve the older value's semantics, so that later behavior was not imported. Some other periodic/triggered spell references do not resolve. Holy Light's Fury's extra damage value is absent, and the current CoA page describes a later, conflicting healing version. Throw Shield and Shield Bash require an equipped shield, and Arrow of Vengeance enforces its ranged-weapon requirement. Light-vulnerability remains ambiguous between 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, Damage Transfer's 30% split to a live nearby pet, Tough Oak's one-second Punch/Sepal Stab cooldown reductions, and Briar Shield's sourced 900-second +5% armor aura plus level-scaled physical reflection have focused fixes. Briar Shield uses RuneWaker's local formula for main-hand DPS reflection on landed physical melee/ranged attacks within 50 range; newer Wiki trigger/stat behavior was not imported. Tough Oak's pet-defense rider has no pet-stat model. Oak Blessing (494011) now activates RuneWaker component 620679's +5% physical attack aura and 1%-maximum-HP healing every three seconds only during Heart of the Oak's 60-second duration. Three gaps remain source-limited: Damage Transfer (493399) component 503942 references missing child 493416 via `onHitMagicId` and has 10-second duration/30% modifiers, while the tooltip references local component 503643 with 15-second duration; no child payload exists in the generated catalog, and the [current CoA Warden page](https://chroniclesofarcadia.com/en/wiki/skills/list/7-2) has no matching child details and describes a conflicting version. Revival (499542) references missing state 499544; its only extracted component, 623078, contains no numeric payload. The [current Rogue/Warden page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-7) reuses ID 499542 for Wood Spirit's Will and ID 494043 for Blood Hound, so those later entries do not match local RuneWaker Revival or Weak Point Strike. Weak Point Strike's two local damage components (620294/620295) preserve base and bonus hit formulas but contain no extra-hit proc chance; do not invent one. Other reactive passives remain incomplete. +- **Mage:** rank-1 Catalysis mana ticks, rank-2 Catalysis cast haste, Phoenix's 12-target cap, Control Flame, Flame Follower's Fireball cooldown rider, and rank-1 Elemental Weakness's all-resistance debuff are covered. Elemental Extraction's critical triggers now match its RuneWaker tooltip. Meditative Current now suppresses the generic always-on passive payload and applies its source-complete rule (landed Flame 491150 or Plasma Arrow 491169, +10 spell haste for 10 seconds, 30-second internal cooldown) to player and party Mage casters, with an independent cooldown marker per caster and focused runtime coverage. Mage/Knight Light of Day (493034) now suppresses its generic passive aura and grants +8% Holy damage for 15 seconds only after a landed direct Holy Light Strike (491594) or Light Charge (492634) hit, with an independent 20-second cooldown per caster; player and party regressions cover both triggers, startup suppression, and cooldown behavior. Fireball 490204 is intentionally excluded by the local tooltip. Eruption's local passive row (490221) has no proc chance or effect payload. Party Mage casts now receive the same deterministic proc roll and existing 10% damage-doubling behavior as player casts, but this only establishes runtime parity. The current CoA entry for the same spell ID confirms the identity and Fireball/Flame triggers but has no numeric payload; CoA patch 10.2.1 reports a later 30% proc chance (up from 10%) and Eruption damage increased to 200%, without confirming that patch values match this RuneWaker snapshot or defining whether 200% is a separate proc hit or multiplier. Those later values and the damage interpretation remain unimported and unverified. 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. Weakening Seed and Withering Seed select exactly one local RuneWaker modifier branch per cast; each component maps to its own 20-second −2% target debuff. Caster-directed Nature Power drains are now distinct from hostile-target resource steals, preserving the source ordering for spends and refunds. Mage/Druid Green Guardian retains both local HoT components and, on either Mother Earth's Protection rank expiring, heals the protected recipient for five ticks at two-second intervals. Player and companion casters are covered. Charm faction conversion and Rebirth's debt reductions have no corresponding simulator state; hostile player targets are unsupported, so Necrotic Wound's player-only 10-second healing reduction cannot be applied or tested. Local passive proc probabilities remain unavailable on Revival Slash, Quick Light Protection, Boiling Rock, Inspiration, Inspiration Display, Avalanche, Extended Recovery, and Spring Flower Eulogy; the current CoA Inspiration/Recovery descriptions conflict with the local trigger behavior, so no later proc rates were imported. - **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. +- **Champion:** Fearless Blow's health/Rage gate, Rune Draw pull, Indomitable Spirit threshold, cooldown-reset scopes, rank-1 Forge trigger, Rune Pulse's 5 Rage on landed hit, 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. Soul Forge Mystery activates its sourced +10% Shadow damage bonus for 10 seconds after a critical hit, while its five-minute physical-critical aura remains active. Way of Frenzy now grants +2% Stamina per landed Divine Vengeance hit, stacking to five for 60 seconds; its local tooltip and component supply the trigger, stack cap, modifier, and duration. Shield Form has no player-learnable source skill; Forge rank-2, Chain Drive, Dark Energy Strike, 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 @@ -137,4 +172,4 @@ These are specific, source-backed gaps that still need implementation or a gamep ## 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. +The earlier 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**. The integrated focused pass for the prior phase passed **398 tests across 30 files**. The latest combined follow-up passed **125 tests across 12 files**, covering Mage Missile Barrage, Shaman Maelstrom Weapon, Warrior Blood Craze/Second Wind, Paladin Aura Mastery/Redoubt, and related regression coverage. This continuation's Priest/Warrior stance, Mage Flame Throwing/Empowered Fire, Warrior Anger Management/Charge, and party-runtime regressions passed **153 tests across 11 files**; Warlock Metamorphosis and talent regressions passed a further **39 tests across four files**. The latest focused pass for Soul Link, Champion Soul Forge, Priest party scopes, Disregard Danger, Master of Parry, and shared combat regressions passed **200 tests across five files**. The Hunter trap follow-up passed 23 tests across three files. The current combined Hunter, Warlock, Priest, Knight, and shared-combat regression run passed 270 tests across 14 files; the 8 GB TypeScript build and `git diff --check` passed, and `npm run release:check` passed for 105 changed paths. The Obsidian vault check passed in an earlier wave. The prior phase's `npm run build` passed; KTX2 found zero fallback textures and the environment-performance audit found no regressions. Vite emitted the existing dynamic-import and large-chunk warnings. These focused checks do not prove every ability/talent combination. The full `npm test` suite was not rerun; an earlier run had 15 failing files and 21 failed tests, including dungeon-count and balance expectation failures. diff --git a/src/game/abilityCatalog.ts b/src/game/abilityCatalog.ts index f1e8459d..502dc8bb 100644 --- a/src/game/abilityCatalog.ts +++ b/src/game/abilityCatalog.ts @@ -12,6 +12,7 @@ import { registerAbilityAnimationLookup } from "./abilityAnimationLookup"; import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras"; import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning"; import { preloadRuntimeTalentCatalog } from "./talentRuntimeCatalog"; +import { applyGeneratedBeastWithinTalent } from "./wow335HunterRuntime"; export type RomSkillGroup = "primary" | "general" | "elite" | "passive"; @@ -138,6 +139,7 @@ export interface AbilityAmountScaling { readonly coefficientPerLevel?: number; readonly bonusPowerCoefficient?: number; readonly attackPowerCoefficient?: number; + readonly rangedAttackPowerCoefficient?: number; readonly spiritCoefficient?: number; readonly sourceLevel?: number; readonly maxScalingLevel?: number; @@ -214,7 +216,7 @@ export type AbilityEffect = | { readonly kind: "interrupt"; readonly lockoutMs: number } | { readonly kind: "pull"; readonly toward: "caster" | "destination" } | { readonly kind: "execute"; readonly coefficient: number } - | { readonly kind: "resource-drain"; readonly amount: number; readonly burn: boolean; readonly resource?: ResourceType; readonly percentage?: boolean } + | { readonly kind: "resource-drain"; readonly amount: number; readonly burn: boolean; readonly resource?: ResourceType; readonly percentage?: boolean; readonly recipient?: "caster" } | { readonly kind: "threat"; readonly mode: "flat" | "percent" | "redirect"; @@ -428,10 +430,7 @@ const WARRIOR = [ castTimeMs: 0, cooldownMs: 15_000, gcdMs: 1500, cost: { resource: "rage", amount: 0 }, description: "Charges a distant enemy, briefly stunning it and generating Rage.", effects: [ - { kind: "damage", coefficient: 0.25 }, { kind: "status", status: "stun", durationMs: 1000 }, - { kind: "status", status: "pulled", durationMs: 500 }, - { kind: "resource", amount: 15 }, ], }), defineAbility({ @@ -971,9 +970,10 @@ async function preloadClassicAbilityCatalog(): Promise { WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID, }) => { for (const classId of BASE_CLASS_IDS) { + const abilities = mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]); registerClassAbilities( classId, - mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]), + classId === "hunter" ? abilities.map(applyGeneratedBeastWithinTalent) : abilities, ); } for (const [spellId, ability] of Object.entries(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID)) { diff --git a/src/game/abilityRules.ts b/src/game/abilityRules.ts index c83b199c..1b3a8252 100644 --- a/src/game/abilityRules.ts +++ b/src/game/abilityRules.ts @@ -72,7 +72,7 @@ export function canAbilityCastWhileMoving(ability: AbilityDefinition): boolean { } export function abilityRequiresOutOfCombat(ability: AbilityDefinition): boolean { - return ability.source === "runewaker" + return (ability.source === "runewaker" || ability.source === "wow335-progression") && /\bcannot be used (?:when already )?in combat\b/i.test(ability.description); } diff --git a/src/game/azerothCoreReference.ts b/src/game/azerothCoreReference.ts index e8419754..81084ee5 100644 --- a/src/game/azerothCoreReference.ts +++ b/src/game/azerothCoreReference.ts @@ -96,6 +96,8 @@ export interface CreatureRuntimeBaseStats { readonly armor: number; readonly attackPower: number; readonly rangedAttackPower: number; + /** Portion of average physical melee damage contributed by Attack Power. */ + readonly attackPowerDamageContribution: number; readonly averageAttackDamage: number; readonly baseAttackTimeMs: number; readonly mechanicImmunities: readonly AzerothCoreMechanic[]; @@ -159,6 +161,7 @@ export function creatureRuntimeBaseStats( const baseDamage = stats.baseDamage[expansion] ?? stats.baseDamage[0]; const attackPowerContribution = stats.attackPower / 14 * template.baseVariance; const attackSpeedMultiplier = Math.max(0.1, template.baseAttackTimeMs / 1_000); + const damageScale = template.damageModifier * attackSpeedMultiplier; const minimumDamage = ( baseDamage + attackPowerContribution ) * template.damageModifier * attackSpeedMultiplier; @@ -174,6 +177,7 @@ export function creatureRuntimeBaseStats( armor, attackPower: stats.attackPower, rangedAttackPower: stats.rangedAttackPower, + attackPowerDamageContribution: Math.max(0, attackPowerContribution * damageScale), averageAttackDamage: Math.max(1, Math.round((minimumDamage + maximumDamage) / 2)), baseAttackTimeMs: template.baseAttackTimeMs, mechanicImmunities: template.mechanicImmunities, diff --git a/src/game/combatAuras.ts b/src/game/combatAuras.ts index e6c33d53..22bdacd1 100644 --- a/src/game/combatAuras.ts +++ b/src/game/combatAuras.ts @@ -123,6 +123,8 @@ export type AuraProcAction = export interface AuraProcDefinition { readonly id: string; readonly triggers: ProcTrigger | readonly ProcTrigger[]; + /** Limit proc rolls to events generated by these catalog ability IDs. */ + readonly abilityIds?: readonly string[]; /** Limit attack-triggered procs to the listed landed attack types. */ readonly attackKinds?: readonly AttackKind[]; /** Probability from 0 through 1. */ @@ -163,7 +165,7 @@ export interface AuraDefinition { /** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */ readonly utilityTags?: readonly string[]; /** Enemy control mechanics ignored while this aura is active. */ - readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence")[]; + readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence" | "interrupt")[]; /** Chance from 0 through 1 to reflect an incoming spell. */ readonly reflectSpellChance?: number; /** Portion of incoming target damage transferred to the aura source. */ @@ -189,6 +191,8 @@ export interface ActiveAura { readonly procStates: readonly AuraProcState[]; /** Cumulative redirected damage for an aura that owns a shared redirect cap. */ readonly redirectedDamage?: number; + /** Healing reserved by WoW Druid Living Seed until its target takes a direct hit. */ + readonly druidLivingSeedAmount?: number; /** Number of Fire critical hits landed during the current Combustion buff. */ readonly combustionCriticalHits?: number; } @@ -580,6 +584,8 @@ export function evaluateAuraProcs( if ( proc === undefined || !procHasTrigger(proc, event.trigger) || + (proc.abilityIds !== undefined + && (event.abilityId === undefined || !proc.abilityIds.includes(event.abilityId))) || (proc.attackKinds !== undefined && (event.attackKind === undefined || !proc.attackKinds.includes(event.attackKind))) || state.readyAt > now || diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index a7ad5745..e7c8f613 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -53,6 +53,27 @@ function registerTarget( useCombatStore.getState().selectMob(id); } +function stableUnit(seed: string): number { + let hash = 2166136261; + for (const character of seed) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function paladinVindicationProcTime( + sourceActorId: string, + abilityId: string, + targetId: string, + chance: number, +): number { + for (let now = 1_000; now < 100_000; now += 1) { + if (stableUnit(`${sourceActorId}:${abilityId}:${targetId}:${now}:wow335-paladin-vindication`) < chance) return now; + } + throw new Error("Could not find a seeded Vindication proc time."); +} + function unlockReadySpell(id: string): void { const ability = useCombatStore.getState().abilities.find((entry) => entry.id === id); expect(ability, id).toBeDefined(); @@ -92,6 +113,33 @@ describe("combat store", () => { usePartyStore.getState().initializeParty("combat-store-tests", 1, useCombatStore.getState().health); }); + it("procs Vindication from a Paladin Holy hit with the generated rank chance, amount, duration, and owner", () => { + useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80, xp: 0 }); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1633": 3 }, + })); + registerTarget("vindication-target", [2, 0, 0], 10_000, 0); + const abilityId = "wow335-paladin-exorcism"; + const now = paladinVindicationProcTime(PLAYER_AGGRO_ID, abilityId, "vindication-target", 0.65); + + expect(useCombatStore.getState().damageMob( + "vindication-target", + 25, + now, + { actorId: PLAYER_AGGRO_ID, role: "damage", abilityId }, + "holy", + ).amount).toBeGreaterThan(0); + expect(useCombatStore.getState().mobs["vindication-target"].auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "wow335:paladin:vindication", + sourceActorId: PLAYER_AGGRO_ID, + sourceAbilityId: abilityId, + endsAt: now + 10_000, + magnitude: 357, + }), + ])); + }); + it("completes a ranged cast after its exact WoW 3.3.5a cast time", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 }); const startingResource = useCombatStore.getState().maxResource; diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index fb1de579..3387bc10 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -87,6 +87,27 @@ import { runtimeTalentPointsForLevel as talentPointsForLevel, type RuntimeTalentRanks as TalentRanks, } from "./talentRuntimeCatalog"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; +import { + WOW335_DEATH_KNIGHT_RIME_READY_TAG, + wow335DeathKnightRimeProcSucceeds, +} from "./wow335DeathKnightRimeRuntime"; +import { + WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID, + wow335DeathKnightSuddenDoomTriggerForLandedHit, +} from "./wow335DeathKnightSuddenDoomRuntime"; +import { + WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, + WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_SPELL_ID, + wow335DeathKnightMarkOfBloodAuraDefinition, + wow335DeathKnightMarkOfBloodOnDamage, +} from "./wow335DeathKnightMarkOfBloodRuntime"; +import { + WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG, + WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID, + wow335DeathKnightScentOfBloodOnIncomingEvent, + wow335DeathKnightScentOfBloodOnMeleeHit, +} from "./wow335DeathKnightScentOfBloodRuntime"; import { applyTalentModifierValue, parseTalentModifiers, @@ -105,6 +126,51 @@ import { } from "./mobRuntimeRegistry"; import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry"; import { raceAffixHasteMultiplier, raceAffixMobDamageMultiplier } from "./dungeonRaceAffixBuffs"; +import { + wow335LightwellDamageCancellationFraction, + wow335LightwellRenewProfileForCreature, +} from "./wow335PriestLightwellRuntime"; +import { + WOW335_PRIEST_FOCUSED_WILL_TALENT_ID, + wow335PriestFocusedWillAuraDefinition, + wow335PriestFocusedWillShouldProc, +} from "./wow335PriestFocusedWillRuntime"; +import { + WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG, + WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID, + wow335RogueImprovedKickAuraEffectForRank, + wow335RogueUnfairAdvantageDamageMultiplierForRank, +} from "./wow335RogueTalentRuntime"; +import { + beastWithinRankMechanics, + consumeLockAndLoadCharge, + entrapmentRankMechanics, + freezingTrapDurationMs, + lockAndLoadRankMechanics, + WOW335_HUNTER_ENTRAPMENT_TALENT_ID, + WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG, + WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG, +} from "./wow335HunterRuntime"; +import { wow335PaladinAuraMasteryAurasForEntity } from "./wow335PaladinAuraMasteryRuntime"; +import { + WOW335_DRUID_FERAL_INSTINCT_ATTACK_NAMES, + wow335DruidFeralInstinctEnrageAura, + wow335DruidFeralInstinctTigerAura, +} from "./wow335DruidTalentRuntime"; +import { + WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG, + WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG, + warlockMetamorphosisCanCastAbilityWhileActive, + warlockMetamorphosisFormActionReady, + wow335WarlockImmolationAttackPowerReduction, + wow335WarlockMetamorphosisControlDuration, +} from "./wow335WarlockMetamorphosisRuntime"; +import { + WARLOCK_BACKDRAFT_READY_TAG, + warlockBackdraftDestructionSpellEligible, + warlockBackdraftEffectsForRank, + warlockBackdraftTriggerEligible, +} from "./wow335WarlockTalentRuntime"; import { getPartyRuntimePosition, partyRuntimeDistanceSquared, @@ -136,6 +202,7 @@ import { resolveResourceValue as resolveAuraResourceValue, resolveStatValue as resolveAuraStatValue, type ActiveAura, + type AbsorbConsumption, type AuraDefinition, type DispelCategory, type ProcEvent, @@ -162,41 +229,134 @@ import { type HeirloomContext, } from "./heirloomEquipment"; import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romSelfDrainPercent, romSingleTargetControl, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorPhysicalWeaponTooltip, romWarriorThreatMultiplier } from "./romCombatMechanics"; +import { + ROM_ROGUE_NUMBING_DAGGER_SKILL_ID, + romRogueNumbingDaggerStatusForTarget, +} from "./romRogueNumbingDaggerRuntime"; +import { + ROM_ROGUE_POISON_AURA_TAG, + ROM_ROGUE_POISON_SKILL_ID, + romRoguePoisonApplicationForAttack, +} from "./romRoguePoisonRuntime"; 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_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT, + ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT, + ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, + ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID, + romMageWarriorMeditationPathAllowsMovement, + romMageWarriorMeditationPathCanProc, + romMageWarriorMeditationPathHasState, +} from "./romMageWarriorRuntime"; +import { + ROM_MAGE_KNIGHT_LIGHT_OF_DAY, + ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT, + ROM_MAGE_MEDITATIVE_CURRENT, + ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT, + romMageCooldownReductionMs, + romMageKnightLightOfDayProc, + romMageKnightLightOfDayReadyAt, + romMageMeditativeCurrentProc, + romMageMeditativeCurrentReadyAt, +} from "./romMageRuntime"; +import { + ROM_SCOUT_PRIEST_DRUMS_OF_WAR, + ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA, + romScoutPriestDrumsOfWarProc, +} from "./romScoutPriestOverrides"; +import { + ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG, + ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY, + ROM_SCOUT_PRIEST_SNIPER_TAG, + romRogueMageDayOfRainBaseMissPercent, + romScoutRogueProcActionSpec, + romScoutRogueWhirlwindBonusEffect, +} from "./romScoutRogueOverrides"; import { ROM_ROGUE_DAGGER_MASTERY_TAG, + ROM_ROGUE_INSTANT_SHADOW_READY_TAG, ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID, ROM_ROGUE_TREASURE_HUNTER_TAG, romRogueDaggerDamageMultiplier, + romRogueEnchantedThrowShouldProc, romRogueShadowWalkerAdjustment, romRogueTreasureHunterLootChance, } from "./romRogueMechanics"; -import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides"; -import { romPriestKnightConditionalCastEffects } from "./romPriestKnightOverrides"; +import { + ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID, + romRogueChampionNerveGearEffect, + romRogueChampionNerveGearShouldTrigger, +} from "./romRogueChampionNerveGearRuntime"; +import { + ROM_PRIEST_CHAMPION_REGENERATE_PROC_ACTION_ID, + romPriestChampionCooldownOverride, +} from "./romPriestChampionOverrides"; +import { + ROM_PRIEST_KNIGHT_HOLY_POWER_HEAL_USES_TAG, + romPriestKnightConditionalCastEffects, + romPriestWarriorStanceUseAllowed, +} from "./romPriestKnightOverrides"; +import { + ROM_PRIEST_MAGE_ENLIGHTENMENT, + ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT, + romPriestMageEnlightenmentProc, + romPriestMageEnlightenmentReadyAt, +} from "./romPriestMageRuntime"; import { ROM_KNIGHT_GODS_VENGEANCE_TAG, + ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, + ROM_KNIGHT_MASTER_OF_PARRY_TAG, ROM_KNIGHT_SHIELD_OF_VALOR_TAG, + romKnightShieldOfValorCooldownReductionMs, + romKnightWarriorWhirlwindAdjustment, } from "./romKnightOverrides"; -import { romDruidPeriodicHealAmount } from "./romDruidOverrides"; +import { + ROM_DRUID_GREEN_GUARDIAN_TAG, + ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG, + romDruidPeriodicHealAmount, +} from "./romDruidOverrides"; import { ROM_CHAMPION_FORGE_STACK_AURA, ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND, ROM_CHAMPION_RUNE_OVERLOAD_TAG, + ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID, + ROM_CHAMPION_SOUL_FORGE_PROFILE, + ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID, + ROM_CHAMPION_WAY_OF_FRENZY_PROFILE, } from "./romChampionOverrides"; import { + WOW335_WARLOCK_SOUL_LINK_TAG, + warlockSoulLinkDamageSplit, + warlockSoulLinkDemon, + warlockSoulLinkSharePercent, +} from "./wow335WarlockSoulLinkRuntime"; +import { + ROM_WARDEN_BRIAR_SHIELD_TAG, ROM_WARDEN_DAMAGE_TRANSFER_FRACTION, ROM_WARDEN_DAMAGE_TRANSFER_TAG, ROM_WARDEN_WILL_ATTACK_TAG, + romWardenBriarShieldReflectDamage, + romWardenBriarShieldShouldReflect, } from "./romWardenOverrides"; import { romWarlockStateRefreshTags } from "./romWarlockOverrides"; +import { + ROM_WARRIOR_BRIAR_TECHNIQUE_AURA, + ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG, + ROM_WARRIOR_BRIAR_TECHNIQUE_TRIGGER_SPELL_IDS, +} from "./romWarriorOverrides"; import { romCreatureFamilyForServerEntry, romCreatureFamilyTargetTags, type CombatCreatureFamily } from "./romCreatureFamilies"; +import { + WOW335_PALADIN_BLESSED_LIFE_TALENT_ID, + wow335PaladinBlessedLifeDamageMultiplierForRoll, + wow335PaladinRedoubtBuff, + wow335PaladinRedoubtProfile, + wow335PaladinVindicationProfile, +} from "./wow335PaladinTalentRuntime"; import { defaultPartyRoleForClass, type PartyRole } from "./partyRoles"; import { specializationById } from "./specializationCatalog"; import { @@ -204,6 +364,7 @@ import { itemStatSummary, type DamageSchool, type ItemStats, + type WeaponStats, } from "./itemStats"; import { baseResourcesFor, @@ -232,6 +393,27 @@ import { } from "./roguePoison"; import { hunterExplosiveTrapDirectScaling } from "./wow335AbilityCatalog"; import { + wow335ShamanImprovedWaterShieldCappedRestore, + wow335ShamanImprovedWaterShieldShouldProc, + wow335ShamanWaterShieldOrbManaAtLevel, +} from "./wow335ShamanImprovedWaterShieldRuntime"; +import { + WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG, + WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, + WOW335_SHAMAN_TIDAL_WAVES_MAX_CHARGES, + wow335ShamanTidalWavesBonusHealingPercent, + wow335ShamanTidalWavesCastTimeMultiplier, + wow335ShamanTidalWavesChargesForTrigger, + wow335ShamanTidalWavesCriticalChanceBonusPercent, + wow335ShamanTidalWavesProcChance, +} from "./wow335ShamanTidalWavesRuntime"; +import { + WOW335_MAGE_ARCANE_CONCENTRATION_AURA, + WOW335_MAGE_ARCANE_CONCENTRATION_TAG, + WOW335_MAGE_BRAIN_FREEZE_AURA, + WOW335_MAGE_BRAIN_FREEZE_CONSUMER_SPELLS, + WOW335_MAGE_BRAIN_FREEZE_TAG, + WOW335_MAGE_BRAIN_FREEZE_TRIGGER_SPELLS, WOW335_MAGE_FINGERS_OF_FROST_AURA, WOW335_MAGE_FINGERS_OF_FROST_TAG, WOW335_MAGE_FOCUS_MAGIC_PROC_AURA, @@ -242,6 +424,79 @@ import { WOW335_MAGE_IMPACT_AURA, WOW335_MAGE_IMPACT_TAG, } from "./wow335MageTalentRuntime"; +import { + WOW335_MAGE_FROSTBITE_TALENT_ID, + wow335MageFrostbiteProcDurationMs, +} from "./wow335MageFrostbiteRuntime"; +import { + WOW335_MAGE_FLAME_THROWING_TALENT_ID, + wow335MageFlameThrowingProfile, +} from "./wow335MageFlameThrowingRuntime"; +import { + WOW335_MAGE_ARCANE_POTENCY_AURA, + WOW335_MAGE_ARCANE_POTENCY_TAG, + WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID, + wow335MageArcanePotencyCanProc, + wow335MageArcanePotencyCastProfile, + wow335MageMasterOfElementsManaRefund, +} from "./wow335MageConditionalTalentRuntime"; +import { + WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID, + wow335MageEmpoweredFireEffect, +} from "./wow335MageEmpoweredFireRuntime"; +import { + WOW335_MAGE_MISSILE_BARRAGE_AURA, + WOW335_MAGE_MISSILE_BARRAGE_TAG, + wow335MageMissileBarrageConsumerEligible, + wow335MageMissileBarrageProfileForRank, + wow335MageMissileBarrageReachableProcChancePercentForSpell, +} from "./wow335MageMissileBarrageRuntime"; +import { + WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID, + wow335MageTormentTheWeakDamage, +} from "./wow335MageTormentTheWeakRuntime"; +import { + WOW335_MAGE_MOLTEN_FURY_TALENT_ID, + wow335MageMoltenFuryDamage, +} from "./wow335MageMoltenFuryRuntime"; +import { + WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID, + wow335MageImprovedCounterspellSilenceDurationMs, +} from "./wow335MageImprovedCounterspellRuntime"; +import { + warlockDeathGraspHitChanceBonusForRank, + warlockDeathGraspSpellHasteModifier, + WARLOCK_NIGHTFALL_COOLDOWN_TAG, + WARLOCK_NIGHTFALL_READY_TAG, + warlockNightfallConsumerEligible, + warlockNightfallDamageMultiplier, + warlockNightfallEffectsForRank, + warlockNightfallTriggerEligible, +} from "./wow335WarlockTalentRuntime"; +import { + WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY, + WOW335_WARRIOR_BLOOD_CRAZE_INTERNAL_COOLDOWN_MS, + WOW335_WARRIOR_BLOOD_CRAZE_TALENT_ID, + WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY, + WOW335_WARRIOR_SECOND_WIND_TALENT_ID, + wow335WarriorBloodCrazeProfile, + wow335WarriorSecondWindProfile, +} from "./wow335WarriorTalentRuntime"; +import { + WOW335_WARRIOR_ANGER_MANAGEMENT_CHARGE_AURA_TAG, + WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID, + wow335WarriorAngerManagementProfile, + wow335WarriorChargeRageProfile, + wow335WarriorChargeRank, +} from "./wow335WarriorAngerManagementRuntime"; +import { + WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG, + WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS, + WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS, + WOW335_SHAMAN_MAELSTROM_WEAPON_MAX_STACKS, + wow335ShamanMaelstromWeaponCastTimeReductionPercent, + wow335ShamanMaelstromWeaponProfile, +} from "./wow335ShamanMaelstromWeaponRuntime"; import { baseMissChancePercent, combatAttackerFromCharacterStats, @@ -399,6 +654,7 @@ export interface MobStatus { export interface MobAura { readonly id: string; readonly sourceAbilityId: string; + readonly sourceActorId?: AggroActorId; readonly endsAt: number; readonly magnitude?: number; readonly lifeStealPercent?: number; @@ -454,6 +710,7 @@ export interface MobCombatState { readonly armor: number; readonly maxMana: number; readonly attackPower: number; + readonly attackPowerDamageContribution: number; readonly mechanicImmunities: readonly AzerothCoreMechanic[]; readonly schoolImmunities: readonly AzerothCoreSchool[]; readonly resistances: MobMagicResistances; @@ -521,6 +778,12 @@ export interface ActiveCast { readonly totalTicks: number; readonly ticksCompleted: number; readonly nextTickAt: number; + /** Tidal Waves charges consumed at cast start, snapshotted for its crit rider. */ + readonly tidalWavesChargesConsumed?: number; + /** Arcane Potency's next-spell critical chance, snapshotted when a cast starts. */ + readonly arcanePotencyCriticalChanceBonusPercent?: number; + /** Mana actually committed for this cast, used by Master of Elements refunds. */ + readonly manaSpent?: number; } export interface TimedCombatEffect extends AbilityDamageResource { @@ -534,11 +797,14 @@ export interface TimedCombatEffect extends AbilityDamageResource { readonly fixedAmount?: number; readonly percentMaxHealth?: number; readonly basePoints?: number; + /** Exact per-tick amounts for periodic effects whose total must not drift from rounding. */ + readonly tickAmounts?: readonly number[]; readonly dieSides?: number; readonly pointsPerLevel?: number; readonly coefficientPerLevel?: number; readonly bonusPowerCoefficient?: number; readonly attackPowerCoefficient?: number; + readonly rangedAttackPowerCoefficient?: number; readonly spiritCoefficient?: number; readonly sourceLevel?: number; readonly maxScalingLevel?: number; @@ -549,6 +815,8 @@ export interface TimedCombatEffect extends AbilityDamageResource { readonly extensionTicks?: number; readonly damageSchool?: DamageSchool; readonly spellPenetration?: number; + /** Arcane Potency applies to the spell's periodic damage as well as its direct hit. */ + readonly criticalChanceBonusPercent?: number; readonly tag?: string; readonly warlockSeed?: { readonly threshold: number; @@ -556,6 +824,11 @@ export interface TimedCombatEffect extends AbilityDamageResource { readonly radius: number; readonly accumulatedDamage: number; }; + /** Lightwell Renew ends when the recipient accumulates the tooltip's damage threshold. */ + readonly lightwellDamageCancellation?: { + readonly threshold: number; + readonly accumulatedDamage: number; + }; } export type CombatFeedbackKind = "success" | "error" | "damage" | "heal" | "level-up" | "target" | "talent" | "loot"; @@ -612,6 +885,11 @@ export interface CastRequest { readonly completingCast?: ActiveCast; } +const WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST = Symbol("wow335-death-knight-sudden-doom-free-cast"); +type InternalCastRequest = CastRequest & { + readonly [WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST]?: true; +}; + export interface CastResult { readonly ok: boolean; readonly abilityId?: string; @@ -741,7 +1019,7 @@ export interface CombatAreaEffect { readonly fixedDamagePerTick?: number; readonly fixedHealingPerTick?: number; readonly hunterTrap?: { - readonly kind: "freezing" | "explosive"; + readonly kind: "freezing" | "frost" | "frost-zone" | "immolation" | "explosive"; readonly triggerRadius: number; readonly freezeDurationMs?: number; readonly directDamageScaling?: AbilityAmountScaling & { readonly coefficient: number }; @@ -776,6 +1054,7 @@ export interface CombatState { resourcePools: Readonly>>; maxResourcePools: Readonly>>; lastResourceSpentAt: Readonly>>; + warriorAngerManagementRageRemainderMs: number; resourceName: string; comboPoints: number; comboTargetId: string | null; @@ -788,6 +1067,7 @@ export interface CombatState { playerAnimationEvent: CharacterCombatAnimationEvent | null; autoAttackTargetId: string | null; nextAutoAttackAt: number; + nextOffhandAutoAttackAt: number; mobs: Readonly>; selectedTargetId: string | null; selectedFriendlyActorId: string | null; @@ -881,7 +1161,7 @@ export interface CombatState { restoreActorResource: (id: string, resource: ResourceType, amount: number, percentage?: boolean, sourceActorId?: string, abilityId?: string, now?: number) => number; shieldActor: (id: string, amount: number, durationMs: number, sourceActorId: string, ability: AbilityDefinition, now?: number) => number; applyActorAura: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract, now?: number) => boolean; - applyTimedActorEffect: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract, now?: number) => boolean; + applyTimedActorEffect: (sourceActorId: string, targetActorId: string, ability: AbilityDefinition, effect: Extract, now?: number, criticalChanceBonusPercent?: 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; @@ -953,6 +1233,8 @@ interface MutableCastWork { /** Active synchronous spell/source/target keys used to break proc cycles. */ triggeredSpellStack?: string[]; triggeredProcKeys?: Set; + /** A cast can model one weapon hit as several damage packets. */ + scentOfBloodHitKeys?: Set; } let feedbackSequence = 0; @@ -1148,7 +1430,26 @@ const ROM_RAGE_CLASSES = new Set(["rom-warrior", "rom-champion"]); const RAGE_PER_AUTO_ATTACK = 6; const RAGE_PER_HIT_TAKEN = 7; const ROM_CHAMPION_RAGE_PER_SECOND = 6; + +const ROM_WARRIOR_SCOUT_CALM_PASSIVE_SPELL_ID = 498032; +const ROM_WARRIOR_SCOUT_SKULL_BREAKER_SPELL_ID = 491328; +const ROM_WARRIOR_SCOUT_CALM_TAG = "rom-warrior-scout-calm"; +const ROM_WARRIOR_SCOUT_CALM_AURA: AuraDefinition = { + id: "rom:498032:component:620337", + name: "Calm", + disposition: "buff", + durationMs: 15_000, + utilityTags: [ROM_WARRIOR_SCOUT_CALM_TAG], + modifiers: [{ kind: "stat", stat: "physical-hit", operation: "flat", value: 20 }], +}; const PLAYER_AUTO_ATTACK_RANGE = 5; +const PLAYER_OFFHAND_AUTO_ATTACK_ID = "player-auto-attack-offhand"; +const ROGUE_COMBAT_POTENCY_ENERGY = 5; +const ROGUE_COMBAT_POTENCY_COOLDOWN_ABILITIES = new Set([ + "Blade Flurry", + "Killing Spree", + "Combat Readiness", +]); function hasRomClassPool( classId: ClassId, @@ -1195,16 +1496,18 @@ function gainRomRage( function playerAutoAttackProfile( state: Pick, + auras: readonly ActiveAura[] = [], + weaponOverride?: WeaponStats | null, ): { damage: number; intervalMs: number; hasteMultiplier: number } { - const stats = deriveCharacterStats( + const stats = applyAuraStats(deriveCharacterStats( state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, - ); - const weapon = state.gearStats.meleeWeapon; + ), auras); + const weapon = weaponOverride === undefined ? state.gearStats.meleeWeapon : weaponOverride; const speedMs = weapon?.speedMs ?? 2_400; const speedSeconds = speedMs / 1_000; const weaponDamage = weapon @@ -1219,6 +1522,45 @@ function playerAutoAttackProfile( }; } +function playerOffhandAutoAttackProfile( + state: Pick, + weapon: WeaponStats, + auras: readonly ActiveAura[] = [], +): { damage: number; intervalMs: number } { + const stats = applyAuraStats(deriveCharacterStats( + state.classId, + state.level, + state.gearStats, + state.raceId, + {}, + state.secondaryClassId, + ), auras); + const speedSeconds = weapon.speedMs / 1_000; + const fullWeaponDamage = (weapon.damageMin + weapon.damageMax) / 2 + + stats.attackPower / 14 * speedSeconds; + return { + damage: Math.max(1, Math.round(fullWeaponDamage * 0.5)), + intervalMs: Math.max(500, Math.round(hastedDuration(weapon.speedMs, stats.meleeHastePercent) + / raceAffixHasteMultiplier())), + }; +} + +function playerEquippedMeleeWeapon( + state: Pick, + slot: "main-hand" | "off-hand", +): WeaponStats | null { + // The resolved loadout fills empty slots with generated heirlooms. Those + // are baseline stats, not a player-equipped second weapon, so only inspect + // explicit inventory assignments when deciding whether this hand swings. + const assignedId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.[slot]; + const item = assignedId + ? state.inventory.find((candidate) => candidate.instanceId === assignedId) + : null; + return item?.weaponType ? item.stats.meleeWeapon : null; +} + function abilityTalentContext(ability: AbilityDefinition, periodic = false): TalentModifierContext { const searchable = `${ability.id} ${ability.name} ${ability.description} ${ability.icon}`.toLowerCase(); const tags = new Set(); @@ -1484,12 +1826,21 @@ export function equipmentAdjustedCombatAmount( const coefficient = typeof scaling === "number" ? scaling : scaling.coefficient + Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.coefficientPerLevel ?? 0); - if (typeof scaling === "number" || scaling.basePoints === undefined) { + if (typeof scaling === "number") { return Math.max( 0, Math.round(coefficient * (playerCombatPower(level) + equipmentPower)), ); } + if (scaling.basePoints === undefined) { + // Preserve the historical coefficient-only result when no explicit + // bonusPowerCoefficient is supplied. Mage Empowered Fire sets that field + // to carry a spell-power-only increase without changing base damage. + const gearCoefficient = scaling.bonusPowerCoefficient ?? coefficient; + return Math.max(0, Math.round( + coefficient * playerCombatPower(level) + gearCoefficient * equipmentPower, + )); + } const levelGrowth = Math.max(0, scalingLevel - sourceLevel) * Math.max(0, scaling.pointsPerLevel ?? 0); const averageRoll = Math.abs(scaling.basePoints) + Math.max(0, Math.abs(scaling.dieSides ?? 1) - 1) / 2 @@ -1504,9 +1855,10 @@ export function equipmentAdjustedCombatAmount( 0, scaling.bonusPowerCoefficient ?? coefficient, ); - const attributes = scaling.attackPowerCoefficient || scaling.spiritCoefficient + const attributes = scaling.attackPowerCoefficient || scaling.rangedAttackPowerCoefficient || scaling.spiritCoefficient ? deriveCharacterStats(classId, level, gearStats) : null; const scriptedPower = (attributes?.attackPower ?? 0) * (scaling.attackPowerCoefficient ?? 0) + + (attributes?.rangedAttackPower ?? 0) * (scaling.rangedAttackPowerCoefficient ?? 0) + (attributes?.spirit ?? 0) * (scaling.spiritCoefficient ?? 0); return Math.max(0, Math.round(averageRoll * earlyLevelScale + bonusPower + scriptedPower)); } @@ -1542,6 +1894,47 @@ function playerCharacterStats( }); } +function paladinShieldEquipped(state: Pick): boolean { + if (state.classId !== "paladin") return false; + return 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, + })["off-hand"]?.armorType === "shield"; +} + +function paladinTalentRankDescription(talentRanks: TalentRanks, talentName: string): string | undefined { + let selectedRank = 0; + let selectedDescription: string | undefined; + for (const [nodeId, points] of Object.entries(talentRanks)) { + const node = talentNodeById(nodeId); + const rank = Math.max(0, Math.trunc(points)); + if (node?.name !== talentName || rank <= selectedRank || rank > node.maxRank) continue; + selectedRank = rank; + selectedDescription = node.rankDescriptions[rank - 1]; + } + return selectedDescription; +} + +function paladinRedoubtBlockStats( + baseStats: CharacterStats, + classId: ClassId, + talentRanks: TalentRanks, + hasShield: boolean, +): CharacterStats { + if (classId !== "paladin" || !hasShield) return baseStats; + const profile = wow335PaladinRedoubtProfile(paladinTalentRankDescription(talentRanks, "Redoubt")); + return profile + ? { ...baseStats, blockPercent: baseStats.blockPercent + profile.blockChancePercent } + : baseStats; +} + function npcCombatDefender(mob: MobCombatState, attackerLevel: number, auras: readonly ActiveAura[]): CombatDefenderStats { const levelAdvantage = Math.max(0, mob.level - attackerLevel); const resistance = (value: number, school: DamageSchool): number => Math.max(0, @@ -1568,6 +1961,15 @@ function stableCombatRoll(seed: string) { return (stage: "outcome" | "resistance") => stableCombatUnit(`${seed}:${stage}`); } +function shamanElementalOathCriticalChancePercent( + classId: ClassId, + talentRanks: TalentRanks, + attackKind: WotlkAttackKind, +): number { + if (classId !== "shaman" || attackKind !== "spell") return 0; + return Math.min(3, Math.max(0, Math.trunc(talentRanks["talent-2049"] ?? 0))); +} + function resolvePlayerDamageResult( classId: ClassId, work: MutableCastWork, @@ -1595,6 +1997,16 @@ function resolvePlayerDamageResult( ); const stats = applyAuraStats(baseStats, aurasForEntity(work.auras, sourceActorId)); const baseAttacker = combatAttackerFromCharacterStats(stats, attackKind); + const dayOfRainMissChance = romRogueMageDayOfRainBaseMissPercent( + classId, + work.secondaryClassId, + ability?.dbcSpellId, + aurasForEntity(work.auras, sourceActorId).flatMap((aura) => aura.definition.utilityTags ?? []), + baseAttacker.hitPercent ?? 0, + ); + const resolvedRules = dayOfRainMissChance === null + ? rules + : { ...rules, baseMissChancePercent: dayOfRainMissChance }; const deathchillAbilities = ["Icy Touch", "Howling Blast", "Frost Strike", "Obliterate"]; const deathchillEligible = classId === "death-knight" && Boolean(ability) && deathchillAbilities.includes(ability!.name); @@ -1613,10 +2025,27 @@ function resolvePlayerDamageResult( abilityTalentContext(ability, periodic), ) : applyTalentModifierValue(0, work.talentModifiers, "critical-chance-percent"); + // Elemental Oath's owner and party clauses describe the same rank bonus. + // This shared spell-only hook covers the Shaman and party spell casters + // exactly once without affecting melee/ranged attacks. + const isPlayerOrPartySource = sourceActorId === PLAYER_AGGRO_ID || Boolean(sourceMember) + || onlinePlayerRuntimeActors().some((actor) => actor.id === sourceActorId); + const elementalOathBonus = isPlayerOrPartySource + ? shamanElementalOathCriticalChancePercent(classId, work.talentRanks, attackKind) + : 0; + const deathGraspRank = classId === "warlock" && sourceActorId === PLAYER_AGGRO_ID + ? warlockTalentRank(work.talentRanks, "Death's Grasp", 3) + : 0; + const warlockSpellHitBonus = warlockDeathGraspHitChanceBonusForRank(deathGraspRank, { + sourceIsWarlockPlayer: classId === "warlock" && sourceActorId === PLAYER_AGGRO_ID, + hasAbility: Boolean(ability), + periodic, + }); const attacker: CombatAttackerStats = { ...baseAttacker, + hitPercent: (baseAttacker.hitPercent ?? 0) + warlockSpellHitBonus, criticalChancePercent: (baseAttacker.criticalChancePercent ?? 0) + criticalChanceFromTalents - + criticalChanceBonusPercent + (nextCritical ? 100 : 0), + + elementalOathBonus + criticalChanceBonusPercent + (nextCritical ? 100 : 0), criticalDamageMultiplier: (sourceMember ? 2 : applyTalentModifierValue( 2, work.talentModifiers, @@ -1633,7 +2062,7 @@ function resolvePlayerDamageResult( defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level, aurasForEntity(work.auras, mob.id)), rules: immune ? { - ...rules, + ...resolvedRules, immune: true, canMiss: false, canCrit: false, @@ -1642,7 +2071,7 @@ function resolvePlayerDamageResult( canBlock: false, resistanceMode: "binary", } - : rules, + : resolvedRules, roll: stableCombatRoll(seed), }); } @@ -1664,19 +2093,56 @@ function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind { return "spell"; } -function romWardenWillAttackDamage( +const WARLOCK_SOUL_LEECH_AP_DEBUFF_AURA_ID = "wow335:warlock:soul-leech-ap-debuff"; +const WARLOCK_SOUL_LEECH_AP_DEBUFF_AMOUNT = 2; +const WARLOCK_SOUL_LEECH_AP_DEBUFF_DURATION_MS = 10_000; + +function enemyAttackDamageAfterVindication( + mob: MobCombatState, + attack: EnemyAttackDefinition, + amount: number, + effectMultiplier: number, + now: number, +): number { + if (attack.delivery !== "melee" || mob.attackPower <= 0 || amount <= 0) return amount; + const vindication = mob.auras.find((aura) => ( + aura.id === PALADIN_VINDICATION_AURA_ID + && (aura.endsAt <= 0 || aura.endsAt > now) + )); + const customReduction = vindication + ? Math.max(0, Number.isFinite(vindication.magnitude) ? vindication.magnitude! : 0) + : 0; + const auraReduction = mob.auras + .filter((aura) => (aura.id.startsWith(WOW335_WARLOCK_IMMOLATION_MOB_AURA_PREFIX) + || aura.id === WARLOCK_SOUL_LEECH_AP_DEBUFF_AURA_ID) + && (aura.endsAt <= 0 || aura.endsAt > now)) + .reduce((maximum, aura) => Math.max(maximum, Math.max(0, aura.magnitude ?? 0)), 0); + const reducedAttackPower = Math.min(mob.attackPower, Math.max(customReduction, auraReduction)); + if (reducedAttackPower <= 0) return amount; + const apDamageReduction = Math.max(0, mob.attackPowerDamageContribution) + * reducedAttackPower / mob.attackPower + * Math.max(0, effectMultiplier); + return Math.max(0, Math.round(amount - apDamageReduction)); +} + +function enemyDamageAfterTargetAuras( amount: number, auras: readonly ActiveAura[], mobId: string, attack: EnemyAttackDefinition, + now: number, ): 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, { + const attackKind = enemyAttackKind(attack); + const outgoingAuras = aurasForEntity(auras, mobId).filter((aura) => ( + (aura.expiresAt === null || aura.expiresAt > now) + && (attackKind === "melee" + || !aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG)) + )); + if (!outgoingAuras.length) return amount; + return Math.round(resolveAuraDamageValue(amount, outgoingAuras, { direction: "dealt", school: attack.school, - attackKind: enemyAttackKind(attack), + attackKind, })); } @@ -1934,7 +2400,9 @@ function initialResourcePools( export function resolvedAbilityCosts( ability: AbilityDefinition, modifiers: readonly TalentModifier[], - state: Pick & Partial>, + state: Pick + & Partial> + & { readonly now?: number }, ): readonly { resource: ResourceType; amount: number }[] { const aggregated = new Map(); const activeResourceType = resourceProfileForClass(state.classId).type; @@ -1954,6 +2422,18 @@ export function resolvedAbilityCosts( abilityTalentContext(ability), ))); const playerAuras = aurasForEntity(state.auras ?? [], PLAYER_AURA_ENTITY_ID); + const mageClearcastingAura = state.classId === "mage" + ? playerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) + && (state.now === undefined || aura.expiresAt === null || aura.expiresAt > state.now)) + : undefined; + const mageBrainFreezeAura = state.classId === "mage" && WOW335_MAGE_BRAIN_FREEZE_CONSUMER_SPELLS.has(ability.name) + ? playerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG) + && (state.now === undefined || aura.expiresAt === null || aura.expiresAt > state.now)) + : undefined; + const mageMissileBarrageAura = state.classId === "mage" && wow335MageMissileBarrageConsumerEligible(ability.name) + ? playerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG) + && (state.now === undefined || aura.expiresAt === null || aura.expiresAt > state.now)) + : undefined; // 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" @@ -1976,7 +2456,15 @@ export function resolvedAbilityCosts( ? 0.5 : 1; const amount = Math.max(0, Math.round(resourceAdjusted * hotStreakCostMultiplier)); - aggregated.set(cost.resource, (aggregated.get(cost.resource) ?? 0) + amount); + // Arcane Concentration's 100% reduction applies to the final cost even if + // another modifier increased it; Brain Freeze and Missile Barrage only + // remove Mana from their named consumer spells. No tooltip names Health. + const finalAmount = cost.resource !== "health" + && (mageClearcastingAura + || ((mageBrainFreezeAura || mageMissileBarrageAura) && cost.resource === "mana")) + ? 0 + : amount; + aggregated.set(cost.resource, (aggregated.get(cost.resource) ?? 0) + finalAmount); } return [...aggregated].map(([resource, amount]) => ({ resource, amount })); } @@ -2067,16 +2555,57 @@ function talentAdjustedRange( return Math.max(0, applyTalentModifierValue(range, modifiers, "range-percent", abilityTalentContext(ability))); } -function talentAdjustedIncomingDamage(amount: number, modifiers: readonly TalentModifier[]): number { +/** Flat spell-range bonuses affect hostile cast distance only, never an AoE radius. */ +function talentAdjustedCastRange( + range: number, + ability: AbilityDefinition, + modifiers: readonly TalentModifier[], +): number { + const context = abilityTalentContext(ability); + const percentAdjusted = applyTalentModifierValue(range, modifiers, "range-percent", context); + const castRangeAdjusted = ability.target === "hostile" + ? applyTalentModifierValue(percentAdjusted, modifiers, "range-flat-yards", context) + : percentAdjusted; + return Math.max(0, castRangeAdjusted); +} + +function talentAdjustedIncomingDamage( + amount: number, + modifiers: readonly TalentModifier[], + school: DamageSchool = "physical", +): number { + const tags = school === "physical" ? ["physical"] : ["magic", school]; return Math.max(0, Math.round( - applyTalentModifierValue(amount, modifiers, "damage-taken-percent"), + applyTalentModifierValue(amount, modifiers, "damage-taken-percent", { tags }), )); } +function applyDruidFeralInstinctCooldownReductionInWork( + work: MutableCastWork, + ability: AbilityDefinition, + damageDealt: number, + now: number, +): void { + if (work.classId !== "druid" || damageDealt <= 0 || !WOW335_DRUID_FERAL_INSTINCT_ATTACK_NAMES.has(ability.name)) return; + const rank = Math.min(3, Math.max(0, Math.trunc(work.talentRanks["talent-799"] ?? 0))); + if (rank <= 0) return; + for (const name of ["Tiger's Fury", "Enrage"]) { + const cooldownAbility = work.abilities.find((candidate) => candidate.name === name); + if (!cooldownAbility) continue; + const readyAt = work.cooldowns[cooldownAbility.id] ?? now; + if (readyAt > now) work.cooldowns[cooldownAbility.id] = Math.max(now, readyAt - 1_000); + } +} + /** Renderer-independent player defenses for hostile-player combat adapters. */ export function characterCombatDefender(state: CombatState): CombatDefenderStats { - const stats = applyAuraStats(playerCharacterStats(state.classId, state.level, state.gearStats, - state.raceId, state.secondaryClassId, state.talentModifiers), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID)); + const stats = paladinRedoubtBlockStats( + applyAuraStats(playerCharacterStats(state.classId, state.level, state.gearStats, + state.raceId, state.secondaryClassId, state.talentModifiers), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID)), + state.classId, + state.talentRanks, + paladinShieldEquipped(state), + ); const defender = combatDefenderFromCharacterStats(stats); const multiplier = talentAdjustedIncomingDamage(10_000, state.talentModifiers) / 10_000; return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 }; @@ -2140,6 +2669,7 @@ function defaultMobStats( armor: number; maxMana: number; attackPower: number; + attackPowerDamageContribution: number; attackDamage: number; swingMs: number; mechanicImmunities: readonly AzerothCoreMechanic[]; @@ -2155,6 +2685,7 @@ function defaultMobStats( armor: server?.armor ?? 0, maxMana: server?.mana ?? 0, attackPower: server?.attackPower ?? 0, + attackPowerDamageContribution: server?.attackPowerDamageContribution ?? 0, attackDamage: server?.averageAttackDamage ?? (boss ? 12 + playerLevel * 2 : 4 + playerLevel * 2), swingMs: server?.baseAttackTimeMs ?? (boss ? 1_650 : 1_900), mechanicImmunities: server?.mechanicImmunities ?? [], @@ -2162,7 +2693,7 @@ function defaultMobStats( }; } -function stableCombatUnit(seed: string): number { +export function stableCombatUnit(seed: string): number { let hash = 2166136261; for (let index = 0; index < seed.length; index += 1) { hash ^= seed.charCodeAt(index); @@ -2171,6 +2702,63 @@ function stableCombatUnit(seed: string): number { return (hash >>> 0) / 0x1_0000_0000; } +function paladinBlessedLifeDamageAfterIncomingHit( + amount: number, + classId: ClassId, + talentRanks: TalentRanks, + landed: boolean, + targetActorId: string, + mobId: string, + attackId: string, + attackRevision: number, + now: number, +): number { + const rank = classId === "paladin" ? talentRanks[WOW335_PALADIN_BLESSED_LIFE_TALENT_ID] ?? 0 : 0; + if (!landed || rank <= 0) return amount; + const roll = stableCombatUnit( + `${targetActorId}:${mobId}:${attackId}:${attackRevision}:${now}:wow335-paladin-blessed-life`, + ); + return Math.round(amount * wow335PaladinBlessedLifeDamageMultiplierForRoll(rank, roll)); +} + +const WOW335_PRIEST_PAIN_AND_SUFFERING_CASTS = new Set(["Mind Flay", "Mind Sear", "Mind Blast"]); + +/** Refresh a caster-owned Shadow Word: Pain after a generated Pain and Suffering proc. */ +export function refreshPriestPainAndSufferingDot( + effects: readonly TimedCombatEffect[], + sourceActorId: string, + targetId: string | null, + triggerAbility: AbilityDefinition, + talentRank: number, + shadowWordPain: AbilityDefinition | null, + now: number, +): readonly TimedCombatEffect[] { + if (!targetId || !WOW335_PRIEST_PAIN_AND_SUFFERING_CASTS.has(triggerAbility.name) || !shadowWordPain) return effects; + const dotDefinition = shadowWordPain.effects.find((effect) => effect.kind === "dot"); + if (!dotDefinition || dotDefinition.kind !== "dot" || dotDefinition.ticks <= 0) return effects; + const rank = Math.min(3, Math.max(0, Math.trunc(talentRank))); + const talent = WOW335_TALENT_NODES.find((node) => node.id === "talent-1909"); + const procChance = rank > 0 ? (talent?.rankEffects[rank - 1]?.procChance ?? 0) / 100 : 0; + if (procChance <= 0 || stableCombatUnit( + `${sourceActorId}:${triggerAbility.id}:${targetId}:${now}:wow335-priest-pain-and-suffering`, + ) >= procChance) return effects; + + let refreshed = false; + const nextEffects = effects.map((effect) => { + if (effect.kind !== "dot" || effect.sourceActorId !== sourceActorId + || effect.sourceAbilityId !== shadowWordPain.id || effect.targetId !== targetId + || effect.remainingTicks <= 0 + || effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs < now) return effect; + refreshed = true; + return { + ...effect, + remainingTicks: dotDefinition.ticks, + nextTickAt: now + effect.intervalMs, + }; + }); + return refreshed ? nextEffects : effects; +} + function applyWrathCastPushback( cast: ActiveCast | null, now: number, @@ -2318,6 +2906,7 @@ function summonedMobCombatState( schoolLockedUntil: {}, attackRange: attack.range, attackDamage: stats.attackDamage, + attackPowerDamageContribution: stats.attackPowerDamageContribution, swingMs: stats.swingMs, aggroRange: 0, leashRange: summoner.leashRange, @@ -2338,7 +2927,7 @@ function currentOrigin(state: CombatState, origin?: CombatPosition): CombatPosit } function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveAura[] { - return auras.filter((aura) => aura.targetId === entityId); + return wow335PaladinAuraMasteryAurasForEntity(auras, entityId); } function hasControlImmunity( @@ -2364,6 +2953,44 @@ function deathKnightAntiMagicShellBlocksMagicEffect( )); } +function deathKnightAntiMagicShellRunicPowerGain( + auras: readonly ActiveAura[], + consumptions: readonly AbsorbConsumption[], + targetId: string, + school: DamageSchool, + now: number, +): number { + if (school === "physical") return 0; + const liveShellIds = new Set(auras.filter((aura) => ( + aura.targetId === targetId + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell") + )).map((aura) => aura.instanceId)); + const absorbed = consumptions.reduce((total, consumption) => ( + liveShellIds.has(consumption.auraInstanceId) ? total + Math.max(0, consumption.amount) : total + ), 0); + // Spell 49088 receives 20% of absorbed damage in the DBC's 10x RP units. + return absorbed * 0.02; +} + +function grantDeathKnightAntiMagicShellRunicPower( + classId: ClassId, + resource: number, + resourcePools: Partial>, + maxResource: number, + maxResourcePools: Partial>, + gain: number, +): { resource: number; resourcePools: Partial> } { + if (classId !== "death-knight" || gain <= 0) return { resource, resourcePools }; + const current = resourcePools["runic-power"] ?? resource; + const maximum = maxResourcePools["runic-power"] ?? maxResource; + const runicPower = clamp(current + gain, 0, maximum); + return { + resource: runicPower, + resourcePools: { ...resourcePools, "runic-power": runicPower }, + }; +} + function applyDeathKnightAntiMagicZoneInWork( work: MutableCastWork, center: CombatPosition, @@ -2611,6 +3238,38 @@ function activeWardenDamageTransferPet( ) <= 300) ?? null; } +function resolveWarlockSoulLinkDamage( + amount: number, + classId: ClassId, + talentRanks: TalentRanks, + auras: readonly ActiveAura[], + summons: readonly CombatSummon[], + ownerActorId: string, + ownerPosition: CombatPosition | null, + now: number, +): { readonly demon: CombatSummon | null; readonly casterDamage: number; readonly demonDamage: number } { + if (classId !== "warlock" || !ownerPosition) return { demon: null, casterDamage: amount, demonDamage: 0 }; + const soulLinkRank = warlockTalentRank(talentRanks, "Soul Link", 1); + if (soulLinkRank <= 0 || !auras.some((aura) => ( + aura.sourceId === ownerActorId + && aura.targetId === ownerActorId + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(WOW335_WARLOCK_SOUL_LINK_TAG) + ))) return { demon: null, casterDamage: amount, demonDamage: 0 }; + + const demon = warlockSoulLinkDemon(summons, ownerActorId, ownerPosition, now); + if (!demon) return { demon: null, casterDamage: amount, demonDamage: 0 }; + const sharePercent = warlockSoulLinkSharePercent( + soulLinkRank, + warlockTalentRank(talentRanks, "Demonic Embrace", 3), + ); + const split = warlockSoulLinkDamageSplit(amount, sharePercent); + // If the demon cannot survive the full share, only its remaining health is + // transferred; the overflow stays with the caster. + const demonDamage = Math.min(demon.health, split.demonDamage); + return { demon, casterDamage: amount - demonDamage, demonDamage }; +} + function syncPassiveAuras( current: readonly ActiveAura[], abilities: readonly AbilityDefinition[], @@ -2709,6 +3368,18 @@ function executeTriggeredAuraProcsInWork( work.auras = work.auras.filter((aura) => aura.instanceId !== triggered.auraInstanceId); continue; } + if (work.classId === "rom-knight" + && action.kind === "custom" + && action.id === ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC) { + const masterOfParryAura = work.auras.find((aura) => aura.instanceId === triggered.auraInstanceId); + if (masterOfParryAura?.definition.utilityTags?.includes(ROM_KNIGHT_MASTER_OF_PARRY_TAG) + && masterOfParryAura.procStates.some((state) => ( + state.procId === ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC && state.remainingCharges === 0 + ))) { + work.auras = work.auras.filter((aura) => aura.instanceId !== triggered.auraInstanceId); + } + continue; + } if (work.classId === "shaman" && action.kind === "custom" && action.id === "wow335-shamanistic-rage-mana") { @@ -2868,6 +3539,105 @@ function executeTriggeredAuraProcsInWork( ? work.abilities.find((ability) => ability.id === triggered.event.abilityId) ?? abilityById(triggered.event.abilityId) : null; + if (action.id === ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID) { + const data = action.data; + const auraId = data?.auraId; + const auraName = data?.auraName; + const durationMs = data?.durationMs; + const modifierValue = data?.modifierValue; + if (work.classId === "rom-champion" + && work.secondaryClassId === "rom-warlock" + && triggered.sourceId === PLAYER_AURA_ENTITY_ID + && triggered.event.trigger === "crit" + && data?.sourceSpellId === ROM_CHAMPION_SOUL_FORGE_PROFILE.sourceSpellId + && data.sourceComponentId === ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId + && data.target === "aura-source" + && data.modifierKind === "damage" + && data.modifierDirection === "dealt" + && data.modifierSchool === ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicSchool + && data.modifierOperation === "percent" + && typeof auraId === "string" + && typeof auraName === "string" + && typeof durationMs === "number" + && durationMs > 0 + && typeof modifierValue === "number") { + const definition: AuraDefinition = { + id: auraId, + name: auraName, + disposition: "buff", + durationMs, + maxStacks: 1, + stackBehavior: "refresh", + modifiers: [{ + kind: "damage", + direction: "dealt", + school: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicSchool, + operation: "percent", + value: modifierValue, + }], + }; + work.auras = [...applyCombatAura(work.auras, { + definition, + sourceId: triggered.sourceId, + targetId: triggered.sourceId, + now, + }).auras]; + } + continue; + } + if (action.id === ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID) { + const data = action.data; + const durationMs = data?.durationMs; + const maxStacks = data?.maxStacks; + const staminaPercentPerStack = data?.staminaPercentPerStack; + if (work.classId === "rom-champion" + && work.secondaryClassId === "rom-priest" + && triggered.sourceId === PLAYER_AURA_ENTITY_ID + && (triggered.event.trigger === "hit" || triggered.event.trigger === "crit") + && triggeringAbility?.dbcSpellId === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerSpellId + && data?.sourceSpellId === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId + && data.sourceComponentId === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceComponentId + && typeof durationMs === "number" && durationMs > 0 + && maxStacks === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.maxStacks + && typeof staminaPercentPerStack === "number" && staminaPercentPerStack > 0) { + const definition: AuraDefinition = { + id: "rom-champion-way-of-frenzy-stacks", + name: "Way of Frenzy", + description: `Each landed Divine Vengeance hit grants ${Math.round(staminaPercentPerStack * 100)}% Stamina for ${durationMs / 1_000} seconds, stacking up to ${maxStacks} times.`, + disposition: "buff", + durationMs, + maxStacks, + stackBehavior: "add", + modifiers: [{ + kind: "stat", + stat: "stamina", + operation: "percent", + value: staminaPercentPerStack, + perStack: true, + }], + }; + work.auras = [...applyCombatAura(work.auras, { + definition, + sourceId: triggered.sourceId, + targetId: triggered.sourceId, + now, + }).auras]; + } + continue; + } + if (action.id === ROM_PRIEST_CHAMPION_REGENERATE_PROC_ACTION_ID) { + if (work.classId !== "rom-priest" + || work.secondaryClassId !== "rom-champion" + || triggered.sourceId !== PLAYER_AURA_ENTITY_ID + || triggered.event.trigger !== "periodic" + || triggeringAbility?.dbcSpellId !== 490269) continue; + const heartRuneEnergy = work.abilities.find((ability) => ability.source === "runewaker" + && ability.classId === "rom-priest" + && ability.secondaryClassId === "rom-champion" + && ability.dbcSpellId === 498612); + if (heartRuneEnergy) work.cooldowns[heartRuneEnergy.id] = 0; + continue; + } if (action.id === "rom-champion-forge-hit") { if (work.classId === "rom-champion" && triggered.sourceId === PLAYER_AGGRO_ID @@ -2909,8 +3679,16 @@ function executeTriggeredAuraProcsInWork( } 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); + const spec = romScoutRogueProcActionSpec(action.id, triggeringAbility?.dbcSpellId ?? 0, poisonous, action.data); if (spec) { + if (action.id === "rom-sinister-methods") { + if (work.classId !== "rom-rogue" || work.secondaryClassId !== "rom-priest" + || triggered.sourceId !== PLAYER_AURA_ENTITY_ID) continue; + const readyAt = work.cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY] ?? now; + if (readyAt > now) continue; + if (spec.kind !== "aura" || !spec.cooldownMs) continue; + work.cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY] = now + spec.cooldownMs; + } const recipient = spec.recipient === "caster" ? triggered.sourceId : triggered.event.otherId ?? triggered.targetId; if (spec.kind === "aura" && recipient) { @@ -2933,6 +3711,12 @@ function executeTriggeredAuraProcsInWork( work.actors[recipient] = next; mirrorCompanionActor(next); } + } else if (spec.kind === "status" && recipient && work.mobs[recipient]) { + work.mobs[recipient] = applyStatus(work.mobs[recipient]!, { + kind: "status", + status: spec.status, + durationMs: spec.durationMs, + }, triggeringAbility?.id ?? triggered.procId, now, triggered.sourceId); } else if (spec.kind === "dot" && recipient && work.mobs[recipient]) { work.effects = work.effects.filter((effect) => !(effect.kind === "dot" && effect.sourceActorId === triggered.sourceId @@ -3066,6 +3850,71 @@ function executeTriggeredAuraProcsInWork( } } +function maybeProcHunterLockAndLoadInWork( + work: MutableCastWork, + sourceActorId: string, + sourceAbilityId: string, + abilityName: string, + trigger: "trap" | "periodic", + targetId: string, + now: number, +): boolean { + const playerSource = sourceActorId === PLAYER_AURA_ENTITY_ID; + const partyMember = playerSource + ? undefined + : usePartyStore.getState().members.find((member) => member.id === sourceActorId); + const ownerClassId = playerSource ? work.classId : partyMember?.classId; + const talentRanks = playerSource ? work.talentRanks : partyMember?.talentRanks; + if (ownerClassId !== "hunter" || !talentRanks) return false; + + const periodicAbilities = new Set(["Immolation Trap", "Explosive Trap", "Black Arrow"]); + if ((trigger === "trap" && !["Freezing Arrow", "Freezing Trap"].includes(abilityName)) + || (trigger === "periodic" && !periodicAbilities.has(abilityName))) return false; + const rank = Math.max(0, Math.min(3, Math.trunc(talentRanks["talent-1306"] ?? 0))); + const mechanics = rank > 0 ? lockAndLoadRankMechanics(rank) : null; + if (!mechanics) return false; + + const ownerId = playerSource ? PLAYER_AURA_ENTITY_ID : sourceActorId; + if (work.auras.some((aura) => aura.targetId === ownerId + && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG) + && (aura.expiresAt === null || aura.expiresAt > now))) return false; + const chance = trigger === "trap" ? mechanics.trapProcChance : mechanics.periodicProcChance; + if (chance <= 0 || stableCombatUnit( + `${ownerId}:${sourceAbilityId}:${trigger}:${targetId}:${now}:wow335-hunter-lock-and-load`, + ) >= chance) return false; + + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:hunter:lock-and-load-ready", + name: "Lock and Load", + description: mechanics.description, + disposition: "buff", + durationMs: mechanics.durationMs, + maxStacks: mechanics.freeCastCount, + initialStacks: mechanics.freeCastCount, + stackBehavior: "replace", + utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], + }, + sourceId: ownerId, + targetId: ownerId, + now, + }).auras]; + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:hunter:lock-and-load-icd", + name: "Lock and Load Internal Cooldown", + description: mechanics.description, + disposition: "buff", + durationMs: mechanics.internalCooldownMs, + utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG], + }, + sourceId: ownerId, + targetId: ownerId, + now, + }).auras]; + return true; +} + function evaluateProcsInWork( work: MutableCastWork, event: ProcEvent, @@ -3393,10 +4242,133 @@ const DRUID_NATURES_GRACE_AURA: AuraDefinition = { modifiers: [{ kind: "stat", stat: "haste", operation: "flat", value: 20 }], }; +const DRUID_LIVING_SEED_AURA: AuraDefinition = { + id: "wow335:druid:living-seed", + name: "Living Seed", + description: "Stores 30% of an eligible critical heal and blooms when the target next takes direct damage.", + disposition: "buff", + durationMs: 15_000, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: ["wow335-druid-living-seed"], +}; + +const DRUID_LIVING_SEED_ABILITY_ID = "wow335:druid:living-seed"; +const DRUID_LIVING_SEED_ELIGIBLE_HEALS = new Set(["Healing Touch", "Regrowth", "Swiftmend"]); + function druidTalentRank(work: MutableCastWork, talentId: string, maxRank: number): number { return Math.min(maxRank, Math.max(0, Math.trunc(work.talentRanks[talentId] ?? 0))); } +function maybeProcWarriorBloodCrazeInWork( + work: MutableCastWork, + wasCritical: boolean, + now: number, +): void { + if (work.classId !== "warrior" || !wasCritical || work.health <= 0 + || (work.cooldowns[WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY] ?? 0) > now) return; + const rank = Math.min(3, Math.max(0, Math.trunc(work.talentRanks[WOW335_WARRIOR_BLOOD_CRAZE_TALENT_ID] ?? 0))); + const enraged = work.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now)); + const profile = wow335WarriorBloodCrazeProfile( + baseResourcesFor(work.classId, work.progression.level).health, + rank, + enraged, + ); + if (!profile) return; + + work.cooldowns[WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY] = now + + WOW335_WARRIOR_BLOOD_CRAZE_INTERNAL_COOLDOWN_MS; + work.effects.push(timedEffect( + "hot", + "wow335-warrior-blood-craze", + PLAYER_AGGRO_ID, + null, + { coefficient: 0, fixedAmount: profile.healingPerTick }, + profile.ticks, + profile.intervalMs, + now, + "wow335-warrior-blood-craze", + )); +} + +function maybeProcWarriorSecondWindInWork( + work: MutableCastWork, + seed: string, + now: number, +): void { + if (work.classId !== "warrior" || work.health <= 0 || work.health / work.maxHealth >= 0.35 + || (work.cooldowns[WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY] ?? 0) > now) return; + const rank = Math.min(3, Math.max(0, Math.trunc(work.talentRanks[WOW335_WARRIOR_SECOND_WIND_TALENT_ID] ?? 0))); + const profile = wow335WarriorSecondWindProfile( + baseResourcesFor("warrior", work.progression.level).health, + rank, + ); + if (!profile || stableCombatUnit(`${seed}:warrior-second-wind`) >= profile.procChancePercent / 100) return; + + work.cooldowns[WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY] = now + profile.cooldownMs; + work.effects.push({ + ...timedEffect( + "hot", + "wow335-warrior-second-wind", + PLAYER_AGGRO_ID, + null, + { coefficient: 0, fixedAmount: profile.healingPerTick }, + profile.ticks, + profile.intervalMs, + now, + "wow335-warrior-second-wind", + ), + tickAmounts: profile.tickAmounts, + }); +} + +function applyWarriorChargeTalentEffectsInWork( + work: MutableCastWork, + ability: AbilityDefinition, + now: number, +): void { + if (work.classId !== "warrior") return; + const chargeRank = wow335WarriorChargeRank(ability.dbcSpellId); + if (chargeRank <= 0) return; + const angerRank = work.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0; + const profile = wow335WarriorChargeRageProfile( + chargeRank, + angerRank, + ); + if (!profile) return; + + const maximum = work.maxResourcePools.rage ?? work.maxResource; + const before = work.resourcePools.rage ?? work.resource; + const rage = Math.min(maximum, before + profile.totalRage); + work.resourcePools = { ...work.resourcePools, rage }; + if (resourceProfileForClass(work.classId).type === "rage") work.resource = rage; + + const angerManagement = wow335WarriorAngerManagementProfile(angerRank); + if (!angerManagement || angerManagement.chargeDamageReductionPercent <= 0 + || angerManagement.chargeDamageReductionDurationMs <= 0) return; + const applied = applyCombatAura(work.auras, { + definition: { + id: "wow335:warrior:anger-management-charge-reduction", + name: "Anger Management", + disposition: "buff", + durationMs: angerManagement.chargeDamageReductionDurationMs, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: [WOW335_WARRIOR_ANGER_MANAGEMENT_CHARGE_AURA_TAG], + modifiers: [{ + kind: "damage", + direction: "taken", + operation: "percent", + value: -angerManagement.chargeDamageReductionPercent / 100, + }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }); + work.auras = [...applied.auras]; +} + const PRIEST_SURGE_OF_LIGHT_AURA: AuraDefinition = { id: "wow335:priest:surge-of-light", name: "Surge of Light", @@ -3469,6 +4441,92 @@ function paladinTalentRank(talentRanks: TalentRanks, talentName: string, maximum ), 0))); } +const PALADIN_VINDICATION_AURA_ID = "wow335:paladin:vindication"; +const WOW335_WARLOCK_IMMOLATION_MOB_AURA_PREFIX = "wow335:warlock:immolation-ap:"; + +function applyWarlockImmolationAttackPowerInWork( + work: MutableCastWork, + targetId: string, + sourceActorId: string, + sourceAbilityId: string, + sourceLevel: number, + now: number, +): void { + const mob = work.mobs[targetId]; + if (!mob || mob.dead) return; + const reduction = wow335WarlockImmolationAttackPowerReduction(sourceLevel); + if (reduction <= 0) return; + const id = `${WOW335_WARLOCK_IMMOLATION_MOB_AURA_PREFIX}${sourceActorId}`; + work.mobs[targetId] = { + ...mob, + auras: [ + ...mob.auras.filter((aura) => aura.id !== id), + { + id, + sourceAbilityId, + sourceActorId, + endsAt: now + 30_000, + magnitude: reduction, + }, + ], + }; +} + +function paladinVindicationSource( + work: MutableCastWork, + sourceActorId: AggroActorId, +): { classId: ClassId; talentRank: number; level: number } | null { + if (sourceActorId === PLAYER_AGGRO_ID) { + if (baseClassFor(work.classId) !== "paladin") return null; + return { + classId: work.classId, + talentRank: Math.max(0, Math.trunc(work.talentRanks["talent-1633"] ?? 0)), + level: work.progression.level, + }; + } + const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); + if (!member || baseClassFor(member.classId) !== "paladin") return null; + return { + classId: member.classId, + talentRank: Math.max(0, Math.trunc(member.talentRanks["talent-1633"] ?? 0)), + level: member.level, + }; +} + +function maybeProcPaladinVindicationInWork( + work: MutableCastWork, + targetId: string, + source: ThreatSource, + school: DamageSchool, + now: number, + landed: boolean, +): void { + if (!landed || !source.abilityId) return; + const paladin = paladinVindicationSource(work, source.actorId); + if (!paladin) return; + const ability = abilityById(source.abilityId); + if (!ability || (school !== "holy" && abilityAttackKind(paladin.classId, ability) !== "melee")) return; + const profile = wow335PaladinVindicationProfile(paladin.talentRank, paladin.level); + if (!profile || stableCombatUnit( + `${source.actorId}:${source.abilityId}:${targetId}:${now}:wow335-paladin-vindication`, + ) >= profile.procChance) return; + const mob = work.mobs[targetId]; + if (!mob || mob.dead) return; + work.mobs[targetId] = { + ...mob, + auras: [ + ...mob.auras.filter((aura) => aura.id !== PALADIN_VINDICATION_AURA_ID), + { + id: PALADIN_VINDICATION_AURA_ID, + sourceAbilityId: source.abilityId, + sourceActorId: source.actorId, + endsAt: now + profile.durationMs, + magnitude: profile.attackPowerReduction, + }, + ], + }; +} + const PALADIN_RECKONING_TAG = "wow335-paladin-reckoning"; const PALADIN_RECKONING_AURA: AuraDefinition = { id: "wow335:paladin:reckoning", @@ -3501,6 +4559,33 @@ function maybeProcPaladinReckoningInWork( }).auras]; } +function maybeProcPaladinRedoubtInWork( + work: MutableCastWork, + blocked: boolean, + hasShield: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "paladin" || !blocked || !hasShield) return; + const profile = wow335PaladinRedoubtProfile(paladinTalentRankDescription(work.talentRanks, "Redoubt")); + if (!profile || stableCombatUnit(`${seed}:wow335-paladin-redoubt`) >= profile.procChance) return; + const baseStats = playerCharacterStats( + work.classId, + work.progression.level, + work.gearStats, + work.raceId, + work.secondaryClassId, + work.talentModifiers, + ); + const strength = applyAuraStats(baseStats, aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID)).strength; + work.auras = [...applyCombatAura(work.auras, { + definition: wow335PaladinRedoubtBuff(profile, strength), + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).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) @@ -3643,6 +4728,47 @@ function applyDruidSelfAura(work: MutableCastWork, definition: AuraDefinition, n }).auras]; } +function maybeProcDruidLivingSeed( + work: MutableCastWork, + ability: AbilityDefinition, + targetId: string, + critical: boolean, + effectiveHealing: number, + seed: string, + now: number, +): void { + if (work.classId !== "druid" || ability.source === "runewaker" + || !DRUID_LIVING_SEED_ELIGIBLE_HEALS.has(ability.name) || !critical || effectiveHealing <= 0) return; + const rank = druidTalentRank(work, "talent-1922", 3); + const procChance = [0, 0.33, 0.66, 1][rank] ?? 0; + if (rank <= 0 || stableCombatUnit(seed) >= procChance) return; + const applied = applyCombatAura(work.auras, { + definition: DRUID_LIVING_SEED_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId, + now, + }); + work.auras = [...applied.auras.map((aura) => aura.instanceId === applied.aura.instanceId + ? { ...aura, druidLivingSeedAmount: Math.round(effectiveHealing * 0.3) } + : aura)]; +} + +function consumeDruidLivingSeed( + auras: readonly ActiveAura[], + targetId: string, + now: number, +): { auras: ActiveAura[]; healingAmount: number } { + const seedAura = auras.find((aura) => aura.targetId === targetId + && aura.definition.utilityTags?.includes("wow335-druid-living-seed")); + if (!seedAura) return { auras: [...auras], healingAmount: 0 }; + return { + auras: auras.filter((aura) => aura.instanceId !== seedAura.instanceId), + healingAmount: seedAura.expiresAt === null || seedAura.expiresAt > now + ? Math.max(0, Math.round(seedAura.druidLivingSeedAmount ?? 0)) + : 0, + }; +} + function maybeProcDruidNatureGrace( work: MutableCastWork, ability: AbilityDefinition, @@ -3666,6 +4792,78 @@ function maybeProcDruidOmenOfClarity(work: MutableCastWork, seed: string, now: n applyDruidSelfAura(work, DRUID_CLEARCASTING_AURA, now); } +function maybeProcDruidPrimalFury( + work: MutableCastWork, + sourceActorId: string, + periodic: boolean, + critical: boolean, + landed: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "druid" || sourceActorId !== PLAYER_AGGRO_ID + || periodic || !critical || !landed) return; + const rank = druidTalentRank(work, "talent-801", 2); + if (rank <= 0) return; + const activeFormSpellIds = new Set(work.auras + .filter((aura) => aura.targetId === sourceActorId && (aura.expiresAt === null || aura.expiresAt > now)) + .flatMap((aura) => { + const match = /^wow335:(5487|9634|768):/.exec(aura.definition.id); + return match ? [Number(match[1])] : []; + })); + const procChance = rank / 2; + + if ((activeFormSpellIds.has(5487) || activeFormSpellIds.has(9634)) + && stableCombatUnit(`${seed}:wow335-druid-primal-fury-rage`) < procChance) { + const maximum = work.maxResourcePools.rage ?? 100; + const before = work.resourcePools.rage ?? 0; + const after = clamp(before + 5, 0, maximum); + work.resourcePools = { ...work.resourcePools, rage: after }; + if (resourceProfileForClass(work.classId).type === "rage") work.resource = after; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "resource", + sourceActorId, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: "wow335-druid-primal-fury", + rawAmount: 5, + effectiveAmount: after - before, + overheal: 5 - (after - before), + absorbed: 0, + critical: false, + }); + } +} + +function maybeProcDruidPrimalFuryCombo( + work: MutableCastWork, + ability: AbilityDefinition, + targetId: string, + comboAmount: number, + combatEventIdBeforeAbilityEffects: number, + now: number, +): void { + if (work.classId !== "druid" || ability.source === "runewaker" || comboAmount <= 0 + || !ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "finisher-damage")) return; + const rank = druidTalentRank(work, "talent-801", 2); + const catFormActive = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.id.startsWith("wow335:768:")); + const directCritical = work.combatEvents.some((event) => event.id > combatEventIdBeforeAbilityEffects + && event.kind === "damage" + && event.sourceActorId === PLAYER_AGGRO_ID + && event.targetActorId === targetId + && event.abilityId === ability.id + && event.occurredAt === now + && event.critical); + const procChance = rank / 2; + if (rank <= 0 || !catFormActive || !directCritical || work.comboPoints >= 5 + || stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:wow335-druid-primal-fury-combo`) >= procChance) return; + + work.comboPoints = clamp((work.comboTargetId === targetId ? work.comboPoints : 0) + 1, 0, 5); + work.comboTargetId = targetId; +} + 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); @@ -3749,6 +4947,14 @@ function damageMobInWork( critical: combatResult?.critical ?? false, outcome: combatResult?.outcome ?? "hit", }); + maybeProcPaladinVindicationInWork( + work, + id, + source, + school, + now, + combatResult?.landed ?? applied > 0, + ); const thorns = mob.auras.find((aura) => aura.id === "thorns"); if (thorns && source.actorId === PLAYER_AGGRO_ID && applied > 0) { const reflected = Math.min( @@ -3992,6 +5198,73 @@ function mageTalentRank(talentRanks: TalentRanks, talentName: string, maximumRan ), 0))); } +interface Wow335MageTalentOwner { + readonly level: number; + readonly talentRanks: TalentRanks; + readonly spellPenetration: number; +} + +function wow335MageTalentOwnerForActor( + work: Pick, + sourceActorId: AggroActorId, +): Wow335MageTalentOwner | null { + if (sourceActorId === PLAYER_AGGRO_ID) { + return work.classId === "mage" + ? { + level: work.progression.level, + talentRanks: work.talentRanks, + spellPenetration: work.gearStats.spellPenetration, + } + : null; + } + const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId); + return member?.classId === "mage" + ? { level: member.level, talentRanks: member.talentRanks, spellPenetration: member.gearStats.spellPenetration } + : null; +} + +function applyMageFrostbiteInWork( + work: MutableCastWork, + sourceActorId: AggroActorId, + targetId: string, + abilityId: string, + rank: number, + school: string, + landed: boolean, + damage: number, + roll: number, + now: number, +): boolean { + const durationMs = wow335MageFrostbiteProcDurationMs(rank, school, landed, damage, roll); + const target = work.mobs[targetId]; + if (durationMs <= 0 || !target) return false; + work.mobs[targetId] = applyStatus(target, { + kind: "status", + status: "freeze", + durationMs, + }, abilityId, now, sourceActorId); + return true; +} + +function maybeProcMageMissileBarrage( + work: MutableCastWork, + abilityName: string, + seed: string, + now: number, +): boolean { + if (work.classId !== "mage") return false; + const rank = mageTalentRank(work.talentRanks, "Missile Barrage", 3); + const chance = wow335MageMissileBarrageReachableProcChancePercentForSpell(rank, abilityName) / 100; + if (chance <= 0 || stableCombatUnit(`${seed}:wow335-mage-missile-barrage`) >= chance) return false; + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_MISSILE_BARRAGE_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + return true; +} + const WARLOCK_SOUL_LEECH_DIRECT_SPELLS = new Set([ "Chaos Bolt", "Conflagrate", @@ -4010,6 +5283,66 @@ 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"]); +const WARLOCK_BACKDRAFT_READY_AURA: AuraDefinition = { + id: "wow335:warlock:backdraft-ready", + name: "Backdraft", + description: "Your next three Destruction spells cast within 15 seconds have reduced cast time and global cooldown.", + disposition: "buff", + durationMs: 15_000, + maxStacks: 3, + initialStacks: 3, + stackBehavior: "replace", + utilityTags: [WARLOCK_BACKDRAFT_READY_TAG], +}; + +function consumeWarlockBackdraftCharge(auras: readonly ActiveAura[], instanceId: string): ActiveAura[] { + return auras.flatMap((aura) => { + if (aura.instanceId !== instanceId) return [aura]; + return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; + }); +} + +function maybeApplyWarlockBackdraft( + auras: readonly ActiveAura[], + classId: ClassId, + talentRanks: TalentRanks, + abilityName: string, + now: number, +): ActiveAura[] { + if (classId !== "warlock" || !warlockBackdraftTriggerEligible(abilityName)) return [...auras]; + const effects = warlockBackdraftEffectsForRank(warlockTalentRank(talentRanks, "Backdraft", 3)); + if (effects.rank <= 0) return [...auras]; + return [...applyCombatAura(auras, { + definition: WARLOCK_BACKDRAFT_READY_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + stacks: effects.charges, + }).auras]; +} + +const WARLOCK_NIGHTFALL_READY_AURA: AuraDefinition = { + id: "wow335:warlock:nightfall-ready", + name: "Nightfall", + description: "Your next Shadow Bolt or Seed of Corruption is instant and costs less mana.", + disposition: "buff", + // The generated talent rank rows do not supply the proc-aura duration. + // WotLKDB's linked Shadow Trance spell 17941 supplies 10 seconds. + durationMs: 10_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [WARLOCK_NIGHTFALL_READY_TAG], +}; + +const WARLOCK_NIGHTFALL_COOLDOWN_AURA: AuraDefinition = { + id: "wow335:warlock:nightfall-cooldown", + name: "Nightfall Internal Cooldown", + disposition: "buff", + durationMs: 5_000, + hideFromAuraStrip: true, + utilityTags: [WARLOCK_NIGHTFALL_COOLDOWN_TAG], +}; + 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 @@ -4091,6 +5424,28 @@ function maybeProcWarlockDamageTalentsInWork( if (work.classId !== "warlock" || sourceActorId !== PLAYER_AGGRO_ID || !ability || attackKind !== "spell" || !result.combatResult?.landed || result.amount <= 0) return; + if (warlockNightfallTriggerEligible(ability.name)) { + const nightfall = warlockNightfallEffectsForRank(warlockTalentRank(work.talentRanks, "Nightfall", 2)); + const coolingDown = work.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WARLOCK_NIGHTFALL_COOLDOWN_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)); + if (nightfall.rank > 0 && !coolingDown + && stableCombatUnit(`${seed}:wow335-warlock-nightfall`) < nightfall.procChancePercent / 100) { + work.auras = [...applyCombatAura(work.auras, { + definition: WARLOCK_NIGHTFALL_READY_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + work.auras = [...applyCombatAura(work.auras, { + definition: WARLOCK_NIGHTFALL_COOLDOWN_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + if (ability.name === "Corruption") { const eradicationRank = warlockTalentRank(work.talentRanks, "Eradication", 3); if (eradicationRank > 0 && stableCombatUnit(`${seed}:wow335-warlock-eradication`) < 0.15) { @@ -4157,6 +5512,22 @@ function maybeProcWarlockDamageTalentsInWork( absorbed: 0, critical: false, }); + const target = work.mobs[targetId]; + if (target && !target.dead) { + work.mobs[targetId] = { + ...target, + auras: [ + ...target.auras.filter((aura) => aura.id !== WARLOCK_SOUL_LEECH_AP_DEBUFF_AURA_ID), + { + id: WARLOCK_SOUL_LEECH_AP_DEBUFF_AURA_ID, + sourceAbilityId: ability.id, + sourceActorId: PLAYER_AGGRO_ID, + endsAt: now + WARLOCK_SOUL_LEECH_AP_DEBUFF_DURATION_MS, + magnitude: WARLOCK_SOUL_LEECH_AP_DEBUFF_AMOUNT, + }, + ], + }; + } work.auras = [...applyCombatAura(work.auras, { definition: WARLOCK_SOUL_LEECH_COOLDOWN_AURA, sourceId: PLAYER_AURA_ENTITY_ID, @@ -4180,6 +5551,22 @@ function shamanTalentRank(work: MutableCastWork, talentName: string, maximumRank 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"; +const SHAMAN_RIPTIDE_ABILITY_ID = "wow335-shaman-riptide"; +const SHAMAN_RIPTIDE_CHAIN_HEAL_BONUS = 1.25; + +function shamanRiptideHotOnTargetInWork( + work: MutableCastWork, + targetId: string, + now: number, +): TimedCombatEffect | undefined { + const effectTargetId = targetId === PLAYER_AURA_ENTITY_ID ? null : targetId; + return work.effects.find((effect) => effect.kind === "hot" + && effect.sourceActorId === PLAYER_AGGRO_ID + && effect.sourceAbilityId === SHAMAN_RIPTIDE_ABILITY_ID + && effect.targetId === effectTargetId + && effect.remainingTicks > 0 + && effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs > now); +} function shamanClearcastingEligible(ability: AbilityDefinition): boolean { return !ability.passive @@ -4213,7 +5600,7 @@ function maybeProcShamanElementalFocusInWork( name: "Clearcasting", description: "Reduces the mana cost of your next two Fire, Frost, or Nature spells and abilities by 30%.", disposition: "buff", - durationMs: null, + durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace", @@ -4233,6 +5620,35 @@ function maybeProcShamanElementalFocusInWork( }).auras]; } +function maybeProcShamanMaelstromWeaponInWork( + work: MutableCastWork, + seed: string, + now: number, + weaponSpeedMs: number, +): void { + if (work.classId !== "shaman") return; + const rank = shamanTalentRank(work, "Maelstrom Weapon", 3); + const profile = wow335ShamanMaelstromWeaponProfile(rank, weaponSpeedMs); + if (!profile || stableCombatUnit(`${seed}:wow335-shaman-maelstrom-weapon`) >= profile.procChancePerWeaponHit) return; + + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:shaman:maelstrom-weapon", + name: "Maelstrom Weapon", + description: "Your next listed spell has its cast time reduced by 20% per stack.", + disposition: "buff", + durationMs: profile.durationMs, + maxStacks: Math.min(WOW335_SHAMAN_MAELSTROM_WEAPON_MAX_STACKS, profile.maxStacks), + initialStacks: 1, + stackBehavior: "add", + utilityTags: [WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + function maybeProcShamanReverberationInWork( work: MutableCastWork, ability: AbilityDefinition | undefined, @@ -4281,6 +5697,87 @@ function maybeProcShamanismAfterDamage( } } +function maybeProcShamanImprovedWaterShieldInWork( + work: MutableCastWork, + ability: AbilityDefinition, + targetId: string, + school: DamageSchool, + critical: boolean, + now: number, +): void { + if (work.classId !== "shaman" || ability.source === "runewaker") return; + const rank = shamanTalentRank(work, "Improved Water Shield", 3); + if (!wow335ShamanImprovedWaterShieldShouldProc( + rank, + ability.name, + school, + critical, + stableCombatUnit(`${ability.id}:${targetId}:${now}:wow335-shaman-improved-water-shield`), + )) return; + + const requested = wow335ShamanWaterShieldOrbManaAtLevel(work.progression.level); + if (requested <= 0) return; + const maximum = work.maxResourcePools.mana ?? defaultResourceMaximum("mana"); + const current = work.resourcePools.mana ?? 0; + const restored = wow335ShamanImprovedWaterShieldCappedRestore(current, maximum, requested); + if (restored <= 0) return; + work.resourcePools.mana = current + restored; + if (resourceProfileForClass(work.classId).type === "mana") work.resource = work.resourcePools.mana; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "resource", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: "wow335-shaman-improved-water-shield", + rawAmount: requested, + effectiveAmount: restored, + overheal: requested - restored, + absorbed: 0, + critical: false, + }); +} + +function consumeShamanTidalWavesChargeInAuras( + auras: readonly ActiveAura[], + instanceId: string, +): ActiveAura[] { + return auras.flatMap((aura) => { + if (aura.instanceId !== instanceId) return [aura]; + return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; + }); +} + +function maybeProcShamanTidalWavesInWork( + work: MutableCastWork, + ability: AbilityDefinition, + rawHealingAmount: number, + now: number, +): void { + if (work.classId !== "shaman" || ability.source === "runewaker" || rawHealingAmount <= 0) return; + const charges = wow335ShamanTidalWavesChargesForTrigger(ability.name); + if (charges <= 0) return; + const rank = shamanTalentRank(work, "Tidal Waves", 3); + const chance = wow335ShamanTidalWavesProcChance(rank); + if (chance <= 0 || stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${now}:wow335-shaman-tidal-waves`) >= chance) return; + + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335:shaman:tidal-waves", + name: "Tidal Waves", + description: "Tidal Waves charges reduce Healing Wave cast time or increase Lesser Healing Wave critical chance.", + disposition: "buff", + durationMs: WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, + maxStacks: WOW335_SHAMAN_TIDAL_WAVES_MAX_CHARGES, + initialStacks: charges, + stackBehavior: "add", + utilityTags: [WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; +} + function maybeProcShamanHealingTalentsInWork( work: MutableCastWork, ability: AbilityDefinition, @@ -4291,7 +5788,10 @@ function maybeProcShamanHealingTalentsInWork( critical: boolean, now: number, ): void { - if (work.classId !== "shaman" || !critical || ability.source === "runewaker") return; + if (work.classId !== "shaman" || ability.source === "runewaker") return; + + maybeProcShamanImprovedWaterShieldInWork(work, ability, targetId, school, critical, now); + if (!critical) return; const ancestralHealingRank = shamanTalentRank(work, "Ancestral Healing", 3); if (ancestralHealingRank > 0 && school === "nature") { @@ -4386,6 +5886,45 @@ function rogueTalentRank(talentRanks: TalentRanks, talentName: string, maximumRa ), 0))); } +function maybeProcRogueCombatPotencyInWork( + work: MutableCastWork, + seed: string, + now: number, +): void { + if (work.classId !== "rogue") return; + const rank = rogueTalentRank(work.talentRanks, "Combat Potency", 3); + if (rank <= 0) return; + + if (rank === 3) { + for (const ability of work.abilities) { + if (!ROGUE_COMBAT_POTENCY_COOLDOWN_ABILITIES.has(ability.name)) continue; + const readyAt = work.cooldowns[ability.id] ?? now; + if (readyAt > now) work.cooldowns[ability.id] = Math.max(now, readyAt - 1_000); + } + } + + const procChance = [0, 0.33, 0.66, 1][rank] ?? 0; + if (stableCombatUnit(`${seed}:rogue-combat-potency`) >= procChance) return; + + const maximum = work.maxResourcePools.energy ?? work.maxResource; + const current = work.resourcePools.energy ?? work.resource; + const restored = Math.max(0, Math.min(ROGUE_COMBAT_POTENCY_ENERGY, maximum - current)); + work.resourcePools.energy = current + restored; + if (resourceProfileForClass(work.classId).type === "energy") work.resource = current + restored; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "resource", + sourceActorId: PLAYER_AURA_ENTITY_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: "wow335-rogue-combat-potency", + rawAmount: ROGUE_COMBAT_POTENCY_ENERGY, + effectiveAmount: restored, + overheal: ROGUE_COMBAT_POTENCY_ENERGY - restored, + absorbed: 0, + critical: false, + }); +} + function grantRogueComboPointInWork(work: MutableCastWork, targetId: string): void { work.comboPoints = clamp((work.comboTargetId === targetId ? work.comboPoints : 0) + 1, 0, 5); work.comboTargetId = targetId; @@ -4474,6 +6013,198 @@ function previousEligibleHotStreakCritical( return false; } +function maybeProcRomMageMeditativeCurrentInWork( + work: MutableCastWork, + ability: AbilityDefinition | undefined, + sourceActorId: string, + attackKind: WotlkAttackKind, + periodic: boolean, + landed: boolean, + now: number, +): void { + if (work.classId !== "rom-mage" || sourceActorId !== PLAYER_AGGRO_ID + || attackKind !== "spell" || periodic || !landed || !ability) return; + + const passiveLearned = work.abilities.some((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-mage" + && candidate.dbcSpellId === ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId + && candidate.passive + && (!candidate.secondaryClassId || candidate.secondaryClassId === work.secondaryClassId) + && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= work.progression.level); + const readyAt = romMageMeditativeCurrentReadyAt(work.auras, sourceActorId, now); + const proc = romMageMeditativeCurrentProc( + work.classId, + ability, + passiveLearned ? [ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId] : [], + landed, + readyAt, + now, + ); + if (proc.effect?.kind !== "apply-aura") return; + + for (const effect of [proc.effect, ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT]) { + work.auras = [...applyCombatAura(work.auras, { + definition: auraDefinitionForAbility(effect.aura, ability), + sourceId: sourceActorId, + targetId: sourceActorId, + now, + }).auras]; + } +} + +function maybeProcRomMageKnightLightOfDayInWork( + work: MutableCastWork, + ability: AbilityDefinition | undefined, + sourceActorId: string, + attackKind: WotlkAttackKind, + periodic: boolean, + landed: boolean, + now: number, +): void { + if (attackKind !== "spell" || periodic || !landed || !ability) return; + + const partyMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); + const characterClassId = partyMember?.classId ?? work.classId; + const secondaryClassId = partyMember ? partyMember.secondaryClassId : work.secondaryClassId; + if (characterClassId !== "rom-mage" || secondaryClassId !== "rom-knight") return; + + const level = partyMember?.level ?? work.progression.level; + const talentRanks = partyMember?.talentRanks ?? work.talentRanks; + const passiveRow = abilitiesForCharacter(characterClassId, secondaryClassId).find((candidate) => ( + candidate.source === "runewaker" + && candidate.passive + && candidate.dbcSpellId === ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId + )); + const learnedPassive = passiveRow && learnedAbilityForBuild(passiveRow, level, talentRanks); + const readyAt = romMageKnightLightOfDayReadyAt(work.auras, sourceActorId, now); + const proc = romMageKnightLightOfDayProc( + characterClassId, + secondaryClassId, + ability, + learnedPassive ? [ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId] : [], + landed, + readyAt, + now, + ); + if (proc.effect?.kind !== "apply-aura") return; + + for (const effect of [proc.effect, ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT]) { + work.auras = [...applyCombatAura(work.auras, { + definition: auraDefinitionForAbility(effect.aura, ability), + sourceId: sourceActorId, + targetId: sourceActorId, + now, + }).auras]; + } +} + +function maybeProcRomScoutPriestDrumsOfWarInWork( + work: MutableCastWork, + ability: AbilityDefinition | undefined, + sourceActorId: string, + attackKind: WotlkAttackKind, + periodic: boolean, + landed: boolean, + now: number, +): void { + if (periodic || attackKind !== "ranged" || !landed || !ability) return; + + const partyMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); + const characterClassId = partyMember ? partyMember.classId : work.classId; + const secondaryClassId = partyMember ? partyMember.secondaryClassId : work.secondaryClassId; + if (characterClassId !== "rom-scout" || secondaryClassId !== "rom-priest") return; + + const level = partyMember?.level ?? work.progression.level; + const talentRanks = partyMember?.talentRanks ?? work.talentRanks; + const passiveRow = abilitiesForCharacter(characterClassId, secondaryClassId).find((candidate) => ( + candidate.source === "runewaker" + && candidate.passive + && candidate.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId + )); + const learnedPassive = passiveRow && learnedAbilityForBuild(passiveRow, level, talentRanks); + const cooldownKey = `${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${sourceActorId}`; + const proc = romScoutPriestDrumsOfWarProc( + characterClassId, + secondaryClassId, + ability, + learnedPassive ? [ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId] : [], + landed, + now, + work.cooldowns[cooldownKey] ?? 0, + attackKind, + ); + if (!proc) return; + + const combatState = useCombatStore.getState(); + const sourcePosition = sourceActorId === PLAYER_AGGRO_ID + ? combatState.playerPosition + : getPartyRuntimePosition(sourceActorId); + if (!sourcePosition) return; + + const partyMembers = usePartyStore.getState().members; + const recipients = [ + { + id: PLAYER_AURA_ENTITY_ID, + health: work.health, + position: combatState.playerPosition, + }, + ...partyMembers.map((member) => ({ + id: member.id, + health: work.actors[member.id]?.health ?? member.health, + position: getPartyRuntimePosition(member.id), + })), + ].filter((candidate) => candidate.health > 0 + && candidate.position + && Math.sqrt(distanceSquared(sourcePosition, candidate.position)) <= proc.radius); + if (recipients.length === 0) return; + + work.cooldowns[cooldownKey] = now + proc.internalCooldownMs; + for (const recipient of recipients) { + work.auras = [...applyCombatAura(work.auras, { + definition: ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA, + sourceId: sourceActorId, + targetId: recipient.id, + now, + }).auras]; + } +} + +function applyRomRoguePoisonOnNormalAttackInWork( + work: MutableCastWork, + sourceActorId: string, + targetId: string, + attackKind: WotlkAttackKind, + attackLanded: boolean, + seed: string, + now: number, +): void { + if (work.classId !== "rom-rogue" || !attackLanded || work.mobs[targetId]?.dead) return; + const poisonSkill = work.abilities.find((ability) => ability.dbcSpellId === ROM_ROGUE_POISON_SKILL_ID); + if (!poisonSkill || !learnedAbilityForBuild(poisonSkill, work.progression.level, work.talentRanks)) return; + const poisonActive = aurasForEntity(work.auras, sourceActorId).some((aura) => ( + aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + )); + const application = romRoguePoisonApplicationForAttack({ + classId: work.classId, + sourceActorId, + targetActorId: targetId, + attackKind, + isNormalAttack: true, + attackLanded, + poisonActive, + roll: stableCombatUnit(`${seed}:rom-rogue-poison`), + }); + if (!application) return; + work.auras = [...applyCombatAura(work.auras, { + definition: application.definition, + sourceId: application.sourceId, + targetId: application.targetId, + stacks: application.stacks, + now, + }).auras]; +} + function resolveAndDamageMobInWork( classId: ClassId, work: MutableCastWork, @@ -4488,6 +6219,7 @@ function resolveAndDamageMobInWork( periodic = false, rules: CombatResultRules = {}, criticalChanceBonusPercent = 0, + weaponSpeedMs = work.gearStats.meleeWeapon?.speedMs ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS, ): DamageResult { const mob = work.mobs[id]; if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) { @@ -4522,6 +6254,8 @@ function resolveAndDamageMobInWork( aura.definition.utilityTags?.includes(WARLOCK_IMPROVED_SHADOW_BOLT_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); + const warlockNightfallReady = classId === "warlock" && source.actorId === PLAYER_AGGRO_ID + && activeSourceAuras.some((aura) => aura.definition.utilityTags?.includes(WARLOCK_NIGHTFALL_READY_TAG)); const combustion = !periodic && attackKind === "spell" && school === "fire" ? sourceAuras.find((aura) => aura.definition.utilityTags?.includes("fire-crit-stacking")) : undefined; @@ -4561,11 +6295,24 @@ function resolveAndDamageMobInWork( mainHandDagger, passiveTags: daggerMasteryTags, }); - const modifiedAmount = resolveAuraDamageValue(amount, sourceAuras, { + const moltenFuryOwner = ability?.classId === "mage" && ability.source === "wow335-progression" + ? wow335MageTalentOwnerForActor(work, source.actorId) + : null; + const moltenFuryAmount = moltenFuryOwner + ? wow335MageMoltenFuryDamage( + amount, + moltenFuryOwner.talentRanks[WOW335_MAGE_MOLTEN_FURY_TALENT_ID] ?? 0, + school, + mob.health, + mob.maxHealth, + ) + : amount; + const modifiedAmount = resolveAuraDamageValue(moltenFuryAmount, sourceAuras, { direction: "dealt", school, attackKind, - }) * (druidSolarEclipse ? 1.3 : 1) * rogueDaggerMultiplier * mageProcDamageMultiplier + }) * warlockNightfallDamageMultiplier(ability?.name ?? "", warlockNightfallReady) + * (druidSolarEclipse ? 1.3 : 1) * rogueDaggerMultiplier * mageProcDamageMultiplier * (reverberationAura ? 1.2 : 1); const combatResult = resolvePlayerDamageResult( classId, @@ -4596,6 +6343,89 @@ function resolveAndDamageMobInWork( work.gearStats.spellPenetration, combatResult, ); + if (classId === "death-knight" + && source.actorId === PLAYER_AGGRO_ID + && attackKind === "melee" + && !periodic + && combatResult.landed) { + const scentRank = work.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0; + const hitKey = `${source.actorId}:${id}:${seed}`; + const scentHitKeys = work.scentOfBloodHitKeys ?? (work.scentOfBloodHitKeys = new Set()); + if (scentRank > 0 && !scentHitKeys.has(hitKey)) { + scentHitKeys.add(hitKey); + const scentOfBlood = wow335DeathKnightScentOfBloodOnMeleeHit( + work.auras, + source.actorId, + scentRank, + true, + now, + ); + if (scentOfBlood.runicPowerGain > 0) { + work.auras = [...scentOfBlood.auras]; + const currentRunicPower = work.resourcePools["runic-power"] ?? work.resource; + const maximumRunicPower = work.maxResourcePools["runic-power"] ?? work.maxResource; + const restored = Math.max(0, Math.min( + scentOfBlood.runicPowerGain, + maximumRunicPower - currentRunicPower, + )); + work.resourcePools["runic-power"] = currentRunicPower + restored; + work.resource = currentRunicPower + restored; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "resource", + sourceActorId: source.actorId, + targetActorId: source.actorId, + abilityId: ability?.id ?? source.abilityId ?? WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG, + rawAmount: scentOfBlood.runicPowerGain, + effectiveAmount: restored, + overheal: scentOfBlood.runicPowerGain - restored, + absorbed: 0, + critical: false, + }); + } + } + } + maybeProcRomMageMeditativeCurrentInWork( + work, + ability, + source.actorId, + attackKind, + periodic, + combatResult.landed, + now, + ); + maybeProcRomMageKnightLightOfDayInWork( + work, + ability, + source.actorId, + attackKind, + periodic, + combatResult.landed, + now, + ); + maybeProcRomScoutPriestDrumsOfWarInWork( + work, + ability, + source.actorId, + attackKind, + periodic, + combatResult.landed, + now, + ); + if (!periodic + && combatResult.landed + && ability?.source === "runewaker" + && ability.classId === "rom-druid" + && ability.secondaryClassId === "rom-warrior" + && ROM_WARRIOR_BRIAR_TECHNIQUE_TRIGGER_SPELL_IDS.has(ability.dbcSpellId) + && activeSourceAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG))) { + work.auras = [...applyCombatAura(work.auras, { + definition: ROM_WARRIOR_BRIAR_TECHNIQUE_AURA, + sourceId: source.actorId, + targetId: source.actorId, + now, + }).auras]; + } if (reverberationAura && combatResult.landed && result.amount > 0) { work.auras = work.auras.filter((aura) => aura.instanceId !== reverberationAura.instanceId); } @@ -4616,6 +6446,26 @@ function resolveAndDamageMobInWork( } work.auras = work.auras.filter((aura) => aura.instanceId !== impactAura.instanceId); } + const brainFreezeChannelDamage = ability?.name === "Blizzard" || ability?.name === "Frozen Orb"; + if (classId === "mage" && source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" + && combatResult.landed && result.amount > 0 && ability + && WOW335_MAGE_BRAIN_FREEZE_TRIGGER_SPELLS.has(ability.name) + && (!periodic || brainFreezeChannelDamage)) { + const brainFreezeRank = mageTalentRank(work.talentRanks, "Brain Freeze", 3); + const brainFreezeChance = [0, 0.05, 0.10, 0.15][brainFreezeRank] ?? 0; + if (brainFreezeChance > 0 && stableCombatUnit(`${seed}:mage-brain-freeze`) < brainFreezeChance) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_BRAIN_FREEZE_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + if (classId === "mage" && source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" + && combatResult.landed && ability && !periodic) { + maybeProcMageMissileBarrage(work, ability.name, seed, now); + } 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; @@ -4632,6 +6482,26 @@ function resolveAndDamageMobInWork( } if (classId === "mage" && source.actorId === PLAYER_AGGRO_ID && !periodic && attackKind === "spell" && combatResult.landed && result.amount > 0 && ability) { + const arcaneConcentrationRank = mageTalentRank(work.talentRanks, "Arcane Concentration", 3); + const arcaneConcentrationChance = [0, 0.05, 0.10, 0.15][arcaneConcentrationRank] ?? 0; + if (arcaneConcentrationChance > 0 + && stableCombatUnit(`${seed}:mage-arcane-concentration`) < arcaneConcentrationChance) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_ARCANE_CONCENTRATION_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + const arcanePotencyRank = mageTalentRank(work.talentRanks, "Arcane Potency", 2); + if (wow335MageArcanePotencyCanProc(arcanePotencyRank, "arcane-concentration-clearcasting")) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_ARCANE_POTENCY_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } // 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); @@ -4679,29 +6549,29 @@ function resolveAndDamageMobInWork( }).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), - ]; - } + } + const mageTalentOwner = wow335MageTalentOwnerForActor(work, source.actorId); + if (mageTalentOwner && !periodic && attackKind === "spell" && combatResult.critical + && school === "fire" && result.amount > 0 && ability) { + const rank = mageTalentRank(mageTalentOwner.talentRanks, "Ignite", 5); + if (rank > 0) { + // Keep the existing local DBC burn payload. The talent owner is resolved + // per source so one caster's ranks cannot apply Ignite for another. + 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, source.actorId, id, { + coefficient: 0, + fixedAmount: amount, + }, 1, 2_000, now, "wow335-mage-ignite", "fire", mageTalentOwner.spellPenetration), + nextTickAt: tickAt, + }); + work.effects = [ + ...work.effects, + makeIgniteTick(firstTick, now + 2_000), + makeIgniteTick(secondTick, now + 4_000), + ]; } } maybeProcWarlockDamageTalentsInWork( @@ -4773,10 +6643,29 @@ function resolveAndDamageMobInWork( seed, now, ); + if (classId === "druid" && source.actorId === PLAYER_AGGRO_ID) { + maybeProcDruidPrimalFury( + work, + source.actorId, + periodic, + combatResult.critical, + combatResult.landed, + seed, + now, + ); + } if (source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && !periodic && combatResult.landed && result.amount > 0) { maybeProcShamanismAfterDamage(work, ability, seed, now); } + if (classId === "warrior" && source.actorId === PLAYER_AGGRO_ID + && attackKind === "melee" && !periodic && combatResult.landed && result.amount > 0) { + maybeProcWarriorSecondWindInWork(work, seed, now); + } + if (classId === "shaman" && source.actorId === PLAYER_AGGRO_ID + && attackKind === "melee" && !periodic && combatResult.landed && result.amount > 0) { + maybeProcShamanMaelstromWeaponInWork(work, seed, now, weaponSpeedMs); + } if (source.actorId === PLAYER_AGGRO_ID && attackKind === "spell" && !periodic && combatResult.landed && result.amount > 0) { maybeProcShamanElementalFocusInWork(work, ability, school, combatResult.critical, periodic, seed, now); @@ -4801,7 +6690,12 @@ function resolveAndDamageMobInWork( && !periodic && !["Envenom", "Instant Poison", "Deadly Poison"].includes(ability?.name ?? "") ) { - const hitCount = ability?.name === "Mutilate" || (rules.isAutoAttack && rules.isDualWielding) ? 2 : 1; + // Rogue main- and off-hand auto attacks now resolve as separate landed + // hits, so each hand applies its own poison once. Other classes retain the + // legacy single-event dual-wield behavior, and Mutilate still represents + // two weapon strikes in one ability event. + const hitCount = ability?.name === "Mutilate" + || (rules.isAutoAttack && rules.isDualWielding && work.classId !== "rogue") ? 2 : 1; applyRogueWeaponPoisonsInWork( work, source.actorId, @@ -4811,6 +6705,17 @@ function resolveAndDamageMobInWork( now, ); } + if (rules.isAutoAttack && !periodic && classId === "rom-rogue") { + applyRomRoguePoisonOnNormalAttackInWork( + work, + source.actorId, + id, + attackKind, + combatResult.landed, + seed, + now, + ); + } if (result.killed) { evaluateProcsInWork(work, { trigger: "kill", @@ -4864,9 +6769,13 @@ function applyStatus( ): MobCombatState { if (mob.dead) return mob; if (mob.mechanicImmunities.includes(effect.status as AzerothCoreMechanic)) return mob; - const baseDurationMs = mob.boss && effect.status === "stun" - ? Math.min(3000, effect.durationMs) + const durationFromTargetSpecificAbility = effect.status === "sleep" + && abilityById(abilityId)?.dbcSpellId === ROM_ROGUE_NUMBING_DAGGER_SKILL_ID + ? romRogueNumbingDaggerStatusForTarget(mob.characterDefender ? "player" : "npc").durationMs : effect.durationMs; + const baseDurationMs = mob.boss && effect.status === "stun" + ? Math.min(3000, durationFromTargetSpecificAbility) + : durationFromTargetSpecificAbility; const diminishingStatuses = new Set([ "stun", "fear", "root", "sleep", "blind", "freeze", "charm", "confuse", "knockdown", ]); @@ -5112,7 +7021,9 @@ function executeTriggeredSpellInWork( const resource = effect.resource ?? resourceProfileForClass(work.classId).type; const maximum = work.maxResourcePools[resource] ?? defaultResourceMaximum(resource); const amount = effect.percentage ? maximum * effect.amount / 100 : effect.amount; - if (mobTargets.length && !effect.burn) { + if (effect.recipient === "caster") { + work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - amount); + } else if (mobTargets.length && !effect.burn) { const resourceBefore = work.resourcePools[resource] ?? 0; work.resourcePools[resource] = clamp(resourceBefore + amount, 0, maximum); } else if (!mobTargets.length) { @@ -5257,6 +7168,7 @@ function timedEffect( tag?: string, damageSchool?: DamageSchool, spellPenetration?: number, + criticalChanceBonusPercent?: number, ): TimedCombatEffect { effectSequence += 1; return { @@ -5272,6 +7184,7 @@ function timedEffect( ...(scaling.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: scaling.coefficientPerLevel }), ...(scaling.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: scaling.bonusPowerCoefficient }), ...(scaling.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: scaling.attackPowerCoefficient }), + ...(scaling.rangedAttackPowerCoefficient === undefined ? {} : { rangedAttackPowerCoefficient: scaling.rangedAttackPowerCoefficient }), ...(scaling.spiritCoefficient === undefined ? {} : { spiritCoefficient: scaling.spiritCoefficient }), ...(scaling.sourceLevel === undefined ? {} : { sourceLevel: scaling.sourceLevel }), ...(scaling.maxScalingLevel === undefined ? {} : { maxScalingLevel: scaling.maxScalingLevel }), @@ -5284,9 +7197,25 @@ function timedEffect( ...(tag ? { tag } : {}), ...(damageSchool ? { damageSchool } : {}), ...(spellPenetration ? { spellPenetration } : {}), + ...(criticalChanceBonusPercent ? { criticalChanceBonusPercent } : {}), }; } +function accumulateLightwellDamageCancellation( + effects: readonly TimedCombatEffect[], + targetId: string, + effectiveDamage: number, +): TimedCombatEffect[] { + if (!Number.isFinite(effectiveDamage) || effectiveDamage <= 0) return [...effects]; + return effects.flatMap((effect) => { + const cancellation = effect.kind === "hot" ? effect.lightwellDamageCancellation : undefined; + if (!cancellation || (effect.targetId ?? PLAYER_AURA_ENTITY_ID) !== targetId) return [effect]; + const accumulatedDamage = cancellation.accumulatedDamage + effectiveDamage; + if (accumulatedDamage >= cancellation.threshold) return []; + return [{ ...effect, lightwellDamageCancellation: { ...cancellation, accumulatedDamage } }]; + }); +} + const WARLOCK_SEED_PROFILE_BY_SPELL_ID: Readonly((set, get) => ({ armor: defaults.armor, maxMana: defaults.maxMana, attackPower: defaults.attackPower, + attackPowerDamageContribution: defaults.attackPowerDamageContribution * damageMultiplier, mechanicImmunities, schoolImmunities, resistances, @@ -6137,6 +8069,7 @@ export const useCombatStore = create((set, get) => ({ armor: defaults.armor, maxMana: defaults.maxMana, attackPower: defaults.attackPower, + attackPowerDamageContribution: defaults.attackPowerDamageContribution * damageMultiplier, mechanicImmunities, schoolImmunities, resistances, @@ -6197,6 +8130,7 @@ export const useCombatStore = create((set, get) => ({ selectedTargetId: state.selectedTargetId === id ? null : state.selectedTargetId, autoAttackTargetId: state.autoAttackTargetId === id ? null : state.autoAttackTargetId, nextAutoAttackAt: state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt, + nextOffhandAutoAttackAt: state.autoAttackTargetId === id ? 0 : state.nextOffhandAutoAttackAt, comboPoints: state.selectedTargetId === id ? 0 : state.comboPoints, effects: state.effects.filter((effect) => effect.targetId !== id), auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id), @@ -6396,6 +8330,9 @@ export const useCombatStore = create((set, get) => ({ nextAutoAttackAt: state.autoAttackTargetId && state.autoAttackTargetId !== id ? 0 : state.nextAutoAttackAt, + nextOffhandAutoAttackAt: state.autoAttackTargetId && state.autoAttackTargetId !== id + ? 0 + : state.nextOffhandAutoAttackAt, comboPoints: state.selectedTargetId === id ? state.comboPoints : 0, comboTargetId: state.selectedTargetId === id ? state.comboTargetId : id, feedback: nextFeedback("target", mob.name, { targetId: id }), @@ -6432,6 +8369,7 @@ export const useCombatStore = create((set, get) => ({ selectedTargetId: null, autoAttackTargetId: null, nextAutoAttackAt: 0, + nextOffhandAutoAttackAt: 0, comboPoints: 0, comboTargetId: null, feedback: state.selectedTargetId @@ -6504,7 +8442,9 @@ export const useCombatStore = create((set, get) => ({ if (!ability || targetingRulesForAbility(ability).shape !== "ground") return false; const state = get(); const targeting = targetingRulesForAbility(ability); - const maximumRange = targeting.groundRange ?? ability.range.max; + const maximumRange = talentAdjustedCastRange( + targeting.groundRange ?? ability.range.max, ability, state.talentModifiers, + ); const player = combatSpatialActor(PLAYER_AURA_ENTITY_ID); const yaw = player?.yaw ?? 0; const distance = Math.min(10, Math.max(0, maximumRange)); @@ -6773,9 +8713,9 @@ export const useCombatStore = create((set, get) => ({ }, castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => { - const request: CastRequest = Array.isArray(requestOrOrigin) + const request: InternalCastRequest = Array.isArray(requestOrOrigin) ? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast } - : (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast }; + : (requestOrOrigin as InternalCastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast }; const origin = request.origin; const now = request.now ?? legacyNow; const completingCast = request.completingCast ?? legacyCompletingCast; @@ -6787,7 +8727,25 @@ export const useCombatStore = create((set, get) => ({ let ability = catalogAbility ? abilityAtLevel(catalogAbility, state.level, learnedTalentRank) : null; + const suddenDoomFreeDeathCoil = request[WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST] === true + && ability?.id === WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID; const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID); + const rimeReadyAura = state.classId === "death-knight" && ability?.name === "Howling Blast" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_RIME_READY_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const consumesRimeReady = Boolean(rimeReadyAura); + const lockAndLoadTalentRank = state.classId === "hunter" + ? Math.max(0, Math.min(3, Math.trunc(state.talentRanks["talent-1306"] ?? 0))) + : 0; + const hunterLockAndLoadAura = ability && lockAndLoadTalentRank > 0 + && ["Arcane Shot", "Explosive Shot"].includes(ability.name) + ? activePlayerAuras.find((aura) => aura.stacks > 0 + && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const consumesHunterLockAndLoad = Boolean(hunterLockAndLoadAura + && lockAndLoadRankMechanics(lockAndLoadTalentRank)); const romWarriorPassiveSpellIds = state.classId === "rom-warrior" ? state.abilities.filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-warrior" @@ -6796,9 +8754,36 @@ export const useCombatStore = create((set, get) => ({ && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) .map((candidate) => candidate.dbcSpellId) : []; + const romKnightWarriorPassiveSpellIds = state.classId === "rom-knight" + && state.secondaryClassId === "rom-warrior" + ? state.abilities.filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-knight" + && candidate.secondaryClassId === "rom-warrior" + && candidate.dbcSpellId === 491639 + && candidate.passive + && learnedAbilityForBuild(candidate, state.level, state.talentRanks) !== null) + .map((candidate) => candidate.dbcSpellId) + : []; + const romMageWarriorMeditationPathPassiveSpellIds = state.classId === "rom-mage" + && state.secondaryClassId === "rom-warrior" + ? state.abilities.filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-mage" + && candidate.secondaryClassId === "rom-warrior" + && candidate.passive + && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= state.level) + .map((candidate) => candidate.dbcSpellId) + : []; const warriorAdjustment = ability ? romWarriorAbilityAdjustment(ability, romWarriorPassiveSpellIds) : { rageCostReduction: 0, cooldownReductionMs: 0 }; + const knightWarriorWhirlwindAdjustment = ability + ? romKnightWarriorWhirlwindAdjustment( + state.classId, + state.secondaryClassId, + ability, + romKnightWarriorPassiveSpellIds, + ) + : { 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" @@ -6824,11 +8809,15 @@ export const useCombatStore = create((set, get) => ({ : 0; if (ability && (warriorAdjustment.rageCostReduction > 0 || warriorAdjustment.cooldownReductionMs > 0 + || knightWarriorWhirlwindAdjustment.rageCostReduction > 0 + || knightWarriorWhirlwindAdjustment.cooldownReductionMs > 0 || rogueShadowWalkerAdjustment.energyCostReduction > 0 || rogueShadowWalkerAdjustment.cooldownIncreaseMs > 0 || flameFollowerCooldownReduction > 0)) { + const rageCostReduction = warriorAdjustment.rageCostReduction + + knightWarriorWhirlwindAdjustment.rageCostReduction; const adjustedCosts = (ability.costs ?? [ability.cost]).map((cost) => cost.resource === "rage" - ? { ...cost, amount: Math.max(0, cost.amount - warriorAdjustment.rageCostReduction) } + ? { ...cost, amount: Math.max(0, cost.amount - rageCostReduction) } : cost.resource === "energy" ? { ...cost, amount: Math.max(0, cost.amount - rogueShadowWalkerAdjustment.energyCostReduction) } : cost); @@ -6836,12 +8825,34 @@ export const useCombatStore = create((set, get) => ({ ...ability, cooldownMs: Math.max(0, ability.cooldownMs - warriorAdjustment.cooldownReductionMs + - knightWarriorWhirlwindAdjustment.cooldownReductionMs - flameFollowerCooldownReduction + rogueShadowWalkerAdjustment.cooldownIncreaseMs), cost: adjustedCosts[0] ?? ability.cost, costs: adjustedCosts, }; } + const warlockNightfallAura = state.classId === "warlock" && ability + && warlockNightfallConsumerEligible(ability.name) + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WARLOCK_NIGHTFALL_READY_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const warlockNightfallRank = warlockNightfallAura + ? warlockTalentRank(state.talentRanks, "Nightfall", 2) + : 0; + const consumesWarlockNightfall = Boolean(warlockNightfallAura && warlockNightfallRank > 0); + if (consumesWarlockNightfall && ability) { + const costMultiplier = 1 - warlockNightfallEffectsForRank(warlockNightfallRank).manaCostReductionPercent / 100; + const adjustedCosts = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => cost.resource === "mana" + ? { ...cost, amount: Math.round(cost.amount * costMultiplier) } + : cost); + ability = { + ...ability, + castTimeMs: 0, + 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" @@ -6902,6 +8913,9 @@ export const useCombatStore = create((set, get) => ({ ? ([0, 5, 10, 15][serendipityRank] ?? 0) * serendipityAura!.stacks : 0; const hasRuneWakerState = (tag: string) => activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(tag)); + const activePlayerAuraTags = activePlayerAuras + .filter((aura) => aura.expiresAt === null || aura.expiresAt > now) + .flatMap((aura) => aura.definition.utilityTags ?? []); const holyAuraFriendly = Boolean( ability?.source === "runewaker" && ability.name === "Holy Aura" @@ -6915,6 +8929,44 @@ 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 mageArcaneConcentrationAura = state.classId === "mage" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const mageArcanePotencyAura = state.classId === "mage" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_POTENCY_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const mageBrainFreezeAura = state.classId === "mage" + && ability && WOW335_MAGE_BRAIN_FREEZE_CONSUMER_SPELLS.has(ability.name) + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const mageMissileBarrageAura = state.classId === "mage" + && ability && wow335MageMissileBarrageConsumerEligible(ability.name) + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const consumesMageArcaneConcentration = Boolean( + mageArcaneConcentrationAura && ability && !ability.passive, + ); + const mageArcanePotencyRank = state.classId === "mage" + ? mageTalentRank(state.talentRanks, "Arcane Potency", 2) + : 0; + const mageArcanePotencyProfile = ability + ? wow335MageArcanePotencyCastProfile( + mageArcanePotencyRank, + Boolean(mageArcanePotencyAura), + ability.effects.some((effect) => ["damage", "dot", "finisher-damage"].includes(effect.kind)) + && abilityAttackKind(state.classId, ability) === "spell", + ) + : { consumesCharge: false, criticalChanceBonusPercent: 0 }; + const mageArcanePotencyCriticalChanceBonusPercent = completingCast?.arcanePotencyCriticalChanceBonusPercent + ?? mageArcanePotencyProfile.criticalChanceBonusPercent; + const consumesMageArcanePotency = Boolean(!completingCast && mageArcanePotencyAura + && ability && !ability.passive && mageArcanePotencyProfile.consumesCharge); + const consumesMageBrainFreeze = Boolean(mageBrainFreezeAura && ability); + const consumesMageMissileBarrage = Boolean(mageMissileBarrageAura && ability); const druidClearcastingAura = state.classId === "druid" ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes("wow335-druid-clearcasting")) : undefined; @@ -6925,6 +8977,33 @@ export const useCombatStore = create((set, get) => ({ const consumesShamanClearcasting = Boolean( shamanClearcastingAura && ability && shamanClearcastingEligible(ability), ); + const maelstromWeaponRank = state.classId === "shaman" + ? shamanTalentRankFromRanks(state.talentRanks, "Maelstrom Weapon", 3) + : 0; + const maelstromWeaponAura = !completingCast && maelstromWeaponRank > 0 && ability + && WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS.has(ability.name) + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const maelstromWeaponProfile = maelstromWeaponAura + ? wow335ShamanMaelstromWeaponProfile(maelstromWeaponRank) + : null; + const consumesMaelstromWeapon = Boolean(maelstromWeaponAura && maelstromWeaponProfile); + const maelstromWeaponCastTimeReductionPercent = consumesMaelstromWeapon && maelstromWeaponProfile + ? wow335ShamanMaelstromWeaponCastTimeReductionPercent(maelstromWeaponProfile, maelstromWeaponAura!.stacks) + : 0; + const shamanTidalWavesRank = state.classId === "shaman" + ? shamanTalentRankFromRanks(state.talentRanks, "Tidal Waves", 3) + : 0; + const shamanTidalWavesAura = shamanTidalWavesRank > 0 && ability?.source !== "runewaker" + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const tidalWavesChargesForCast = completingCast?.tidalWavesChargesConsumed + ?? shamanTidalWavesAura?.stacks + ?? 0; + const consumesTidalWaves = Boolean(!completingCast && shamanTidalWavesAura + && ["Healing Wave", "Lesser Healing Wave"].includes(ability?.name ?? "")); const warlockBacklashAura = state.classId === "warlock" && ability && WARLOCK_BACKLASH_SPELLS.has(ability.name) ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WARLOCK_BACKLASH_READY_TAG) @@ -6934,13 +9013,25 @@ export const useCombatStore = create((set, get) => ({ const warlockBacklashRank = consumesWarlockBacklash ? warlockTalentRank(state.talentRanks, "Backlash", 3) : 0; + const warlockBackdraftAura = !completingCast && state.classId === "warlock" && ability + && warlockBackdraftDestructionSpellEligible(ability.name) + ? activePlayerAuras.find((aura) => aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)) + : undefined; + const warlockBackdraftRank = warlockBackdraftAura + ? warlockTalentRank(state.talentRanks, "Backdraft", 3) + : 0; + const warlockBackdraftEffects = warlockBackdraftRank > 0 + ? warlockBackdraftEffectsForRank(warlockBackdraftRank) + : null; + const consumesWarlockBackdraft = Boolean(warlockBackdraftAura && warlockBackdraftEffects); const consumesDruidClearcasting = Boolean( druidClearcastingAura && ability && druidClearcastingEligible(ability, effectiveTarget ?? ability.target), ); const modifiers: readonly TalentModifier[] = [ - ...state.talentModifiers, + ...state.talentModifiers.filter((modifier) => modifier.sourceTalentId !== "talent-1854:active"), ...(consumesInnerFocus && ability?.name === "Greater Heal" ? [{ kind: "critical-chance-percent", amount: 25, @@ -6951,6 +9042,19 @@ export const useCombatStore = create((set, get) => ({ rank: 1, description: "Inner Focus increases the critical chance of your Greater Heal by 25 percentage points.", } satisfies TalentModifier] : []), + ...(consumesMageBrainFreeze ? [{ + kind: "damage-percent" as const, + amount: 25, + unit: "percent" as const, + // Keep the scope free of school names: the talent modifier matcher + // treats "Frostfire" as a Frost school tag, even on Fireball. + // Eligibility is already constrained by the Brain Freeze aura lookup. + scope: "your next damage spell", + sourceTalentId: "talent-1854:active", + sourceTalentName: "Brain Freeze", + rank: mageTalentRank(state.talentRanks, "Brain Freeze", 3), + description: "Brain Freeze increases this Fireball or Frostfire Bolt's damage by 25%.", + }] : []), ...(consumesSerendipity ? (["cast-time-percent", "resource-cost-percent"] as const).map((kind) => ({ kind, amount: -serendipityReductionPercent, @@ -6987,18 +9091,24 @@ export const useCombatStore = create((set, get) => ({ ? 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) => { + ? resolvedAbilityCosts(ability, modifiers, { ...state, now }).map((cost) => { + if (suddenDoomFreeDeathCoil && cost.resource === "runic-power") return { ...cost, amount: 0 }; + if (consumesHunterLockAndLoad && cost.resource === "mana") return { ...cost, amount: 0 }; 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 + const resourceCost = ability && !suddenDoomFreeDeathCoil + && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight ? Math.max(0, talentAdjustedResourceCost(ability, modifiers) - (ability.cost.resource === "rage" ? warriorFlatRageCostReduction : 0)) + * (consumesHunterLockAndLoad && ability.cost.resource === "mana" ? 0 : 1) : 0; const costsToSpend = completingCast?.mode === "channel" ? [] : costs; + const manaSpentForCast = completingCast?.manaSpent + ?? costsToSpend.filter((cost) => cost.resource === "mana").reduce((total, cost) => total + cost.amount, 0); const baseTargeting = ability ? targetingRulesForAbility(ability) : null; const targeting = ability?.dbcSpellId === 490457 && baseTargeting && hasRuneWakerState("rom-master-reflected-shot") ? { ...baseTargeting, maximumTargets: 5 } @@ -7041,7 +9151,7 @@ export const useCombatStore = create((set, get) => ({ && spellSchool === "nature", ); const runeCost = ability && baseClassFor(state.classId) === "death-knight" - ? runeCostForAbility(ability) + ? consumesRimeReady ? null : runeCostForAbility(ability) : null; let failure: CastFailureReason | undefined; if (!ability) failure = "unknown-ability"; @@ -7050,6 +9160,17 @@ 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"; + else if (!romPriestWarriorStanceUseAllowed(ability, activePlayerAuraTags)) failure = "condition-not-met"; + else if (state.classId === "warlock" && ability + && !warlockMetamorphosisFormActionReady(ability.id, activePlayerAuras.some((aura) => ( + aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + )))) failure = "condition-not-met"; + else if (state.classId === "warlock" && ability + && activePlayerAuras.some((aura) => ( + aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + )) && !warlockMetamorphosisCanCastAbilityWhileActive(ability)) failure = "condition-not-met"; // 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"; @@ -7066,8 +9187,9 @@ export const useCombatStore = create((set, get) => ({ && aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => aura.definition.utilityTags?.includes("pacified")) ) failure = "player-controlled"; else if (state.activeCast && !completingCast) failure = "already-casting"; - else if (!completingCast && ability.gcdMs > 0 && state.globalCooldownEndsAt > now) failure = "global-cooldown"; - else if (!completingCast && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown"; + else if (!completingCast && !suddenDoomFreeDeathCoil + && ability.gcdMs > 0 && state.globalCooldownEndsAt > now) failure = "global-cooldown"; + else if (!completingCast && !consumesHunterLockAndLoad && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown"; else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked"; 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"; @@ -7116,7 +9238,7 @@ export const useCombatStore = create((set, get) => ({ if (!failure && ability && effectiveTarget === "hostile" && targeting?.shape !== "ground") { let target = targetId ? state.mobs[targetId] : null; if (!completingCast && (!target || target.dead) && state.settings.autoTargetNearest) { - const maximumRange = talentAdjustedRange(ability.range.max, ability, modifiers); + const maximumRange = talentAdjustedCastRange(ability.range.max, ability, modifiers); const minimumRange = talentAdjustedRange(ability.range.min, ability, modifiers); const eligible = eligibleTargetIds(state, playerOrigin, maximumRange).find((id) => { const distance = Math.sqrt(mobRuntimeDistanceSquared(id, playerOrigin)); @@ -7133,7 +9255,7 @@ export const useCombatStore = create((set, get) => ({ else { const distance = Math.sqrt(mobRuntimeDistanceSquared(targetId, playerOrigin)); if (distance < talentAdjustedRange(ability.range.min, ability, modifiers)) failure = "too-close"; - else if (distance > talentAdjustedRange(ability.range.max, ability, modifiers)) failure = "out-of-range"; + else if (distance > talentAdjustedCastRange(ability.range.max, ability, modifiers)) failure = "out-of-range"; } } } else if (!failure && ability && effectiveTarget === "friendly" && targetId) { @@ -7177,6 +9299,11 @@ export const useCombatStore = create((set, get) => ({ (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(requiredTargetStateTag) ))) failure = "condition-not-met"; + if (!failure && state.classId === "rom-rogue" && state.secondaryClassId === "rom-mage" + && ability?.source === "runewaker" && ability.dbcSpellId === 497206 + && !activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG))) { + 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)) { @@ -7313,7 +9440,7 @@ export const useCombatStore = create((set, get) => ({ let groundPosition: CombatPosition | null = completingCast?.groundPosition ?? null; if (!failure && ability && targeting?.shape === "ground") { const requestedGround = request.groundPosition ?? targetPosition ?? playerOrigin; - const maximumGroundRange = talentAdjustedRange(targeting.groundRange ?? ability.range.max, ability, modifiers); + const maximumGroundRange = talentAdjustedCastRange(targeting.groundRange ?? ability.range.max, ability, modifiers); const projected = projectCombatGroundPoint(requestedGround, maximumGroundRange, playerOrigin); if (!projected || Math.sqrt(distanceSquared(playerOrigin, projected)) > maximumGroundRange) failure = "invalid-destination"; else if (targeting.requiresLineOfSight && !combatHasLineOfSight(playerOrigin, projected)) failure = "line-of-sight"; @@ -7337,6 +9464,7 @@ export const useCombatStore = create((set, get) => ({ ? { autoAttackTargetId: targetId, nextAutoAttackAt: state.autoAttackTargetId === targetId ? state.nextAutoAttackAt : now, + nextOffhandAutoAttackAt: state.autoAttackTargetId === targetId ? state.nextOffhandAutoAttackAt : now, } : {}; @@ -7438,8 +9566,16 @@ export const useCombatStore = create((set, get) => ({ }]; }) : []; + const warlockSpellHasteModifier = state.classId === "warlock" + && abilityAttackKind(state.classId, ability) === "spell" + ? warlockDeathGraspSpellHasteModifier(warlockTalentRank(state.talentRanks, "Death's Grasp", 3)) + : null; + const castTimeBaseModifiers = warlockSpellHasteModifier + ? modifiers.filter((modifier) => modifier.sourceTalentName !== "Death's Grasp") + : modifiers; const castTimeModifiers: readonly TalentModifier[] = [ - ...modifiers, + ...castTimeBaseModifiers, + ...(warlockSpellHasteModifier ? [warlockSpellHasteModifier] : []), ...(hasDruidNaturesGrace ? [{ kind: "haste-percent" as const, amount: 20, @@ -7462,24 +9598,49 @@ export const useCombatStore = create((set, get) => ({ }] : []), ...romMageSpellHasteModifiers, ]; - const ordinaryCastTimeMs = talentAdjustedCastTime( + const romSniperCastTimeReductionMs = ability.source === "runewaker" + && state.classId === "rom-scout" + && state.secondaryClassId === "rom-priest" + && ability.dbcSpellId === 490450 + && activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(ROM_SCOUT_PRIEST_SNIPER_TAG)) + ? 1_000 + : 0; + const ordinaryCastTimeMs = Math.max(0, talentAdjustedCastTime( ability, castTimeModifiers, state.gearStats, state.level, state.classId, - ); - const castTimeMs = consumesPresenceOfMind || consumesHotStreak || consumesNatureSwiftness + ) - romSniperCastTimeReductionMs); + const tidalWavesAdjustedCastTimeMs = ability.name === "Healing Wave" && tidalWavesChargesForCast > 0 + ? Math.max(0, Math.round(ordinaryCastTimeMs * wow335ShamanTidalWavesCastTimeMultiplier(tidalWavesChargesForCast))) + : ordinaryCastTimeMs; + const missileBarrageProfile = consumesMageMissileBarrage + ? wow335MageMissileBarrageProfileForRank(mageTalentRank(state.talentRanks, "Missile Barrage", 3)) + : null; + const missileBarrageAdjustedCastTimeMs = missileBarrageProfile + ? Math.max(0, Math.round(tidalWavesAdjustedCastTimeMs * missileBarrageProfile.channelDurationMultiplier)) + : tidalWavesAdjustedCastTimeMs; + const maelstromAdjustedCastTimeMs = Math.max(0, Math.round( + missileBarrageAdjustedCastTimeMs * (1 - maelstromWeaponCastTimeReductionPercent / 100), + )); + const castTimeBeforeBackdraftMs = consumesPresenceOfMind || consumesHotStreak || consumesMageBrainFreeze || consumesNatureSwiftness || consumesSurgeOfLight || consumesWarlockBacklash ? 0 - : lastPrayerReady ? Math.min(1_000, ordinaryCastTimeMs) : ordinaryCastTimeMs; - const gcdMs = talentAdjustedGcd( + : lastPrayerReady ? Math.min(1_000, maelstromAdjustedCastTimeMs) : maelstromAdjustedCastTimeMs; + const castTimeMs = warlockBackdraftEffects + ? Math.max(0, Math.round(castTimeBeforeBackdraftMs * (1 - warlockBackdraftEffects.reductionPercent / 100))) + : castTimeBeforeBackdraftMs; + const gcdBeforeBackdraftMs = talentAdjustedGcd( ability, castTimeModifiers, state.gearStats, state.level, state.classId, ); + const gcdMs = warlockBackdraftEffects && gcdBeforeBackdraftMs > 0 + ? Math.max(1_000, Math.round(gcdBeforeBackdraftMs * (1 - warlockBackdraftEffects.reductionPercent / 100))) + : gcdBeforeBackdraftMs; const improvedRuneTapRank = state.classId === "death-knight" && ability.name === "Rune Tap" ? Math.max(0, Math.min(3, Math.trunc(state.talentRanks["talent-1942"] ?? 0))) : 0; @@ -7496,7 +9657,7 @@ export const useCombatStore = create((set, get) => ({ state.level, state.abilities, ); - const cooldownMs = Math.max(0, + const cooldownMs = consumesHunterLockAndLoad ? 0 : Math.max(0, talentAdjustedCooldown(ability, modifiers) - improvedRuneTapRank * 10_000 - improvedWhirlwindRank * 1_000 @@ -7533,24 +9694,53 @@ export const useCombatStore = create((set, get) => ({ pushbackHits: 0, school: spellSchool, canCastWhileMoving: canAbilityCastWhileMoving(ability) - || (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")), + || (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")) + || romMageWarriorMeditationPathAllowsMovement( + state.classId, + state.secondaryClassId, + romMageWarriorMeditationPathPassiveSpellIds.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID), + activePlayerAuras, + PLAYER_AURA_ENTITY_ID, + now, + ), origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]], groundPosition, totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 0, ticksCompleted: 0, nextTickAt: ability.castMode === "channel" ? channelPulseAt(ability, now, castTimeMs, 0) : 0, + ...(consumesTidalWaves ? { tidalWavesChargesConsumed: tidalWavesChargesForCast } : {}), + ...(mageArcanePotencyCriticalChanceBonusPercent > 0 + ? { arcanePotencyCriticalChanceBonusPercent: mageArcanePotencyCriticalChanceBonusPercent } : {}), + ...(state.classId === "mage" ? { manaSpent: manaSpentForCast } : {}), }; const channelCooldowns: Record = { ...state.cooldowns }; if (activeCast.mode === "channel" && cooldownMs > 0) { channelCooldowns[ability.id] = now + cooldownMs; } + const castStartAuras = consumesWarlockBackdraft && warlockBackdraftAura + ? consumeWarlockBackdraftCharge(state.auras, warlockBackdraftAura.instanceId) + : state.auras; + const castStartAurasAfterOtherConsumes = castStartAuras.filter((aura) => !( + aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.definition.utilityTags?.includes("rom-last-prayer") + || (consumesWarlockBacklash && aura.instanceId === warlockBacklashAura?.instanceId) + || (consumesMaelstromWeapon && aura.instanceId === maelstromWeaponAura?.instanceId) + || (activeCast.mode === "channel" && consumesMageArcaneConcentration + && aura.instanceId === mageArcaneConcentrationAura?.instanceId) + || (activeCast.mode === "channel" && consumesMageMissileBarrage + && aura.instanceId === mageMissileBarrageAura?.instanceId) + || (consumesMageArcanePotency && aura.instanceId === mageArcanePotencyAura?.instanceId)) + )); + const castStartAurasAfterTidalWaves = consumesTidalWaves && shamanTidalWavesAura + ? consumeShamanTidalWavesChargeInAuras(castStartAurasAfterOtherConsumes, shamanTidalWavesAura.instanceId) + : castStartAurasAfterOtherConsumes; set({ activeCast, - ...(lastPrayerReady || consumesWarlockBacklash ? { auras: state.auras.filter((aura) => !( - aura.targetId === PLAYER_AURA_ENTITY_ID - && (aura.definition.utilityTags?.includes("rom-last-prayer") - || (consumesWarlockBacklash && aura.instanceId === warlockBacklashAura?.instanceId)) - )) } : {}), + ...(lastPrayerReady || consumesWarlockBacklash || consumesMaelstromWeapon || consumesTidalWaves || consumesWarlockBackdraft + || consumesMageArcanePotency + || (activeCast.mode === "channel" && (consumesMageArcaneConcentration || consumesMageMissileBarrage)) + ? { auras: castStartAurasAfterTidalWaves } + : {}), ...autoAttackUpdate, playerAnimationEvent: nextAnimationEvent("ability-start", ability.id), health: activeCast.mode === "channel" ? healthAfterCosts : state.health, @@ -7626,10 +9816,34 @@ export const useCombatStore = create((set, get) => ({ raceId: state.raceId, secondaryClassId: state.secondaryClassId, }; + if (consumesRimeReady && rimeReadyAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== rimeReadyAura.instanceId); + } + if (consumesHunterLockAndLoad && hunterLockAndLoadAura) { + work.auras = [...consumeLockAndLoadCharge(work.auras, hunterLockAndLoadAura.instanceId)]; + } + if (state.classId === "rom-rogue" && state.secondaryClassId === "rom-mage" + && ability.dbcSpellId === 497206) { + work.auras = work.auras.filter((aura) => !( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG) + )); + } if (consumesWarlockBacklash && warlockBacklashAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== warlockBacklashAura.instanceId); } + if (consumesWarlockBackdraft && warlockBackdraftAura) { + work.auras = consumeWarlockBackdraftCharge(work.auras, warlockBackdraftAura.instanceId); + } + work.auras = maybeApplyWarlockBackdraft(work.auras, state.classId, work.talentRanks, ability.name, now); + if (consumesMaelstromWeapon && maelstromWeaponAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== maelstromWeaponAura.instanceId); + } + if (consumesTidalWaves && shamanTidalWavesAura) { + work.auras = consumeShamanTidalWavesChargeInAuras(work.auras, shamanTidalWavesAura.instanceId); + } let priestSerendipityTriggered = false; + let improvedKickInterruptLanded = false; if (consumesInnerFocus && innerFocusAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== innerFocusAura.instanceId); } @@ -7642,6 +9856,18 @@ export const useCombatStore = create((set, get) => ({ if (consumesDruidClearcasting && druidClearcastingAura) { work.auras = work.auras.filter((aura) => aura.instanceId !== druidClearcastingAura.instanceId); } + if (consumesMageArcaneConcentration && mageArcaneConcentrationAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== mageArcaneConcentrationAura.instanceId); + } + if (consumesMageArcanePotency && mageArcanePotencyAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== mageArcanePotencyAura.instanceId); + } + if (consumesMageBrainFreeze && mageBrainFreezeAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== mageBrainFreezeAura.instanceId); + } + if (consumesMageMissileBarrage && mageMissileBarrageAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== mageMissileBarrageAura.instanceId); + } if (consumesShamanClearcasting && shamanClearcastingAura) { work.auras = work.auras.flatMap((aura) => aura.instanceId !== shamanClearcastingAura.instanceId ? [aura] @@ -7671,18 +9897,43 @@ export const useCombatStore = create((set, get) => ({ * romWarriorThreatMultiplier(ability, romWarriorPassiveSpellIds), }; - if (targeting?.shape === "ground" && groundPosition && completingCast?.mode !== "channel") { + const selfPlacedHunterTrap = state.classId === "hunter" + && ["Freezing Trap", "Frost Trap", "Immolation Trap"].includes(ability.name); + if (((targeting?.shape === "ground" && groundPosition) || selfPlacedHunterTrap) + && completingCast?.mode !== "channel") { const hunterTrapKind = state.classId === "hunter" && ability.name === "Freezing Arrow" ? "freezing" + : state.classId === "hunter" && ability.name === "Freezing Trap" + ? "freezing" + : state.classId === "hunter" && ability.name === "Frost Trap" + ? "frost" + : state.classId === "hunter" && ability.name === "Immolation Trap" + ? "immolation" : state.classId === "hunter" && ability.name === "Explosive Trap" ? "explosive" : null; if (hunterTrapKind) { areaEffectSequence += 1; - const rank = ability.ranks?.find((candidate) => candidate.spellId === ability.dbcSpellId); + const trapPosition = groundPosition ?? state.playerPosition; + const trapDurationMs = ability.effects.find((effect) => effect.kind === "utility")?.durationMs + || 30_000; const directDamageScaling = hunterTrapKind === "explosive" ? hunterExplosiveTrapDirectScaling(ability.dbcSpellId) ?? undefined : undefined; + const replacedHunterTraps = work.areaEffects.filter((area) => ( + area.sourceActorId === PLAYER_AGGRO_ID && area.hunterTrap + )); + for (const previous of replacedHunterTraps) { + if (previous.hunterTrap?.kind !== "frost-zone") continue; + const previousSlowSourceId = `hunter-trap-zone:${previous.id}`; + for (const [id, mob] of Object.entries(work.mobs)) { + if (!mob.statuses.some((status) => status.sourceAbilityId === previousSlowSourceId)) continue; + work.mobs[id] = { + ...mob, + statuses: mob.statuses.filter((status) => status.sourceAbilityId !== previousSlowSourceId), + }; + } + } work.areaEffects = work.areaEffects.filter((area) => !( area.sourceActorId === PLAYER_AGGRO_ID && area.hunterTrap )); @@ -7690,20 +9941,22 @@ export const useCombatStore = create((set, get) => ({ id: `hunter-trap-${areaEffectSequence}`, sourceActorId: PLAYER_AGGRO_ID, sourceAbilityId: ability.id, - position: groundPosition, + position: trapPosition, radius: 2, createdAt: now, - expiresAt: now + 30_000, + expiresAt: now + trapDurationMs, tickIntervalMs: 0, nextTickAt: Number.MAX_SAFE_INTEGER, hunterTrap: { kind: hunterTrapKind, triggerRadius: 2, - ...(hunterTrapKind === "freezing" ? { freezeDurationMs: 20_000 } : {}), + ...(hunterTrapKind === "freezing" + ? { freezeDurationMs: freezingTrapDurationMs(ability.dbcSpellId) } + : {}), ...(directDamageScaling ? { directDamageScaling } : {}), }, }); - } else { + } else if (targeting?.shape === "ground" && groundPosition) { 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) @@ -7799,6 +10052,14 @@ export const useCombatStore = create((set, get) => ({ && (candidate.sourceUnlockLevel ?? candidate.unlockLevel) <= work.progression.level) .map((candidate) => candidate.dbcSpellId) : []; + const learnedRomPriestChampionPassiveSpellIds = state.classId === "rom-priest" && state.secondaryClassId === "rom-champion" + ? work.abilities.filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-priest" + && candidate.secondaryClassId === "rom-champion" + && 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" @@ -7825,6 +10086,7 @@ export const useCombatStore = create((set, get) => ({ learnedPassiveSpellIds: [ ...romWarriorPassiveSpellIds, ...learnedRomPriestPassiveSpellIds, + ...learnedRomPriestChampionPassiveSpellIds, ...learnedRomWarlockPriestPassiveSpellIds, ], passiveTags: work.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID) @@ -7866,6 +10128,10 @@ export const useCombatStore = create((set, get) => ({ psi: clamp((work.resourcePools.psi ?? 0) + psiGain, 0, maximumPsi), }; } + if (state.classId === "mage") { + const empoweredFireRank = state.talentRanks[WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID] ?? 0; + castEffects = castEffects.map((effect) => wow335MageEmpoweredFireEffect(ability.name, effect, empoweredFireRank)); + } const warlockStateRefreshTags = romWarlockStateRefreshTags(ability, romCastTargetTags); if (targetId && warlockStateRefreshTags.length) { const refreshTagSet = new Set(warlockStateRefreshTags); @@ -7972,7 +10238,14 @@ export const useCombatStore = create((set, get) => ({ })() : null; let clearedSingleTargetControls = false; + let missileBarrageSlowCastChecked = false; let abilityFeedback: { kind: CombatFeedbackKind; message: string } | null = null; + const shamanLavaLashOffhandSpeedMs = state.classId === "shaman" && ability.name === "Lava Lash" + ? playerEquippedMeleeWeapon(state, "off-hand")?.speedMs + : undefined; + let shamanTidalWavesProcAttempted = false; + const combatEventIdBeforeAbilityEffects = work.combatEvents.at(-1)?.id ?? 0; + let suddenDoomTrigger: ReturnType = null; for (const effect of effectsToResolve) { if (state.classId === "warlock" && ability.name === "Seed of Corruption" && effect.kind === "apply-aura" && effect.sourceAuraType === 4) continue; @@ -8031,6 +10304,15 @@ export const useCombatStore = create((set, get) => ({ const targetAmount = consumesSurgeOfLight && ability.name === "Smite" ? Math.round(faithAdjustedAmount * 1.6) : faithAdjustedAmount; + const mageTargetAmount = state.classId === "mage" && target + ? wow335MageTormentTheWeakDamage( + targetAmount, + work.talentRanks[WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID] ?? 0, + ability.name, + target.statuses, + now, + ) + : targetAmount; const sunderStacks = effect.bonusPerSunderStack ? work.auras.find((aura) => aura.targetId === id && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0 : 0; @@ -8038,7 +10320,7 @@ export const useCombatStore = create((set, get) => ({ state.classId, work, id, - Math.round((targetAmount + sunderStacks * (effect.bonusPerSunderStack ?? 0)) + Math.round((mageTargetAmount + sunderStacks * (effect.bonusPerSunderStack ?? 0)) * Math.pow(effect.falloffPerJump ?? 1, targetIndex)), now, castThreatSource, @@ -8049,11 +10331,67 @@ export const useCombatStore = create((set, get) => ({ false, ability.name === "Shiv" || (consumesSurgeOfLight && ability.name === "Smite") ? { canCrit: false } : {}, + mageArcanePotencyCriticalChanceBonusPercent, + shamanLavaLashOffhandSpeedMs, ); + if (state.classId === "death-knight" + && effect.kind === "damage" + && id === targetId + && result.combatResult?.landed) { + suddenDoomTrigger = wow335DeathKnightSuddenDoomTriggerForLandedHit( + state.talentRanks["talent-1955"] ?? 0, + ability.id, + id, + true, + stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${id}:${now}:wow335-death-knight-sudden-doom`), + ); + } + applyDruidFeralInstinctCooldownReductionInWork(work, ability, result.amount, now); + if (state.classId === "rom-warrior" + && state.secondaryClassId === "rom-scout" + && ability.source === "runewaker" + && ability.dbcSpellId === ROM_WARRIOR_SCOUT_SKULL_BREAKER_SPELL_ID + && romWarriorPassiveSpellIds.includes(ROM_WARRIOR_SCOUT_CALM_PASSIVE_SPELL_ID) + && result.combatResult + && !result.combatResult.landed) { + work.auras = [...applyCombatAura(work.auras, { + definition: ROM_WARRIOR_SCOUT_CALM_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } if (state.classId === "priest" && ability.name === "Smite" && result.amount > 0 && !innerFocusSerendipityBlocked) { priestSerendipityTriggered = true; } + if (state.classId === "mage" && effect.kind === "damage" && attackKind === "spell") { + const masterOfElementsRefund = wow335MageMasterOfElementsManaRefund( + state.talentRanks[WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID] ?? 0, + manaSpentForCast, + true, + Boolean(result.combatResult?.critical), + ); + if (masterOfElementsRefund > 0 && result.combatResult?.critical) { + const maximumMana = work.maxResourcePools.mana ?? defaultResourceMaximum("mana"); + const currentMana = work.resourcePools.mana ?? 0; + const restored = Math.max(0, Math.min(masterOfElementsRefund, maximumMana - currentMana)); + work.resourcePools.mana = currentMana + restored; + if (resourceProfileForClass(state.classId).type === "mana") work.resource = currentMana + restored; + recordCombatEventInWork(work, { + occurredAt: now, + kind: "resource", + sourceActorId: castThreatSource.actorId, + targetActorId: castThreatSource.actorId, + abilityId: ability.id, + rawAmount: masterOfElementsRefund, + effectiveAmount: restored, + overheal: masterOfElementsRefund - restored, + absorbed: 0, + critical: false, + }); + } + } if (state.classId === "warlock" && ability.name === "Conflagrate" && result.combatResult?.landed) { const consumedDot = activeWarlockConflagrateDot(work.effects, id, PLAYER_AGGRO_ID, now); @@ -8238,6 +10576,10 @@ export const useCombatStore = create((set, get) => ({ "critical-chance-percent", abilityTalentContext(ability), ) + + shamanElementalOathCriticalChancePercent(state.classId, work.talentRanks, "spell") + + (state.classId === "shaman" + ? wow335ShamanTidalWavesCriticalChanceBonusPercent(ability.name, tidalWavesChargesForCast) + : 0) + (divineFavorReady ? 100 : 0) ); const receivesTidalForceBonus = state.classId === "shaman" @@ -8247,6 +10589,14 @@ export const useCombatStore = create((set, get) => ({ id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)]; if (targeting?.maximumTargets) healingTargetIds = healingTargetIds.slice(0, targeting.maximumTargets); for (const [targetIndex, healingTargetId] of [...new Set(healingTargetIds)].entries()) { + const shamanRiptideHot = state.classId === "shaman" + && ability.name === "Chain Heal" + && targetIndex === 0 + ? shamanRiptideHotOnTargetInWork(work, healingTargetId, now) + : undefined; + if (shamanRiptideHot) { + work.effects = work.effects.filter((candidate) => candidate.id !== shamanRiptideHot.id); + } 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); @@ -8302,12 +10652,17 @@ export const useCombatStore = create((set, get) => ({ const runeTapBonus = state.classId === "death-knight" && ability.name === "Rune Tap" ? [0, 0.33, 0.66, 1][improvedRuneTapRank] ?? 0 : 0; + const tidalWavesBonusHealingAmount = state.classId === "shaman" + && ["Healing Wave", "Lesser Healing Wave"].includes(ability.name) + ? Math.round(Math.max(0, casterStats.spellPower) + * wow335ShamanTidalWavesBonusHealingPercent(shamanTidalWavesRank)) + : 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); + * (1 + runeTapBonus) + tidalWavesBonusHealingAmount; const unmodifiedAmount = talentAdjustedAmount( swiftmendAmount ?? sourceHealAmount, modifiers, @@ -8326,7 +10681,10 @@ export const useCombatStore = create((set, get) => ({ if (swiftmendHot) { work.effects = work.effects.filter((candidate) => candidate.id !== swiftmendHot.id); } - const falloffAdjustedAmount = Math.round(targetAdjustedAmount * Math.pow(effect.falloffPerJump ?? 1, targetIndex)); + const riptideAdjustedAmount = shamanRiptideHot + ? Math.round(targetAdjustedAmount * SHAMAN_RIPTIDE_CHAIN_HEAL_BONUS) + : targetAdjustedAmount; + const falloffAdjustedAmount = Math.round(riptideAdjustedAmount * Math.pow(effect.falloffPerJump ?? 1, targetIndex)); const amount = Math.round(resolveAuraHealingValue( resolveAuraHealingValue( falloffAdjustedAmount, @@ -8397,6 +10755,10 @@ export const useCombatStore = create((set, get) => ({ resolution.critical, now, ); + if (!shamanTidalWavesProcAttempted && ["Chain Heal", "Riptide"].includes(ability.name)) { + shamanTidalWavesProcAttempted = true; + maybeProcShamanTidalWavesInWork(work, ability, resolution.rawAmount, now); + } } if (state.classId === "priest") { const divineAegisRank = priestTalentRank(work.talentRanks, "Divine Aegis", 3); @@ -8470,6 +10832,15 @@ export const useCombatStore = create((set, get) => ({ } } if (state.classId === "druid" && resolution.critical) { + maybeProcDruidLivingSeed( + work, + ability, + healingTargetId, + true, + healed, + `${PLAYER_AGGRO_ID}:${ability.id}:${healingTargetId}:${now}:heal:wow335-druid-living-seed`, + now, + ); maybeProcDruidNatureGrace( work, ability, @@ -8763,6 +11134,16 @@ export const useCombatStore = create((set, get) => ({ work.comboPoints = clamp((work.comboTargetId === comboTargetId ? work.comboPoints : 0) + effect.amount, 0, state.classId === "rom-knight" ? 4 : 5); work.comboTargetId = comboTargetId; + if (comboTargetId) { + maybeProcDruidPrimalFuryCombo( + work, + ability, + comboTargetId, + effect.amount, + combatEventIdBeforeAbilityEffects, + now, + ); + } } else if (effect.kind === "dot") { if (targeting?.shape === "ground") continue; const selfDrainPercent = romSelfDrainPercent(ability.dbcSpellId); @@ -8797,6 +11178,7 @@ export const useCombatStore = create((set, get) => ({ effect.tag, effect.school ?? damageSchool, work.gearStats.spellPenetration, + mageArcanePotencyCriticalChanceBonusPercent, ); work.effects.push(seedProfile ? { ...timed, tag: "wow335-warlock-seed", warlockSeed: seedProfile } @@ -8869,10 +11251,16 @@ export const useCombatStore = create((set, get) => ({ 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), - }, ability.id, now, PLAYER_AGGRO_ID); + if (mob) { + const next = applyStatus(mob, { + ...effect, + durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers), + }, ability.id, now, PLAYER_AGGRO_ID); + if (state.classId === "rogue" && ability.name === "Kick" + && effect.status === "interrupt" && mob.activeCast?.interruptible + && !next.activeCast) improvedKickInterruptLanded = true; + work.mobs[id] = next; + } } } else if (effect.kind === "apply-aura") { const sourceDurationMs = ability.name === "Envenom" @@ -8889,7 +11277,7 @@ export const useCombatStore = create((set, get) => ({ && effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG) ? sourceDurationMs : talentAdjustedDuration(sourceDurationMs, ability, modifiers); - const sourceAura = ability.dbcSpellId === 48707 && state.classId === "death-knight" + const sourceAuraBase = ability.dbcSpellId === 48707 && state.classId === "death-knight" ? { ...effect.aura, absorbs: effect.aura.absorbs?.map((absorb) => ({ @@ -8898,6 +11286,10 @@ export const useCombatStore = create((set, get) => ({ })), } : effect.aura; + const sourceAura = state.classId === "death-knight" + && ability.dbcSpellId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_SPELL_ID + ? wow335DeathKnightMarkOfBloodAuraDefinition(sourceAuraBase) + : sourceAuraBase; const excludesLiquidationTarget = state.classId === "rom-warlock" && state.secondaryClassId === "rom-rogue" && ability.dbcSpellId === 498695 @@ -8983,6 +11375,30 @@ export const useCombatStore = create((set, get) => ({ now, }); work.auras = [...auraResult.auras]; + if (state.classId === "mage" && ability.name === "Presence of Mind" + && wow335MageArcanePotencyCanProc( + mageArcanePotencyRank, + "presence-of-mind", + )) { + work.auras = [...applyCombatAura(work.auras, { + definition: WOW335_MAGE_ARCANE_POTENCY_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + if (!missileBarrageSlowCastChecked && state.classId === "mage" && ability.name === "Slow" + && effectiveTarget === "hostile" && effect.aura.disposition === "debuff" + && recipientId !== PLAYER_AURA_ENTITY_ID && work.mobs[recipientId] + && auraResult.aura.targetId === recipientId) { + missileBarrageSlowCastChecked = true; + maybeProcMageMissileBarrage( + work, + ability.name, + `${castThreatSource.actorId}:${ability.id}:${recipientId}:${now}:slow`, + now, + ); + } if (ability.name === "Power Word: Shield") { work.auras = [...applyCombatAura(work.auras, { definition: consumesInnerFocus @@ -9124,11 +11540,28 @@ export const useCombatStore = create((set, get) => ({ } else if (effect.kind === "interrupt") { for (const id of affectedMobIds) { const mob = work.mobs[id]; - if (mob) work.mobs[id] = applyStatus(mob, { - kind: "status", - status: "interrupt", - durationMs: effect.lockoutMs, - }, ability.id, now); + if (mob) { + const next = applyStatus(mob, { + kind: "status", + status: "interrupt", + durationMs: effect.lockoutMs, + }, ability.id, now); + if (state.classId === "rogue" && ability.name === "Kick" + && mob.activeCast?.interruptible && !next.activeCast) improvedKickInterruptLanded = true; + const silenceDurationMs = mob.activeCast?.interruptible && state.classId === "mage" + ? wow335MageImprovedCounterspellSilenceDurationMs( + work.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0, + ability.name, + ) + : 0; + work.mobs[id] = silenceDurationMs > 0 && next.activeCast === null + ? applyStatus(next, { + kind: "status", + status: "silence", + durationMs: silenceDurationMs, + }, ability.id, now, PLAYER_AGGRO_ID) + : next; + } } } else if (effect.kind === "pull") { if (ability.target === "friendly" && targetId && effect.toward === "caster") { @@ -9174,7 +11607,10 @@ export const useCombatStore = create((set, get) => ({ const maximum = work.maxResourcePools[resource] ?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource)); const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount; - if (ability.target === "hostile") { + if (effect.recipient === "caster") { + work.resourcePools[resource] = Math.max(0, (work.resourcePools[resource] ?? 0) - drainAmount); + if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource; + } else if (ability.target === "hostile") { let drainedFromPlayers = 0; let hasPlayerTarget = false; for (const id of affectedMobIds) { @@ -9225,7 +11661,16 @@ export const useCombatStore = create((set, get) => ({ }; } } else if (effect.kind === "movement") { - if (ability.dbcSpellId === 490344 && targetId) { + if (ability.dbcSpellId === 54785 && targetPosition) { + const dx = targetPosition[0] - playerOrigin[0]; + const dz = targetPosition[2] - playerOrigin[2]; + const length = Math.max(0.001, Math.hypot(dx, dz)); + playerPositionAfterEffects = [ + targetPosition[0] - dx / length * 1.5, + targetPosition[1], + targetPosition[2] - dz / length * 1.5, + ]; + } else if (ability.dbcSpellId === 490344 && targetId) { const target = getMobRuntimeTransform(targetId); if (target) playerPositionAfterEffects = [ target.position[0] - Math.sin(target.yaw) * 1.5, @@ -9568,10 +12013,42 @@ export const useCombatStore = create((set, get) => ({ } } + if (improvedKickInterruptLanded) { + const kickBonus = wow335RogueImprovedKickAuraEffectForRank(work.talentRanks["talent-206"] ?? 0); + if (kickBonus) { + work.auras = [...applyCombatAura(work.auras, { + definition: kickBonus.aura, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + const currentEnergy = work.resourcePools.energy ?? 0; + const maximumEnergy = work.maxResourcePools.energy ?? defaultResourceMaximum("energy"); + const restoredEnergy = Math.max(0, Math.min(40, maximumEnergy - currentEnergy)); + work.resourcePools.energy = currentEnergy + restoredEnergy; + if (resourceProfileForClass(work.classId).type === "energy") work.resource = currentEnergy + restoredEnergy; + if (restoredEnergy > 0) { + recordCombatEventInWork(work, { + occurredAt: now, + kind: "resource", + sourceActorId: PLAYER_AURA_ENTITY_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: ability.id, + rawAmount: 40, + effectiveAmount: restoredEnergy, + overheal: 40 - restoredEnergy, + absorbed: 0, + critical: false, + }); + } + } + } + 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; + const generatedMechanics = beastWithinRankMechanics(talentRank); + const procChance = generatedMechanics?.procChance ?? 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"); @@ -9592,6 +12069,10 @@ export const useCombatStore = create((set, get) => ({ } } + if (consumesWarlockNightfall && warlockNightfallAura) { + work.auras = work.auras.filter((aura) => aura.instanceId !== warlockNightfallAura.instanceId); + } + // 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. @@ -9617,6 +12098,14 @@ export const useCombatStore = create((set, get) => ({ && consumedRuneWakerTags.some((tag) => aura.definition.utilityTags?.includes(tag)) )); } + if (state.classId === "rom-priest" && state.secondaryClassId === "rom-knight" + && ability.source === "runewaker" && ability.dbcSpellId === 490261) { + work.auras = work.auras.flatMap((aura) => { + if (aura.targetId !== PLAYER_AURA_ENTITY_ID + || !aura.definition.utilityTags?.includes(ROM_PRIEST_KNIGHT_HOLY_POWER_HEAL_USES_TAG)) return [aura]; + return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; + }); + } if (divineFavorReady) { work.auras = work.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal"))); @@ -9643,6 +12132,20 @@ export const useCombatStore = create((set, get) => ({ } } if (state.classId === "priest") { + const painAndSufferingRank = Math.min(3, Math.max(0, Math.trunc(work.talentRanks["talent-1909"] ?? 0))); + if (painAndSufferingRank > 0 && targetId + && WOW335_PRIEST_PAIN_AND_SUFFERING_CASTS.has(ability.name)) { + const shadowWordPain = work.abilities.find((candidate) => candidate.name === "Shadow Word: Pain") ?? null; + work.effects = [...refreshPriestPainAndSufferingDot( + work.effects, + PLAYER_AGGRO_ID, + targetId, + ability, + painAndSufferingRank, + shadowWordPain, + now, + )]; + } if (priestSerendipityTriggered) { work.auras = work.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-priest-serendipity") @@ -9672,6 +12175,33 @@ export const useCombatStore = create((set, get) => ({ )); } + const nerveGearPassiveSource = state.classId === "rom-rogue" && state.secondaryClassId === "rom-champion" + ? work.abilities.find((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-rogue" + && candidate.secondaryClassId === "rom-champion" + && candidate.passive + && candidate.dbcSpellId === ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID) + : undefined; + const nerveGearPassive = nerveGearPassiveSource + ? learnedAbilityForBuild(nerveGearPassiveSource, work.progression.level, work.talentRanks) + : null; + if (nerveGearPassive && romRogueChampionNerveGearShouldTrigger( + state.classId, + state.secondaryClassId, + ability, + true, + )) { + const effect = romRogueChampionNerveGearEffect(); + if (effect.kind === "apply-aura") { + work.auras = [...applyCombatAura(work.auras, { + definition: effect.aura, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + evaluateProcsInWork(work, { trigger: "cast", subjectId: PLAYER_AURA_ENTITY_ID, @@ -9692,6 +12222,64 @@ export const useCombatStore = create((set, get) => ({ critical: false, outcome: "completed", }); + if (state.classId === "rom-priest" && state.secondaryClassId === "rom-mage" + && ability.source === "runewaker" + && ability.dbcSpellId === ROM_PRIEST_MAGE_ENLIGHTENMENT.triggerSpellId) { + const passiveSource = work.abilities.find((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-priest" + && candidate.secondaryClassId === "rom-mage" + && candidate.passive + && candidate.dbcSpellId === ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId); + const passive = passiveSource + ? learnedAbilityForBuild(passiveSource, work.progression.level, work.talentRanks) + : null; + if (passive) { + const readyAt = romPriestMageEnlightenmentReadyAt(work.auras, PLAYER_AURA_ENTITY_ID, now); + const proc = romPriestMageEnlightenmentProc( + state.classId, + state.secondaryClassId, + ability, + [ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId], + true, + readyAt, + now, + ); + if (proc.effect?.kind === "apply-aura") { + for (const effect of [proc.effect, ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT]) { + work.auras = [...applyCombatAura(work.auras, { + definition: auraDefinitionForAbility(effect.aura, ability), + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + } + } + if (romMageWarriorMeditationPathCanProc( + state.classId, + state.secondaryClassId, + romMageWarriorMeditationPathPassiveSpellIds.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID), + ability, + ) && !romMageWarriorMeditationPathHasState( + work.auras, + PLAYER_AURA_ENTITY_ID, + ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, + now, + )) { + work.auras = [...applyCombatAura(work.auras, { + definition: ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT.aura, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + work.auras = [...applyCombatAura(work.auras, { + definition: ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT.aura, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } if (state.classId === "rogue") { maybeProcRogueCastTalentsInWork( work, @@ -9703,6 +12291,53 @@ export const useCombatStore = create((set, get) => ({ ); } + const rimeRank = state.classId === "death-knight" + ? Math.max(0, Math.min(3, Math.trunc(work.talentRanks["talent-1992"] ?? 0))) + : 0; + if (rimeRank > 0 && ability.name === "Obliterate" && targetId + && wow335DeathKnightRimeProcSucceeds( + rimeRank, + stableCombatUnit(`${PLAYER_AGGRO_ID}:${ability.id}:${targetId}:${now}:wow335-death-knight-rime`), + )) { + const howlingBlast = work.abilities.find((candidate) => candidate.name === "Howling Blast"); + if (howlingBlast) { + work.cooldowns[howlingBlast.id] = now; + work.auras = [...applyCombatAura(work.auras, { + definition: { + id: "wow335-death-knight-rime-ready", + name: "Rime", + description: "Your next Howling Blast consumes no runes.", + disposition: "buff", + durationMs: null, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: [WOW335_DEATH_KNIGHT_RIME_READY_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } + + if (state.classId === "warrior" && targetId) { + applyWarriorChargeTalentEffectsInWork(work, ability, now); + } + + if (state.classId === "druid") { + const feralInstinctRank = Math.min(3, Math.max(0, Math.trunc(state.talentRanks["talent-799"] ?? 0))); + const sourcedDurationMs = ability.effects + .filter((effect): effect is Extract => effect.kind === "apply-aura") + .reduce((duration, effect) => Math.max(duration, effect.aura.durationMs ?? 0), 0); + if (ability.name === "Tiger's Fury") { + const aura = wow335DruidFeralInstinctTigerAura(feralInstinctRank, sourcedDurationMs); + if (aura) applyDruidSelfAura(work, aura, now); + } else if (ability.name === "Enrage") { + const aura = wow335DruidFeralInstinctEnrageAura(feralInstinctRank, sourcedDurationMs); + if (aura) applyDruidSelfAura(work, aura, 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, @@ -9726,7 +12361,7 @@ export const useCombatStore = create((set, get) => ({ const cooldowns: Record = { ...work.cooldowns }; if (cooldownMs > 0 && completingCast?.mode !== "channel") cooldowns[ability.id] = now + cooldownMs; - if (ability.cooldownGroup && completingCast?.mode !== "channel") { + if (ability.cooldownGroup && completingCast?.mode !== "channel" && !consumesHunterLockAndLoad) { for (const grouped of state.abilities.filter((entry) => entry.cooldownGroup === ability.cooldownGroup)) { const groupedReverberationReductionMs = state.classId === "shaman" && grouped.cooldownGroup === "shaman-shock" ? shamanTalentRankFromRanks(state.talentRanks, "Reverberation", 3) * 500 @@ -9735,6 +12370,7 @@ export const useCombatStore = create((set, get) => ({ cooldowns[grouped.id] = now + Math.max(cooldownMs, groupedCooldownMs); } } + if (consumesHunterLockAndLoad) cooldowns[ability.id] = 0; for (const adjustment of cooldownAdjustments) { const candidates = cooldownResetCandidates( state.abilities, ability.id, adjustment.abilityName, adjustment.excludeAbilityNames, @@ -9806,7 +12442,7 @@ export const useCombatStore = create((set, get) => ({ maxResourcePools: work.maxResourcePools, lastResourceSpentAt: work.lastResourceSpentAt, deathKnightRunes: work.deathKnightRunes, - talentModifiers: work.talentModifiers, + talentModifiers: work.talentModifiers.filter((modifier) => modifier.sourceTalentId !== "talent-1854:active"), comboPoints: work.comboPoints, comboTargetId: work.comboTargetId, mobs: work.mobs, @@ -9821,7 +12457,8 @@ export const useCombatStore = create((set, get) => ({ areaEffects: work.areaEffects, lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult, cooldowns, - globalCooldownEndsAt: completingCast || gcdMs <= 0 ? state.globalCooldownEndsAt : now + gcdMs, + globalCooldownEndsAt: completingCast || suddenDoomFreeDeathCoil || gcdMs <= 0 + ? state.globalCooldownEndsAt : now + gcdMs, activeCast: null, ...autoAttackUpdate, playerAnimationEvent: nextAnimationEvent("ability-release", ability.id), @@ -9843,6 +12480,14 @@ export const useCombatStore = create((set, get) => ({ !completingCast, ); + if (suddenDoomTrigger) { + get().castAbility(suddenDoomTrigger.abilityId, { + targetActorId: suddenDoomTrigger.targetId, + now, + [WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST]: true, + } as CastRequest); + } + const chimeraDamage = ability.name === "Chimera Shot" && targetId && work.totalDamage > 0 ? get().triggerChimeraShot(PLAYER_AGGRO_ID, targetId, ability.id, now) : 0; @@ -9959,15 +12604,19 @@ 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 } } : {}), + ...(state.classId === "death-knight" || state.classId === "druid" + ? { resourcePools: { ...work.resourcePools } } : {}), maxResource: work.maxResource, talentModifiers: work.talentModifiers, lastCombatResult: result.combatResult ?? state.lastCombatResult, selectedTargetId: work.mobs[id]?.dead && state.selectedTargetId === id ? null : state.selectedTargetId, autoAttackTargetId: work.mobs[id]?.dead && state.autoAttackTargetId === id ? null : state.autoAttackTargetId, nextAutoAttackAt: work.mobs[id]?.dead && state.autoAttackTargetId === id ? 0 : state.nextAutoAttackAt, - comboPoints: work.mobs[id]?.dead && state.selectedTargetId === id ? 0 : state.comboPoints, - comboTargetId: work.mobs[id]?.dead && state.comboTargetId === id ? null : state.comboTargetId, + nextOffhandAutoAttackAt: work.mobs[id]?.dead && state.autoAttackTargetId === id ? 0 : state.nextOffhandAutoAttackAt, + comboPoints: work.mobs[id]?.dead && state.selectedTargetId === id ? 0 + : state.classId === "druid" ? work.comboPoints : state.comboPoints, + comboTargetId: work.mobs[id]?.dead && state.comboTargetId === id ? null + : state.classId === "druid" ? work.comboTargetId : state.comboTargetId, feedback: result.levelsGained > 0 ? nextFeedback("level-up", levelUpMessage(state.classId, state.level, progression.level)) : result.combatResult @@ -10161,6 +12810,9 @@ export const useCombatStore = create((set, get) => ({ nextAutoAttackAt: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId ? 0 : state.nextAutoAttackAt, + nextOffhandAutoAttackAt: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId + ? 0 + : state.nextOffhandAutoAttackAt, }); return result; }, @@ -10763,7 +13415,7 @@ export const useCombatStore = create((set, get) => ({ return true; }, - applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => { + applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow(), criticalChanceBonusPercent = 0) => { const state = get(); if (!state.actors[sourceActorId]) return false; const selfDrainPercent = effect.kind === "dot" ? romSelfDrainPercent(ability.dbcSpellId) : 0; @@ -10800,6 +13452,7 @@ export const useCombatStore = create((set, get) => ({ selfDrainPercent > 0 ? "rom-self-health-drain" : effect.kind === "dot" ? effect.tag : undefined, effect.kind === "dot" ? presentationSchoolForAbility(ability) : undefined, sourceMember?.gearStats.spellPenetration, + criticalChanceBonusPercent, ), fixedAmount: amount }); set({ effects: next }); return true; @@ -10861,23 +13514,42 @@ export const useCombatStore = create((set, get) => ({ 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 profile = wow335LightwellRenewProfileForCreature(well.creatureId); + if (!profile) return 0; + const ability = abilityById(well.sourceAbilityId); + if (!ability) return 0; + const scaling = { + coefficient: profile.spellPowerCoefficientPerTick ?? 0, + basePoints: profile.baseHealPerTick, + sourceLevel: ownerLevel, + maxScalingLevel: ownerLevel, + }; + const cancellationFraction = wow335LightwellDamageCancellationFraction(ability.description); + const ownerMember = usePartyStore.getState().members.find((member) => member.id === well.ownerActorId); + const tickAmount = well.ownerActorId === PLAYER_AGGRO_ID + ? equipmentAdjustedCombatAmount(scaling, ownerLevel, state.gearStats, state.classId, ability) + : ownerMember + ? equipmentAdjustedCombatAmount(scaling, ownerLevel, ownerMember.gearStats, ownerMember.classId, ability) + : profile.baseHealPerTick; + const amount = tickAmount * profile.ticks; const periodicHeal = { ...timedEffect( "hot", well.sourceAbilityId, well.ownerActorId, target === PLAYER_AURA_ENTITY_ID ? null : target, - { coefficient: tickAmount }, - 6, - 1_000, + scaling, + profile.ticks, + profile.intervalMs, now, ), - fixedAmount: tickAmount, + ...(well.ownerActorId === PLAYER_AGGRO_ID ? {} : { fixedAmount: tickAmount }), + ...(cancellationFraction === null ? {} : { + lightwellDamageCancellation: { + threshold: targetActor.maxHealth * cancellationFraction, + accumulatedDamage: 0, + }, + }), } satisfies TimedCombatEffect; set((current) => ({ effects: [...current.effects, periodicHeal], @@ -10896,14 +13568,104 @@ export const useCombatStore = create((set, get) => ({ if (!sourceActor?.alive) return null; const targeting = targetingRulesForAbility(ability); if (targeting.shape !== "ground") return null; + const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const sourcePosition = actorPosition(state, sourceActorId) ?? state.playerPosition; - const projected = projectCombatGroundPoint(position, targeting.groundRange ?? ability.range.max, sourcePosition); + const selfPlacedHunterTrapKind = sourceMember?.classId === "hunter" + ? ability.name === "Freezing Trap" ? "freezing" + : ability.name === "Frost Trap" ? "frost" + : ability.name === "Immolation Trap" ? "immolation" + : ability.name === "Explosive Trap" ? "explosive" + : null + : null; + if (selfPlacedHunterTrapKind) { + const trapDurationMs = ability.effects.find((effect) => effect.kind === "utility")?.durationMs || 30_000; + const directDamageScaling = selfPlacedHunterTrapKind === "explosive" + ? hunterExplosiveTrapDirectScaling(ability.dbcSpellId) ?? undefined + : undefined; + const replacedTraps = state.areaEffects.filter((area) => ( + area.sourceActorId === sourceActorId && area.hunterTrap + )); + const removedZoneSlowIds = new Set(replacedTraps + .filter((area) => area.hunterTrap?.kind === "frost-zone") + .map((area) => `hunter-trap-zone:${area.id}`)); + const mobs = removedZoneSlowIds.size === 0 ? state.mobs : Object.fromEntries( + Object.entries(state.mobs).map(([id, mob]) => [id, { + ...mob, + statuses: mob.statuses.filter((status) => !removedZoneSlowIds.has(status.sourceAbilityId)), + }]), + ); + areaEffectSequence += 1; + const id = `${sourceActorId}:area-${areaEffectSequence}`; + const area: CombatAreaEffect = { + id, + sourceActorId, + sourceAbilityId: ability.id, + position: sourcePosition, + radius: 2, + createdAt: now, + expiresAt: now + trapDurationMs, + tickIntervalMs: 0, + nextTickAt: Number.MAX_SAFE_INTEGER, + hunterTrap: { + kind: selfPlacedHunterTrapKind, + triggerRadius: 2, + ...(selfPlacedHunterTrapKind === "freezing" + ? { freezeDurationMs: freezingTrapDurationMs(ability.dbcSpellId) } + : {}), + ...(directDamageScaling ? { directDamageScaling } : {}), + }, + }; + set({ + areaEffects: [ + ...state.areaEffects.filter((area) => !( + area.sourceActorId === sourceActorId && area.hunterTrap + )), + area, + ], + mobs, + }); + return id; + } + const baseGroundRange = targeting.groundRange ?? ability.range.max; + const flameThrowingBonusYards = sourceMember?.classId === "mage" && ability.target === "hostile" + && ability.effects.some((effect) => ( + effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot" + || effect.kind === "execute" || effect.kind === "resource-drain" + )) + ? wow335MageFlameThrowingProfile( + sourceMember.talentRanks[WOW335_MAGE_FLAME_THROWING_TALENT_ID] ?? 0, + ).damagingSpellRangeBonusYards + : 0; + const projected = projectCombatGroundPoint( + position, baseGroundRange + flameThrowingBonusYards, sourcePosition, + ); if (!projected || !combatHasLineOfSight(sourcePosition, projected)) return null; + if (sourceMember?.classId === "hunter" && ability.name === "Freezing Arrow") { + areaEffectSequence += 1; + const id = `${sourceActorId}:area-${areaEffectSequence}`; + const area: CombatAreaEffect = { + id, + sourceActorId, + sourceAbilityId: ability.id, + position: projected, + radius: 2, + createdAt: now, + expiresAt: now + 30_000, + tickIntervalMs: 0, + nextTickAt: Number.MAX_SAFE_INTEGER, + hunterTrap: { + kind: "freezing", + triggerRadius: 2, + freezeDurationMs: freezingTrapDurationMs(ability.dbcSpellId), + }, + }; + set({ areaEffects: [...state.areaEffects, area] }); + return id; + } const periodic = ability.effects.filter((effect): effect is Extract => ( effect.kind === "dot" || effect.kind === "hot" )); if (!periodic.length) return null; - const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId); const power = sourceMember ? sourceMember.level * 4 + sourceMember.gearStats.spellPower + sourceMember.gearStats.attackPower * 0.35 + sourceMember.gearStats.rangedAttackPower * 0.35 @@ -10937,7 +13699,7 @@ export const useCombatStore = create((set, get) => ({ const state = get(); if (state.classId === "rom-warden" && activeWardenNatureCrystal(state.summons, now)) return 0; const mitigated = damageAfterEquipmentMitigation( - talentAdjustedIncomingDamage(amount, state.talentModifiers), + talentAdjustedIncomingDamage(amount, state.talentModifiers, school), state.gearStats, state.level, school, @@ -10964,12 +13726,22 @@ export const useCombatStore = create((set, get) => ({ damageTransferActive ? playerAuras : damageTransferAuras, damageTransferContext, ); + const soulLinkDamage = resolveWarlockSoulLinkDamage( + incoming, + state.classId, + state.talentRanks, + state.auras, + state.summons, + PLAYER_AURA_ENTITY_ID, + state.playerPosition, + now, + ); const damageTransferredToPet = damageTransferActive ? Math.round(damageBeforeTransfer * ROM_WARDEN_DAMAGE_TRANSFER_FRACTION) : 0; const namedAbsorb = absorbAuraDamage(state.auras, { targetId: PLAYER_AURA_ENTITY_ID, - amount: incoming, + amount: soulLinkDamage.casterDamage, school, now, countsAsAttack: true, @@ -10988,19 +13760,32 @@ export const useCombatStore = create((set, get) => ({ const petHealthDamage = damageTransferPet && petAbsorb ? Math.min(damageTransferPet.health, Math.max(0, Math.round(petAbsorb.remainingDamage))) : 0; - const nextSummons = damageTransferPet && petAbsorb && petHealthDamage > 0 + const wardenUpdatedSummons = 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 nextSummons = soulLinkDamage.demon && soulLinkDamage.demonDamage > 0 + ? wardenUpdatedSummons.flatMap((summon) => summon.id !== soulLinkDamage.demon!.id + ? [summon] + : summon.health - soulLinkDamage.demonDamage > 0 + ? [{ ...summon, health: summon.health - soulLinkDamage.demonDamage }] + : []) + : wardenUpdatedSummons; + const nextActors = { ...state.actors }; + if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { + const updatedDemon = nextSummons.find((summon) => summon.id === soulLinkDamage.demon!.id); + if (updatedDemon) nextActors[updatedDemon.id] = combatActorFromSummon(updatedDemon, state.level, state.auras); + else delete nextActors[soulLinkDamage.demon.id]; + } 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; + let health = preventedDeath ? state.maxHealth : state.health - healthDamage; const postAbsorbAuras = petAbsorb?.auras ?? namedAbsorb.auras; const aurasAfterCheatDeath = preventedDeath ? postAbsorbAuras.filter((aura) => aura.instanceId !== remodeledBody!.instanceId) @@ -11019,10 +13804,20 @@ export const useCombatStore = create((set, get) => ({ { ...state.resourcePools }, { ...state.maxResourcePools }, ); + const deathKnightResource = grantDeathKnightAntiMagicShellRunicPower( + state.classId, + rage.resource, + rage.resourcePools, + state.maxResource, + state.maxResourcePools, + deathKnightAntiMagicShellRunicPowerGain( + state.auras, namedAbsorb.consumptions, PLAYER_AURA_ENTITY_ID, school, now, + ), + ); const procResult = evaluateAuraProcs(aurasAfterCheatDeath, { trigger: "damage-taken", subjectId: PLAYER_AURA_ENTITY_ID, - amount: incoming, + amount: soulLinkDamage.casterDamage, school, }, now, ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:damage-taken:${state.health}`)); let nextAuras = spendDeathKnightBoneShieldCharge(procResult.auras, procResult.triggered); @@ -11056,6 +13851,18 @@ export const useCombatStore = create((set, get) => ({ now, }).auras]; } + let livingSeedRequested = 0; + let livingSeedHealing = 0; + if (incoming > 0) { + const livingSeed = consumeDruidLivingSeed(nextAuras, PLAYER_AURA_ENTITY_ID, now); + nextAuras = livingSeed.auras; + livingSeedRequested = livingSeed.healingAmount; + if (health > 0 && livingSeedRequested > 0) { + const effectiveSeedHealing = Math.min(livingSeedRequested, Math.max(0, state.maxHealth - health)); + health += effectiveSeedHealing; + livingSeedHealing = effectiveSeedHealing; + } + } let combatEvents = recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "damage", @@ -11069,6 +13876,22 @@ export const useCombatStore = create((set, get) => ({ critical: false, outcome: healthDamage > 0 ? "hit" : "absorb", }); + if (livingSeedHealing > 0) { + combatEvents = recordCombatEvent(combatEvents, { + occurredAt: now, + kind: "healing", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: DRUID_LIVING_SEED_ABILITY_ID, + school: "nature", + rawAmount: livingSeedRequested, + effectiveAmount: livingSeedHealing, + overheal: livingSeedRequested - livingSeedHealing, + absorbed: 0, + critical: false, + outcome: "hit", + }); + } if (damageTransferPet && petAbsorb && damageTransferredToPet > 0) { combatEvents = recordCombatEvent(combatEvents, { occurredAt: now, @@ -11084,13 +13907,31 @@ export const useCombatStore = create((set, get) => ({ outcome: petHealthDamage > 0 ? "hit" : "absorb", }); } + if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { + combatEvents = recordCombatEvent(combatEvents, { + occurredAt: now, + kind: "damage", + sourceActorId: "external", + targetActorId: soulLinkDamage.demon.id, + abilityId: "wow335-warlock-soul-link", + school, + rawAmount: soulLinkDamage.demonDamage, + effectiveAmount: soulLinkDamage.demonDamage, + overheal: 0, + absorbed: 0, + critical: false, + outcome: "hit", + }); + } set({ auras: nextAuras, + effects: accumulateLightwellDamageCancellation(state.effects, PLAYER_AURA_ENTITY_ID, healthDamage), summons: nextSummons, + actors: nextActors, shield: state.shield - absorbed, health, - resource: rage.resource, - resourcePools: rage.resourcePools, + resource: deathKnightResource.resource, + resourcePools: deathKnightResource.resourcePools, combatEvents, activeCast: health <= 0 ? null @@ -11099,6 +13940,7 @@ export const useCombatStore = create((set, get) => ({ : state.activeCast, autoAttackTargetId: health <= 0 ? null : state.autoAttackTargetId, nextAutoAttackAt: health <= 0 ? 0 : state.nextAutoAttackAt, + nextOffhandAutoAttackAt: health <= 0 ? 0 : state.nextOffhandAutoAttackAt, playerAnimationEvent: healthDamage > 0 ? nextAnimationEvent(health <= 0 ? "death" : "wound") : state.playerAnimationEvent, @@ -11231,6 +14073,7 @@ export const useCombatStore = create((set, get) => ({ pendingGroundTarget: null, autoAttackTargetId: null, nextAutoAttackAt: 0, + nextOffhandAutoAttackAt: 0, comboPoints: 0, comboTargetId: null, playerPosition: [...position], @@ -11598,7 +14441,7 @@ export const useCombatStore = create((set, get) => ({ formula: attack.runewakerFormula, baselineDamage: mob.attackDamage, }).amount - : baselineDamage; + : enemyAttackDamageAfterVindication(mob, attack, baselineDamage, effect.multiplier, now); for (const actorId of targets) { incomingHits.push({ actorId, @@ -11611,7 +14454,13 @@ export const useCombatStore = create((set, get) => ({ } } else if (effect.kind === "leech") { hasDamageResolution = true; - const attackDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier)); + const attackDamage = enemyAttackDamageAfterVindication( + mob, + attack, + Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier)), + effect.damageMultiplier, + now, + ); for (const actorId of targets) { incomingHits.push({ actorId, @@ -11748,7 +14597,13 @@ export const useCombatStore = create((set, get) => ({ formula: attack.runewakerFormula, baselineDamage: mob.attackDamage, }).amount - : baselineDamage; + : enemyAttackDamageAfterVindication( + mob, + attack, + baselineDamage, + effect.damageMultiplier!, + now, + ); for (const actorId of targets) { incomingHits.push({ actorId, @@ -11763,7 +14618,17 @@ 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 }; + const auraTargetId = actorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : actorId; + const hasMetamorphosis = aurasForEntity(namedAuras, auraTargetId).some((aura) => ( + aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + )); + const controlDurationMs = wow335WarlockMetamorphosisControlDuration( + effect.durationMs, + effect.mechanic, + hasMetamorphosis, + ); + const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs }; if (actorId === PLAYER_AGGRO_ID) playerControl = control; else memberControls.push({ memberId: actorId, ...control }); namedAuras = [...applyCombatAura(namedAuras, { @@ -11776,7 +14641,7 @@ export const useCombatStore = create((set, get) => ({ ? `${attack.name} prevents movement until the effect ends.` : `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`, disposition: "debuff", - durationMs: effect.durationMs, + durationMs: controlDurationMs, dispelCategory: "magic", }, sourceId: id, @@ -11960,7 +14825,8 @@ export const useCombatStore = create((set, get) => ({ ? { controlledUntil: Math.max(state.controlledUntil, playerControl.endsAt), controlMechanic: playerControl.mechanic, - schoolLockedUntil: state.activeCast && playerControl.mechanic === "silence" + schoolLockedUntil: state.activeCast + && (playerControl.mechanic === "silence" || playerControl.mechanic === "interrupt") ? { ...state.schoolLockedUntil, [state.activeCast.school]: Math.max(state.schoolLockedUntil[state.activeCast.school] ?? 0, playerControl.endsAt), @@ -12029,22 +14895,31 @@ export const useCombatStore = create((set, get) => ({ }; if (hit.actorId === PLAYER_AGGRO_ID) { const current = get(); - const defender = applyAuraStats( - playerCharacterStats( - current.classId, - current.level, - current.gearStats, - current.raceId, - current.secondaryClassId, - current.talentModifiers, + const defender = paladinRedoubtBlockStats( + applyAuraStats( + playerCharacterStats( + current.classId, + current.level, + current.gearStats, + current.raceId, + current.secondaryClassId, + current.talentModifiers, + ), + aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID), ), - aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID), + current.classId, + current.talentRanks, + paladinShieldEquipped(current), ); const resolvedCombatResult = resolveEnemyDamageResult( hit.mob, hit.attack, defender, - talentAdjustedIncomingDamage(Math.round(hit.amount * raceAffixMobDamageMultiplier(hit.mob.id, now)), current.talentModifiers), + talentAdjustedIncomingDamage( + Math.round(hit.amount * raceAffixMobDamageMultiplier(hit.mob.id, now)), + current.talentModifiers, + hit.attack.school, + ), hit.actorId, hit.attackRevision, now, @@ -12053,15 +14928,26 @@ export const useCombatStore = create((set, get) => ({ ); const combatResult = { ...resolvedCombatResult, - finalDamage: romWardenWillAttackDamage( - resolvedCombatResult.finalDamage, current.auras, hit.mob.id, hit.attack, + finalDamage: enemyDamageAfterTargetAuras( + resolvedCombatResult.finalDamage, current.auras, hit.mob.id, hit.attack, now, ), }; commitMeleeAutoProc(combatResult.landed); - const priestMitigated = priestTestOfFaithIncomingDamage( + const blessedLifeDamage = paladinBlessedLifeDamageAfterIncomingHit( combatResult.finalDamage, current.classId, current.talentRanks, + combatResult.landed, + hit.actorId, + hit.mob.id, + hit.attack.id, + hit.attackRevision, + now, + ); + const priestMitigated = priestTestOfFaithIncomingDamage( + blessedLifeDamage, + current.classId, + current.talentRanks, current.activeCast, hit.attack.school, now, @@ -12088,12 +14974,22 @@ export const useCombatStore = create((set, get) => ({ damageTransferActive ? playerAuras : damageTransferAuras, damageTransferContext, ); + const soulLinkDamage = resolveWarlockSoulLinkDamage( + auraAdjustedDamage, + current.classId, + current.talentRanks, + current.auras, + current.summons, + PLAYER_AURA_ENTITY_ID, + current.playerPosition, + now, + ); const damageTransferredToPet = damageTransferActive ? Math.round(damageBeforeTransfer * ROM_WARDEN_DAMAGE_TRANSFER_FRACTION) : 0; const namedAbsorb = absorbAuraDamage(current.auras, { targetId: PLAYER_AURA_ENTITY_ID, - amount: auraAdjustedDamage, + amount: soulLinkDamage.casterDamage, school: hit.attack.school, now, countsAsAttack: true, @@ -12112,13 +15008,20 @@ export const useCombatStore = create((set, get) => ({ const petHealthDamage = damageTransferPet && petAbsorb ? Math.min(damageTransferPet.health, Math.max(0, Math.round(petAbsorb.remainingDamage))) : 0; - const nextSummons = damageTransferPet && petAbsorb && petHealthDamage > 0 + const wardenUpdatedSummons = 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 nextSummons = soulLinkDamage.demon && soulLinkDamage.demonDamage > 0 + ? wardenUpdatedSummons.flatMap((summon) => summon.id !== soulLinkDamage.demon!.id + ? [summon] + : summon.health - soulLinkDamage.demonDamage > 0 + ? [{ ...summon, health: summon.health - soulLinkDamage.demonDamage }] + : []) + : wardenUpdatedSummons; const health = current.health - healthDamage; const rage = gainRomRage( current.classId, @@ -12134,6 +15037,16 @@ export const useCombatStore = create((set, get) => ({ { ...current.resourcePools }, { ...current.maxResourcePools }, ); + const deathKnightResource = grantDeathKnightAntiMagicShellRunicPower( + current.classId, + rage.resource, + rage.resourcePools, + current.maxResource, + current.maxResourcePools, + deathKnightAntiMagicShellRunicPowerGain( + current.auras, namedAbsorb.consumptions, PLAYER_AURA_ENTITY_ID, hit.attack.school, now, + ), + ); const avoidTrigger = combatResult.dodged ? "dodge" : combatResult.parried @@ -12144,13 +15057,13 @@ export const useCombatStore = create((set, get) => ({ const procEvent: ProcEvent | null = avoidTrigger ? { 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) + : healthDamage > 0 || (combatResult.landed && soulLinkDamage.casterDamage > 0) ? { trigger: "damage-taken", subjectId: PLAYER_AURA_ENTITY_ID, otherId: hit.mob.id, abilityId: hit.attack.id, - amount: Math.max(healthDamage, auraAdjustedDamage), + amount: Math.max(healthDamage, soulLinkDamage.casterDamage), school: hit.attack.school, attackKind: enemyAttackKind(hit.attack), } @@ -12161,7 +15074,11 @@ export const useCombatStore = create((set, get) => ({ classId: current.classId, abilities: current.abilities, mobs: { ...get().mobs }, - effects: [...current.effects], + effects: accumulateLightwellDamageCancellation( + current.effects, + PLAYER_AURA_ENTITY_ID, + healthDamage, + ), auras: [...(petAbsorb?.auras ?? namedAbsorb.auras)], summons: [...nextSummons], areaEffects: [...current.areaEffects], @@ -12172,9 +15089,9 @@ export const useCombatStore = create((set, get) => ({ maxHealth: current.maxHealth, shield: current.shield - absorbed, shieldExpiresAt: current.shieldExpiresAt, - resource: rage.resource, + resource: deathKnightResource.resource, maxResource: current.maxResource, - resourcePools: { ...rage.resourcePools }, + resourcePools: { ...deathKnightResource.resourcePools }, maxResourcePools: { ...current.maxResourcePools }, lastResourceSpentAt: { ...current.lastResourceSpentAt }, comboPoints: current.comboPoints, @@ -12190,12 +15107,62 @@ export const useCombatStore = create((set, get) => ({ raceId: current.raceId, secondaryClassId: current.secondaryClassId, }; + if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { + const updatedDemon = nextSummons.find((summon) => summon.id === soulLinkDamage.demon!.id); + if (updatedDemon) procWork.actors[updatedDemon.id] = combatActorFromSummon( + updatedDemon, current.level, procWork.auras, + ); + else delete procWork.actors[soulLinkDamage.demon.id]; + } if (healthDamage > 0) { procWork.auras = procWork.auras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("stealth") )); } + const scentOfBloodTrigger = combatResult.dodged + ? "dodge" + : combatResult.parried + ? "parry" + : combatResult.landed && combatResult.rawDamage > 0 + ? "damage-taken" + : null; + if (current.classId === "death-knight" && scentOfBloodTrigger) { + const scentEventId = `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${hit.actorId}`; + const scentOfBlood = wow335DeathKnightScentOfBloodOnIncomingEvent( + procWork.auras, + PLAYER_AURA_ENTITY_ID, + current.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0, + scentOfBloodTrigger, + stableCombatUnit(`${scentEventId}:wow335-death-knight-scent-of-blood`), + now, + scentEventId, + ); + procWork.auras = [...scentOfBlood.auras]; + } + if (current.classId === "rom-rogue" && current.secondaryClassId === "rom-mage" + && avoidTrigger === "dodge") { + const instantShadow = procWork.abilities.find((ability) => ( + ability.source === "runewaker" && ability.dbcSpellId === 497206 + )); + if (instantShadow && (procWork.cooldowns[instantShadow.id] ?? 0) <= now) { + procWork.auras = [...applyCombatAura(procWork.auras, { + definition: { + id: "rom-rogue:instant-shadow-ready", + name: "Instant Shadow ready", + disposition: "buff", + // RuneWaker defines the dodge prerequisite but no timeout for using the ready state. + durationMs: null, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [ROM_ROGUE_INSTANT_SHADOW_READY_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + } if (current.classId === "rogue" && avoidTrigger === "parry") { procWork.auras = [...applyCombatAura(procWork.auras, { definition: { @@ -12212,6 +15179,46 @@ export const useCombatStore = create((set, get) => ({ now, }).auras]; } + const unfairAdvantageRank = Math.max(0, current.talentRanks["talent-2073"] ?? 0); + const unfairAdvantageMultiplier = wow335RogueUnfairAdvantageDamageMultiplierForRank( + unfairAdvantageRank, + ); + const unfairAdvantageOnCooldown = procWork.auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG) + )); + if (current.classId === "rogue" && unfairAdvantageMultiplier > 0 + && (avoidTrigger === "dodge" || avoidTrigger === "parry") + && !unfairAdvantageOnCooldown) { + procWork.auras = [...applyCombatAura(procWork.auras, { + definition: { + id: WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG, + name: "Unfair Advantage cooldown", + disposition: "buff", + durationMs: 2_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + const mainHandDamage = playerAutoAttackProfile(current, procWork.auras).damage; + damageMobInWork( + procWork.classId, + procWork, + hit.mob.id, + mainHandDamage * unfairAdvantageMultiplier, + now, + playerThreatSource( + { talentModifiers: procWork.talentModifiers }, + WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID, + ), + "physical", + ); + } if (current.classId === "hunter" && avoidTrigger === "parry") { procWork.auras = [...applyCombatAura(procWork.auras, { definition: { @@ -12228,21 +15235,77 @@ export const useCombatStore = create((set, get) => ({ now, }).auras]; } - if (current.classId === "rom-knight" && avoidTrigger === "parry" - && procWork.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + const focusedWillRank = current.talentRanks[WOW335_PRIEST_FOCUSED_WILL_TALENT_ID] ?? 0; + const focusedWillAura = current.classId === "priest" + ? wow335PriestFocusedWillAuraDefinition(focusedWillRank) + : null; + if (focusedWillAura && wow335PriestFocusedWillShouldProc( + focusedWillRank, + combatResult.landed, + combatResult.critical, + )) { + procWork.auras = [...applyCombatAura(procWork.auras, { + definition: focusedWillAura, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }).auras]; + } + const shieldOfValorCooldownReduction = romKnightShieldOfValorCooldownReductionMs( + 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))) { + && aura.definition.utilityTags?.includes(ROM_KNIGHT_SHIELD_OF_VALOR_TAG)), + combatResult.parried, + ); + if (current.classId === "rom-knight" && shieldOfValorCooldownReduction > 0) { 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.cooldowns[holySeal.id] = Math.max(now, readyAt - shieldOfValorCooldownReduction); } } procWork.auras = consumeInnerFireCharges( procWork.auras, PLAYER_AURA_ENTITY_ID, combatResult.landed, enemyAttackKind(hit.attack), ); if (procEvent) evaluateProcsInWork(procWork, procEvent, now); + const incomingAttackKind = enemyAttackKind(hit.attack); + const briarShieldActive = procWork.auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(ROM_WARDEN_BRIAR_SHIELD_TAG) + )); + const briarShieldInRange = impactOrigin && impactTarget + && distanceSquared(impactOrigin, impactTarget) <= 50 * 50; + const hasWardenClass = current.classId === "rom-warden" || current.secondaryClassId === "rom-warden"; + if (hasWardenClass && romWardenBriarShieldShouldReflect({ + auraActive: briarShieldActive, + landed: combatResult.landed, + school: hit.attack.school, + attackKind: incomingAttackKind, + withinRange: Boolean(briarShieldInRange), + })) { + const reflectedAmount = romWardenBriarShieldReflectDamage( + current.level, + current.gearStats.meleeWeapon, + ); + if (reflectedAmount > 0 && procWork.mobs[hit.mob.id]) { + const briarShield = procWork.abilities.find((ability) => ability.dbcSpellId === 493642); + damageMobInWork( + procWork.classId, + procWork, + hit.mob.id, + reflectedAmount, + now, + playerThreatSource( + { talentModifiers: procWork.talentModifiers }, + briarShield?.id ?? "rom-warden-general-1", + true, + ), + "physical", + ); + } + } procWork.auras = maybeApplyWarlockBacklash( procWork.auras, current.classId, @@ -12260,6 +15323,13 @@ export const useCombatStore = create((set, get) => ({ `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${now}`, now, ); + maybeProcPaladinRedoubtInWork( + procWork, + combatResult.blocked, + paladinShieldEquipped(current), + `${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}:${now}`, + now, + ); maybeProcPaladinEyeForAnEyeInWork(procWork, hit.mob.id, healthDamage, combatResult.critical, now); recordCombatEventInWork(procWork, { occurredAt: now, @@ -12275,6 +15345,55 @@ export const useCombatStore = create((set, get) => ({ critical: combatResult.critical, outcome: combatResult.outcome, }); + maybeProcWarriorBloodCrazeInWork(procWork, combatResult.critical, now); + if (combatResult.landed) { + const livingSeed = consumeDruidLivingSeed(procWork.auras, PLAYER_AURA_ENTITY_ID, now); + procWork.auras = livingSeed.auras; + if (livingSeed.healingAmount > 0) { + const healed = healPlayerInWork(procWork, livingSeed.healingAmount); + procWork.totalHealing += healed; + recordCombatEventInWork(procWork, { + occurredAt: now, + kind: "healing", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: DRUID_LIVING_SEED_ABILITY_ID, + school: "nature", + rawAmount: livingSeed.healingAmount, + effectiveAmount: healed, + overheal: livingSeed.healingAmount - healed, + absorbed: 0, + critical: false, + outcome: healed > 0 ? "hit" : "overheal", + }); + } + const markOfBlood = wow335DeathKnightMarkOfBloodOnDamage( + procWork.auras, + hit.mob.id, + health, + procWork.maxHealth, + now, + combatResult.rawDamage > 0, + ); + procWork.auras = [...markOfBlood.auras]; + if (markOfBlood.triggered && markOfBlood.healingAmount > 0) { + const healed = healPlayerInWork(procWork, markOfBlood.healingAmount); + procWork.totalHealing += healed; + recordCombatEventInWork(procWork, { + occurredAt: now, + kind: "healing", + sourceActorId: markOfBlood.sourceActorId ?? PLAYER_AGGRO_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: markOfBlood.abilityId ?? WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, + rawAmount: markOfBlood.healingAmount, + effectiveAmount: healed, + overheal: markOfBlood.healingAmount - healed, + absorbed: 0, + critical: false, + outcome: healed > 0 ? "hit" : "overheal", + }); + } + } if (damageTransferPet && petAbsorb && damageTransferredToPet > 0) { recordCombatEventInWork(procWork, { occurredAt: now, @@ -12291,8 +15410,25 @@ export const useCombatStore = create((set, get) => ({ outcome: petHealthDamage > 0 ? "hit" : "absorb", }); } + if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { + recordCombatEventInWork(procWork, { + occurredAt: now, + kind: "damage", + sourceActorId: hit.mob.id, + targetActorId: soulLinkDamage.demon.id, + abilityId: "wow335-warlock-soul-link", + school: hit.attack.school, + rawAmount: soulLinkDamage.demonDamage, + effectiveAmount: soulLinkDamage.demonDamage, + overheal: 0, + absorbed: 0, + critical: combatResult.critical, + outcome: "hit", + }); + } set({ auras: procWork.auras, + effects: procWork.effects, summons: procWork.summons, mobs: procWork.mobs, shield: procWork.shield, @@ -12310,6 +15446,7 @@ export const useCombatStore = create((set, get) => ({ : current.activeCast, autoAttackTargetId: procWork.health <= 0 ? null : current.autoAttackTargetId, nextAutoAttackAt: procWork.health <= 0 ? 0 : current.nextAutoAttackAt, + nextOffhandAutoAttackAt: procWork.health <= 0 ? 0 : current.nextOffhandAutoAttackAt, playerAnimationEvent: healthDamage > 0 ? nextAnimationEvent(procWork.health <= 0 ? "death" : "wound") : current.playerAnimationEvent, @@ -12329,8 +15466,8 @@ 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, + const wardenAdjustedAmount = enemyDamageAfterTargetAuras( + incomingAmount, current.auras, hit.mob.id, hit.attack, now, ); commitMeleeAutoProc(true); const adjusted = resolveAuraDamageValue( @@ -12346,27 +15483,73 @@ export const useCombatStore = create((set, get) => ({ countsAsAttack: true, }); const applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage))); + const livingSeed = consumeDruidLivingSeed(absorbed.auras, summon.id, now); + const healthAfterDamage = summon.health - applied; + const livingSeedHealing = healthAfterDamage > 0 + ? Math.min(livingSeed.healingAmount, summon.maxHealth - healthAfterDamage) + : 0; + const healthAfterLivingSeed = healthAfterDamage + livingSeedHealing; + const markOfBlood = wow335DeathKnightMarkOfBloodOnDamage( + livingSeed.auras, + hit.mob.id, + healthAfterDamage, + summon.maxHealth, + now, + incomingAmount > 0, + ); + const markOfBloodHealing = healthAfterLivingSeed > 0 + ? Math.min(markOfBlood.healingAmount, summon.maxHealth - healthAfterLivingSeed) + : 0; + const healthAfterMarkOfBlood = healthAfterLivingSeed + markOfBloodHealing; + let combatEvents = recordCombatEvent(current.combatEvents, { + occurredAt: now, + kind: "damage", + sourceActorId: hit.mob.id, + targetActorId: summon.id, + abilityId: hit.attack.id, + school: hit.attack.school, + rawAmount: incomingAmount, + effectiveAmount: applied, + overheal: 0, + absorbed: absorbed.absorbed, + critical, + outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb", + }); + if (livingSeed.healingAmount > 0) combatEvents = recordCombatEvent(combatEvents, { + occurredAt: now, + kind: "healing", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: summon.id, + abilityId: DRUID_LIVING_SEED_ABILITY_ID, + school: "nature", + rawAmount: livingSeed.healingAmount, + effectiveAmount: livingSeedHealing, + overheal: livingSeed.healingAmount - livingSeedHealing, + absorbed: 0, + critical: false, + outcome: livingSeedHealing > 0 ? "hit" : "overheal", + }); + if (markOfBlood.triggered && markOfBlood.healingAmount > 0) combatEvents = recordCombatEvent(combatEvents, { + occurredAt: now, + kind: "healing", + sourceActorId: markOfBlood.sourceActorId ?? PLAYER_AGGRO_ID, + targetActorId: summon.id, + abilityId: markOfBlood.abilityId ?? WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, + rawAmount: markOfBlood.healingAmount, + effectiveAmount: markOfBloodHealing, + overheal: markOfBlood.healingAmount - markOfBloodHealing, + absorbed: 0, + critical: false, + outcome: markOfBloodHealing > 0 ? "hit" : "overheal", + }); set({ - auras: absorbed.auras, + auras: markOfBlood.auras, summons: current.summons.flatMap((candidate) => candidate.id !== summon.id ? [candidate] - : candidate.health - applied > 0 - ? [{ ...candidate, health: candidate.health - applied }] + : healthAfterMarkOfBlood > 0 + ? [{ ...candidate, health: healthAfterMarkOfBlood }] : []), - combatEvents: recordCombatEvent(current.combatEvents, { - occurredAt: now, - kind: "damage", - sourceActorId: hit.mob.id, - targetActorId: summon.id, - abilityId: hit.attack.id, - school: hit.attack.school, - rawAmount: incomingAmount, - effectiveAmount: applied, - overheal: 0, - absorbed: absorbed.absorbed, - critical, - outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb", - }), + combatEvents, }); appliedDamage += applied; continue; @@ -12376,8 +15559,8 @@ 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, + const wardenAdjustedAmount = enemyDamageAfterTargetAuras( + incomingAmount, current.auras, hit.mob.id, hit.attack, now, ); commitMeleeAutoProc(true); const adjusted = resolveAuraDamageValue( @@ -12393,29 +15576,82 @@ export const useCombatStore = create((set, get) => ({ countsAsAttack: true, }); const damaged = Math.min(onlineActor.health, Math.max(0, Math.round(absorbed.remainingDamage))); - const nextActor: CombatActorState = { + let nextActor: CombatActorState = { ...onlineActor, health: onlineActor.health - damaged, alive: onlineActor.health - damaged > 0, cast: onlineActor.health - damaged > 0 ? onlineActor.cast : null, }; + const livingSeed = consumeDruidLivingSeed(absorbed.auras, onlineActor.id, now); + const livingSeedHealing = nextActor.health > 0 + ? Math.min(livingSeed.healingAmount, nextActor.maxHealth - nextActor.health) + : 0; + if (livingSeedHealing > 0) nextActor = { + ...nextActor, + health: nextActor.health + livingSeedHealing, + alive: true, + }; + const markOfBlood = wow335DeathKnightMarkOfBloodOnDamage( + livingSeed.auras, + hit.mob.id, + onlineActor.health - damaged, + nextActor.maxHealth, + now, + incomingAmount > 0, + ); + const markOfBloodHealing = nextActor.health > 0 + ? Math.min(markOfBlood.healingAmount, nextActor.maxHealth - nextActor.health) + : 0; + if (markOfBloodHealing > 0) nextActor = { + ...nextActor, + health: nextActor.health + markOfBloodHealing, + alive: true, + }; + let combatEvents = recordCombatEvent(current.combatEvents, { + occurredAt: now, + kind: "damage", + sourceActorId: hit.mob.id, + targetActorId: onlineActor.id, + abilityId: hit.attack.id, + school: hit.attack.school, + rawAmount: incomingAmount, + effectiveAmount: damaged, + overheal: 0, + absorbed: absorbed.absorbed, + critical, + outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb", + }); + if (livingSeed.healingAmount > 0) combatEvents = recordCombatEvent(combatEvents, { + occurredAt: now, + kind: "healing", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: onlineActor.id, + abilityId: DRUID_LIVING_SEED_ABILITY_ID, + school: "nature", + rawAmount: livingSeed.healingAmount, + effectiveAmount: livingSeedHealing, + overheal: livingSeed.healingAmount - livingSeedHealing, + absorbed: 0, + critical: false, + outcome: livingSeedHealing > 0 ? "hit" : "overheal", + }); + if (markOfBlood.triggered && markOfBlood.healingAmount > 0) combatEvents = recordCombatEvent(combatEvents, { + occurredAt: now, + kind: "healing", + sourceActorId: markOfBlood.sourceActorId ?? PLAYER_AGGRO_ID, + targetActorId: onlineActor.id, + abilityId: markOfBlood.abilityId ?? WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, + rawAmount: markOfBlood.healingAmount, + effectiveAmount: markOfBloodHealing, + overheal: markOfBlood.healingAmount - markOfBloodHealing, + absorbed: 0, + critical: false, + outcome: markOfBloodHealing > 0 ? "hit" : "overheal", + }); set({ actors: { ...current.actors, [nextActor.id]: nextActor }, - auras: absorbed.auras, - combatEvents: recordCombatEvent(current.combatEvents, { - occurredAt: now, - kind: "damage", - sourceActorId: hit.mob.id, - targetActorId: onlineActor.id, - abilityId: hit.attack.id, - school: hit.attack.school, - rawAmount: incomingAmount, - effectiveAmount: damaged, - overheal: 0, - absorbed: absorbed.absorbed, - critical, - outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb", - }), + auras: markOfBlood.auras, + combatEvents, }); appliedDamage += damaged; continue; @@ -12452,24 +15688,66 @@ export const useCombatStore = create((set, get) => ({ ); const combatResult = { ...resolvedCombatResult, - finalDamage: romWardenWillAttackDamage( - resolvedCombatResult.finalDamage, get().auras, hit.mob.id, hit.attack, + finalDamage: enemyDamageAfterTargetAuras( + resolvedCombatResult.finalDamage, get().auras, hit.mob.id, hit.attack, now, ), }; commitMeleeAutoProc(combatResult.landed); const currentAuras = get().auras; const auraAdjustedDamage = resolveAuraDamageValue( - combatResult.finalDamage, + paladinBlessedLifeDamageAfterIncomingHit( + combatResult.finalDamage, + member.classId, + member.talentRanks, + combatResult.landed, + hit.actorId, + hit.mob.id, + hit.attack.id, + hit.attackRevision, + now, + ), aurasForEntity(currentAuras, hit.actorId), { direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) }, ); + const soulLinkDamage = resolveWarlockSoulLinkDamage( + auraAdjustedDamage, + member.classId, + member.talentRanks, + currentAuras, + currentBeforeHit.summons, + member.id, + actorPosition(currentBeforeHit, member.id), + now, + ); const namedAbsorb = absorbAuraDamage(currentAuras, { targetId: hit.actorId, - amount: auraAdjustedDamage, + amount: soulLinkDamage.casterDamage, school: hit.attack.school, now, countsAsAttack: true, }); + const antiMagicShellRunicPowerGain = deathKnightAntiMagicShellRunicPowerGain( + currentAuras, + namedAbsorb.consumptions, + hit.actorId, + hit.attack.school, + now, + ); + const existingRunicPower = authoritativeActor.resources["runic-power"]; + const resourcesAfterAntiMagicShell = member.classId === "death-knight" && antiMagicShellRunicPowerGain > 0 + ? { + ...authoritativeActor.resources, + "runic-power": normalizeActorResourcePool( + "runic-power", + (existingRunicPower?.current ?? member.resourcePools["runic-power"] ?? 0) + + antiMagicShellRunicPowerGain, + existingRunicPower?.maximum + ?? (member.resourceType === "runic-power" ? member.maxResource : 100), + existingRunicPower?.regenerationPerSecond ?? 0, + existingRunicPower?.lastSpentAt ?? 0, + ), + } + : authoritativeActor.resources; 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)); @@ -12502,6 +15780,13 @@ export const useCombatStore = create((set, get) => ({ authoritativeActor.health, Math.max(0, Math.round(namedAbsorb.remainingDamage - redirected)), ); + const nextSummons = soulLinkDamage.demon && soulLinkDamage.demonDamage > 0 + ? currentBeforeHit.summons.flatMap((summon) => summon.id !== soulLinkDamage.demon!.id + ? [summon] + : summon.health - soulLinkDamage.demonDamage > 0 + ? [{ ...summon, health: summon.health - soulLinkDamage.demonDamage }] + : []) + : currentBeforeHit.summons; let nextCast = authoritativeActor.cast; if (damaged > 0 && nextCast && nextCast.pushbackHits < 2) { const castingAbility = abilityById(nextCast.abilityId); @@ -12517,33 +15802,116 @@ export const useCombatStore = create((set, get) => ({ } } const ragePool = authoritativeActor.resources.rage; - const nextActor: CombatActorState = { + let nextActor: CombatActorState = { ...authoritativeActor, health: authoritativeActor.health - damaged, alive: authoritativeActor.health - damaged > 0, cast: authoritativeActor.health - damaged > 0 ? nextCast : null, resources: damaged > 0 && ragePool ? { - ...authoritativeActor.resources, + ...resourcesAfterAntiMagicShell, rage: { ...ragePool, current: Math.min(ragePool.maximum, ragePool.current + RAGE_PER_HIT_TAKEN), }, } - : authoritativeActor.resources, + : resourcesAfterAntiMagicShell, }; - mirrorCompanionActor(nextActor); + const partyShieldOfValorCooldownReduction = romKnightShieldOfValorCooldownReductionMs( + member.classId === "rom-knight" && aurasAfterRedirect.some((aura) => aura.targetId === member.id + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(ROM_KNIGHT_SHIELD_OF_VALOR_TAG)), + combatResult.parried, + ); + if (partyShieldOfValorCooldownReduction > 0) { + const holySeal = abilitiesForCharacter(member.classId, member.secondaryClassId).find((ability) => ( + ability.source === "runewaker" && ability.dbcSpellId === 490149 + )); + if (holySeal) { + const readyAt = nextActor.cooldowns[holySeal.id] ?? now; + nextActor = { + ...nextActor, + cooldowns: { + ...nextActor.cooldowns, + [holySeal.id]: Math.max(now, readyAt - partyShieldOfValorCooldownReduction), + }, + }; + } + } + const unfairAdvantagePartyMultiplier = member.classId === "rogue" + ? wow335RogueUnfairAdvantageDamageMultiplierForRank(member.talentRanks["talent-2073"] ?? 0) + : 0; + const unfairAdvantagePartyShouldProc = unfairAdvantagePartyMultiplier > 0 + && member.gearStats.meleeWeapon !== null + && (combatResult.dodged || combatResult.parried) + && (authoritativeActor.cooldowns[WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID] ?? 0) <= now; + const unfairAdvantagePartyDamage = unfairAdvantagePartyShouldProc + ? playerAutoAttackProfile({ + ...member, + secondaryClassId: member.secondaryClassId ?? null, + }, aurasForEntity(currentAuras, member.id)).damage + * unfairAdvantagePartyMultiplier + : 0; + if (unfairAdvantagePartyShouldProc) { + nextActor = { + ...nextActor, + cooldowns: { + ...nextActor.cooldowns, + [WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID]: now + 2_000, + }, + }; + } if (redirected > 0) { const player = get(); const transferred = Math.min(player.health, redirected); - set({ health: player.health - transferred, activeCast: player.health - transferred <= 0 ? null : player.activeCast }); + set({ + health: player.health - transferred, + effects: accumulateLightwellDamageCancellation(player.effects, PLAYER_AURA_ENTITY_ID, transferred), + activeCast: player.health - transferred <= 0 ? null : player.activeCast, + }); appliedDamage += transferred; } appliedDamage += damaged; const procState = get(); - const aurasAfterHit = consumeInnerFireCharges( + let aurasAfterHit = consumeInnerFireCharges( aurasAfterRedirect, member.id, combatResult.landed, enemyAttackKind(hit.attack), ); + const focusedWillRank = member.talentRanks[WOW335_PRIEST_FOCUSED_WILL_TALENT_ID] ?? 0; + const focusedWillAura = member.classId === "priest" + ? wow335PriestFocusedWillAuraDefinition(focusedWillRank) + : null; + if (focusedWillAura && wow335PriestFocusedWillShouldProc( + focusedWillRank, + combatResult.landed, + combatResult.critical, + )) { + aurasAfterHit = [...applyCombatAura(aurasAfterHit, { + definition: focusedWillAura, + sourceId: member.id, + targetId: member.id, + now, + }).auras]; + } + const partyScentOfBloodTrigger = combatResult.dodged + ? "dodge" + : combatResult.parried + ? "parry" + : combatResult.landed && combatResult.rawDamage > 0 + ? "damage-taken" + : null; + if (member.classId === "death-knight" && partyScentOfBloodTrigger) { + const scentEventId = `${member.id}:${hit.mob.id}:${hit.attack.id}:${hit.attackRevision}`; + const scentOfBlood = wow335DeathKnightScentOfBloodOnIncomingEvent( + aurasAfterHit, + member.id, + member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0, + partyScentOfBloodTrigger, + stableCombatUnit(`${scentEventId}:wow335-death-knight-scent-of-blood`), + now, + scentEventId, + ); + aurasAfterHit = [...scentOfBlood.auras]; + } let actorsAfterHit = { ...procState.actors, [nextActor.id]: nextActor }; let aurasAfterProcs = aurasAfterHit; let eventsAfterHit = recordCombatEvent(procState.combatEvents, { @@ -12560,6 +15928,87 @@ export const useCombatStore = create((set, get) => ({ critical: combatResult.critical, outcome: combatResult.outcome, }); + if (soulLinkDamage.demon && soulLinkDamage.demonDamage > 0) { + eventsAfterHit = recordCombatEvent(eventsAfterHit, { + occurredAt: now, + kind: "damage", + sourceActorId: hit.mob.id, + targetActorId: soulLinkDamage.demon.id, + abilityId: "wow335-warlock-soul-link", + school: hit.attack.school, + rawAmount: soulLinkDamage.demonDamage, + effectiveAmount: soulLinkDamage.demonDamage, + overheal: 0, + absorbed: 0, + critical: combatResult.critical, + outcome: "hit", + }); + const updatedDemon = nextSummons.find((summon) => summon.id === soulLinkDamage.demon!.id); + if (updatedDemon) actorsAfterHit[updatedDemon.id] = combatActorFromSummon( + updatedDemon, currentBeforeHit.actors[updatedDemon.id]?.level ?? member.level, currentAuras, + ); + else delete actorsAfterHit[soulLinkDamage.demon.id]; + } + const livingSeed = combatResult.landed + ? consumeDruidLivingSeed(aurasAfterHit, member.id, now) + : { auras: aurasAfterHit, healingAmount: 0 }; + aurasAfterHit = livingSeed.auras; + aurasAfterProcs = aurasAfterHit; + const livingSeedHealing = nextActor.health > 0 + ? Math.min(livingSeed.healingAmount, nextActor.maxHealth - nextActor.health) + : 0; + if (livingSeedHealing > 0) nextActor = { + ...nextActor, + health: nextActor.health + livingSeedHealing, + alive: true, + }; + const markOfBlood = wow335DeathKnightMarkOfBloodOnDamage( + aurasAfterHit, + hit.mob.id, + authoritativeActor.health - damaged, + nextActor.maxHealth, + now, + combatResult.landed && combatResult.rawDamage > 0, + ); + aurasAfterHit = [...markOfBlood.auras]; + aurasAfterProcs = aurasAfterHit; + const markOfBloodHealing = nextActor.health > 0 + ? Math.min(markOfBlood.healingAmount, nextActor.maxHealth - nextActor.health) + : 0; + if (markOfBloodHealing > 0) nextActor = { + ...nextActor, + health: nextActor.health + markOfBloodHealing, + alive: true, + }; + actorsAfterHit = { ...actorsAfterHit, [nextActor.id]: nextActor }; + mirrorCompanionActor(nextActor); + if (livingSeed.healingAmount > 0) eventsAfterHit = recordCombatEvent(eventsAfterHit, { + occurredAt: now, + kind: "healing", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: member.id, + abilityId: DRUID_LIVING_SEED_ABILITY_ID, + school: "nature", + rawAmount: livingSeed.healingAmount, + effectiveAmount: livingSeedHealing, + overheal: livingSeed.healingAmount - livingSeedHealing, + absorbed: 0, + critical: false, + outcome: livingSeedHealing > 0 ? "hit" : "overheal", + }); + if (markOfBlood.triggered && markOfBlood.healingAmount > 0) eventsAfterHit = recordCombatEvent(eventsAfterHit, { + occurredAt: now, + kind: "healing", + sourceActorId: markOfBlood.sourceActorId ?? PLAYER_AGGRO_ID, + targetActorId: member.id, + abilityId: markOfBlood.abilityId ?? WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, + rawAmount: markOfBlood.healingAmount, + effectiveAmount: markOfBloodHealing, + overheal: markOfBlood.healingAmount - markOfBloodHealing, + absorbed: 0, + critical: false, + outcome: markOfBloodHealing > 0 ? "hit" : "overheal", + }); if (damaged > 0 && aurasAfterHit.some((aura) => aura.targetId === member.id && isPrayerOfMendingAura(aura))) { const procProgression = progressionFromState(procState); const procWork: MutableCastWork = { @@ -12610,7 +16059,9 @@ export const useCombatStore = create((set, get) => ({ } set({ actors: actorsAfterHit, + summons: nextSummons, auras: aurasAfterProcs, + effects: accumulateLightwellDamageCancellation(procState.effects, member.id, damaged), combatEvents: eventsAfterHit, health: procState.health, lastCombatResult: combatResult, @@ -12620,6 +16071,19 @@ export const useCombatStore = create((set, get) => ({ { amount: damaged, abilityId: hit.attack.id }, ), }); + if (unfairAdvantagePartyShouldProc) { + useCombatStore.getState().damageMob( + hit.mob.id, + unfairAdvantagePartyDamage, + now, + { + actorId: member.id, + role: member.role, + abilityId: WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID, + }, + "physical", + ); + } } } for (const control of memberControls) { @@ -12702,6 +16166,20 @@ export const useCombatStore = create((set, get) => ({ const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1); const resourceProfile = resourceProfileForClass(state.classId); const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead); + const angerManagementProfile = state.classId === "warrior" + ? wow335WarriorAngerManagementProfile( + state.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0, + ) + : null; + const angerManagementElapsedMs = angerManagementProfile + ? state.warriorAngerManagementRageRemainderMs + Math.round(delta * 1_000) + : 0; + const angerManagementTicks = angerManagementProfile + ? Math.floor(angerManagementElapsedMs / angerManagementProfile.tickIntervalMs) + : 0; + const warriorAngerManagementRageRemainderMs = angerManagementProfile + ? angerManagementElapsedMs % angerManagementProfile.tickIntervalMs + : 0; const auraExpiration = expireAuras(state.auras, now); let activeAuras = [...auraExpiration.active]; const hasChampionRuneOverload = state.classId === "rom-champion" @@ -12771,6 +16249,15 @@ export const useCombatStore = create((set, get) => ({ }).current; } resourcePools[resourceProfile.type] = resource; + if (angerManagementProfile && angerManagementTicks > 0) { + const rageMaximum = state.maxResourcePools.rage ?? state.maxResource; + const rage = Math.min( + rageMaximum, + (resourcePools.rage ?? 0) + angerManagementTicks * angerManagementProfile.ragePerTick, + ); + resourcePools.rage = rage; + if (resourceProfile.type === "rage") resource = rage; + } if (state.classId === "rom-champion" && (resourcePools.rage ?? 0) <= 0) { activeAuras = activeAuras.filter((aura) => !( aura.targetId === PLAYER_AURA_ENTITY_ID @@ -12828,14 +16315,80 @@ export const useCombatStore = create((set, get) => ({ for (const targetId of new Set(auraExpiration.expired.map((aura) => aura.targetId))) { applyAuraHealthTransitionInWork(work, targetId, state.auras, activeAuras); } + const protectionExpirations = auraExpiration.expired.filter((aura) => ( + aura.definition.utilityTags?.includes(ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG) + && aura.expiresAt !== null + )); + const partyMembers = usePartyStore.getState().members; + const playerGreenGuardian = state.classId === "rom-mage" && state.secondaryClassId === "rom-druid" + ? state.abilities.find((ability) => ability.dbcSpellId === 494331 && ability.passive) + : undefined; + const playerHasGreenGuardian = Boolean(playerGreenGuardian && activeAuras.some((aura) => ( + aura.sourceId === PLAYER_AURA_ENTITY_ID + && aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_DRUID_GREEN_GUARDIAN_TAG) + ))); + for (const protection of protectionExpirations) { + const isPlayerCaster = protection.sourceId === PLAYER_AURA_ENTITY_ID; + const caster = isPlayerCaster + ? undefined + : partyMembers.find((member) => member.id === protection.sourceId); + const greenGuardian = isPlayerCaster + ? playerHasGreenGuardian ? playerGreenGuardian : undefined + : caster?.classId === "rom-mage" && caster.secondaryClassId === "rom-druid" + ? (() => { + const catalogPassive = abilitiesForCharacter(caster.classId, caster.secondaryClassId) + .find((ability) => ability.dbcSpellId === 494331 && ability.passive); + return catalogPassive ? learnedAbilityForBuild(catalogPassive, caster.level, caster.talentRanks) : null; + })() + : undefined; + if (!greenGuardian) continue; + const targetId = protection.targetId === PLAYER_AURA_ENTITY_ID ? null : protection.targetId; + const sourceActorId = caster?.id ?? PLAYER_AGGRO_ID; + const hots = greenGuardian.effects.filter((effect): effect is Extract => ( + effect.kind === "hot" + )); + work.effects = work.effects.filter((effect) => !( + effect.kind === "hot" + && effect.sourceAbilityId === greenGuardian.id + && effect.sourceActorId === sourceActorId + && effect.targetId === targetId + )); + for (const hot of hots) { + const triggered = timedEffect( + "hot", + greenGuardian.id, + sourceActorId, + targetId, + hot, + hot.ticks, + hot.intervalMs, + protection.expiresAt!, + ); + work.effects.push(caster && hot.percentMaxHealth === undefined + ? { + ...triggered, + fixedAmount: equipmentAdjustedCombatAmount( + hot, + caster.level, + caster.gearStats, + caster.classId, + greenGuardian, + ), + } + : triggered); + } + } let autoAttackTargetId = state.autoAttackTargetId; let nextAutoAttackAt = state.nextAutoAttackAt; + let nextOffhandAutoAttackAt = state.nextOffhandAutoAttackAt; let autoAttackEvent: CharacterCombatAnimationEvent | null = null; const autoAttackTarget = autoAttackTargetId ? work.mobs[autoAttackTargetId] : null; if (!autoAttackTarget || autoAttackTarget.dead || state.health <= 0) { autoAttackTargetId = null; nextAutoAttackAt = 0; + nextOffhandAutoAttackAt = 0; } else { const autoAttackTargetPosition = getMobPosition(autoAttackTargetId!); const autoAttackSource = combatSpatialActor(PLAYER_AGGRO_ID); @@ -12852,7 +16405,19 @@ export const useCombatStore = create((set, get) => ({ && (!autoAttackSource || isPositionInFacingArc(autoAttackSource, autoAttackTargetPosition ?? state.playerPosition, Math.PI)) ); if (canAutoAttack) { - const profile = playerAutoAttackProfile(state); + const mainHandWeapon = state.classId === "rogue" + ? playerEquippedMeleeWeapon(state, "main-hand") + : null; + const offHandWeapon = state.classId === "rogue" + ? playerEquippedMeleeWeapon(state, "off-hand") + : null; + const profile = playerAutoAttackProfile( + state, + aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), + state.classId === "rogue" && offHandWeapon && !mainHandWeapon + ? null + : mainHandWeapon ?? undefined, + ); if (hasRomChampionRagePool(state.classId, state.secondaryClassId)) { const rage = gainRomRage( state.classId, @@ -12867,11 +16432,10 @@ export const useCombatStore = create((set, get) => ({ work.resourcePools = rage.resourcePools; } if (now >= nextAutoAttackAt) { - const offHandId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.["off-hand"]; - const isDualWielding = Boolean( - offHandId && state.inventory.some((item) => item.instanceId === offHandId && item.weaponType), - ); - const autoAttackSchool = state.gearStats.meleeWeapon?.damageSchool ?? "physical"; + const isDualWielding = Boolean(offHandWeapon); + const autoAttackSchool = mainHandWeapon?.damageSchool + ?? (offHandWeapon ? "physical" : state.gearStats.meleeWeapon?.damageSchool) + ?? "physical"; const result = resolveAndDamageMobInWork( state.classId, work, @@ -12886,6 +16450,57 @@ export const useCombatStore = create((set, get) => ({ false, { isAutoAttack: true, isDualWielding }, ); + let autoAttackKilled = result.killed; + const enchantedThrowActive = work.auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG) + )); + if (romRogueEnchantedThrowShouldProc({ + classId: state.classId, + secondaryClassId: state.secondaryClassId, + sourceActorId: PLAYER_AGGRO_ID, + playerActorId: PLAYER_AGGRO_ID, + attackKind: "melee", + isAutoAttack: true, + attackLanded: Boolean(result.combatResult?.landed), + enchantedThrowActive, + }) && !result.killed) { + // The generated 491178 row points to hidden child 491185, and its + // tooltip explicitly defines that child as Throw (490309) damage. + // Reuse the local Throw payload so both paths share its damage and + // physical-school semantics without inventing projectile inventory. + const throwAbility = abilityById("rom-rogue-general-1"); + const throwDamage = throwAbility?.effects.find((effect) => effect.kind === "damage"); + if (throwAbility?.source === "runewaker" && throwDamage?.kind === "damage") { + const throwAmount = talentAdjustedAmount( + equipmentAdjustedCombatAmount( + throwDamage, + work.progression.level, + work.gearStats, + state.classId, + throwAbility, + ), + work.talentModifiers, + "damage-percent", + throwAbility, + ); + const throwSchool = throwDamage.school ?? abilityDamageSchool(state.classId, throwAbility); + const throwResult = resolveAndDamageMobInWork( + state.classId, + work, + autoAttackTargetId!, + throwAmount, + now, + playerThreatSource(state, throwAbility.id), + abilityAttackKind(state.classId, throwAbility), + throwSchool, + `${PLAYER_AGGRO_ID}:rom-enchanted-throw:${autoAttackTargetId}:${now}`, + throwAbility, + ); + autoAttackKilled = throwResult.killed; + } + } if (result.amount > 0 && ( hasNativeRomWarriorRage || (!hasRomRagePool(state.classId, state.secondaryClassId) && work.maxResourcePools.rage !== undefined) @@ -12921,9 +16536,62 @@ export const useCombatStore = create((set, get) => ({ }); } nextAutoAttackAt = now + profile.intervalMs; + if (autoAttackKilled) { + autoAttackTargetId = null; + nextAutoAttackAt = 0; + nextOffhandAutoAttackAt = 0; + } + } + if (state.classId !== "rogue" || !offHandWeapon || !autoAttackTargetId) { + nextOffhandAutoAttackAt = 0; + } else if (now >= nextOffhandAutoAttackAt) { + const offhandProfile = playerOffhandAutoAttackProfile( + state, + offHandWeapon, + aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), + ); + const offhandSchool = offHandWeapon.damageSchool; + const offhandSeed = `${PLAYER_AGGRO_ID}:offhand-auto:${autoAttackTargetId}:${now}`; + const result = resolveAndDamageMobInWork( + state.classId, + work, + autoAttackTargetId, + offhandProfile.damage, + now, + playerThreatSource(state, PLAYER_OFFHAND_AUTO_ATTACK_ID), + "melee", + offhandSchool, + offhandSeed, + undefined, + false, + { isAutoAttack: true, isDualWielding: true }, + ); + if (result.combatResult?.landed) { + maybeProcRogueCombatPotencyInWork(work, offhandSeed, now); + } + if (result.amount > 0) { + autoAttackEvent = nextAnimationEvent("attack"); + const targetPosition = getMobPosition(autoAttackTargetId); + emitCombatPresentation({ + occurredAt: now, + phase: "release", + actorGroup: "player", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: autoAttackTargetId, + abilityId: PLAYER_OFFHAND_AUTO_ATTACK_ID, + name: "Off-hand Auto Attack", + source: "fallback", + school: offhandSchool, + delivery: "melee", + origin: state.playerPosition, + targetPosition: targetPosition ?? state.playerPosition, + }); + } + nextOffhandAutoAttackAt = now + offhandProfile.intervalMs; if (result.killed) { autoAttackTargetId = null; nextAutoAttackAt = 0; + nextOffhandAutoAttackAt = 0; } } } @@ -12959,7 +16627,27 @@ export const useCombatStore = create((set, get) => ({ 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 sourceAmount = effect.fixedAmount ?? (ability + const immolationCasterMember = effect.kind === "dot" && effect.tag === WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG + ? usePartyStore.getState().members.find((member) => member.id === effect.sourceActorId) + : null; + const immolationTickSuppressed = effect.kind === "dot" + && effect.tag === WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG + && (effect.sourceActorId === PLAYER_AGGRO_ID + ? state.controlledUntil > nextTickAt && enemyControlPreventsActions(state.controlMechanic) + : Boolean(immolationCasterMember + && immolationCasterMember.controlledUntil > nextTickAt + && enemyControlPreventsActions(immolationCasterMember.controlMechanic))); + const immolationCasterLevel = effect.sourceActorId === PLAYER_AGGRO_ID + ? progression.level + : immolationCasterMember?.level ?? work.actors[effect.sourceActorId]?.level ?? progression.level; + const dotMageTalentOwner = effect.kind === "dot" + ? wow335MageTalentOwnerForActor(work, effect.sourceActorId) + : null; + const livingBombCanCrit = effect.kind === "dot" && ability?.name === "Living Bomb" + && dotMageTalentOwner !== null + && mageTalentRank(dotMageTalentOwner.talentRanks, "Empowered Fire", 3) >= 3; + const scheduledTickAmount = effect.tickAmounts?.[effect.tickAmounts.length - remainingTicks]; + const sourceAmount = scheduledTickAmount ?? effect.fixedAmount ?? (ability ? equipmentAdjustedCombatAmount( effect, progression.level, @@ -12968,18 +16656,14 @@ export const useCombatStore = create((set, get) => ({ ability, ) : Math.max(0, Math.round(effect.coefficient * playerCombatPower(progression.level)))); - const spiritOfLifePercent = effect.kind === "hot" - && state.classId === "rom-druid" - && ability?.dbcSpellId === 494938 - ? effect.percentMaxHealth ?? 0 - : 0; + const percentMaxHealth = effect.kind === "hot" ? 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) + const baseAmount = percentMaxHealth > 0 + ? romDruidPeriodicHealAmount(sourceAmount, hotTargetMaxHealth, percentMaxHealth) : sourceAmount; const unadjustedTickAmount = ability && effect.fixedAmount === undefined ? talentAdjustedAmount( @@ -12995,7 +16679,9 @@ export const useCombatStore = create((set, get) => ({ const amount = state.classId === "warlock" && ability?.name === "Curse of Agony" ? warlockCurseOfAgonyTickAmount(unadjustedTickAmount, tickNumber) : unadjustedTickAmount; - if (warPrayerSealTick && effect.targetId) { + if (immolationTickSuppressed) { + // Loss-of-control effects suppress the pulse and its linked attack-power debuff. + } else if (warPrayerSealTick && effect.targetId) { const targetId = effect.targetId; work.comboPoints = clamp((work.comboTargetId === targetId ? work.comboPoints : 0) + 1, 0, 4); work.comboTargetId = targetId; @@ -13014,6 +16700,16 @@ export const useCombatStore = create((set, get) => ({ partySelfDrainTicks.push({ memberId: effect.targetId, amount, sourceAbilityId: effect.sourceAbilityId }); } } else if (effect.kind === "dot" && effect.targetId) { + if (effect.tag === WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG) { + applyWarlockImmolationAttackPowerInWork( + work, + effect.targetId, + effect.sourceActorId, + effect.sourceAbilityId, + immolationCasterLevel, + nextTickAt, + ); + } const dotAttackKind = ability ? abilityAttackKind(state.classId, ability) : "spell"; const result = resolveAndDamageMobInWork( state.classId, @@ -13029,13 +16725,53 @@ export const useCombatStore = create((set, get) => ({ true, { canMiss: false, - canCrit: false, + canCrit: livingBombCanCrit || (effect.criticalChanceBonusPercent ?? 0) > 0, canDodge: false, canParry: false, canBlock: false, }, + effect.criticalChanceBonusPercent ?? 0, ); + const frostbiteRank = dotMageTalentOwner?.talentRanks[WOW335_MAGE_FROSTBITE_TALENT_ID] ?? 0; + if (effect.sourceActorId !== PLAYER_AGGRO_ID + && effect.damageSchool === "frost" && frostbiteRank > 0) { + applyMageFrostbiteInWork( + work, + effect.sourceActorId, + effect.targetId, + effect.sourceAbilityId, + frostbiteRank, + effect.damageSchool, + result.combatResult?.landed === true, + result.amount, + stableCombatUnit(`${effect.id}:${effect.targetId}:${nextTickAt}:${remainingTicks}:mage-frostbite`), + nextTickAt, + ); + } + if (result.amount > 0 && ability) { + maybeProcHunterLockAndLoadInWork( + work, + effect.sourceActorId, + effect.sourceAbilityId, + ability.name, + "periodic", + effect.targetId, + nextTickAt, + ); + } grantDamageResourceInWork(work, effect, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt); + if (effect.tag === "wow335-mage-ignite" && result.amount > 0) { + const empoweredFireRank = dotMageTalentOwner + ? mageTalentRank(dotMageTalentOwner.talentRanks, "Empowered Fire", 3) + : 0; + if (dotMageTalentOwner && empoweredFireRank > 0) { + const baseMana = baseResourcesFor("mage", dotMageTalentOwner.level).mana; + const restored = Math.round(baseMana * 0.02); + grantDamageResourceInWork(work, { + onDamageResource: { resource: "mana", amount: restored }, + }, result.amount, effect.sourceActorId, effect.sourceAbilityId, nextTickAt); + } + } } else if (effect.kind === "hot") { const hotTargetId = effect.targetId ?? PLAYER_AURA_ENTITY_ID; const modifiedHealing = Math.round(resolveAuraHealingValue( @@ -13069,7 +16805,7 @@ export const useCombatStore = create((set, get) => ({ ); } } - if (!warPrayerSealTick) evaluateProcsInWork(work, { + if (!warPrayerSealTick && !immolationTickSuppressed) evaluateProcsInWork(work, { trigger: "periodic", subjectId: effect.sourceActorId, otherId: effect.targetId ?? PLAYER_AURA_ENTITY_ID, @@ -13077,7 +16813,7 @@ export const useCombatStore = create((set, get) => ({ amount, ...(effect.damageSchool ? { school: effect.damageSchool } : {}), }, nextTickAt); - if (!warPrayerSealTick && ability) { + if (!warPrayerSealTick && !immolationTickSuppressed && ability) { const sourceActorId = effect.sourceActorId; const actorGroup = sourceActorId === PLAYER_AGGRO_ID ? "player" : "party"; const origin = sourceActorId === PLAYER_AGGRO_ID @@ -13124,8 +16860,101 @@ export const useCombatStore = create((set, get) => ({ 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 areaMageTalentOwner = wow335MageTalentOwnerForActor(work, area.sourceActorId); + const trapOwner = area.sourceActorId === PLAYER_AGGRO_ID + ? state.classId === "hunter" ? null : undefined + : usePartyStore.getState().members.find((member) => member.id === area.sourceActorId); + const ability = catalogAbility ? abilityAtLevel( + catalogAbility, + trapOwner?.level ?? state.level, + trapOwner ? learnedAbilityRank(catalogAbility, trapOwner.talentRanks) : undefined, + ) : null; + const isHunterTrap = Boolean(area.hunterTrap && ( + (area.sourceActorId === PLAYER_AGGRO_ID && state.classId === "hunter") + || trapOwner?.classId === "hunter" + )); + if (isHunterTrap && area.hunterTrap) { + const zoneSlowSourceId = `hunter-trap-zone:${area.id}`; + if (area.expiresAt <= now) { + if (area.hunterTrap.kind === "frost-zone") { + for (const [id, mob] of Object.entries(work.mobs)) { + if (mob.statuses.some((status) => status.sourceAbilityId === zoneSlowSourceId)) { + work.mobs[id] = { + ...mob, + statuses: mob.statuses.filter((status) => status.sourceAbilityId !== zoneSlowSourceId), + }; + } + } + } + return []; + } + const sourceAbilities = trapOwner + ? abilitiesForCharacter(trapOwner.classId, trapOwner.secondaryClassId) + : work.abilities; + const trapCatalogAbility = sourceAbilities.find((candidate) => candidate.id === area.sourceAbilityId) + ?? catalogAbility; + const trapAbility = trapCatalogAbility + ? abilityAtLevel( + trapCatalogAbility, + trapOwner?.level ?? state.level, + learnedAbilityRank(trapCatalogAbility, trapOwner?.talentRanks ?? work.talentRanks), + ) + : null; + if (area.hunterTrap.kind === "frost-zone") { + const ownerTalentRanks = trapOwner?.talentRanks ?? work.talentRanks; + const entrapmentRank = Math.max(0, Math.min(3, Math.trunc( + ownerTalentRanks[WOW335_HUNTER_ENTRAPMENT_TALENT_ID] ?? 0, + ))); + const entrapment = entrapmentRank > 0 ? entrapmentRankMechanics(entrapmentRank) : null; + const slowMagnitude = Math.min(0.95, 0.5 + (entrapment?.frostTrapSlowBonus ?? 0)); + const inZone = (id: string): boolean => { + const position = getMobPosition(id); + return Boolean(position + && !work.mobs[id]?.dead + && Math.sqrt(distanceSquared(area.position, position)) <= area.radius + && combatHasLineOfSight(area.position, position)); + }; + + // Remove this zone's slow as soon as a mob leaves it; refresh it while + // the mob remains inside so movement speed is continuously reduced. + for (const [id, mob] of Object.entries(work.mobs)) { + if (inZone(id) || !mob.statuses.some((status) => status.sourceAbilityId === zoneSlowSourceId)) continue; + work.mobs[id] = { + ...mob, + statuses: mob.statuses.filter((status) => status.sourceAbilityId !== zoneSlowSourceId), + }; + } + for (const id of nearbyMobRuntimeIds(area.position, area.radius)) { + if (!inZone(id)) continue; + const mob = work.mobs[id]; + if (!mob) continue; + work.mobs[id] = applyStatus(mob, { + kind: "status", + status: "slow", + durationMs: Math.max(2_500, area.tickIntervalMs + 500), + magnitude: slowMagnitude, + }, zoneSlowSourceId, now, area.sourceActorId); + } + + while (nextTickAt <= now && nextTickAt < area.expiresAt) { + if (entrapment && entrapment.rootProcChance > 0) { + for (const id of nearbyMobRuntimeIds(area.position, area.radius)) { + if (!inZone(id)) continue; + const mob = work.mobs[id]; + if (!mob || stableCombatUnit( + `${area.sourceActorId}:${area.id}:${id}:${nextTickAt}:wow335-hunter-entrapment`, + ) >= entrapment.rootProcChance) continue; + work.mobs[id] = applyStatus(mob, { + kind: "status", + status: "root", + durationMs: entrapment.rootDurationMs, + }, trapAbility?.id ?? area.sourceAbilityId, nextTickAt, area.sourceActorId); + } + } + nextTickAt += area.tickIntervalMs; + } + return [nextTickAt === area.nextTickAt ? area : { ...area, nextTickAt }]; + } const entrantId = nearbyMobRuntimeIds(area.position, area.hunterTrap.triggerRadius) .filter((id) => { const position = getMobPosition(id); @@ -13142,30 +16971,106 @@ export const useCombatStore = create((set, get) => ({ 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, { + const freezingTrap = trapAbility + && ["Freezing Arrow", "Freezing Trap"].includes(trapAbility.name) + ? trapAbility + : null; + if (target && freezingTrap) { + const trapped = applyStatus(target, { kind: "status", status: "freeze", durationMs: area.hunterTrap.freezeDurationMs ?? 20_000, breakOnDamage: true, - }, freezingArrow.id, now, area.sourceActorId); + }, freezingTrap.id, now, area.sourceActorId); + work.mobs[entrantId] = trapped; + if (trapped.statuses.some((status) => status.kind === "freeze" + && status.sourceAbilityId === freezingTrap.id + && status.sourceActorId === area.sourceActorId + && status.endsAt > now)) { + maybeProcHunterLockAndLoadInWork( + work, + area.sourceActorId, + area.sourceAbilityId, + freezingTrap.name, + "trap", + entrantId, + now, + ); + } } return []; } - const explosiveTrap = ability; + if (area.hunterTrap.kind === "frost") { + const durationMs = 30_000; + const frostZone: CombatAreaEffect = { + ...area, + radius: 10, + expiresAt: now + durationMs, + tickIntervalMs: 2_000, + nextTickAt: now + 2_000, + hunterTrap: { ...area.hunterTrap, kind: "frost-zone" }, + }; + return [frostZone]; + } + if (area.hunterTrap.kind === "immolation") { + if (trapAbility) { + const periodic = trapAbility.effects.find((effect) => effect.kind === "dot" + && effect.tag === "hunter-immolation-trap"); + if (periodic && periodic.kind === "dot") { + const ownerLevel = trapOwner?.level ?? state.level; + const ownerGearStats = trapOwner?.gearStats ?? state.gearStats; + const ownerClassId = trapOwner?.classId ?? state.classId; + const ownerModifiers = trapOwner + ? talentModifiersForRanks(trapOwner.talentRanks, trapOwner.classId) + : work.talentModifiers; + const tickDamage = talentAdjustedAmount( + equipmentAdjustedCombatAmount( + periodic, + ownerLevel, + ownerGearStats, + ownerClassId, + trapAbility, + ), + ownerModifiers, + "damage-percent", + trapAbility, + true, + ); + work.effects.push(timedEffect( + "dot", + trapAbility.id, + area.sourceActorId, + entrantId, + { ...periodic, fixedAmount: tickDamage }, + periodic.ticks, + periodic.intervalMs, + now, + periodic.tag, + periodic.school ?? "fire", + )); + } + } + return []; + } + const explosiveTrap = trapAbility; if (!explosiveTrap) return []; + const ownerLevel = trapOwner?.level ?? state.level; + const ownerGearStats = trapOwner?.gearStats ?? state.gearStats; + const ownerClassId = trapOwner?.classId ?? state.classId; + const ownerModifiers = trapOwner + ? talentModifiersForRanks(trapOwner.talentRanks, trapOwner.classId) + : work.talentModifiers; const directScaling = area.hunterTrap.directDamageScaling; if (directScaling) { const directDamage = talentAdjustedAmount( equipmentAdjustedCombatAmount( directScaling, - state.level, - state.gearStats, - state.classId, + ownerLevel, + ownerGearStats, + ownerClassId, explosiveTrap, ), - work.talentModifiers, + ownerModifiers, "damage-percent", explosiveTrap, ); @@ -13173,7 +17078,7 @@ export const useCombatStore = create((set, get) => ({ const position = getMobPosition(id); if (!position || work.mobs[id]?.dead || !combatHasLineOfSight(area.position, position)) continue; resolveAndDamageMobInWork( - state.classId, + ownerClassId, work, id, directDamage, @@ -13193,9 +17098,17 @@ export const useCombatStore = create((set, get) => ({ const durationMs = periodic.length ? Math.max(...periodic.map((effect) => effect.ticks * effect.intervalMs)) : 20_000; + const damagePerTick = periodic.reduce((sum, effect) => sum + talentAdjustedAmount( + equipmentAdjustedCombatAmount(effect, ownerLevel, ownerGearStats, ownerClassId, explosiveTrap), + ownerModifiers, + "damage-percent", + explosiveTrap, + true, + ), 0); return [{ ...area, radius: 10, + ...(periodic.length ? { fixedDamagePerTick: damagePerTick } : {}), expiresAt: now + durationMs, tickIntervalMs, nextTickAt: now + tickIntervalMs, @@ -13209,9 +17122,15 @@ export const useCombatStore = create((set, get) => ({ const fixedAmount = periodic.kind === "dot" ? area.fixedDamagePerTick : area.fixedHealingPerTick; if (fixedAmount !== undefined && resolvedFixedKinds.has(periodic.kind)) continue; if (fixedAmount !== undefined) resolvedFixedKinds.add(periodic.kind); + const areaOwnerLevel = trapOwner?.level ?? state.level; + const areaOwnerGearStats = trapOwner?.gearStats ?? state.gearStats; + const areaOwnerClassId = trapOwner?.classId ?? state.classId; + const areaOwnerModifiers = trapOwner + ? talentModifiersForRanks(trapOwner.talentRanks, trapOwner.classId) + : state.talentModifiers; const amount = fixedAmount ?? talentAdjustedAmount( - equipmentAdjustedCombatAmount(periodic, state.level, state.gearStats, state.classId, ability), - state.talentModifiers, + equipmentAdjustedCombatAmount(periodic, areaOwnerLevel, areaOwnerGearStats, areaOwnerClassId, ability), + areaOwnerModifiers, periodic.kind === "dot" ? "damage-percent" : "healing-percent", ability, true, @@ -13220,20 +17139,48 @@ export const useCombatStore = create((set, get) => ({ for (const id of nearbyMobRuntimeIds(area.position, area.radius)) { const position = getMobPosition(id); if (!position || work.mobs[id]?.dead || !combatHasLineOfSight(area.position, position)) continue; - resolveAndDamageMobInWork( - state.classId, + const periodicSchool = abilityDamageSchool(state.classId, ability); + const result = resolveAndDamageMobInWork( + areaOwnerClassId, work, id, amount, nextTickAt, threatSourceForActor(state, area.sourceActorId, ability.id, true), abilityAttackKind(state.classId, ability), - abilityDamageSchool(state.classId, ability), + periodicSchool, `${area.id}:${id}:${nextTickAt}`, ability, true, { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, ); + const frostbiteRank = areaMageTalentOwner?.talentRanks[WOW335_MAGE_FROSTBITE_TALENT_ID] ?? 0; + if (area.sourceActorId !== PLAYER_AGGRO_ID + && periodicSchool === "frost" && frostbiteRank > 0) { + applyMageFrostbiteInWork( + work, + area.sourceActorId, + id, + ability.id, + frostbiteRank, + periodicSchool, + result.combatResult?.landed === true, + result.amount, + stableCombatUnit(`${area.id}:${id}:${nextTickAt}:mage-frostbite`), + nextTickAt, + ); + } + if (result.amount > 0) { + maybeProcHunterLockAndLoadInWork( + work, + area.sourceActorId, + ability.id, + ability.name, + "periodic", + id, + nextTickAt, + ); + } } } else { const partyMembers = usePartyStore.getState().members; @@ -13521,11 +17468,13 @@ export const useCombatStore = create((set, get) => ({ if (autoAttackTargetId && work.mobs[autoAttackTargetId]?.dead) { autoAttackTargetId = null; nextAutoAttackAt = 0; + nextOffhandAutoAttackAt = 0; } return { resource: clamp(work.resource, 0, work.maxResource), maxResource: work.maxResource, resourcePools: { ...work.resourcePools, [resourceProfile.type]: clamp(work.resource, 0, work.maxResource) }, + warriorAngerManagementRageRemainderMs, maxResourcePools: work.maxResourcePools, lastResourceSpentAt: work.lastResourceSpentAt, deathKnightRunes: work.deathKnightRunes, @@ -13560,6 +17509,7 @@ export const useCombatStore = create((set, get) => ({ : state.selectedFriendlyActorId, autoAttackTargetId, nextAutoAttackAt, + nextOffhandAutoAttackAt, playerAnimationEvent: autoAttackEvent ?? state.playerAnimationEvent, comboPoints: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? 0 : work.comboPoints, comboTargetId: state.comboTargetId && work.mobs[state.comboTargetId]?.dead ? null : work.comboTargetId, @@ -13638,7 +17588,7 @@ export const useCombatStore = create((set, get) => ({ : Math.sqrt(partyRuntimeDistanceSquared(cast.targetId, cast.origin)) : 0; const minimumRange = ability ? talentAdjustedRange(ability.range.min, ability, get().talentModifiers) : 0; - const maximumRange = ability ? talentAdjustedRange(ability.range.max, ability, get().talentModifiers) : 0; + const maximumRange = ability ? talentAdjustedCastRange(ability.range.max, ability, get().talentModifiers) : 0; const targetUnavailable = groundChannel ? !cast.groundPosition : channelHostile ? !target || target.dead || !targetPosition : (ability.target === "friendly" || ability.target === "any") && cast.targetId @@ -13685,11 +17635,23 @@ export const useCombatStore = create((set, get) => ({ : nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(channelRadius, ability, get().talentModifiers)) .filter((id) => !get().mobs[id]?.dead && Boolean(getMobPosition(id) && combatHasLineOfSight(channelCenter, getMobPosition(id)!))); - for (const channelTargetId of channelTargets) get().damageMob( + for (const channelTargetId of channelTargets) { + const latest = get(); + const channelTarget = latest.mobs[channelTargetId]; + const mageTargetAmount = latest.classId === "mage" && channelTarget + ? wow335MageTormentTheWeakDamage( + damageAmount, + latest.talentRanks[WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID] ?? 0, + ability.name, + channelTarget.statuses, + nextTickAt, + ) + : damageAmount; + get().damageMob( channelTargetId, talentAdjustedAmount( - damageAmount, - get().talentModifiers, + mageTargetAmount, + latest.talentModifiers, "damage-percent", ability, ), @@ -13703,13 +17665,15 @@ export const useCombatStore = create((set, get) => ({ periodic: true, rules: { canMiss: false, - canCrit: false, + canCrit: (cast.arcanePotencyCriticalChanceBonusPercent ?? 0) > 0, canDodge: false, canParry: false, canBlock: false, }, + criticalChanceBonusPercent: cast.arcanePotencyCriticalChanceBonusPercent ?? 0, }, ); + } } if (healingAmount > 0 && !(ability.target === "any" && channelHostile)) { const perTargetHealing = talentAdjustedAmount( @@ -13925,6 +17889,7 @@ export const useCombatStore = create((set, get) => ({ playerAnimationEvent: nextAnimationEvent("ability-cancel"), autoAttackTargetId: null, nextAutoAttackAt: 0, + nextOffhandAutoAttackAt: 0, mobs, selectedTargetId: null, selectedFriendlyActorId: null, diff --git a/src/game/deathKnightSourceRuntime.test.ts b/src/game/deathKnightSourceRuntime.test.ts index 057decad..5a7cb9cf 100644 --- a/src/game/deathKnightSourceRuntime.test.ts +++ b/src/game/deathKnightSourceRuntime.test.ts @@ -4,6 +4,7 @@ import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./party import { installCombatClock } from "./combatClock"; import { createDeathKnightRunes } from "./combatActors"; import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; +import { applyAura as applyCombatAura } from "./combatAuras"; import { usePartyStore } from "./partyStore"; import { talentNodesForTree } from "./talentCatalog"; @@ -60,6 +61,31 @@ function registerXpTarget(id: string, xpReward: number) { setMobPosition(id, [2, 0, 0]); } +function installAntiMagicShell(targetId: string, maximumHealth: number) { + const result = applyCombatAura(useCombatStore.getState().auras, { + definition: { + id: "wow335:48707:anti-magic-shell", + name: "Anti-Magic Shell", + disposition: "buff", + durationMs: 5_000, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: ["wow335-death-knight-anti-magic-shell"], + absorbs: [{ + id: "wow335:48707:anti-magic-shell-absorb", + amount: maximumHealth * 0.5, + schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], + fraction: 0.75, + }], + }, + sourceId: targetId, + targetId, + now, + }); + useCombatStore.setState({ auras: [...result.auras] }); + return result.aura; +} + describe("Death Knight source-backed runtime mechanics", () => { beforeEach(() => { now = 1_000; @@ -117,6 +143,7 @@ describe("Death Knight source-backed runtime mechanics", () => { 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); + useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } }); const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID && candidate.definition.name === "Anti-Magic Shell"); expect(aura?.absorbs[0]).toMatchObject({ @@ -127,10 +154,141 @@ describe("Death Knight source-backed runtime mechanics", () => { now += 1; expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(1.5); + expect(useCombatStore.getState().resource).toBe(1.5); expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura!.instanceId) ?.absorbs[0].remaining).toBe(Math.round(useCombatStore.getState().maxHealth * 0.5) - 75); }); + it("caps AMS absorption at half maximum health before awarding proportional Runic Power", () => { + useCombatStore.setState({ + health: 1_000, + maxHealth: 1_000, + resource: 20, + resourcePools: { "runic-power": 20 }, + }); + expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true); + useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } }); + + expect(useCombatStore.getState().damagePlayer(1_000, "fire", undefined, now + 1)).toBe(500); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(10); + expect(useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell"))?.absorbs[0].remaining).toBe(0); + }); + + it("grants Runic Power for magic absorbed during the normal enemy-attack path", () => { + expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true); + useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } }); + const shell = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-shell"))!; + const capacityBefore = shell.absorbs[0].remaining; + useCombatStore.getState().registerMob("ams-player-attacker", { + name: "AMS player attacker", + maxHealth: 1_000, + xpReward: 0, + attacks: [{ + id: "ams-player-fire", + name: "Fire strike", + delivery: "melee", + target: "primary", + school: "fire", + animation: "attack", + range: 5, + cooldownMs: 1_000, + damageMultiplier: 1, + }], + }); + setMobPosition("ams-player-attacker", [2, 0, 0]); + useCombatStore.getState().damageMob("ams-player-attacker", 1, now); + + useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); + const shellAfterHit = useCombatStore.getState().auras.find((aura) => aura.instanceId === shell.instanceId); + const absorbed = capacityBefore - (shellAfterHit?.absorbs[0].remaining ?? 0); + expect(absorbed).toBeGreaterThan(0); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBeCloseTo(absorbed * 0.02); + }); + + it("clamps AMS Runic Power gain and ignores physical, missing, and expired shells", () => { + expect(useCombatStore.getState().castAbility(ability("Anti-Magic Shell").id, { now }).ok).toBe(true); + useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } }); + useCombatStore.getState().damagePlayer(100, "physical", undefined, now + 1); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0); + + useCombatStore.setState({ auras: [] }); + useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 2); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0); + + const shell = installAntiMagicShell(PLAYER_AURA_ENTITY_ID, useCombatStore.getState().maxHealth); + useCombatStore.setState({ + resource: 99, + resourcePools: { "runic-power": 99 }, + auras: useCombatStore.getState().auras.map((aura) => aura.instanceId === shell.instanceId + ? { ...aura, expiresAt: now + 3 } + : aura), + }); + useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 4); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(99); + + useCombatStore.setState({ + resource: 99, + resourcePools: { "runic-power": 99 }, + auras: [shell], + }); + useCombatStore.getState().damagePlayer(100, "fire", undefined, now + 1); + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(100); + }); + + it("grants a Death Knight companion Runic Power from its own live AMS absorb", () => { + const target = usePartyStore.getState().members[0]; + if (!target) throw new Error("Expected a party member for the companion AMS test."); + const companion = { + ...target, + classId: "death-knight" as const, + resourceType: "runic-power" as const, + resource: 0, + maxResource: 100, + resourcePools: { ...target.resourcePools, "runic-power": 0 }, + }; + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === target.id ? companion : member), + })); + useCombatStore.setState((state) => { + const actors = { ...state.actors }; + delete actors[target.id]; + return { ...state, actors }; + }); + registerPartyRuntimePosition(target.id, [0, 0, 0]); + const aura = installAntiMagicShell(target.id, companion.maxHealth); + useCombatStore.getState().registerMob("ams-companion-attacker", { + name: "AMS companion attacker", + maxHealth: 1_000, + xpReward: 0, + attacks: [{ + id: "ams-companion-fire", + name: "Fire strike", + delivery: "melee", + target: "primary", + school: "fire", + animation: "attack", + range: 5, + cooldownMs: 1_000, + damageMultiplier: 1, + }], + }); + setMobPosition("ams-companion-attacker", [2, 0, 0]); + useCombatStore.getState().damageMob("ams-companion-attacker", 1, now, { + actorId: target.id, + role: target.role, + }); + + useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); + const updated = usePartyStore.getState().members.find((member) => member.id === target.id)!; + const shellAfterHit = useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura.instanceId); + const absorbed = companion.maxHealth * 0.5 - (shellAfterHit?.absorbs[0].remaining ?? 0); + expect(absorbed).toBeGreaterThan(0); + expect(updated.resourcePools["runic-power"]).toBeCloseTo(absorbed * 0.02); + }); + 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); diff --git a/src/game/enemyAttacks.ts b/src/game/enemyAttacks.ts index 5eef2a41..0aca1f4f 100644 --- a/src/game/enemyAttacks.ts +++ b/src/game/enemyAttacks.ts @@ -20,7 +20,7 @@ export type EnemyDamageSchool = | "shadow" | "holy"; -export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence"; +export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence" | "interrupt"; /** Frost-style slows preserve actions while reducing authored movement speed. */ export const ENEMY_SLOW_MOVEMENT_MULTIPLIER = 0.6; @@ -28,13 +28,13 @@ export const ENEMY_SLOW_MOVEMENT_MULTIPLIER = 0.6; export function enemyControlPreventsActions( mechanic: EnemyControlMechanic | null, ): boolean { - return mechanic !== null && mechanic !== "root" && mechanic !== "slow"; + return mechanic !== null && mechanic !== "root" && mechanic !== "slow" && mechanic !== "interrupt"; } export function enemyControlPreventsMovement( mechanic: EnemyControlMechanic | null, ): boolean { - return mechanic !== null && mechanic !== "slow"; + return mechanic !== null && mechanic !== "slow" && mechanic !== "interrupt"; } export function enemyControlMovementMultiplier( diff --git a/src/game/enemyCasting.test.ts b/src/game/enemyCasting.test.ts index 581acc9f..972131f1 100644 --- a/src/game/enemyCasting.test.ts +++ b/src/game/enemyCasting.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { useCombatStore } from "./combatStore"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { normalizeEnemyAttack, @@ -59,6 +60,51 @@ describe("enemy casting and interrupts", () => { expect(normalizeEnemyAttack({ ...withoutCastTime, animation: "attack" }).castTimeMs).toBe(0); }); + it("reduces modeled enemy melee damage by the active Vindication AP share", () => { + const swingDamage = (vindicated: boolean): number => { + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob("ap-attacker", { + name: "AP attacker", + serverEntry: 598, + level: 80, + maxHealth: 10_000, + xpReward: 0, + attacks: [DEFAULT_ENEMY_ATTACK], + }); + setMobPosition("ap-attacker", [2, 0, 0]); + useCombatStore.getState().selectMob("ap-attacker"); + useCombatStore.getState().damageMob("ap-attacker", 1, 1_000); + if (vindicated) { + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + "ap-attacker": { + ...state.mobs["ap-attacker"], + auras: [{ + id: "wow335:paladin:vindication", + sourceAbilityId: "wow335-paladin-exorcism", + sourceActorId: "paladin-caster", + endsAt: 11_000, + magnitude: 357, + }], + }, + }, + })); + } + const healthBeforeSwing = useCombatStore.getState().health; + useCombatStore.getState().advanceMobCombat(1_450); + return healthBeforeSwing - useCombatStore.getState().health; + }; + + const unmodified = swingDamage(false); + const reduced = swingDamage(true); + expect(unmodified).toBeGreaterThan(0); + expect(reduced).toBeGreaterThan(0); + expect(reduced).toBeLessThan(unmodified); + }); + it("delays an enemy spell effect until its cast completes", () => { registerCaster([spell("shadow-bolt", "shadow")]); const startingHealth = useCombatStore.getState().health; @@ -130,6 +176,61 @@ describe("enemy casting and interrupts", () => { expect(useCombatStore.getState().mobs.caster.schoolLockedUntil).toEqual({}); }); + it("grants Improved Kick's capstone only for a successful Kick interrupt", () => { + useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 }); + registerCaster([spell("shadow-bolt", "shadow", 2_000)]); + useCombatStore.getState().advanceMobCombat(1_000); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-206": 2 }, + resource: 70, + resourcePools: { ...state.resourcePools, energy: 70 }, + })); + + expect(useCombatStore.getState().castAbility("rogue-kick", [0, 0, 0], 1_100).ok).toBe(true); + expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); + expect(useCombatStore.getState().resourcePools.energy).toBe(85); + const bonus = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes("wow335-rogue-improved-kick") + )); + expect(bonus).toMatchObject({ appliedAt: 1_100, expiresAt: 7_100 }); + expect(bonus?.definition.modifiers).toEqual([ + { kind: "stat", stat: "melee-haste", operation: "flat", value: 35 }, + { kind: "stat", stat: "ranged-haste", operation: "flat", value: 35 }, + ]); + + useCombatStore.setState((state) => ({ + autoAttackTargetId: "caster", + nextAutoAttackAt: 1_101, + })); + useCombatStore.getState().tick(0, 1_101); + const buffedSwingInterval = useCombatStore.getState().nextAutoAttackAt - 1_101; + useCombatStore.setState((state) => ({ nextAutoAttackAt: 7_101 })); + useCombatStore.getState().tick(0, 7_101); + const unbuffedSwingInterval = useCombatStore.getState().nextAutoAttackAt - 7_101; + expect(buffedSwingInterval).toBeLessThan(unbuffedSwingInterval); + + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("wow335-rogue-improved-kick") + ))).toBe(false); + }); + + it("does not grant Improved Kick's interrupt rider when Kick finds no cast", () => { + useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 }); + registerCaster([spell("shadow-bolt", "shadow", 2_000)]); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-206": 2 }, + resource: 70, + resourcePools: { ...state.resourcePools, energy: 70 }, + })); + + expect(useCombatStore.getState().castAbility("rogue-kick", [0, 0, 0], 1_100).ok).toBe(true); + expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull(); + expect(useCombatStore.getState().resourcePools.energy).toBe(45); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("wow335-rogue-improved-kick") + ))).toBe(false); + }); + it("cancels casts immediately when crowd control lands", () => { useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 4 }); registerCaster([spell("shadow-bolt", "shadow", 2_000)]); diff --git a/src/game/partyAbilityAi.test.ts b/src/game/partyAbilityAi.test.ts index 39a1570a..c7de92ec 100644 --- a/src/game/partyAbilityAi.test.ts +++ b/src/game/partyAbilityAi.test.ts @@ -300,6 +300,17 @@ describe("catalog-driven party rotations", () => { })).toBe(17); }); + it("applies Savage Fury only to its named party Druid damage abilities", () => { + const base = { ...memberFor("druid", "damage"), gearStats: EMPTY_ITEM_STATS, talentRanks: {} }; + const talented = { ...base, talentRanks: { "talent-805": 3 } }; + const claw = abilitiesForClass("druid").find((candidate) => candidate.name === "Claw")!; + const wrath = abilitiesForClass("druid").find((candidate) => candidate.name === "Wrath")!; + const damage = { kind: "damage" as const, coefficient: 0, basePoints: 100, dieSides: 1, sourceLevel: 80 }; + + expect(partyAbilityAmount(talented, claw, damage)).toBeCloseTo(partyAbilityAmount(base, claw, damage) * 1.12); + expect(partyAbilityAmount(talented, wrath, damage)).toBe(partyAbilityAmount(base, wrath, damage)); + }); + it("scales Envenom from consumed Deadly Poison doses", () => { const member = { ...memberFor("rogue", "damage"), diff --git a/src/game/partyAbilityAi.ts b/src/game/partyAbilityAi.ts index b263c6b3..72bce1da 100644 --- a/src/game/partyAbilityAi.ts +++ b/src/game/partyAbilityAi.ts @@ -19,6 +19,18 @@ import type { PartyMember } from "./partyStore"; import { baseResourcesFor } from "./wotlkStats"; import { companionResourceMaximum } from "./combatActorAuthority"; import { currentCompanionCooldownPolicy, type CompanionCooldownMechanicRule } from "./companionCooldownPolicy"; +import { WOW335_MAGE_ARCANE_CONCENTRATION_TAG } from "./wow335MageTalentRuntime"; +import { + WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID, + wow335MageImprovedCounterspellCooldownReductionMs, +} from "./wow335MageImprovedCounterspellRuntime"; +import { WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG } from "./wow335HunterRuntime"; +import { + romPriestWarriorStanceRequired, + romPriestWarriorStanceUseAllowed, +} from "./romPriestKnightOverrides"; +import { romKnightWarriorWhirlwindAdjustment } from "./romKnightOverrides"; +import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers"; export interface PartyAbilityContext { readonly now: number; @@ -51,9 +63,82 @@ function currentResource(member: PartyMember, type: ResourceType): number { return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0); } -function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] { - const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id); - return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => { +export function partyHunterLockAndLoadReady( + member: PartyMember, + ability: AbilityDefinition, + now?: number, +): boolean { + if (member.classId !== "hunter" + || !["Arcane Shot", "Explosive Shot"].includes(ability.name) + || Math.max(0, Math.trunc(member.talentRanks["talent-1306"] ?? 0)) <= 0) return false; + return useCombatStore.getState().auras.some((aura) => aura.targetId === member.id + && aura.stacks > 0 + && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) + && (now === undefined || aura.expiresAt === null || aura.expiresAt > now)); +} + +export function partyAbilityWithHunterLockAndLoad( + member: PartyMember, + ability: AbilityDefinition, + now: number, +): AbilityDefinition { + if (!partyHunterLockAndLoadReady(member, ability, now)) return ability; + const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ( + cost.resource === "mana" ? { ...cost, amount: 0 } : cost + )); + return { ...ability, cooldownMs: 0, cost: costs[0] ?? ability.cost, costs }; +} + +function partyKnightWarriorWhirlwindAdjustment( + member: PartyMember, + ability: AbilityDefinition, +): ReturnType { + if (member.classId !== "rom-knight" || member.secondaryClassId !== "rom-warrior") { + return { rageCostReduction: 0, cooldownReductionMs: 0 }; + } + const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId) + .filter((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-knight" + && candidate.secondaryClassId === "rom-warrior" + && candidate.dbcSpellId === 491639 + && candidate.passive + && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null) + .map((candidate) => candidate.dbcSpellId); + return romKnightWarriorWhirlwindAdjustment( + member.classId, + member.secondaryClassId, + ability, + learnedPassiveSpellIds, + ); +} + +function partyAbilityWithKnightWarriorWhirlwind( + member: PartyMember, + ability: AbilityDefinition, +): AbilityDefinition { + const adjustment = partyKnightWarriorWhirlwindAdjustment(member, ability); + if (adjustment.rageCostReduction <= 0 && adjustment.cooldownReductionMs <= 0) return ability; + const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ( + cost.resource === "rage" && !cost.percentage + ? { ...cost, amount: Math.max(0, cost.amount - adjustment.rageCostReduction) } + : cost + )); + return { + ...ability, + cooldownMs: Math.max(0, ability.cooldownMs - adjustment.cooldownReductionMs), + cost: costs[0] ?? ability.cost, + costs, + }; +} + +export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinition, now?: number): readonly { resource: ResourceType; amount: number }[] { + const adjustedAbility = partyAbilityWithKnightWarriorWhirlwind(member, ability); + const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id + && (now === undefined || aura.expiresAt === null || aura.expiresAt > now)); + const mageClearcasting = member.classId === "mage" && auras.some((aura) => ( + aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) + )); + return (adjustedAbility.costs?.length ? adjustedAbility.costs : [adjustedAbility.cost]).map((cost) => { const maximum = cost.percentageBasis === "base-mana" ? baseResourcesFor(member.classId, member.level).mana : cost.resource === "health" ? member.maxHealth : companionResourceMaximum(member, cost.resource); @@ -64,7 +149,12 @@ function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly "power", "cost", ); - return { resource: cost.resource, amount: Math.max(0, Math.round(adjusted)) }; + return { + resource: cost.resource, + amount: cost.resource === "mana" && partyHunterLockAndLoadReady(member, ability, now) + ? 0 + : cost.resource !== "health" && mageClearcasting ? 0 : Math.max(0, Math.round(adjusted)), + }; }); } @@ -75,10 +165,17 @@ export function partyAbilityIsReady( ignoreResourceCosts = false, ): boolean { if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false; - if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now) || (member.cooldowns[ability.id] ?? 0) > now) return false; + if (romPriestWarriorStanceRequired(ability)) { + const activeAuraTags = useCombatStore.getState().auras + .filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) + .flatMap((aura) => aura.definition.utilityTags ?? []); + if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false; + } + if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now) + || (!partyHunterLockAndLoadReady(member, ability, now) && (member.cooldowns[ability.id] ?? 0) > now)) return false; if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false; if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false; - if (!ignoreResourceCosts && abilityCosts(member, ability).some(cost => cost.resource === "health" + if (!ignoreResourceCosts && partyAbilityCosts(member, ability, now).some(cost => cost.resource === "health" ? member.health <= cost.amount : currentResource(member, cost.resource) < cost.amount)) return false; const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null; return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null; @@ -116,7 +213,7 @@ function shouldReserveForHigherPriority( if ((member.schoolLockedUntil[candidate.castRules?.school ?? "physical"] ?? 0) > now) return false; const categories = aiHintsForAbility(candidate).categories; if (!categories.includes("damage") || categories.includes("execute")) return false; - const blockedCosts = abilityCosts(member, candidate).filter((cost) => ( + const blockedCosts = partyAbilityCosts(member, candidate).filter((cost) => ( cost.amount > currentResource(member, cost.resource) )); if (!blockedCosts.length) return false; @@ -216,7 +313,7 @@ export function commitPartyAbilityUse( const lastResourceSpentAt = { ...member.lastResourceSpentAt }; let resource = member.resource; let health = member.health; - for (const cost of options.ignoreResourceCosts ? [] : abilityCosts(member, ability)) { + for (const cost of options.ignoreResourceCosts ? [] : partyAbilityCosts(member, ability, now)) { const amount = cost.amount; if (cost.resource === "health") { health -= amount; continue; } const current = currentResource(member, cost.resource); @@ -227,6 +324,14 @@ export function commitPartyAbilityUse( const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null; const runes = runeCost ? spendDeathKnightRunes(member.runes, runeCost, now) : member.runes; if (!runes) return null; + const cooldownMs = member.classId === "mage" + ? Math.max(0, ability.cooldownMs - wow335MageImprovedCounterspellCooldownReductionMs( + member.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0, + ability.name, + )) + : ability.cooldownMs; + const knightWarriorCooldownReductionMs = partyKnightWarriorWhirlwindAdjustment(member, ability).cooldownReductionMs; + const adjustedCooldownMs = Math.max(0, cooldownMs - knightWarriorCooldownReductionMs); return { ...member, health, @@ -234,8 +339,8 @@ export function commitPartyAbilityUse( resourcePools, lastResourceSpentAt, runes, - cooldowns: ability.cooldownMs > 0 - ? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs } + cooldowns: adjustedCooldownMs > 0 + ? { ...member.cooldowns, [ability.id]: now + adjustedCooldownMs } : member.cooldowns, globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt, tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt") @@ -273,11 +378,20 @@ export function partyAbilityAmount( : effect.kind === "finisher-damage" ? effect.baseCoefficient + effect.perComboCoefficient * member.comboPoints : 0; - return equipmentAdjustedCombatAmount( + const baseAmount = equipmentAdjustedCombatAmount( "coefficient" in effect ? effect : coefficient, member.level, member.gearStats, member.classId, ability, ) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0); + if (member.classId === "druid" && (effect.kind === "damage" || effect.kind === "dot")) { + return applyTalentModifierValue( + baseAmount, + talentModifiersForRanks(member.talentRanks, "druid"), + "damage-percent", + { abilityName: ability.name }, + ); + } + return baseAmount; } diff --git a/src/game/partyRuntime.test.ts b/src/game/partyRuntime.test.ts index 086c1e35..67e99c53 100644 --- a/src/game/partyRuntime.test.ts +++ b/src/game/partyRuntime.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { useCombatStore } from "./combatStore"; +import { useCombatStore, type EnemyActiveCast } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, @@ -23,8 +23,9 @@ import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents"; -import { abilitiesForClass } from "./abilityCatalog"; +import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog"; import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime"; +import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts"; import { baseClassFor } from "../app/characterCatalog"; function registerEngagedMob(id = "pack:a", maxHealth = 200): void { @@ -36,6 +37,60 @@ function registerEngagedMob(id = "pack:a", maxHealth = 200): void { } } +function stableUnit(seed: string): number { + let hash = 2166136261; + for (const character of seed) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function partyHealCriticalAt(sourceId: string, abilityId: string, targetId: string, chance: number): number { + for (let now = 10_000; now < 1_000_000; now += 1) { + if (stableUnit(`${sourceId}:${abilityId}:${targetId}:${now}:heal`) < chance) return now; + } + throw new Error("Could not find a seeded companion healing crit."); +} + +function createFrostbitePartyMage(seed: string) { + const mage = createConfiguredPartyMember({ + name: "Frostbite Mage", + classId: "mage", + specialization: "Frost", + specializationId: "mage:frost", + talentTreeId: "mage-frost", + talentRanks: { "talent-38": 3 }, + role: "damage", + }, 80, 0, seed); + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); + usePartyStore.setState({ members: [{ + ...mage, + gearStats: EMPTY_ITEM_STATS, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...mage.resourcePools, mana: 10_000 }, + }] }); + registerPartyRuntimePosition(mage.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + return mage; +} + +function registerFrostbiteTarget(id: string): void { + useCombatStore.getState().registerMob(id, { + name: id, + level: 1, + maxHealth: 100_000, + xpReward: 0, + }); + setMobPosition(id, [2, 0, 0]); + useCombatStore.getState().damageMob(id, 1, 100); + for (const member of usePartyStore.getState().members) { + updatePartyRuntimeTargetVisibility(member.id, id, true); + } + usePartyStore.setState({ command: "attack", activeMobId: id }); +} + describe("party combat runtime", () => { beforeEach(() => { clearMobRuntimeRegistry(); @@ -75,6 +130,361 @@ describe("party combat runtime", () => { expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt); }); + it("refreshes a companion Priest's Shadow Word: Pain from its own completed Mind Blast", () => { + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + const priest = createConfiguredPartyMember({ + name: "Shadow Priest", + classId: "priest", + specialization: "Shadow", + specializationId: "priest:shadow", + talentTreeId: "priest-shadow", + role: "damage", + }, 80, 0, "pain-and-suffering-companion"); + const priestAbilities = abilitiesForClass("priest"); + const shadowWordPainBase = priestAbilities.find((ability) => ability.name === "Shadow Word: Pain")!; + const shadowWordPain = abilityAtLevel(shadowWordPainBase, 80); + const shadowWordPainDot = shadowWordPain.effects.find((effect) => effect.kind === "dot")!; + const mindBlast = abilityAtLevel(priestAbilities.find((ability) => ability.name === "Mind Blast")!, 80); + const targetId = "pain-and-suffering-companion-target"; + usePartyStore.getState().initializeParty("pain-and-suffering-companion", 80, + useCombatStore.getState().health, "damage"); + usePartyStore.setState({ members: [{ + ...priest, + talentRanks: { "talent-1909": 3 }, + gearStats: EMPTY_ITEM_STATS, + activeCast: { + abilityId: mindBlast.id, + school: "shadow", + mode: "cast", + startedAt: 10_000, + completesAt: 10_000 + mindBlast.castTimeMs, + originalDurationMs: mindBlast.castTimeMs, + pushbackHits: 0, + origin: [0, 0, 0], + targetId, + }, + activeCastTargetId: targetId, + status: "attacking", + }] }); + registerPartyRuntimePosition(priest.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + registerEngagedMob(targetId, 100_000); + expect(useCombatStore.getState().applyTimedActorEffect( + priest.id, targetId, shadowWordPain, shadowWordPainDot, 1_000, + )).toBe(true); + const before = useCombatStore.getState().effects.find((effect) => ( + effect.kind === "dot" && effect.sourceActorId === priest.id && effect.targetId === targetId + ))!; + + advanceDungeonPartyCombat(mindBlast.castTimeMs + 10_000); + const after = useCombatStore.getState().effects.find((effect) => ( + effect.kind === "dot" && effect.sourceActorId === priest.id && effect.targetId === targetId + ))!; + expect(after.remainingTicks).toBe(shadowWordPainDot.ticks); + expect(after.nextTickAt).toBe(10_000 + mindBlast.castTimeMs + after.intervalMs); + expect(after.fixedAmount).toBe(before.fixedAmount); + expect(after.intervalMs).toBe(before.intervalMs); + }); + + it("lets a party Mage Frostbite freeze after landed direct Frostbolt damage", () => { + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + const seed = "party-mage-frostbite-direct"; + const mage = createFrostbitePartyMage(seed); + const frostbolt = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Frostbolt")!, 80); + const targetId = "party-mage-frostbite-direct-target"; + const impactAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index) + .find((now) => stableUnit(`${mage.id}:${frostbolt.id}:${targetId}:${now}:wow335-mage-frostbite`) < 0.15); + if (impactAt === undefined) throw new Error("Could not find a seeded party Frostbite proc time."); + usePartyStore.setState({ members: [{ + ...usePartyStore.getState().members[0]!, + activeCast: { + abilityId: frostbolt.id, + school: "frost", + mode: "cast", + startedAt: impactAt - frostbolt.castTimeMs, + completesAt: impactAt, + originalDurationMs: frostbolt.castTimeMs, + pushbackHits: 0, + origin: [0, 0, 0], + targetId, + }, + activeCastTargetId: targetId, + status: "attacking", + }] }); + useCombatStore.getState().synchronizeActors(); + registerFrostbiteTarget(targetId); + const beforeHealth = useCombatStore.getState().mobs[targetId]!.health; + + advanceDungeonPartyCombat(impactAt - frostbolt.castTimeMs); + advanceDungeonPartyCombat(impactAt); + + expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(beforeHealth); + expect(useCombatStore.getState().mobs[targetId]!.statuses).toContainEqual(expect.objectContaining({ + kind: "freeze", + sourceAbilityId: frostbolt.id, + sourceActorId: mage.id, + endsAt: impactAt + 4_000, + })); + }); + + it("lets a party Mage Frostbite proc on a landed Blizzard channel pulse", () => { + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + const seed = "party-mage-frostbite-blizzard"; + const mage = createFrostbitePartyMage(seed); + const blizzard = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Blizzard")!, 80); + const targetId = "party-mage-frostbite-blizzard-target"; + const durationMs = blizzard.castTimeMs; + expect(blizzard.castMode).toBe("channel"); + expect(durationMs).toBeGreaterThan(0); + + let startedAt = 0; + let procAt = 0; + for (let start = 10_000; start < 20_000 && procAt === 0; start += 1) { + const pulses = Array.from({ length: channelPulseCount(blizzard) }, (_, index) => ( + channelPulseAt(blizzard, start, durationMs, index) + )); + const proc = pulses.find((pulseAt) => ( + stableUnit(`${mage.id}:${blizzard.id}:${targetId}:${pulseAt}:wow335-mage-frostbite`) < 0.15 + )); + if (proc !== undefined) { + startedAt = start; + procAt = proc; + } + } + if (procAt === 0) throw new Error("Could not find a seeded party Blizzard Frostbite pulse."); + usePartyStore.setState({ members: [{ + ...usePartyStore.getState().members[0]!, + activeCast: { + abilityId: blizzard.id, + school: "frost", + mode: "channel", + startedAt, + completesAt: startedAt + durationMs, + originalDurationMs: durationMs, + pushbackHits: 0, + origin: [0, 0, 0], + targetId, + channelTicksCompleted: 0, + }, + activeCastTargetId: targetId, + status: "attacking", + }] }); + useCombatStore.getState().synchronizeActors(); + registerFrostbiteTarget(targetId); + const beforeHealth = useCombatStore.getState().mobs[targetId]!.health; + + advanceDungeonPartyCombat(startedAt); + advanceDungeonPartyCombat(procAt); + + expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(beforeHealth); + expect(useCombatStore.getState().mobs[targetId]!.statuses).toContainEqual(expect.objectContaining({ + kind: "freeze", + sourceAbilityId: blizzard.id, + sourceActorId: mage.id, + endsAt: procAt + 4_000, + })); + }); + + it("lets a party Mage proc its learned Eruption passive on Fireball", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-mage", level: 80 }); + const seed = "party-rom-mage-eruption"; + const mage = createConfiguredPartyMember({ + name: "Eruption Mage", + classId: "rom-mage", + secondaryClassId: "rom-druid", + specialization: "Primary", + role: "damage", + }, 80, 0, seed); + const fireball = abilitiesForClass("rom-mage").find((ability) => ability.name === "Fireball")!; + const targetId = "party-rom-mage-eruption-target"; + const impactAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index) + .find((now) => stableUnit(`${mage.id}:${fireball.id}:${targetId}:${now}:rom-eruption`) < 0.1); + if (impactAt === undefined) throw new Error("Could not find a seeded party Eruption proc time."); + + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom"); + usePartyStore.getState().setCommand("attack"); + usePartyStore.setState({ members: [{ + ...mage, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...mage.resourcePools, mana: 10_000 }, + activeCast: { + abilityId: fireball.id, + school: "fire", + mode: "cast", + startedAt: impactAt, + completesAt: impactAt, + originalDurationMs: 0, + pushbackHits: 0, + origin: [0, 0, 0], + targetId, + }, + activeCastTargetId: targetId, + status: "attacking", + }] }); + registerPartyRuntimePosition(mage.id, [0, 0, 0]); + registerFrostbiteTarget(targetId); + updatePartyRuntimeTargetVisibility(mage.id, targetId, true); + useCombatStore.getState().synchronizeActors(); + + advanceDungeonPartyCombat(impactAt); + + expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(99_999); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === mage.id + && aura.definition.id === "rom-mage-druid-control-flame-buff")).toBe(true); + }); + + it("procs a party Paladin's source-owned Vindication from its completed Holy spell", () => { + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + const seed = "vindication-paladin-party"; + const paladin = createConfiguredPartyMember({ + name: "Vindication Paladin", + classId: "paladin", + specialization: "Retribution", + specializationId: "paladin:retribution", + talentTreeId: "paladin-retribution", + talentRanks: { "talent-1633": 3 }, + role: "damage", + }, 80, 0, seed); + const exorcism = abilityAtLevel(abilitiesForClass("paladin").find((ability) => ability.name === "Exorcism")!, 80); + const targetId = "vindication-paladin-party-target"; + const completionAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index) + .find((now) => stableUnit(`${paladin.id}:${exorcism.id}:${targetId}:${now}:wow335-paladin-vindication`) < 0.65); + if (completionAt === undefined) throw new Error("Could not find a seeded party Vindication proc time."); + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); + usePartyStore.setState({ members: [{ + ...paladin, + gearStats: EMPTY_ITEM_STATS, + activeCast: { + abilityId: exorcism.id, + school: "holy", + mode: "cast", + startedAt: completionAt - exorcism.castTimeMs, + completesAt: completionAt, + originalDurationMs: exorcism.castTimeMs, + pushbackHits: 0, + origin: [0, 0, 0], + targetId, + }, + activeCastTargetId: targetId, + status: "attacking", + }] }); + registerPartyRuntimePosition(paladin.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + registerEngagedMob(targetId, 100_000); + + advanceDungeonPartyCombat(completionAt); + + expect(useCombatStore.getState().mobs[targetId].auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "wow335:paladin:vindication", + sourceActorId: paladin.id, + sourceAbilityId: exorcism.id, + endsAt: completionAt + 10_000, + magnitude: 357, + }), + ])); + }); + + it("grants a party Rogue Improved Kick only after it interrupts an active cast", () => { + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + const seed = "improved-kick-party-runtime"; + const rogue = createConfiguredPartyMember({ + name: "Combat Rogue", + classId: "rogue", + specialization: "Combat", + specializationId: "rogue:combat", + talentTreeId: "rogue-combat", + talentRanks: { "talent-206": 2 }, + role: "damage", + }, 80, 0, seed); + const kick = abilitiesForClass("rogue").find((ability) => ability.name === "Kick"); + if (!kick) throw new Error("Missing generated Rogue Kick"); + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); + usePartyStore.setState({ members: [{ + ...rogue, + resource: 0, + resourcePools: { ...rogue.resourcePools, energy: 0 }, + activeCast: { + abilityId: kick.id, + school: "physical", + mode: "cast", + startedAt: 900, + completesAt: 900, + originalDurationMs: 0, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: "improved-kick-party-target", + }, + activeCastTargetId: "improved-kick-party-target", + status: "attacking", + }] }); + registerPartyRuntimePosition(rogue.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + const targetId = "improved-kick-party-target"; + useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 100_000, xpReward: 0 }); + setMobPosition(targetId, [2, 0, 0]); + useCombatStore.getState().damageMob(targetId, 1, 100); + updatePartyRuntimeTargetVisibility(rogue.id, targetId, true); + const hostileCast: EnemyActiveCast = { + attackId: "party-test-shadow-bolt", + name: "Shadow Bolt", + spellId: 686, + school: "shadow", + interruptible: true, + targetActorId: null, + sourcePosition: [2, 0, 0], + targetPosition: [0, 0, 0], + sourceYaw: 0, + startedAt: 500, + completesAt: 2_500, + }; + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, activeCast: hostileCast } }, + })); + + advanceDungeonPartyCombat(1_000); + + expect(useCombatStore.getState().mobs[targetId]?.activeCast).toBeNull(); + expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ + sourceId: rogue.id, + targetId: rogue.id, + appliedAt: 1_000, + expiresAt: 7_000, + definition: expect.objectContaining({ + utilityTags: ["wow335-rogue-improved-kick"], + modifiers: [ + { kind: "stat", stat: "melee-haste", operation: "flat", value: 35 }, + { kind: "stat", stat: "ranged-haste", operation: "flat", value: 35 }, + ], + }), + })); + expect(usePartyStore.getState().members[0]?.resourcePools.energy).toBe(40); + + useCombatStore.getState().selectMob(targetId); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => ({ + ...member, + nextActionAt: 10_000, + gearStats: { ...member.gearStats, hasteRating: 0 }, + })), + })); + useCombatStore.getState().synchronizeActors(); + const autoAttackTimes: number[] = []; + const unsubscribe = subscribeCombatPresentation((message) => { + if (message.kind === "event" + && message.event.sourceActorId === rogue.id + && message.event.abilityId === "party-auto-attack" + && message.event.phase === "impact") { + autoAttackTimes.push(message.event.occurredAt); + } + }); + advanceDungeonPartyCombat(2_200); + advanceDungeonPartyCombat(3_400); + unsubscribe(); + expect(autoAttackTimes).toEqual([2_200, 3_400]); + }); + it("precasts Earth Shield on the tank while the Restoration Shaman is at full health", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const tank = createConfiguredPartyMember({ @@ -354,6 +764,62 @@ describe("party combat runtime", () => { .toBeGreaterThan(startingHealth); }); + it("applies a Shaman owner's Elemental Oath to party-caster healing crits", () => { + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80 }); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-2049": 3 }, + })); + const tank = createConfiguredPartyMember({ + name: "Tank", classId: "warrior", specialization: "Protection", + specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", + }, 80, 0, "elemental-oath-party-heal"); + const healer = createConfiguredPartyMember({ + name: "Healer", classId: "priest", specialization: "Holy", + specializationId: "priest:holy", talentTreeId: "priest-holy", role: "healer", + }, 80, 1, "elemental-oath-party-heal"); + const greaterHeal = abilitiesForClass("priest").find((ability) => ability.name === "Greater Heal")!; + const startingHealth = Math.round(tank.maxHealth * 0.3); + usePartyStore.getState().initializeParty("elemental-oath-party-heal", 80, + useCombatStore.getState().health, "damage"); + usePartyStore.setState({ + members: [ + { ...tank, health: startingHealth, gearStats: EMPTY_ITEM_STATS }, + { + ...healer, + gearStats: EMPTY_ITEM_STATS, + resourceType: "mana", + resourceName: "Mana", + resource: 10_000, + maxResource: 10_000, + resourcePools: { mana: 10_000 }, + cooldowns: Object.fromEntries(abilitiesForClass("priest") + .filter((ability) => ability.id !== greaterHeal.id) + .map((ability) => [ability.id, 60_000])), + }, + ], + }); + registerPartyRuntimePosition(tank.id, [0, 0, 0]); + registerPartyRuntimePosition(healer.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + registerEngagedMob("elemental-oath-party-heal-boss", 100_000); + + const impactAt = partyHealCriticalAt(healer.id, greaterHeal.id, tank.id, 0.03); + const castStartAt = impactAt - greaterHeal.castTimeMs; + advanceDungeonPartyCombat(castStartAt); + const activeCast = usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast; + expect(activeCast?.abilityId).toBe(greaterHeal.id); + expect(activeCast?.completesAt).toBe(impactAt); + advanceDungeonPartyCombat(impactAt); + + expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({ + kind: "healing", + sourceActorId: healer.id, + targetActorId: tank.id, + abilityId: greaterHeal.id, + critical: true, + })); + }); + it("keeps companion Penance active for three healing pulses", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const tank = createConfiguredPartyMember({ diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index 3aa8888c..652a749e 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -18,7 +18,7 @@ import { targetingRulesForAbility, } from "./abilityRules"; import type { MobCombatState } from "./combatStore"; -import { useCombatStore } from "./combatStore"; +import { abilityDamageSchool, refreshPriestPainAndSufferingDot, stableCombatUnit, useCombatStore } from "./combatStore"; import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs"; import { activeRaceOrbId } from "./dungeonRaceAffixRuntime"; import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority"; @@ -39,7 +39,22 @@ import { useGameStore } from "./store"; import { useManastormStore } from "./manastormStore"; import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime"; import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro"; -import type { ActiveAura, DispelCategory } from "./combatAuras"; +import { + applyAura as applyCombatAura, + resolveStatValue as resolveAuraStatValue, + type ActiveAura, + type DispelCategory, +} from "./combatAuras"; +import { + ROM_ROGUE_POISON_AURA_TAG, + ROM_ROGUE_POISON_SKILL_ID, + romRoguePoisonApplicationForAttack, +} from "./romRoguePoisonRuntime"; +import { + ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID, + romRogueChampionNerveGearEffect, + romRogueChampionNerveGearShouldTrigger, +} from "./romRogueChampionNerveGearRuntime"; import { equipmentAttackPower, hastedDuration, @@ -60,7 +75,10 @@ import { choosePartyAbility, choosePartyRotationDecision, commitPartyAbilityUse, + partyAbilityWithHunterLockAndLoad, + partyHunterLockAndLoadReady, partyAbilityAmount, + partyAbilityCosts, } from "./partyAbilityAi"; import { minimumHostileTargetsForAbility } from "./combatRotation"; import { emitRotationDecision } from "./rotationTelemetry"; @@ -68,8 +86,65 @@ import { imminentCooldownMechanic } from "./companionCooldownPolicy"; import { normalizeActorResourcePool } from "./combatActors"; import { enemyControlPreventsActions } from "./enemyAttacks"; import { advanceCombatResourcePool } from "./combatResources"; +import { wow335DeathKnightSuddenDoomTriggerForLandedHit } from "./wow335DeathKnightSuddenDoomRuntime"; +import { + WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID, + wow335DeathKnightScentOfBloodOnMeleeHit, +} from "./wow335DeathKnightScentOfBloodRuntime"; +import { + WOW335_MAGE_ARCANE_CONCENTRATION_AURA, + WOW335_MAGE_ARCANE_CONCENTRATION_TAG, +} from "./wow335MageTalentRuntime"; +import { + WOW335_MAGE_FROSTBITE_TALENT_ID, + wow335MageFrostbiteProcDurationMs, +} from "./wow335MageFrostbiteRuntime"; +import { + WOW335_MAGE_ARCANE_POTENCY_AURA, + WOW335_MAGE_ARCANE_POTENCY_TAG, + WOW335_MAGE_ARCANE_POTENCY_TALENT_ID, + WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID, + wow335MageArcanePotencyCanProc, + wow335MageArcanePotencyCastProfile, + wow335MageMasterOfElementsManaRefund, +} from "./wow335MageConditionalTalentRuntime"; +import { + WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID, + wow335MageEmpoweredFireEffect, +} from "./wow335MageEmpoweredFireRuntime"; import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts"; import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules"; +import { + romPriestWarriorStanceRequired, + romPriestWarriorStanceUseAllowed, +} from "./romPriestKnightOverrides"; +import { + WOW335_MAGE_FLAME_THROWING_TALENT_ID, + wow335MageFlameThrowingProfile, +} from "./wow335MageFlameThrowingRuntime"; +import { + WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID, + wow335MageTormentTheWeakDamage, +} from "./wow335MageTormentTheWeakRuntime"; +import { + WOW335_MAGE_MOLTEN_FURY_TALENT_ID, + wow335MageMoltenFuryDamage, +} from "./wow335MageMoltenFuryRuntime"; +import { + WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID, + wow335MageImprovedCounterspellSilenceDurationMs, +} from "./wow335MageImprovedCounterspellRuntime"; +import { + consumeLockAndLoadCharge, + WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG, +} from "./wow335HunterRuntime"; +import { + WOW335_WARRIOR_ANGER_MANAGEMENT_CHARGE_AURA_TAG, + WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID, + wow335WarriorAngerManagementProfile, + wow335WarriorChargeRageProfile, + wow335WarriorChargeRank, +} from "./wow335WarriorAngerManagementRuntime"; import { combatHasLineOfSight, combatSpatialActor, @@ -84,10 +159,59 @@ import { ROGUE_DEADLY_POISON_DOSE_TAG, ROGUE_ENVENOM_BUFF_TAG, } from "./roguePoison"; +import { wow335RogueImprovedKickAuraEffectForRank } from "./wow335RogueTalentRuntime"; +import { + WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG, + warlockMetamorphosisCanCastAbilityWhileActive, + warlockMetamorphosisFormActionReady, +} from "./wow335WarlockMetamorphosisRuntime"; +import { + wow335ShamanImprovedWaterShieldShouldProc, + wow335ShamanWaterShieldOrbManaAtLevel, +} from "./wow335ShamanImprovedWaterShieldRuntime"; +import { + WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG, + WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, + wow335ShamanTidalWavesBonusHealingPercent, + wow335ShamanTidalWavesCastTimeMultiplier, + wow335ShamanTidalWavesChargesForTrigger, + wow335ShamanTidalWavesCriticalChanceBonusPercent, + wow335ShamanTidalWavesProcChance, +} from "./wow335ShamanTidalWavesRuntime"; +import { + WARLOCK_BACKDRAFT_READY_TAG, + warlockBackdraftDestructionSpellEligible, + warlockBackdraftEffectsForRank, + warlockBackdraftTriggerEligible, +} from "./wow335WarlockTalentRuntime"; 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"; +import { + ROM_MAGE_KNIGHT_LIGHT_OF_DAY, + ROM_MAGE_MEDITATIVE_CURRENT, + ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT, + romMageMeditativeCurrentProc, + romMageMeditativeCurrentReadyAt, +} from "./romMageRuntime"; +import { + ROM_PRIEST_MAGE_ENLIGHTENMENT, + ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT, + romPriestMageEnlightenmentProc, + romPriestMageEnlightenmentReadyAt, +} from "./romPriestMageRuntime"; +import { ROM_SCOUT_PRIEST_DRUMS_OF_WAR } from "./romScoutPriestOverrides"; +import { + ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT, + ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT, + ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID, + ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, + ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, + romMageWarriorMeditationPathAllowsMovement, + romMageWarriorMeditationPathCanProc, + romMageWarriorMeditationPathHasState, +} from "./romMageWarriorRuntime"; const OUT_OF_COMBAT_REGEN_FRACTION = 0.06; const ATTACK_ACQUISITION_RANGE = 30; @@ -95,6 +219,330 @@ const HEALER_ACTION_INTERVAL_MS = 2_000; const TANK_TAUNT_RANGE = 30; const TANK_TAUNT_COOLDOWN_MS = 8_000; const TANK_TAUNT_DURATION_MS = 3_000; +const PARTY_SHAMAN_RIPTIDE_ABILITY_ID = "wow335-shaman-riptide"; +const PARTY_SHAMAN_RIPTIDE_CHAIN_HEAL_MULTIPLIER = 1.25; + +function stablePartyUnit(seed: string): number { + let hash = 2_166_136_261; + for (const character of seed) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +interface PartyShamanTidalWavesCast { + readonly ability: AbilityDefinition; + readonly auraInstanceId: string; + readonly chargesConsumed: number; +} + +function partyShamanTidalWavesCast( + member: PartyMember, + ability: AbilityDefinition, + now: number, +): PartyShamanTidalWavesCast | null { + if (member.classId !== "shaman" || ability.source === "runewaker" + || !["Healing Wave", "Lesser Healing Wave"].includes(ability.name) + || wow335ShamanTidalWavesProcChance(member.talentRanks["talent-2063"] ?? 0) <= 0) return null; + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.sourceId === member.id + && candidate.targetId === member.id + && candidate.stacks > 0 + && candidate.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) + && (candidate.expiresAt === null || candidate.expiresAt > now) + )); + if (!aura) return null; + return { + ability: ability.name === "Healing Wave" + ? { ...ability, castTimeMs: Math.max(0, Math.round( + ability.castTimeMs * wow335ShamanTidalWavesCastTimeMultiplier(aura.stacks), + )) } + : ability, + auraInstanceId: aura.instanceId, + chargesConsumed: aura.stacks, + }; +} + +function consumePartyShamanTidalWaves(memberId: string, auraInstanceId: string): void { + useCombatStore.setState((state) => ({ + auras: state.auras.flatMap((aura) => { + if (aura.instanceId !== auraInstanceId || aura.sourceId !== memberId || aura.targetId !== memberId) return [aura]; + const remaining = Math.max(0, aura.stacks - 1); + return remaining > 0 ? [{ ...aura, stacks: remaining }] : []; + }), + })); +} + +function partyShamanTidalWavesBonusHealing(member: PartyMember, ability: AbilityDefinition, now: number): number { + if (member.classId !== "shaman" || ability.source === "runewaker" + || !["Healing Wave", "Lesser Healing Wave"].includes(ability.name)) return 0; + const rank = member.talentRanks["talent-2063"] ?? 0; + const bonusPercent = wow335ShamanTidalWavesBonusHealingPercent(rank); + if (bonusPercent <= 0) return 0; + const auras = useCombatStore.getState().auras.filter((aura) => ( + aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) + )); + const resolvedBonusHealing = resolveAuraStatValue(member.gearStats.spellPower, auras, "spell-power"); + return Math.round(Math.max(0, resolvedBonusHealing) * bonusPercent); +} + +function partyShamanRiptideHotOnTarget(memberId: string, targetId: string, now: number) { + return useCombatStore.getState().effects.find((effect) => effect.kind === "hot" + && effect.sourceActorId === memberId + && effect.sourceAbilityId === PARTY_SHAMAN_RIPTIDE_ABILITY_ID + && effect.targetId === targetId + && effect.remainingTicks > 0 + && effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs > now); +} + +function maybeProcPartyShamanImprovedWaterShield( + member: PartyMember, + ability: AbilityDefinition, + targetId: string, + school: CombatPresentationSchool, + critical: boolean, + now: number, +): PartyMember { + if (member.classId !== "shaman" || ability.source === "runewaker" + || !wow335ShamanImprovedWaterShieldShouldProc( + member.talentRanks["talent-583"] ?? 0, + ability.name, + school, + critical, + stablePartyUnit(`${member.id}:${ability.id}:${targetId}:${now}:wow335-shaman-improved-water-shield`), + )) return member; + const requested = wow335ShamanWaterShieldOrbManaAtLevel(member.level); + if (requested <= 0) return member; + const combat = useCombatStore.getState(); + const restored = combat.restoreActorResource( + member.id, "mana", requested, false, member.id, "wow335-shaman-improved-water-shield", now, + ); + if (restored <= 0) return member; + const mirrored = memberFromCompanionActor(member, useCombatStore.getState().actors[member.id]); + return { + ...member, + ...(member.resourceType === "mana" ? { resource: mirrored.resource, maxResource: mirrored.maxResource } : {}), + resourcePools: { ...member.resourcePools, mana: mirrored.resourcePools.mana }, + lastResourceSpentAt: { ...member.lastResourceSpentAt, mana: mirrored.lastResourceSpentAt.mana }, + }; +} + +function maybeProcPartyShamanTidalWaves( + member: PartyMember, + ability: AbilityDefinition, + rawHealingAmount: number, + now: number, +): void { + if (member.classId !== "shaman" || ability.source === "runewaker" || rawHealingAmount <= 0) return; + const charges = wow335ShamanTidalWavesChargesForTrigger(ability.name); + const chance = wow335ShamanTidalWavesProcChance(member.talentRanks["talent-2063"] ?? 0); + if (charges <= 0 || chance <= 0 + || stablePartyUnit(`${member.id}:${ability.id}:${now}:wow335-shaman-tidal-waves`) >= chance) return; + useCombatStore.getState().applyActorAura(member.id, member.id, ability, { + kind: "apply-aura", + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + aura: { + id: "wow335:shaman:tidal-waves", + name: "Tidal Waves", + description: "Charges reduce Healing Wave cast time or increase Lesser Healing Wave critical chance.", + disposition: "buff", + durationMs: WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, + maxStacks: 3, + initialStacks: charges, + stackBehavior: "add", + utilityTags: [WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG], + }, + }, now); +} + +function grantPartyImprovedKickCapstone(member: PartyMember, ability: AbilityDefinition, now: number): PartyMember { + if (member.classId !== "rogue" || ability.name !== "Kick") return member; + const effect = wow335RogueImprovedKickAuraEffectForRank(member.talentRanks["talent-206"] ?? 0); + if (!effect) return member; + const combat = useCombatStore.getState(); + combat.applyActorAura(member.id, member.id, ability, effect, now); + combat.restoreActorResource(member.id, "energy", 40, false, member.id, ability.id, now); + const refreshed = usePartyStore.getState().members.find((candidate) => candidate.id === member.id); + return refreshed ? { + ...member, + resource: refreshed.resource, + resourcePools: refreshed.resourcePools, + lastResourceSpentAt: refreshed.lastResourceSpentAt, + } : member; +} + +const WOW335_MAGE_ARCANE_CONCENTRATION_TALENT_ID = "talent-75"; + +function partyMageArcanePotencyBonus( + member: PartyMember, + ability: AbilityDefinition, + auraActive: boolean, +): number { + if (member.classId !== "mage") return 0; + const damagingSpell = presentationSchoolForAbility(ability) !== "physical" + && ability.effects.some((effect) => ["damage", "dot", "finisher-damage"].includes(effect.kind)); + return wow335MageArcanePotencyCastProfile( + member.talentRanks[WOW335_MAGE_ARCANE_POTENCY_TALENT_ID] ?? 0, + auraActive, + damagingSpell, + ).criticalChanceBonusPercent; +} + +function consumePartyMageCastAuras(member: PartyMember, ability: AbilityDefinition): void { + if (member.classId !== "mage" || ability.passive) return; + useCombatStore.setState((state) => ({ + auras: state.auras.filter((aura) => !(aura.targetId === member.id + && (aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) + || aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_POTENCY_TAG)))), + })); +} + +function consumePartyHunterLockAndLoad(memberId: string): void { + const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === memberId + && candidate.stacks > 0 + && candidate.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)); + if (!aura) return; + useCombatStore.setState((state) => ({ + auras: [...consumeLockAndLoadCharge(state.auras, aura.instanceId)], + })); +} + +function applyPartyMageArcanePotency( + member: PartyMember, + ability: AbilityDefinition, + trigger: "presence-of-mind" | "arcane-concentration-clearcasting", + now: number, +): void { + if (member.classId !== "mage" + || !wow335MageArcanePotencyCanProc(member.talentRanks[WOW335_MAGE_ARCANE_POTENCY_TALENT_ID] ?? 0, trigger)) return; + useCombatStore.getState().applyActorAura(member.id, member.id, ability, { + kind: "apply-aura", + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + aura: WOW335_MAGE_ARCANE_POTENCY_AURA, + }, now); +} + +function maybeProcPartyMageArcaneConcentration( + member: PartyMember, + ability: AbilityDefinition, + targetId: string, + now: number, +): void { + if (member.classId !== "mage" || ability.source === "runewaker" + || presentationSchoolForAbility(ability) === "physical") return; + const rank = Math.min(3, Math.max(0, Math.trunc( + member.talentRanks[WOW335_MAGE_ARCANE_CONCENTRATION_TALENT_ID] ?? 0, + ))); + const chance = rank * 0.05; + if (chance <= 0 || stablePartyUnit( + `${member.id}:${ability.id}:${targetId}:${now}:wow335-mage-arcane-concentration`, + ) >= chance) return; + useCombatStore.getState().applyActorAura(member.id, member.id, ability, { + kind: "apply-aura", + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + aura: WOW335_MAGE_ARCANE_CONCENTRATION_AURA, + }, now); + applyPartyMageArcanePotency(member, ability, "arcane-concentration-clearcasting", now); +} + +interface PartyWarlockBackdraftCast { + readonly ability: AbilityDefinition; + readonly auraInstanceId: string; + readonly castTimeMs: number; + readonly gcdMs: number; +} + +function partyWarlockBackdraftCast( + member: PartyMember, + ability: AbilityDefinition, + now: number, +): PartyWarlockBackdraftCast | null { + if (member.classId !== "warlock" || !warlockBackdraftDestructionSpellEligible(ability.name)) return null; + const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-1888"] ?? 0))); + const effects = warlockBackdraftEffectsForRank(rank); + if (effects.rank <= 0) return null; + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.sourceId === member.id + && candidate.targetId === member.id + && candidate.stacks > 0 + && candidate.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + && (candidate.expiresAt === null || candidate.expiresAt > now) + )); + if (!aura) return null; + const haste = raceAffixHasteMultiplier(now); + const reduction = effects.reductionPercent / 100; + const castTimeMs = Math.max(0, Math.round(Math.round(ability.castTimeMs / haste) * (1 - reduction))); + const gcdMs = ability.gcdMs > 0 + ? Math.max(1_000, Math.round(Math.round(ability.gcdMs / haste) * (1 - reduction))) + : 0; + return { + ability: { ...ability, castTimeMs, gcdMs }, + auraInstanceId: aura.instanceId, + castTimeMs, + gcdMs, + }; +} + +function consumePartyWarlockBackdraftCharge(memberId: string, auraInstanceId: string): void { + useCombatStore.setState((state) => ({ + auras: state.auras.flatMap((aura) => { + if (aura.instanceId !== auraInstanceId || aura.sourceId !== memberId || aura.targetId !== memberId) return [aura]; + return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; + }), + })); +} + +function applyPartyWarlockBackdraft(member: PartyMember, ability: AbilityDefinition, now: number): void { + if (member.classId !== "warlock" || !warlockBackdraftTriggerEligible(ability.name)) return; + const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-1888"] ?? 0))); + const effects = warlockBackdraftEffectsForRank(rank); + if (effects.rank <= 0) return; + useCombatStore.getState().applyActorAura(member.id, member.id, ability, { + kind: "apply-aura", + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + aura: { + id: "wow335:warlock:backdraft-ready", + name: "Backdraft", + description: "Your next three Destruction spells cast within 15 seconds have reduced cast time and global cooldown.", + disposition: "buff", + durationMs: effects.durationMs, + maxStacks: effects.charges, + initialStacks: effects.charges, + stackBehavior: "replace", + utilityTags: [WARLOCK_BACKDRAFT_READY_TAG], + }, + }, now); +} + +function partyWarlockAbilityAmount( + member: PartyMember, + ability: AbilityDefinition, + effect: AbilityEffect, + poisonDoses = 0, +): number { + const amount = partyAbilityAmount(member, ability, effect, poisonDoses); + if (member.classId !== "warlock" || ability.name !== "Shadowburn" || effect.kind !== "damage") return amount; + const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-1888"] ?? 0))); + return Math.round(amount * (1 + warlockBackdraftEffectsForRank(rank).shadowburnDamagePercent / 100)); +} + let lastAdvanceAt = 0; let nextRegenAt = 0; const nextWeaponAttackAt = new Map(); @@ -659,7 +1107,7 @@ function tankPriorityTargetId( return loose[0]?.id ?? fallbackTargetId; } -function memberSwingMs(member: PartyMember): number { +function memberSwingMs(member: PartyMember, now: number): number { const base = member.role === "tank" ? 1_800 : member.classId === "rogue" @@ -667,16 +1115,24 @@ function memberSwingMs(member: PartyMember): number { || member.specialization === "Feral (Cat)" ? 1_550 : 1_900; + const hasteStat = partyUsesRangedAttacks(member) + ? baseClassFor(member.classId) === "hunter" ? "ranged-haste" : "spell-haste" + : "melee-haste"; + const hasteRating = ratingPercent( + member.gearStats.hasteRating, + partyUsesRangedAttacks(member) + ? baseClassFor(member.classId) === "hunter" ? "rangedHaste" : "spellHaste" + : "meleeHaste", + member.level, + member.classId, + ); + const memberAuras = useCombatStore.getState().auras.filter((aura) => ( + aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) + )); + const effectiveHaste = resolveAuraStatValue(hasteRating, memberAuras, hasteStat); return Math.round(hastedDuration( base, - ratingPercent( - member.gearStats.hasteRating, - partyUsesRangedAttacks(member) - ? baseClassFor(member.classId) === "hunter" ? "rangedHaste" : "spellHaste" - : "meleeHaste", - member.level, - member.classId, - ), + effectiveHaste, 500, ) / raceAffixHasteMultiplier()); } @@ -720,6 +1176,51 @@ function emitPartyAbility( }); } +function consumePartyScentOfBloodMeleeCharge( + member: PartyMember, + abilityId: string, + landedMeleeHit: boolean, + now: number, +): PartyMember { + if (member.classId !== "death-knight" + || (member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0) <= 0 + || !landedMeleeHit) return member; + + let chargeUse = { auras: useCombatStore.getState().auras, runicPowerGain: 0, remainingCharges: 0 }; + useCombatStore.setState((state) => { + chargeUse = wow335DeathKnightScentOfBloodOnMeleeHit( + state.auras, + member.id, + member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0, + true, + now, + ); + return chargeUse.runicPowerGain > 0 ? { auras: [...chargeUse.auras] } : {}; + }); + if (chargeUse.runicPowerGain <= 0) return member; + + const combat = useCombatStore.getState(); + const restored = combat.restoreActorResource( + member.id, + "runic-power", + chargeUse.runicPowerGain, + false, + member.id, + abilityId, + now, + ); + if (restored <= 0) return member; + const mirrored = memberFromCompanionActor(member, useCombatStore.getState().actors[member.id]); + return { + ...member, + ...(member.resourceType === "runic-power" ? { resource: mirrored.resource } : {}), + resourcePools: { + ...member.resourcePools, + "runic-power": mirrored.resourcePools["runic-power"] ?? member.resourcePools["runic-power"], + }, + }; +} + /** Weapon swings continue during the GCD and supply Rage for paid attacks. */ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: number): PartyMember { if (partyUsesRangedAttacks(member) && baseClassFor(member.classId) !== "hunter") return member; @@ -732,10 +1233,60 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu || !combatHasLineOfSight(origin, targetPosition) || (spatial && !isPositionInFacingArc(spatial, targetPosition, Math.PI)) || now < (nextWeaponAttackAt.get(member.id) ?? 0)) return member; - nextWeaponAttackAt.set(member.id, now + memberSwingMs(member)); + nextWeaponAttackAt.set(member.id, now + memberSwingMs(member, now)); const abilityId = "party-auto-attack"; + const scentRank = member.classId === "death-knight" + ? member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0 + : 0; const result = useCombatStore.getState().damageMob(targetId, partyAttackDamage(member), now, - { actorId: member.id, role: member.role, abilityId }, "physical"); + { actorId: member.id, role: member.role, abilityId }, "physical", scentRank > 0 ? { + attackKind: "melee", + seed: `${member.id}:${targetId}:${abilityId}:${now}:scent-of-blood-auto-attack`, + abilityId, + } : undefined); + let runtimeMember = consumePartyScentOfBloodMeleeCharge( + member, + abilityId, + scentRank > 0 && (result.combatResult?.landed ?? result.amount > 0), + now, + ); + if (member.classId === "rom-rogue" && result.amount > 0) { + const poisonSkill = abilitiesForCharacter(member.classId, member.secondaryClassId) + .find((ability) => ability.dbcSpellId === ROM_ROGUE_POISON_SKILL_ID); + const learnedPoison = poisonSkill + && learnedAbilityForBuild(poisonSkill, member.level, member.talentRanks); + const combat = useCombatStore.getState(); + const poisonActive = combat.auras.some((aura) => ( + aura.targetId === member.id + && aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now) + )); + const application = learnedPoison && poisonActive && !combat.mobs[targetId]?.dead + ? romRoguePoisonApplicationForAttack({ + classId: member.classId, + sourceActorId: member.id, + targetActorId: targetId, + attackKind: partyUsesRangedAttacks(member) + ? baseClassFor(member.classId) === "hunter" ? "ranged" : "spell" + : "melee", + isNormalAttack: true, + attackLanded: true, + poisonActive, + roll: stablePartyUnit(`${member.id}:${targetId}:${abilityId}:${now}:rom-rogue-poison`), + }) + : null; + if (application) { + useCombatStore.setState((state) => ({ + auras: [...applyCombatAura(state.auras, { + definition: application.definition, + sourceId: application.sourceId, + targetId: application.targetId, + stacks: application.stacks, + now, + }).auras], + })); + } + } if (result.amount > 0 && baseClassFor(member.classId) === "rogue") { useCombatStore.getState().triggerRogueWeaponPoisons(member.id, targetId, abilityId, 1, now); } @@ -745,12 +1296,12 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical", delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true }); } - if (member.resourcePools.rage === undefined || result.amount <= 0) return member; - const rage = Math.min(companionResourceMaximum(member, "rage"), member.resourcePools.rage + 6); + if (runtimeMember.resourcePools.rage === undefined || result.amount <= 0) return runtimeMember; + const rage = Math.min(companionResourceMaximum(runtimeMember, "rage"), runtimeMember.resourcePools.rage + 6); return { - ...member, - resource: member.resourceType === "rage" ? rage : member.resource, - resourcePools: { ...member.resourcePools, rage }, + ...runtimeMember, + resource: runtimeMember.resourceType === "rage" ? rage : runtimeMember.resource, + resourcePools: { ...runtimeMember.resourcePools, rage }, }; } @@ -759,15 +1310,63 @@ function partyFriendlyPosition(actorId: string): MobVector3 | null { return getPartyRuntimePosition(actorId); } -function partyAbilityTargetIsValid( +function partyMeditationPathPassive(member: PartyMember): AbilityDefinition | null { + if (member.classId !== "rom-mage" || member.secondaryClassId !== "rom-warrior") return null; + const source = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( + candidate.passive && candidate.dbcSpellId === ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID + )); + return source ? learnedAbilityForBuild(source, member.level, member.talentRanks) : null; +} + +export function partyCastCanMoveDuringMeditationPath( + member: PartyMember, + ability: AbilityDefinition, + now: number, +): boolean { + return canAbilityCastWhileMoving(ability) + || romMageWarriorMeditationPathAllowsMovement( + member.classId, + member.secondaryClassId, + Boolean(partyMeditationPathPassive(member)), + useCombatStore.getState().auras, + member.id, + now, + ); +} + +export function partyAbilityTargetIsValid( member: PartyMember, ability: AbilityDefinition, targetId: string, castOrigin?: MobVector3, + now = Date.now(), + castCanMove = false, ): boolean { const combat = useCombatStore.getState(); + if (romPriestWarriorStanceRequired(ability)) { + const activeAuraTags = combat.auras + .filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) + .flatMap((aura) => aura.definition.utilityTags ?? []); + if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false; + } + if (member.classId === "warlock") { + const metamorphosisActive = combat.auras.some((aura) => aura.targetId === member.id + && aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > now)); + if (!warlockMetamorphosisFormActionReady(ability.id, metamorphosisActive) + || (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false; + } const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition; - if (castOrigin && !canAbilityCastWhileMoving(ability) && distanceSquared(origin, castOrigin) > 0.04) return false; + const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement( + member.classId, + member.secondaryClassId, + Boolean(partyMeditationPathPassive(member)), + combat.auras, + member.id, + now, + ); + if (castOrigin && !canAbilityCastWhileMoving(ability) && !castCanMove && !meditationAllowsMovement + && distanceSquared(origin, castOrigin) > 0.04) return false; const mob = combat.mobs[targetId]; const hostile = Boolean(mob); if (abilityRequiresOutOfCombat(ability) @@ -822,9 +1421,19 @@ function partyAbilityTargetIsValid( // companion formation deliberately settles just outside that distance. // Apply the same combat-reach leeway used by companion weapon swings so // builders, diseases, bleeds, and finishers remain usable in formation. - const maximumRange = hostile && catalogMaximumRange > 0 && catalogMaximumRange <= 5 + const attackRange = hostile && catalogMaximumRange > 0 && catalogMaximumRange <= 5 ? Math.max(catalogMaximumRange, partyAttackRange(member)) : catalogMaximumRange; + const damagingSpell = ability.target === "hostile" && ability.effects.some((effect) => ( + effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot" + || effect.kind === "execute" || effect.kind === "resource-drain" + )); + const flameThrowingBonusYards = member.classId === "mage" && damagingSpell + ? wow335MageFlameThrowingProfile( + member.talentRanks[WOW335_MAGE_FLAME_THROWING_TALENT_ID] ?? 0, + ).damagingSpellRangeBonusYards + : 0; + const maximumRange = attackRange + flameThrowingBonusYards; if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false; if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false; if (hostile && targeting.requiresFacing) { @@ -847,8 +1456,9 @@ function partyHostileTargets( const combat = useCombatStore.getState(); const targeting = targetingRulesForAbility(ability); const primaryPosition = getMobPosition(primaryTargetId); - if (!primaryPosition) return []; + if (!primaryPosition && ability.target !== "self") return []; if (targeting.shape === "chain") { + if (!primaryPosition) return []; const selected: string[] = []; let previous = primaryPosition; const ordered = [primaryTargetId, ...Object.keys(combat.mobs).filter((id) => id !== primaryTargetId).sort()]; @@ -879,6 +1489,7 @@ function partyHostileTargets( if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) { const radius = targeting.radius ?? ability.radius ?? 0; const center = ability.target === "self" ? origin : primaryPosition; + if (!center) return []; return Object.values(combat.mobs) .filter((mob) => { const position = getMobPosition(mob.id); @@ -889,7 +1500,7 @@ function partyHostileTargets( .map((mob) => mob.id) .sort(); } - return combatHasLineOfSight(origin, primaryPosition) ? [primaryTargetId] : []; + return primaryPosition && combatHasLineOfSight(origin, primaryPosition) ? [primaryTargetId] : []; } function partyHealingTargets( @@ -1022,7 +1633,7 @@ function choosePartySupportIntent( && ability.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.disposition === "buff" && effect.aura.procs?.some((proc) => proc.triggers === "damage-taken")) - && partyAbilityTargetIsValid(member, ability, preventiveTarget.id), + && partyAbilityTargetIsValid(member, ability, preventiveTarget.id, undefined, now), }); if (preventive) return { ability: preventive, targetId: preventiveTarget.id }; } @@ -1056,8 +1667,8 @@ function choosePartySupportIntent( injuredAllies: injured.length, lowestHealthFraction, friendlyTargets, - canUseAbility: ability => ability.name === "Inner Focus" - && partyAbilityTargetIsValid(member, ability, member.id), + canUseAbility: ability => ability.name === "Inner Focus" + && partyAbilityTargetIsValid(member, ability, member.id, undefined, now), }); if (innerFocus) return { ability: innerFocus, targetId: member.id }; } @@ -1082,7 +1693,8 @@ function choosePartySupportIntent( nextHealingSpellFree: true, canUseAbility: ability => abilityHasDirectHealing(ability) && (healingAbilityCanAidOthers(ability) || target.id === member.id) - && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id), + && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id, + undefined, now), }); if (empoweredHeal) { return { ability: empoweredHeal, targetId: empoweredHeal.target === "self" ? member.id : target.id }; @@ -1103,7 +1715,8 @@ function choosePartySupportIntent( || effect.aura.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken" && modifier.value < 0) ))) - && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id) + && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id, + undefined, now) && !ability.effects.some((effect) => effect.kind === "shield" && (combat.actors[target.id]?.absorb ?? 0) > 0) && !ability.effects.some((effect) => effect.kind === "apply-aura" @@ -1122,7 +1735,8 @@ function choosePartySupportIntent( canUseAbility: ability => ability.effects.some((effect) => effect.kind === "hot") && (healingAbilityCanAidOthers(ability) || target.id === member.id) && !healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects) - && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id), + && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id, + undefined, now), }) : null; if (periodic) return { ability: periodic, targetId: periodic.target === "self" ? member.id : target.id }; const ability = choosePartyAbility(member, { @@ -1137,7 +1751,8 @@ function choosePartySupportIntent( && (healingAbilityCanAidOthers(ability) || target.id === member.id) && !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects) && (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5)) - && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id) + && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id, + undefined, now) && !(ability.effects.some(effect => effect.kind === "shield") && (combat.actors[target.id]?.absorb ?? 0) > 0), }); if (ability) return { ability, targetId: ability.target === "self" ? member.id : target.id }; @@ -1153,6 +1768,10 @@ function executePartyAbility( members: readonly PartyMember[], depth = 0, castStartAlreadyEmitted = false, + channelPulse = false, + tidalWavesChargesConsumed = 0, + arcanePotencyCriticalChanceBonusPercent = 0, + manaSpent = 0, ): PartyAbilityExecutionResult { let combat = useCombatStore.getState(); if (ability.source === "runewaker") { @@ -1173,7 +1792,9 @@ function executePartyAbility( return { member, effectiveAmount: 0 }; } const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition; - const hostileTargets = combat.mobs[targetId] ? partyHostileTargets(ability, targetId, origin, member.id) : []; + const hostileTargets = combat.mobs[targetId] || ability.target === "self" + ? partyHostileTargets(ability, targetId, origin, member.id) + : []; const friendlyTargets = combat.mobs[targetId] ? [] : partyHealingTargets(ability, targetId, members); const consumesColdBlood = ability.name !== "Cold Blood" && hostileTargets.length > 0 && ability.effects.some((effect) => ["damage", "dot", "finisher-damage"].includes(effect.kind)) @@ -1197,14 +1818,25 @@ function executePartyAbility( && ability.name !== "Inner Focus" && abilityHasDirectHealing(ability); if (consumesInnerFocus) pendingInnerFocus.delete(member.id); + const elementalOathRank = combat.classId === "shaman" + ? Math.min(3, Math.max(0, Math.trunc(combat.talentRanks["talent-2049"] ?? 0))) + : 0; const criticalChance = Math.max(0, ratingPercent( member.gearStats.criticalStrikeRating, "spellCrit", member.level, member.classId, - ) / 100 + (consumesInnerFocus ? 0.25 : 0)); + ) / 100 + elementalOathRank / 100 + (consumesInnerFocus ? 0.25 : 0) + + wow335ShamanTidalWavesCriticalChanceBonusPercent(ability.name, tidalWavesChargesConsumed) / 100); + const masterOfElementsCritPercent = member.classId === "mage" + ? Math.min(3, Math.max(0, Math.trunc(member.talentRanks[WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID] ?? 0))) + : 0; + const frostbiteRank = member.classId === "mage" + ? Math.min(3, Math.max(0, Math.trunc(member.talentRanks[WOW335_MAGE_FROSTBITE_TALENT_ID] ?? 0))) + : 0; if (ability.name === "Inner Focus") pendingInnerFocus.add(member.id); const envenomDosesByTarget = new Map(); + let suddenDoomTrigger: ReturnType = null; if (ability.name === "Envenom") { for (const hostileTargetId of hostileTargets) { const doses = Math.min( @@ -1229,6 +1861,10 @@ function executePartyAbility( } let runtimeMember = member; let effectiveAmount = 0; + let directHealingRawAmount = 0; + const empoweredFireRank = member.classId === "mage" + ? member.talentRanks[WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID] ?? 0 + : 0; const castEffects = romEffectsForCast(ability, { sealStacks: member.comboTargetId === targetId ? member.comboPoints : 0, bleedCount: romTargetBleedCount(combat.effects, member.id, targetId), @@ -1244,6 +1880,9 @@ function executePartyAbility( ? combat.mobs[targetId]!.health / Math.max(1, combat.mobs[targetId]!.maxHealth) : 1, naturePower: member.resourcePools["nature-power"] ?? 0, + eruptionProcRoll: stableCombatUnit( + `${member.id}:${ability.id}:${targetId ?? "self"}:${now}:rom-eruption`, + ), passiveTags: combat.auras.filter((aura) => aura.targetId === member.id) .flatMap((aura) => aura.definition.utilityTags ?? []).concat( abilitiesForCharacter(member.classId, member.secondaryClassId) @@ -1259,7 +1898,7 @@ function executePartyAbility( combat.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0) .flatMap((effect) => effect.tag ? [effect.tag] : []), ), - }); + }).map((effect) => wow335MageEmpoweredFireEffect(ability.name, effect, empoweredFireRank)); if (ability.dbcSpellId === 492920 && castEffects.some((effect) => effect.kind === "dot" && effect.tag === "rom-622992")) { useCombatStore.setState((state) => ({ @@ -1286,31 +1925,218 @@ function executePartyAbility( ? { ...effect, ticks: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) } : effect, now, + arcanePotencyCriticalChanceBonusPercent, ); } else { const sunderStacks = effect.kind === "damage" && effect.bonusPerSunderStack ? useCombatStore.getState().auras.find((aura) => aura.targetId === hostileTargetId && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0 : 0; + const partyDamageAmount = partyWarlockAbilityAmount( + runtimeMember, + ability, + effect, + envenomDosesByTarget.get(hostileTargetId) ?? 0, + ); + const targetStatuses = useCombatStore.getState().mobs[hostileTargetId]?.statuses ?? []; + const mageTargetDamageAmount = member.classId === "mage" + ? wow335MageTormentTheWeakDamage( + partyDamageAmount, + member.talentRanks[WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID] ?? 0, + ability.name, + targetStatuses, + now, + ) + : partyDamageAmount; + const deathKnightSuddenDoomTrigger = member.classId === "death-knight" + && ["Blood Strike", "Heart Strike"].includes(ability.name); + const deathKnightScentOfBloodRank = member.classId === "death-knight" + ? member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0 + : 0; + const deathKnightScentOfBloodMeleeHit = deathKnightScentOfBloodRank > 0 + && effect.kind === "damage" + && ability.source === "wow335-progression" + && spellSchool === "physical"; + const drumsOfWarPassiveSource = member.classId === "rom-scout" + && member.secondaryClassId === "rom-priest" + ? abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( + candidate.source === "runewaker" + && candidate.passive + && candidate.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId + )) + : undefined; + const drumsOfWarPassive = drumsOfWarPassiveSource + ? learnedAbilityForBuild(drumsOfWarPassiveSource, member.level, member.talentRanks) + : null; + const drumsOfWarShot = effect.kind === "damage" + && member.classId === "rom-scout" + && member.secondaryClassId === "rom-priest" + && ability.source === "runewaker" + && ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId + && Boolean(drumsOfWarPassive); + const lightOfDayPassiveSource = member.classId === "rom-mage" + && member.secondaryClassId === "rom-knight" + ? abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( + candidate.source === "runewaker" + && candidate.passive + && candidate.dbcSpellId === ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId + )) + : undefined; + const lightOfDayPassive = lightOfDayPassiveSource + ? learnedAbilityForBuild(lightOfDayPassiveSource, member.level, member.talentRanks) + : null; + const lightOfDayHit = effect.kind === "damage" + && !channelPulse + && member.classId === "rom-mage" + && member.secondaryClassId === "rom-knight" + && ability.source === "runewaker" + && ROM_MAGE_KNIGHT_LIGHT_OF_DAY.triggerSpellIds.includes( + ability.dbcSpellId as (typeof ROM_MAGE_KNIGHT_LIGHT_OF_DAY.triggerSpellIds)[number], + ) + && Boolean(lightOfDayPassive); + const partyUsesCombatRollContext = drumsOfWarShot || lightOfDayHit || deathKnightSuddenDoomTrigger + || deathKnightScentOfBloodMeleeHit + || consumesColdBlood || usesCombustion || arcanePotencyCriticalChanceBonusPercent > 0 + || masterOfElementsCritPercent > 0 + || (effect.kind === "damage" && spellSchool === "frost" && frostbiteRank > 0) + || (member.classId === "mage" + && (member.talentRanks[WOW335_MAGE_ARCANE_CONCENTRATION_TALENT_ID] ?? 0) > 0); + const partyMageMoltenFuryRank = member.classId === "mage" + && ability.classId === "mage" + && ability.source === "wow335-progression" + ? member.talentRanks[WOW335_MAGE_MOLTEN_FURY_TALENT_ID] ?? 0 + : 0; + const partyDamageTargetBeforeHit = useCombatStore.getState().mobs[hostileTargetId]; + const partyRawDirectDamage = mageTargetDamageAmount + + sunderStacks * (effect.kind === "damage" ? effect.bonusPerSunderStack ?? 0 : 0); + const partyDirectDamageWithMoltenFury = effect.kind === "damage" + && partyMageMoltenFuryRank > 0 + && !partyUsesCombatRollContext + && partyDamageTargetBeforeHit + ? wow335MageMoltenFuryDamage( + partyRawDirectDamage, + partyMageMoltenFuryRank, + spellSchool, + partyDamageTargetBeforeHit.health, + partyDamageTargetBeforeHit.maxHealth, + ) + : partyRawDirectDamage; const result = combat.damageMob( hostileTargetId, - partyAbilityAmount( - runtimeMember, - ability, - effect, - envenomDosesByTarget.get(hostileTargetId) ?? 0, - ) + sunderStacks * (effect.kind === "damage" ? effect.bonusPerSunderStack ?? 0 : 0), + partyDirectDamageWithMoltenFury, now, { actorId: member.id, role: member.role, abilityId: ability.id }, spellSchool, - consumesColdBlood || usesCombustion ? { - attackKind: consumesColdBlood ? "melee" : "spell", abilityId: ability.id, + partyUsesCombatRollContext ? { + attackKind: drumsOfWarShot ? "ranged" + : deathKnightScentOfBloodMeleeHit || consumesColdBlood || deathKnightSuddenDoomTrigger + ? "melee" : "spell", abilityId: ability.id, seed: `${member.id}:${ability.id}:${hostileTargetId}:${now}:special-crit`, - criticalChanceBonusPercent: consumesColdBlood ? 100 : 0, + criticalChanceBonusPercent: (consumesColdBlood ? 100 : 0) + + arcanePotencyCriticalChanceBonusPercent + + masterOfElementsCritPercent, ...(ability.name === "Shiv" ? { rules: { canCrit: false } } : {}), } : undefined, ); effectiveAmount += result.amount; + const partyHitLanded = result.combatResult?.landed ?? result.amount > 0; + if (deathKnightScentOfBloodMeleeHit && partyHitLanded) { + runtimeMember = consumePartyScentOfBloodMeleeCharge(runtimeMember, ability.id, true, now); + } + if (effect.kind === "damage" && spellSchool === "frost" && frostbiteRank > 0) { + const freezeDurationMs = wow335MageFrostbiteProcDurationMs( + frostbiteRank, + spellSchool, + result.combatResult?.landed === true, + result.amount, + stablePartyUnit(`${member.id}:${ability.id}:${hostileTargetId}:${now}:wow335-mage-frostbite`), + ); + if (freezeDurationMs > 0) { + useCombatStore.getState().controlMob( + hostileTargetId, + ability.id, + "freeze", + freezeDurationMs, + now, + false, + member.id, + ); + combat = useCombatStore.getState(); + } + } + if (member.classId === "death-knight" + && !channelPulse + && effect.kind === "damage" + && hostileTargetId === targetId + && result.combatResult?.landed) { + suddenDoomTrigger = wow335DeathKnightSuddenDoomTriggerForLandedHit( + member.talentRanks["talent-1955"] ?? 0, + ability.id, + hostileTargetId, + true, + stablePartyUnit(`${member.id}:${ability.id}:${hostileTargetId}:${now}:wow335-death-knight-sudden-doom`), + ); + } + if (member.classId === "rom-mage" && !channelPulse && effect.kind === "damage" + && partyHitLanded) { + const passiveSource = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( + candidate.source === "runewaker" + && candidate.passive + && candidate.dbcSpellId === ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId + )); + const passive = passiveSource + ? learnedAbilityForBuild(passiveSource, member.level, member.talentRanks) + : null; + const readyAt = romMageMeditativeCurrentReadyAt( + useCombatStore.getState().auras, + member.id, + now, + ); + const proc = romMageMeditativeCurrentProc( + member.classId, + ability, + passive ? [ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId] : [], + partyHitLanded, + readyAt, + now, + ); + if (proc.effect?.kind === "apply-aura") { + useCombatStore.getState().applyActorAura(member.id, member.id, ability, proc.effect, now); + useCombatStore.getState().applyActorAura( + member.id, + member.id, + ability, + ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT, + now, + ); + combat = useCombatStore.getState(); + } + } + if (member.classId === "mage" && effect.kind === "damage" + && result.combatResult?.landed && result.amount > 0) { + maybeProcPartyMageArcaneConcentration(member, ability, hostileTargetId, now); + combat = useCombatStore.getState(); + } + if (member.classId === "mage" && !channelPulse && effect.kind === "damage" + && spellSchool !== "physical" && result.combatResult?.critical) { + const refund = wow335MageMasterOfElementsManaRefund( + member.talentRanks[WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID] ?? 0, + manaSpent, + true, + true, + ); + if (refund > 0) { + const maximumMana = companionResourceMaximum(runtimeMember, "mana"); + const currentMana = runtimeMember.resourcePools.mana + ?? (runtimeMember.resourceType === "mana" ? runtimeMember.resource : 0); + const mana = Math.min(maximumMana, Math.max(0, currentMana + refund)); + runtimeMember = { + ...runtimeMember, + ...(runtimeMember.resourceType === "mana" ? { resource: mana } : {}), + resourcePools: { ...runtimeMember.resourcePools, mana }, + }; + } + } if (effect.kind === "damage" && ability.dbcSpellId === 490133) { const currentCombat = useCombatStore.getState(); const bonus = romScoutRogueWhirlwindBonusEffect( @@ -1350,21 +2176,43 @@ function executePartyAbility( runtimeMember = { ...runtimeMember, comboPoints: 0, comboTargetId: null }; } } else if (effect.kind === "heal") { - for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors, - id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) { + const healingTargetIds = healingRecipientIds(effect, member.id, targetId, combat.actors, + id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now); + for (const [targetIndex, friendlyTargetId] of healingTargetIds.entries()) { const actor = useCombatStore.getState().actors[friendlyTargetId]; if (!actor?.alive) continue; - const requested = partyAbilityAmount(runtimeMember, ability, effect) - + Math.round(actor.maxHealth * (effect.percentMaxHealth ?? 0)); + const riptideHot = member.classId === "shaman" + && ability.name === "Chain Heal" + && targetIndex === 0 + ? partyShamanRiptideHotOnTarget(member.id, friendlyTargetId, now) + : undefined; + if (riptideHot) { + useCombatStore.setState((state) => ({ + effects: state.effects.filter((candidate) => candidate.id !== riptideHot.id), + })); + combat = useCombatStore.getState(); + } + const tidalWavesBonus = partyShamanTidalWavesBonusHealing(runtimeMember, ability, now); + const healingSchool = member.classId === "shaman" + ? abilityDamageSchool("shaman", ability) + : spellSchool; + const requested = Math.round((partyAbilityAmount(runtimeMember, ability, effect) + + tidalWavesBonus + + Math.round(actor.maxHealth * (effect.percentMaxHealth ?? 0))) + * (riptideHot ? PARTY_SHAMAN_RIPTIDE_CHAIN_HEAL_MULTIPLIER : 1)); const result = useCombatStore.getState().healActor(friendlyTargetId, requested, { sourceActorId: member.id, abilityId: ability.id, - school: spellSchool, + school: healingSchool, criticalChance, seed: `${member.id}:${ability.id}:${friendlyTargetId}:${now}:heal`, now, }); effectiveAmount += result.effectiveAmount; + directHealingRawAmount += result.rawAmount; + runtimeMember = maybeProcPartyShamanImprovedWaterShield( + runtimeMember, ability, friendlyTargetId, healingSchool, result.critical, now, + ); if (ability.name === "Divine Hymn") { useCombatStore.getState().applyActorAura(member.id, friendlyTargetId, ability, { kind: "apply-aura", recipient: "ability-target", @@ -1416,16 +2264,48 @@ function executePartyAbility( } } } else if (effect.kind === "interrupt") { - for (const hostileTargetId of hostileTargets) combat.interruptMob(hostileTargetId, ability.id, effect.lockoutMs, now); + let kickInterruptedCast = false; + for (const hostileTargetId of hostileTargets) { + const interrupted = combat.interruptMob(hostileTargetId, ability.id, effect.lockoutMs, now); + if (interrupted && member.classId === "mage") { + const silenceDurationMs = wow335MageImprovedCounterspellSilenceDurationMs( + member.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0, + ability.name, + ); + if (silenceDurationMs > 0) { + useCombatStore.getState().controlMob( + hostileTargetId, + ability.id, + "silence", + silenceDurationMs, + now, + false, + member.id, + ); + } + } + kickInterruptedCast ||= interrupted && ability.name === "Kick" && member.classId === "rogue"; + } + if (kickInterruptedCast) runtimeMember = grantPartyImprovedKickCapstone(runtimeMember, ability, now); } else if (effect.kind === "taunt") { - for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) { + const tauntTargets = effect.maxTargets === undefined + ? hostileTargets + : hostileTargets.slice(0, effect.maxTargets); + for (const hostileTargetId of tauntTargets) { combat.tauntMob(hostileTargetId, { actorId: member.id, role: member.role, abilityId: ability.id }, effect.durationMs, now); } } else if (effect.kind === "status") { + let kickInterruptedCast = false; for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) { - combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now, - effect.breakOnDamage, member.id); + if (effect.status === "interrupt") { + const interrupted = useCombatStore.getState().interruptMob(hostileTargetId, ability.id, effect.durationMs, now); + kickInterruptedCast ||= interrupted && ability.name === "Kick" && member.classId === "rogue"; + } else { + combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now, + effect.breakOnDamage, member.id); + } } + if (kickInterruptedCast) runtimeMember = grantPartyImprovedKickCapstone(runtimeMember, ability, now); } else if (effect.kind === "apply-aura") { if (ability.name === "Inner Focus" || ability.name === "Divine Hymn") continue; const recipients = effect.scope === "party" @@ -1447,6 +2327,9 @@ function executePartyAbility( } : effect; useCombatStore.getState().applyActorAura(member.id, recipientId, ability, appliedEffect, now); + if (ability.name === "Presence of Mind" && recipientId === member.id) { + applyPartyMageArcanePotency(member, ability, "presence-of-mind", now); + } if (effect.control && useCombatStore.getState().mobs[recipientId]) { useCombatStore.getState().controlMob(recipientId, ability.id, effect.control.kind, effect.aura.durationMs ?? 1_000, now); } @@ -1547,6 +2430,14 @@ function executePartyAbility( targetPosition[0] - dx / length * 1.5, targetPosition[1], targetPosition[2] - dz / length * 1.5); + } else if (ability.dbcSpellId === 54785 && targetPosition) { + const dx = targetPosition[0] - origin[0]; + const dz = targetPosition[2] - origin[2]; + const length = Math.max(0.001, Math.hypot(dx, dz)); + updatePartyRuntimePosition(member.id, + targetPosition[0] - dx / length * 1.5, + targetPosition[1], + targetPosition[2] - dz / length * 1.5); } else if (effect.toward === "away") { const danger = Object.values(combat.mobs) .filter((mob) => mob.engaged && !mob.dead) @@ -1619,6 +2510,142 @@ function executePartyAbility( } } } + if (!channelPulse && depth === 0 + && member.classId === "rom-priest" + && member.secondaryClassId === "rom-mage" + && ability.source === "runewaker" + && ability.dbcSpellId === ROM_PRIEST_MAGE_ENLIGHTENMENT.triggerSpellId) { + const passiveSource = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( + candidate.source === "runewaker" + && candidate.classId === "rom-priest" + && candidate.secondaryClassId === "rom-mage" + && candidate.passive + && candidate.dbcSpellId === ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId + )); + const passive = passiveSource + ? learnedAbilityForBuild(passiveSource, member.level, member.talentRanks) + : null; + if (passive) { + const readyAt = romPriestMageEnlightenmentReadyAt( + useCombatStore.getState().auras, + member.id, + now, + ); + const proc = romPriestMageEnlightenmentProc( + member.classId, + member.secondaryClassId, + ability, + [ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId], + true, + readyAt, + now, + ); + if (proc.effect?.kind === "apply-aura") { + useCombatStore.getState().applyActorAura(member.id, member.id, ability, proc.effect, now); + useCombatStore.getState().applyActorAura( + member.id, + member.id, + ability, + ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT, + now, + ); + combat = useCombatStore.getState(); + } + } + } + if (!channelPulse && depth === 0) { + maybeProcPartyShamanTidalWaves(member, ability, directHealingRawAmount, now); + } + const meditationPassive = partyMeditationPathPassive(member); + if (!channelPulse && depth === 0 && meditationPassive + && romMageWarriorMeditationPathCanProc( + member.classId, + member.secondaryClassId, + true, + ability, + )) { + const current = useCombatStore.getState(); + if (!romMageWarriorMeditationPathHasState( + current.auras, + member.id, + ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, + now, + )) { + useCombatStore.getState().applyActorAura( + member.id, member.id, meditationPassive, ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT, now, + ); + useCombatStore.getState().applyActorAura( + member.id, member.id, meditationPassive, ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT, now, + ); + combat = useCombatStore.getState(); + } + } + if (!channelPulse && member.classId === "priest" && combat.mobs[targetId] + && ["Mind Flay", "Mind Sear", "Mind Blast"].includes(ability.name)) { + const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-1909"] ?? 0))); + const shadowWordPainCatalog = abilitiesForClass("priest").find(candidate => candidate.name === "Shadow Word: Pain"); + const shadowWordPain = shadowWordPainCatalog + ? abilityAtLevel(shadowWordPainCatalog, member.level) + : null; + if (rank > 0 && shadowWordPain) { + const current = useCombatStore.getState(); + const refreshed = refreshPriestPainAndSufferingDot( + current.effects, + member.id, + targetId, + ability, + rank, + shadowWordPain, + now, + ); + if (refreshed !== current.effects) useCombatStore.setState({ effects: refreshed }); + } + } + const chargeRank = member.classId === "warrior" ? wow335WarriorChargeRank(ability.dbcSpellId) : 0; + if (chargeRank > 0 && hostileTargets.length > 0) { + const angerRank = member.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0; + const charge = wow335WarriorChargeRageProfile(chargeRank, angerRank); + if (charge) { + const resourcePools = { ...runtimeMember.resourcePools }; + const rage = Math.min( + companionResourceMaximum(runtimeMember, "rage"), + (resourcePools.rage ?? runtimeMember.resource) + charge.totalRage, + ); + resourcePools.rage = rage; + runtimeMember = { + ...runtimeMember, + resourcePools, + ...(runtimeMember.resourceType === "rage" ? { resource: rage } : {}), + }; + const angerManagement = wow335WarriorAngerManagementProfile(angerRank); + if (angerManagement && angerManagement.chargeDamageReductionPercent > 0 + && angerManagement.chargeDamageReductionDurationMs > 0) { + useCombatStore.getState().applyActorAura(member.id, member.id, ability, { + kind: "apply-aura", + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: angerManagement.chargeDamageReductionSpellId ?? 0, + aura: { + id: "wow335:warrior:anger-management-charge-reduction", + name: "Anger Management", + disposition: "buff", + durationMs: angerManagement.chargeDamageReductionDurationMs, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: [WOW335_WARRIOR_ANGER_MANAGEMENT_CHARGE_AURA_TAG], + modifiers: [{ + kind: "damage", + direction: "taken", + operation: "percent", + value: -angerManagement.chargeDamageReductionPercent / 100, + }], + }, + }, now); + } + } + } if (ability.name === "Chimera Shot" && effectiveAmount > 0 && combat.mobs[targetId]) { effectiveAmount += useCombatStore.getState().triggerChimeraShot(member.id, targetId, ability.id, now); } @@ -1653,11 +2680,56 @@ function executePartyAbility( runtimeMember = { ...runtimeMember, cooldowns: { ...runtimeMember.cooldowns, [ability.id]: 0 } }; } } + if (!channelPulse && depth === 0) applyPartyWarlockBackdraft(member, ability, now); emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted); emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted); if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) { useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount); } + if (suddenDoomTrigger && depth === 0) { + const deathCoilBase = abilitiesForCharacter(member.classId, member.secondaryClassId) + .find((candidate) => candidate.id === suddenDoomTrigger.abilityId); + const deathCoil = deathCoilBase + ? learnedAbilityForBuild(deathCoilBase, member.level, member.talentRanks) + : null; + if (deathCoil) { + const execution = executePartyAbility( + runtimeMember, + deathCoil, + suddenDoomTrigger.targetId, + now, + members, + depth + 1, + ); + runtimeMember = execution.member; + effectiveAmount += execution.effectiveAmount; + } + } + if (!channelPulse && runtimeMember.classId === "rom-rogue" && runtimeMember.secondaryClassId === "rom-champion") { + const passiveSource = abilitiesForCharacter(runtimeMember.classId, runtimeMember.secondaryClassId) + .find((candidate) => candidate.source === "runewaker" + && candidate.classId === "rom-rogue" + && candidate.secondaryClassId === "rom-champion" + && candidate.passive + && candidate.dbcSpellId === ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID); + const passive = passiveSource + ? learnedAbilityForBuild(passiveSource, runtimeMember.level, runtimeMember.talentRanks) + : null; + if (passive && romRogueChampionNerveGearShouldTrigger( + runtimeMember.classId, + runtimeMember.secondaryClassId, + ability, + true, + )) { + useCombatStore.getState().applyActorAura( + runtimeMember.id, + runtimeMember.id, + passive, + romRogueChampionNerveGearEffect(), + now, + ); + } + } return { member: runtimeMember, effectiveAmount }; } @@ -1741,10 +2813,32 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { manaSpiritRegenerationPerSecond: type === "mana" ? member.gearStats.spirit * 0.02 : 0, }).current; } + const angerManagement = member.classId === "warrior" + ? wow335WarriorAngerManagementProfile( + member.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0, + ) + : null; + const angerManagementElapsedMs = angerManagement + ? (member.warriorAngerManagementRageRemainderMs ?? 0) + Math.round(deltaSeconds * 1_000) + : 0; + const angerManagementTicks = angerManagement + ? Math.floor(angerManagementElapsedMs / angerManagement.tickIntervalMs) + : 0; + const warriorAngerManagementRageRemainderMs = angerManagement + ? angerManagementElapsedMs % angerManagement.tickIntervalMs + : 0; + if (angerManagement && angerManagementTicks > 0) { + const rage = Math.min( + companionResourceMaximum(member, "rage"), + (resourcePools.rage ?? 0) + angerManagementTicks * angerManagement.ragePerTick, + ); + resourcePools.rage = rage; + } return { ...member, resource: resourcePools[member.resourceType] ?? member.resource, resourcePools, + warriorAngerManagementRageRemainderMs, cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)), schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)), }; @@ -1834,7 +2928,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { } if (member.activeCast.mode === "channel") { const cast = member.activeCast; - if (!partyAbilityTargetIsValid(member, castingAbility, castTargetId, cast.origin)) { + if (!partyAbilityTargetIsValid( + member, castingAbility, castTargetId, cast.origin, now, cast.canCastWhileMoving, + )) { emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now); nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 }; continue; @@ -1858,7 +2954,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { return { kind: "damage", coefficient: 0, basePoints: amount, sourceLevel: member.level }; }); const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId, - pulseAt, nextMembers, 0, true); + pulseAt, nextMembers, 0, true, true, + cast.tidalWavesChargesConsumed ?? 0, + cast.arcanePotencyCriticalChanceBonusPercent ?? 0, + cast.manaSpent ?? 0); member = execution.member; completed += 1; } @@ -1875,6 +2974,8 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { castingAbility, castTargetId, activeCast.origin, + now, + activeCast.canCastWhileMoving, ); if (valid) { const execution = executePartyAbility( @@ -1887,13 +2988,18 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { nextMembers, 0, true, + false, + activeCast.tidalWavesChargesConsumed ?? 0, + activeCast.arcanePotencyCriticalChanceBonusPercent ?? 0, + activeCast.manaSpent ?? 0, ); nextMembers[index] = { ...execution.member, status: desiredStatus, nextActionAt: activeCast.mode === "channel" ? Math.max(now + 250, execution.member.globalCooldownEndsAt) - : now + Math.max(250, Math.round(castingAbility.gcdMs / raceAffixHasteMultiplier(now))), + : now + Math.max(250, activeCast.backdraftGcdMs + ?? Math.round(castingAbility.gcdMs / raceAffixHasteMultiplier(now))), }; } else { nextMembers[index] = { @@ -1911,53 +3017,89 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { nextMembers[index] = member.status === "healing" ? member : { ...member, status: "healing" }; continue; } - if (!partyAbilityTargetIsValid(member, supportIntent.ability, supportIntent.targetId)) { + if (!partyAbilityTargetIsValid(member, supportIntent.ability, supportIntent.targetId, undefined, now)) { nextMembers[index] = { ...member, status: "advancing", nextActionAt: now + 250 }; continue; } - const committed = commitPartyAbilityUse(member, supportIntent.ability, now, { + const backdraftCast = partyWarlockBackdraftCast(member, supportIntent.ability, now); + const tidalWavesCast = partyShamanTidalWavesCast(member, supportIntent.ability, now); + const selectedCastAbility = backdraftCast?.ability ?? tidalWavesCast?.ability ?? supportIntent.ability; + const consumesHunterLockAndLoad = partyHunterLockAndLoadReady(member, selectedCastAbility, now); + const castAbility = partyAbilityWithHunterLockAndLoad(member, selectedCastAbility, now); + const committed = commitPartyAbilityUse(member, castAbility, now, { ignoreResourceCosts: pendingInnerFocus.has(member.id) - && abilityHasDirectHealing(supportIntent.ability), + && abilityHasDirectHealing(castAbility), }); if (!committed) { nextMembers[index] = { ...member, status: "ready", nextActionAt: now + 250 }; continue; } + const committedAfterHunterLockAndLoad = consumesHunterLockAndLoad + ? { ...committed, cooldowns: { ...committed.cooldowns, [castAbility.id]: 0 } } + : committed; + if (consumesHunterLockAndLoad) consumePartyHunterLockAndLoad(member.id); + const mageAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id); + const arcanePotencyBonus = partyMageArcanePotencyBonus( + member, + castAbility, + mageAuras.some((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_POTENCY_TAG)), + ); + const manaSpent = partyAbilityCosts(member, castAbility, now) + .filter((cost) => cost.resource === "mana") + .reduce((total, cost) => total + cost.amount, 0); + consumePartyMageCastAuras(member, castAbility); + if (backdraftCast) consumePartyWarlockBackdraftCharge(member.id, backdraftCast.auraInstanceId); + if (tidalWavesCast) consumePartyShamanTidalWaves( + member.id, tidalWavesCast.auraInstanceId, + ); const instantOutOfCombatResurrection = !targetId - && supportIntent.ability.effects.some((effect) => effect.kind === "resurrection"); - if (supportIntent.ability.castTimeMs > 0 && !instantOutOfCombatResurrection) { - const castTimeMs = Math.round(supportIntent.ability.castTimeMs / raceAffixHasteMultiplier(now)); + && castAbility.effects.some((effect) => effect.kind === "resurrection"); + if (castAbility.castTimeMs > 0 && !instantOutOfCombatResurrection) { + const castTimeMs = backdraftCast?.castTimeMs + ?? Math.round(castAbility.castTimeMs / raceAffixHasteMultiplier(now)); const castOrigin = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition; nextMembers[index] = { - ...committed, + ...committedAfterHunterLockAndLoad, status: "healing", activeCast: { - abilityId: supportIntent.ability.id, - school: presentationSchoolForAbility(supportIntent.ability), - mode: supportIntent.ability.castMode === "channel" ? "channel" : "cast", + abilityId: castAbility.id, + school: presentationSchoolForAbility(castAbility), + mode: castAbility.castMode === "channel" ? "channel" : "cast", startedAt: now, completesAt: now + castTimeMs, originalDurationMs: castTimeMs, pushbackHits: 0, + canCastWhileMoving: partyCastCanMoveDuringMeditationPath(member, castAbility, now), origin: castOrigin, targetId: supportIntent.targetId, + ...(backdraftCast ? { backdraftGcdMs: backdraftCast.gcdMs } : {}), + ...(tidalWavesCast ? { tidalWavesChargesConsumed: tidalWavesCast.chargesConsumed } : {}), + ...(arcanePotencyBonus > 0 ? { arcanePotencyCriticalChanceBonusPercent: arcanePotencyBonus } : {}), + ...(member.classId === "mage" ? { manaSpent } : {}), }, activeCastTargetId: supportIntent.targetId, nextActionAt: now + castTimeMs, }; - emitPartyAbility(member, supportIntent.ability, supportIntent.targetId, "cast-start", now); + emitPartyAbility(member, castAbility, supportIntent.targetId, "cast-start", now); } else { const execution = executePartyAbility( - committed, - supportIntent.ability, + committedAfterHunterLockAndLoad, + castAbility, supportIntent.targetId, now, nextMembers, + 0, + false, + false, + tidalWavesCast?.chargesConsumed ?? 0, + arcanePotencyBonus, + manaSpent, ); nextMembers[index] = { ...execution.member, status: "healing", - nextActionAt: now + Math.max(250, Math.round(supportIntent.ability.gcdMs / raceAffixHasteMultiplier(now))), + nextActionAt: now + Math.max(250, backdraftCast?.gcdMs + ?? Math.round(castAbility.gcdMs / raceAffixHasteMultiplier(now))), }; } continue; @@ -2077,7 +3219,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { ? member.id : memberTargetId; const minimumHostiles = minimumHostileTargetsForAbility(ability.name); - return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId) + return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId, undefined, now) && (minimumHostiles <= 1 || partyHostileTargets( ability, abilityTargetId, @@ -2121,18 +3263,41 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { const abilityTargetId = selectedDecision?.targetId ?? (selectedAbility.target === "self" || selectedAbility.target === "friendly" ? member.id : memberTargetId); const inRange = (selectedAbility.target === "self" || targetVisible) - && partyAbilityTargetIsValid(runtimeMember, selectedAbility, abilityTargetId); + && partyAbilityTargetIsValid(runtimeMember, selectedAbility, abilityTargetId, undefined, now); if (!inRange) { nextMembers[index] = runtimeMember.status === "advancing" ? runtimeMember : { ...runtimeMember, status: "advancing" }; continue; } - const committed = commitPartyAbilityUse(runtimeMember, selectedAbility, now); + const backdraftCast = partyWarlockBackdraftCast(runtimeMember, selectedAbility, now); + const tidalWavesCast = partyShamanTidalWavesCast(runtimeMember, selectedAbility, now); + const selectedCastAbility = backdraftCast?.ability ?? tidalWavesCast?.ability ?? selectedAbility; + const consumesHunterLockAndLoad = partyHunterLockAndLoadReady(runtimeMember, selectedCastAbility, now); + const castAbility = partyAbilityWithHunterLockAndLoad(runtimeMember, selectedCastAbility, now); + const committed = commitPartyAbilityUse(runtimeMember, castAbility, now); if (!committed) { nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 }; continue; } + const committedAfterHunterLockAndLoad = consumesHunterLockAndLoad + ? { ...committed, cooldowns: { ...committed.cooldowns, [castAbility.id]: 0 } } + : committed; + if (consumesHunterLockAndLoad) consumePartyHunterLockAndLoad(runtimeMember.id); + const mageAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === runtimeMember.id); + const arcanePotencyBonus = partyMageArcanePotencyBonus( + runtimeMember, + castAbility, + mageAuras.some((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_POTENCY_TAG)), + ); + const manaSpent = partyAbilityCosts(runtimeMember, castAbility, now) + .filter((cost) => cost.resource === "mana") + .reduce((total, cost) => total + cost.amount, 0); + consumePartyMageCastAuras(runtimeMember, castAbility); + if (backdraftCast) consumePartyWarlockBackdraftCharge(runtimeMember.id, backdraftCast.auraInstanceId); + if (tidalWavesCast) consumePartyShamanTidalWaves( + runtimeMember.id, tidalWavesCast.auraInstanceId, + ); if (selectedDecision) { emitRotationDecision({ occurredAt: now, @@ -2144,32 +3309,44 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { reason: selectedDecision.reason, }); } - if (selectedAbility.castTimeMs > 0) { - const castTimeMs = Math.round(selectedAbility.castTimeMs / raceAffixHasteMultiplier(now)); + if (castAbility.castTimeMs > 0) { + const castTimeMs = backdraftCast?.castTimeMs + ?? Math.round(castAbility.castTimeMs / raceAffixHasteMultiplier(now)); nextMembers[index] = { - ...committed, + ...committedAfterHunterLockAndLoad, status: desiredStatus, activeCast: { - abilityId: selectedAbility.id, - school: presentationSchoolForAbility(selectedAbility), - mode: selectedAbility.castMode === "channel" ? "channel" : "cast", + abilityId: castAbility.id, + school: presentationSchoolForAbility(castAbility), + mode: castAbility.castMode === "channel" ? "channel" : "cast", startedAt: now, completesAt: now + castTimeMs, originalDurationMs: castTimeMs, pushbackHits: 0, + canCastWhileMoving: partyCastCanMoveDuringMeditationPath(runtimeMember, castAbility, now), origin: memberPosition, targetId: abilityTargetId, + ...(backdraftCast ? { backdraftGcdMs: backdraftCast.gcdMs } : {}), + ...(tidalWavesCast ? { tidalWavesChargesConsumed: tidalWavesCast.chargesConsumed } : {}), + ...(arcanePotencyBonus > 0 ? { arcanePotencyCriticalChanceBonusPercent: arcanePotencyBonus } : {}), + ...(runtimeMember.classId === "mage" ? { manaSpent } : {}), }, activeCastTargetId: abilityTargetId, nextActionAt: now + castTimeMs, }; - emitPartyAbility(member, selectedAbility, abilityTargetId, "cast-start", now); + emitPartyAbility(member, castAbility, abilityTargetId, "cast-start", now); } else { - const execution = executePartyAbility(committed, selectedAbility, abilityTargetId, now, nextMembers); + const execution = executePartyAbility( + committedAfterHunterLockAndLoad, castAbility, abilityTargetId, now, nextMembers, 0, false, false, + tidalWavesCast?.chargesConsumed ?? 0, + arcanePotencyBonus, + manaSpent, + ); nextMembers[index] = { ...execution.member, status: desiredStatus, - nextActionAt: now + Math.max(250, Math.round(selectedAbility.gcdMs / raceAffixHasteMultiplier(now))), + nextActionAt: now + Math.max(250, backdraftCast?.gcdMs + ?? Math.round(castAbility.gcdMs / raceAffixHasteMultiplier(now))), }; } } diff --git a/src/game/partyStore.ts b/src/game/partyStore.ts index 40150c86..31c1fb9d 100644 --- a/src/game/partyStore.ts +++ b/src/game/partyStore.ts @@ -62,6 +62,17 @@ export { export type PartyCommand = "attack" | "defend" | "stop"; export type PartyMemberStatus = "ready" | "advancing" | "guarding" | "healing" | "attacking" | "holding" | "down"; +type PartyActiveCast = CombatActorCastState & { + readonly canCastWhileMoving?: boolean; + /** Backdraft's already-hasted GCD duration, retained until cast completion. */ + readonly backdraftGcdMs?: number; + /** Tidal Waves charges spent at cast start, retained for Lesser Healing Wave's crit roll. */ + readonly tidalWavesChargesConsumed?: number; + /** Arcane Potency's next-spell critical chance, snapshotted when a Mage cast starts. */ + readonly arcanePotencyCriticalChanceBonusPercent?: number; + /** Mana committed for this cast, used by Master of Elements critical refunds. */ + readonly manaSpent?: number; +}; export interface PartyMember { readonly id: string; @@ -86,11 +97,12 @@ export interface PartyMember { readonly maxResource: number; readonly resourcePools: Readonly>>; readonly lastResourceSpentAt: Readonly>>; + readonly warriorAngerManagementRageRemainderMs?: number; readonly comboPoints: number; readonly comboTargetId: string | null; readonly cooldowns: Readonly>; readonly globalCooldownEndsAt: number; - readonly activeCast: CombatActorCastState | null; + readonly activeCast: PartyActiveCast | null; readonly activeCastTargetId: string | null; readonly schoolLockedUntil: Readonly>>; readonly runes: readonly DeathKnightRuneState[]; diff --git a/src/game/romAbilityCatalog.ts b/src/game/romAbilityCatalog.ts index cf8ebf9e..932a2da5 100644 --- a/src/game/romAbilityCatalog.ts +++ b/src/game/romAbilityCatalog.ts @@ -19,9 +19,14 @@ import { overrideRomKnightEffects } from "./romKnightOverrides"; import { overrideRomWarriorEffects } from "./romWarriorOverrides"; import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides"; import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides"; +import { overrideRomMageEffects } from "./romMageOverrides"; import { overrideRomWarlockEffects } from "./romWarlockOverrides"; import { overrideRomDruidEffects } from "./romDruidOverrides"; import { romPriestDescription } from "./romPriestTooltips"; +import { overrideRomPriestMageEffects } from "./romPriestMageRuntime"; +import { overrideRomPriestWarlockEffects } from "./romPriestWarlockOverrides"; +import { overrideRomChampionEffects } from "./romChampionOverrides"; +import { ROM_WARDEN_BRIAR_SHIELD_TAG } from "./romWardenOverrides"; export type RomSkillGroup = "primary" | "general" | "elite" | "passive"; @@ -344,17 +349,19 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic, if (semantic.kind === "dot" || semantic.kind === "hot") { const intervalMs = durationMs(semantic.intervalSeconds || 2); const ticks = Math.max(1, Math.round((semantic.durationSeconds || semantic.intervalSeconds || 2) / (semantic.intervalSeconds || 2))); + const percentHealthHot = semantic.kind === "hot" && semantic.resource === "health-percent"; return { kind: semantic.kind, coefficient: 0, ...(semantic.kind === "dot" && combatSchool(semantic.school) ? { school: combatSchool(semantic.school) } : {}), - basePoints: Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100), - pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100, + basePoints: percentHealthHot ? 0 : Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100), + pointsPerLevel: percentHealthHot ? 0 : Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100, sourceLevel: 1, ticks, intervalMs, + ...(percentHealthHot ? { percentMaxHealth: Math.max(0, semantic.basePerTick) / 100 } : {}), ...(semantic.kind === "dot" ? { tag: `rom-${effectId}` } : {}), } as AbilityEffect; } @@ -557,6 +564,52 @@ function targetStateAuraEffect(row: GeneratedSemanticRow, stateId: string, name: } function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null { + if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warrior" && row.sourceSkillId === 499955) { + // Briar Technique's modifiers are applied by its named source skills on a + // landed hit; it is a marker here, not a permanent stat aura. + return [stateAuraEffect(row, "druid-warrior-briar-technique-learned", row.name, null)]; + } + if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warrior" + && [494016, 494216].includes(row.sourceSkillId)) { + // The local trigger component 623080 is duplicated onto its two existing + // trigger skills; the hit-time runtime owns its stack application. + return translateRomAbilitySemantics(row) + .map((semantic, index) => semanticEffect(row, semantic, index)) + .filter((effect) => effect.kind !== "apply-aura" + || !effect.aura.id.includes("-623080-")); + } + if (row.sourceSkillId === 493642) { + // RuneWaker defines a 900-second +5% DEF self aura. Its separate + // component 503959 is handled on a qualifying physical hit. Do not treat + // it as damage dealt when Briar Shield is cast. + const defense = translateRomAbilitySemantics(row) + .filter((semantic): semantic is Extract => ( + semantic.kind === "aura" && semantic.modifierType === 135 + )) + .map((semantic, index) => semanticEffect(row, semantic, index)) + .find((effect) => effect.kind === "apply-aura"); + if (!defense || defense.kind !== "apply-aura") return []; + return [{ + ...defense, + aura: { + ...defense.aura, + id: "rom-briar-shield", + name: row.name, + disposition: "buff", + utilityTags: [...(defense.aura.utilityTags ?? []), ROM_WARDEN_BRIAR_SHIELD_TAG], + }, + }]; + } + if (row.classId === "rom-warden" && row.secondaryClassId === "rom-mage" && row.sourceSkillId === 494207) { + // Tough Oak's 1-second cooldown rider is handled per qualifying cast. Its + // +5% pet defense rider has no pet-stat representation in this runtime. + return [stateAuraEffect(row, "warden-tough-oak", row.name, null)]; + } + if (row.classId === "rom-warden" && row.secondaryClassId === "rom-mage" && row.sourceSkillId === 494011) { + // Oak Blessing's local 620679 effect is conditional on Heart of the Oak; + // its buff and periodic heal are added when that parent skill is cast. + return [stateAuraEffect(row, "warden-oak-blessing", row.name, 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. @@ -739,7 +792,13 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] { utilityTags: [...(effect.aura.utilityTags ?? []), "rom-briar-shield"], }, } : effect); - const druidOverride = row.classId === "rom-druid" ? overrideRomDruidEffects(row, translated) : null; + const priestMageOverride = overrideRomPriestMageEffects(row, translated); + if (priestMageOverride) return priestMageOverride; + const championOverride = overrideRomChampionEffects(row, translated); + if (championOverride) return championOverride; + const druidOverride = row.classId === "rom-druid" || row.sourceSkillId === 494331 + ? overrideRomDruidEffects(row, translated) + : null; if (druidOverride) return druidOverride; if (row.sourceSkillId === 498825) return [ ...translated, @@ -806,9 +865,12 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] { const weakenedDuration = row.source?.components?.find((component) => component.id === 501503)?.effectTime ?? 6; return [...translated, targetStateAuraEffect(row, "weakened", "Weakened", durationMs(weakenedDuration))]; } + const mageOverride = overrideRomMageEffects(row, translated); + if (mageOverride) return mageOverride; const pairOverride = overrideRomKnightEffects(row, translated) ?? overrideRomWarriorEffects(row, translated) ?? overrideRomPriestKnightEffects(row, translated) + ?? overrideRomPriestWarlockEffects(row, translated) ?? overrideRomScoutRogueEffects(row, translated) ?? overrideRomMageWarlockEffects(row, translated) ?? overrideRomWarlockEffects(row, translated); diff --git a/src/game/romAbilityCatalogSemantics.test.ts b/src/game/romAbilityCatalogSemantics.test.ts index f20e7804..ca3aa562 100644 --- a/src/game/romAbilityCatalogSemantics.test.ts +++ b/src/game/romAbilityCatalogSemantics.test.ts @@ -438,12 +438,18 @@ describe("executable RuneWaker ability mappings", () => { }); it("marks Briar Shield as the source prerequisite for Thorn Arrow", () => { - expect(sourceSkill(493642).effects).toEqual(expect.arrayContaining([ + const briarShield = sourceSkill(493642); + expect(briarShield.effects).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "apply-aura", - aura: expect.objectContaining({ utilityTags: expect.arrayContaining(["rom-briar-shield"]) }), + aura: expect.objectContaining({ + durationMs: 900_000, + modifiers: [{ kind: "stat", stat: "armor", operation: "percent", value: 0.05 }], + utilityTags: expect.arrayContaining(["rom-briar-shield"]), + }), }), ])); + expect(briarShield.effects.filter((effect) => effect.kind === "damage")).toHaveLength(0); expect(sourceSkill(494037).effects).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "damage" }), ])); diff --git a/src/game/romAbilitySemantics.ts b/src/game/romAbilitySemantics.ts index 84631fd8..f62a7967 100644 --- a/src/game/romAbilitySemantics.ts +++ b/src/game/romAbilitySemantics.ts @@ -95,6 +95,7 @@ export interface RomSourceComponent { export interface RomGeneratedAbilityLike { readonly classId?: string; + readonly secondaryClassId?: string; readonly sourceSkillId?: number; readonly name: string; readonly description: string; @@ -365,7 +366,14 @@ function addIfMissing( if (row.kind === "proc" && semantic.kind === "proc") { return row.trigger === semantic.trigger && row.triggerSkillId === semantic.triggerSkillId; } - if (row.kind === "aura" && semantic.kind === "aura") return row.effect === semantic.effect; + if (row.kind === "aura" && semantic.kind === "aura") { + const seedBranches = new Set([503815, 620458, 503814, 620457]); + const previousComponent = row.sourceComponentIds[0]; + const nextComponent = semantic.sourceComponentIds[0]; + if (row.effect === semantic.effect && previousComponent !== nextComponent + && seedBranches.has(previousComponent) && seedBranches.has(nextComponent)) return false; + return row.effect === semantic.effect; + } if (row.kind === "resource" && semantic.kind === "resource") return row.resource === semantic.resource && row.operation === semantic.operation; if (row.kind === "custom" && semantic.kind === "custom") return row.mechanicId === semantic.mechanicId; return discriminator.length === 0; diff --git a/src/game/romChampionOverrides.ts b/src/game/romChampionOverrides.ts index 749175ec..c5c78ca6 100644 --- a/src/game/romChampionOverrides.ts +++ b/src/game/romChampionOverrides.ts @@ -1,5 +1,6 @@ import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; import type { AuraDefinition } from "./combatAuras"; +import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; export const ROM_CHAMPION_FORGE_STACK_AURA: AuraDefinition = { id: "rom-champion-forge-stack", @@ -20,6 +21,28 @@ export const ROM_CHAMPION_FORGE_STACK_AURA: AuraDefinition = { export const ROM_CHAMPION_RUNE_OVERLOAD_TAG = "rom-champion-rune-overload"; export const ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND = 15; +export const ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID = "rom-champion-soul-forge-critical"; +export const ROM_CHAMPION_SOUL_FORGE_PROFILE = Object.freeze({ + sourceSpellId: 498652, + criticalAuraComponentId: 621339, + criticalAuraDurationMs: 300_000, + darkPowerComponentId: 621341, + trigger: "crit" as const, + chance: 1, + darkMagicAuraDurationMs: 10_000, + darkMagicBonusPercent: 0.1, + darkMagicSchool: "shadow" as const, +}); +export const ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID = "rom-champion-way-of-frenzy-hit"; +export const ROM_CHAMPION_WAY_OF_FRENZY_PROFILE = Object.freeze({ + sourceSpellId: 498720, + sourceComponentId: 621458, + triggerSpellId: 498715, + triggerAbilityId: "rom-champion-rom-priest-elite-20", + durationMs: 60_000, + maxStacks: 5, + staminaPercentPerStack: 0.02, +}); const ROM_CHAMPION_FORGE_ACTIVE_AURA: AuraDefinition = { id: "rom-champion-forge-active", @@ -37,6 +60,56 @@ const ROM_CHAMPION_FORGE_ACTIVE_AURA: AuraDefinition = { }], }; +/** Replace the source component's unconditional stat aura with its described hit trigger. */ +export function overrideRomChampionEffects( + row: RomGeneratedAbilityLike, + _translated: readonly AbilityEffect[], +): readonly AbilityEffect[] | null { + if (row.classId !== "rom-champion" || row.secondaryClassId !== "rom-priest" + || row.sourceSkillId !== ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId) return null; + + const component = row.source?.components?.find((entry) => ( + entry.id === ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceComponentId + )); + const staminaModifier = component?.abilityModifiers?.find((modifier) => modifier.type === 162); + const durationMs = Math.max(0, Math.round((component?.effectTime ?? 0) * 1_000)); + if (!component || !staminaModifier || staminaModifier.value <= 0 || durationMs <= 0) return []; + + return [{ + kind: "apply-aura", + aura: { + id: "rom-champion-way-of-frenzy-passive", + name: row.name, + disposition: "buff", + durationMs: null, + hideFromAuraStrip: true, + procs: [{ + id: "rom-champion-way-of-frenzy-divine-vengeance", + triggers: ["hit", "crit"], + abilityIds: [ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerAbilityId], + chance: 1, + action: { + kind: "custom", + id: ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID, + data: { + sourceSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId, + sourceComponentId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceComponentId, + triggerSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerSpellId, + durationMs, + maxStacks: 5, + staminaPercentPerStack: staminaModifier.value / 100, + }, + }, + }], + }, + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId, + }]; +} + function scaleDamage(effect: Extract, factor: number): AbilityEffect { return { ...effect, @@ -48,6 +121,83 @@ function scaleDamage(effect: Extract, factor: }; } +function casterHealFromDamage(effect: Extract): AbilityEffect { + return { + kind: "heal", + coefficient: effect.coefficient, + ...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints }), + ...(effect.dieSides === undefined ? {} : { dieSides: effect.dieSides }), + ...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel }), + ...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel }), + ...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient }), + ...(effect.attackPowerCoefficient === undefined ? {} : { attackPowerCoefficient: effect.attackPowerCoefficient }), + ...(effect.spiritCoefficient === undefined ? {} : { spiritCoefficient: effect.spiritCoefficient }), + ...(effect.sourceLevel === undefined ? {} : { sourceLevel: effect.sourceLevel }), + ...(effect.maxScalingLevel === undefined ? {} : { maxScalingLevel: effect.maxScalingLevel }), + recipient: "caster", + }; +} + +function soulForgeMysteryEffects(effects: readonly AbilityEffect[]): readonly AbilityEffect[] { + const critAuraIndex = effects.findIndex((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.criticalAuraComponentId}-`)); + const darkAuraIndex = effects.findIndex((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId}-`)); + const critAuraEffect = effects[critAuraIndex]; + const darkAuraEffect = effects[darkAuraIndex]; + if (critAuraIndex < 0 || darkAuraIndex < 0 + || critAuraEffect?.kind !== "apply-aura" || darkAuraEffect?.kind !== "apply-aura") return effects; + + const darkModifier = darkAuraEffect.aura.modifiers?.find((modifier) => modifier.kind === "damage" + && modifier.direction === "dealt" && modifier.school === ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicSchool + && modifier.operation === "percent"); + if (darkModifier?.kind !== "damage") return effects; + + const output: AbilityEffect[] = []; + effects.forEach((effect, index) => { + if (index === darkAuraIndex) return; + if (index === critAuraIndex && effect.kind === "apply-aura") { + output.push({ + ...effect, + aura: { + ...effect.aura, + procs: [ + ...(effect.aura.procs ?? []), + { + id: "rom-champion-soul-forge-dark-magic-on-crit", + triggers: ROM_CHAMPION_SOUL_FORGE_PROFILE.trigger, + chance: ROM_CHAMPION_SOUL_FORGE_PROFILE.chance, + action: { + kind: "custom", + id: ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID, + data: { + sourceSpellId: ROM_CHAMPION_SOUL_FORGE_PROFILE.sourceSpellId, + sourceComponentId: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId, + auraId: "rom-champion-soul-forge-dark-magic", + auraName: darkAuraEffect.aura.name, + durationMs: darkAuraEffect.aura.durationMs ?? ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicAuraDurationMs, + disposition: "buff", + maxStacks: 1, + stackBehavior: "refresh", + target: "aura-source", + modifierKind: "damage", + modifierDirection: "dealt", + modifierSchool: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicSchool, + modifierOperation: darkModifier.operation, + modifierValue: darkModifier.value, + }, + }, + }, + ], + }, + }); + return; + } + output.push(effect); + }); + return output; +} + /** Apply source-backed Champion cast branches that need runtime state. */ export function romChampionCastEffects( ability: AbilityDefinition, @@ -58,6 +208,49 @@ export function romChampionCastEffects( if (ability.source !== "runewaker" || ability.classId !== "rom-champion") return effects; switch (ability.dbcSpellId) { + case 498652: // Soul Forge Mystery adds its 10-second dark magic buff on a critical attack. + if (ability.secondaryClassId !== "rom-warlock") return effects; + return soulForgeMysteryEffects(effects); + case 498595: { // Rune Siphon's positive component 621264 restores the caster's HP. + if (ability.secondaryClassId !== "rom-warlock") return effects; + let healIndex = -1; + for (let index = effects.length - 1; index >= 0; index -= 1) { + if (effects[index]?.kind === "damage") { + healIndex = index; + break; + } + } + if (healIndex < 0) return effects; + return effects.flatMap((effect, index): AbilityEffect[] => { + if (index === healIndex && effect.kind === "damage") return [casterHealFromDamage(effect)]; + if (effect.kind === "heal" && effect.coefficient === 0 && effect.basePoints === undefined + && effect.pointsPerLevel === undefined && effect.percentMaxHealth === undefined) return []; + return [effect]; + }); + } + case 498529: { // Rune Pulse restores Rage on a landed hit (component 622327). + const restoreAura = effects.find((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes("-622327-") + && effect.aura.modifiers?.some((modifier) => modifier.kind === "resource" + && modifier.resource === "rage" && modifier.property === "regeneration")); + if (restoreAura?.kind !== "apply-aura") return effects; + const rageModifier = restoreAura.aura.modifiers?.find((modifier) => modifier.kind === "resource" + && modifier.resource === "rage" && modifier.property === "regeneration"); + const ragePerHit = rageModifier + ? Math.max(0, Math.round(rageModifier.value * (restoreAura.aura.durationMs ?? 0) / 1_000)) + : 0; + const damageIndex = effects.findIndex((effect) => effect.kind === "damage"); + if (ragePerHit <= 0 || damageIndex < 0) return effects; + const output: AbilityEffect[] = []; + effects.forEach((effect, index) => { + if (index === damageIndex && effect.kind === "damage") { + output.push({ ...effect, onDamageResource: { resource: "rage", amount: ragePerHit } }); + } else if (effect !== restoreAura) { + output.push(effect); + } + }); + return output; + } 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 diff --git a/src/game/romChampionRuntime.test.ts b/src/game/romChampionRuntime.test.ts index c2d8330c..3076f534 100644 --- a/src/game/romChampionRuntime.test.ts +++ b/src/game/romChampionRuntime.test.ts @@ -1,9 +1,15 @@ import { beforeEach, describe, expect, it } from "vitest"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry"; -import { useCombatStore } from "./combatStore"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics"; -import { romChampionCastEffects } from "./romChampionOverrides"; +import { + ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID, + ROM_CHAMPION_SOUL_FORGE_PROFILE, + ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID, + ROM_CHAMPION_WAY_OF_FRENZY_PROFILE, + romChampionCastEffects, +} from "./romChampionOverrides"; const empty: RomCastMechanics = { sealStacks: 0, @@ -133,6 +139,234 @@ describe("RoM Champion source-specific behavior", () => { expect(useCombatStore.getState().resourcePools.rage).toBeGreaterThanOrEqual(10); }); + it("restores Rune Pulse's source-defined Rage only when its damage lands", () => { + const runePulse = sourceAbility(498529); + expect(runePulse.description).toContain("When it hits, it will restore"); + + const effects = castEffects(498529); + const hit = effects.find((effect) => effect.kind === "damage"); + expect(hit?.kind).toBe("damage"); + if (hit?.kind === "damage") expect(hit.onDamageResource).toEqual({ resource: "rage", amount: 5 }); + expect(effects.some((effect) => effect.kind === "apply-aura" && effect.aura.id.includes("-622327-"))) + .toBe(false); + }); + + it("routes Rune Siphon's positive source component to the caster as healing", () => { + const runeSiphon = sourceAbility(498595); + expect(runeSiphon.secondaryClassId).toBe("rom-warlock"); + expect(runeSiphon.description).toContain("restores (DMG8) HP to the caster"); + + const effects = castEffects(498595); + expect(effects.filter((effect) => effect.kind === "damage")).toHaveLength(2); + expect(effects.filter((effect) => effect.kind === "heal")).toEqual([ + expect.objectContaining({ kind: "heal", recipient: "caster", basePoints: 24, pointsPerLevel: 4 }), + ]); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", level: 70, + }); + useCombatStore.getState().registerMob("champion-rune-siphon-target", { + name: "Rune Siphon Target", + maxHealth: 100_000, + }); + setMobPosition("champion-rune-siphon-target", [3, 0, 0]); + useCombatStore.getState().selectMob("champion-rune-siphon-target"); + useCombatStore.getState().damagePlayer(Math.floor(useCombatStore.getState().maxHealth / 2)); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100, focus: 100 }, + })); + const healthBeforeCast = useCombatStore.getState().health; + + expect(useCombatStore.getState().castAbility("rom-champion-rom-warlock-elite-20", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().health).toBeGreaterThan(healthBeforeCast); + }); + + it("arms Soul Forge Mystery's source-backed dark-magic bonus on critical attacks", () => { + const soulForge = sourceAbility(498652); + expect(soulForge.secondaryClassId).toBe("rom-warlock"); + expect(soulForge.description).toContain("If critical attack occurs while in effect"); + + const localCritAuras = soulForge.effects.filter((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.criticalAuraComponentId}-`)); + const localDarkAura = soulForge.effects.find((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId}-`)); + expect(localCritAuras.length).toBeGreaterThan(0); + expect(localCritAuras.every((effect) => effect.kind === "apply-aura" + && effect.aura.durationMs === 300_000)).toBe(true); + expect(localDarkAura).toMatchObject({ + kind: "apply-aura", + aura: { + durationMs: 10_000, + modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", school: "shadow", value: 0.1 })], + }, + }); + + const effects = castEffects(498652); + const criticalAura = effects.find((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.criticalAuraComponentId}-`)); + expect(criticalAura?.kind).toBe("apply-aura"); + if (criticalAura?.kind === "apply-aura") { + expect(criticalAura.aura.procs).toEqual(expect.arrayContaining([ + expect.objectContaining({ + triggers: "crit", + chance: 1, + action: expect.objectContaining({ + kind: "custom", + id: ROM_CHAMPION_SOUL_FORGE_PROC_ACTION_ID, + data: expect.objectContaining({ + sourceSpellId: 498652, + sourceComponentId: 621341, + durationMs: 10_000, + disposition: "buff", + maxStacks: 1, + stackBehavior: "refresh", + target: "aura-source", + modifierSchool: "shadow", + modifierValue: 0.1, + }), + }), + }), + ])); + } + expect(effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes(`-${ROM_CHAMPION_SOUL_FORGE_PROFILE.darkPowerComponentId}-`))).toBe(false); + }); + + it("grants Soul Forge Mystery's shadow bonus after a critical hit", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", + secondaryClassId: "rom-warlock", + raceId: "rom-dwarf", + level: 80, + xp: 0, + }); + const soulForge = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 498652)!; + expect(useCombatStore.getState().castAbility(soulForge.id, undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-champion-soul-forge-dark-magic")) + .toBe(false); + + useCombatStore.getState().registerMob("soul-forge-crit-target", { name: "Target", maxHealth: 100_000, xpReward: 0 }); + useCombatStore.getState().damageMob( + "soul-forge-crit-target", + 100, + 2_000, + undefined, + "shadow", + { + attackKind: "spell", + seed: "soul-forge-guaranteed-critical", + abilityId: soulForge.id, + criticalChanceBonusPercent: 100, + rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false }, + }, + ); + + expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + definition: expect.objectContaining({ + id: "rom-champion-soul-forge-dark-magic", + durationMs: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicAuraDurationMs, + modifiers: [expect.objectContaining({ + kind: "damage", + direction: "dealt", + school: "shadow", + operation: "percent", + value: ROM_CHAMPION_SOUL_FORGE_PROFILE.darkMagicBonusPercent, + })], + }), + }), + ])); + }); + + it("arms Way of Frenzy only on Divine Vengeance and caps its local 2% Stamina stacks at five", () => { + const wayOfFrenzy = sourceAbility(ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId); + const divineVengeance = sourceAbility(ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerSpellId); + expect(wayOfFrenzy).toMatchObject({ classId: "rom-champion", secondaryClassId: "rom-priest", passive: true }); + expect(divineVengeance.id).toBe(ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerAbilityId); + expect(wayOfFrenzy.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ + id: "rom-champion-way-of-frenzy-passive", + durationMs: null, + procs: [expect.objectContaining({ + triggers: ["hit", "crit"], + abilityIds: [divineVengeance.id], + chance: 1, + action: expect.objectContaining({ + kind: "custom", + id: ROM_CHAMPION_WAY_OF_FRENZY_PROC_ACTION_ID, + data: expect.objectContaining({ + sourceSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceSpellId, + sourceComponentId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.sourceComponentId, + triggerSpellId: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.triggerSpellId, + durationMs: 60_000, + maxStacks: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.maxStacks, + staminaPercentPerStack: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.staminaPercentPerStack, + }), + }), + })], + }), + }), + ])); + expect(wayOfFrenzy.effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "stamina"))) + .toBe(false); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", + secondaryClassId: "rom-priest", + raceId: "rom-dwarf", + level: 70, + }); + useCombatStore.getState().registerMob("way-of-frenzy-target", { name: "Target", maxHealth: 100_000, xpReward: 0 }); + const stackAuraId = "rom-champion-way-of-frenzy-stacks"; + const stackAura = () => useCombatStore.getState().auras.find((aura) => aura.definition.id === stackAuraId); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-champion-way-of-frenzy-passive")) + .toBe(true); + expect(stackAura()).toBeUndefined(); + + const unrelatedAbility = sourceAbility(498528); + useCombatStore.getState().damageMob("way-of-frenzy-target", 100, 2_000, undefined, "physical", { + attackKind: "melee", + seed: "way-of-frenzy-unrelated-hit", + abilityId: unrelatedAbility.id, + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }); + expect(stackAura()).toBeUndefined(); + + for (let hit = 0; hit < 6; hit += 1) { + useCombatStore.getState().damageMob("way-of-frenzy-target", 100, 3_000 + hit * 1_000, undefined, "physical", { + attackKind: "melee", + seed: `way-of-frenzy-divine-vengeance-${hit}`, + abilityId: divineVengeance.id, + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }); + } + + expect(stackAura()).toMatchObject({ + stacks: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.maxStacks, + expiresAt: 68_000, + definition: { + durationMs: 60_000, + maxStacks: 5, + stackBehavior: "add", + modifiers: [{ + kind: "stat", + stat: "stamina", + operation: "percent", + value: ROM_CHAMPION_WAY_OF_FRENZY_PROFILE.staminaPercentPerStack, + perStack: true, + }], + }, + }); + }); + 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" @@ -191,4 +425,16 @@ describe("RoM Champion source-specific behavior", () => { expect(castEffects(498599).some((effect) => effect.kind === "cooldown-reset")).toBe(false); expect(castEffects(498535).some((effect) => effect.kind === "cooldown-reset")).toBe(false); }); + + it("cleanses every modeled harmful aura on the party with Righteous Explosion", () => { + const cleanse = castEffects(499836).find((effect) => effect.kind === "dispel"); + + expect(cleanse).toMatchObject({ + kind: "dispel", + mode: "cleanse", + relationship: "friendly", + scope: "party", + maxCount: 99, + }); + }); }); diff --git a/src/game/romCombatMechanics.test.ts b/src/game/romCombatMechanics.test.ts index d5a7ed4e..46b2cbec 100644 --- a/src/game/romCombatMechanics.test.ts +++ b/src/game/romCombatMechanics.test.ts @@ -36,7 +36,7 @@ describe("RoM primary class conditional combat effects", () => { 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(empowered[1]).toEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" }); expect(cast("rom-druid-rom-scout-elite-25", { naturePower: 0.5 }).filter((effect) => effect.kind === "resource-drain")) .toEqual([]); }); diff --git a/src/game/romCombatMechanics.ts b/src/game/romCombatMechanics.ts index 0802e138..6d222245 100644 --- a/src/game/romCombatMechanics.ts +++ b/src/game/romCombatMechanics.ts @@ -60,7 +60,22 @@ export function romAbilityNeedsTwoHand(ability: AbilityDefinition): boolean { export function romAbilityNeedsRangedWeapon(ability: AbilityDefinition): boolean { return ability.source === "runewaker" - && [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629].includes(ability.dbcSpellId); + && [ + 490420, // Scout Joint Blow uses a ranged weapon. + 490423, // Scout Shot uses a ranged weapon. + 490438, // Scout Neck Strike requires a ranged weapon. + 491128, // Scout Wind Arrows uses a ranged weapon. + 491292, // Scout Vampire Arrows uses a ranged weapon. + 491452, + 491616, + 492589, // Scout Autoshot uses a ranged weapon. + 494021, + 494037, + 494039, + 494040, + 494042, + 494629, + ].includes(ability.dbcSpellId); } export function romAbilityRequiredTargetStateTag(ability: AbilityDefinition): string | null { @@ -178,6 +193,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe ability, priestKnightEffects, context.friendlyTargetHealthPercent ?? 1, + context.passiveTags ?? [], ); const knightEffects = romKnightCastEffects(ability, priestWarlockEffects, context.passiveTags ?? []); const mageWarlockEffects = romMageWarlockCastEffects( @@ -199,6 +215,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe wardenEffects, context.targetTags ?? [], context.learnedPassiveSpellIds ?? [], + context.targetHealthPercent, ); const warriorPassives = new Set(context.learnedPassiveSpellIds ?? []); const sourceSchoolAdjustedEffects = romWarriorPhysicalWeaponTooltip(ability) @@ -230,7 +247,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe 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 }] : []), + ...(empowered ? [{ kind: "resource-drain" as const, amount: 1, burn: false, resource: "nature-power" as const, recipient: "caster" as const }] : []), ]; } case 493247: // The Final Battle only adds its second hit below 30% target HP. diff --git a/src/game/romDruidNaturePower.test.ts b/src/game/romDruidNaturePower.test.ts index 627cbb04..5dca6584 100644 --- a/src/game/romDruidNaturePower.test.ts +++ b/src/game/romDruidNaturePower.test.ts @@ -27,6 +27,43 @@ function passiveTag(spellId: number): string { } describe("RoM Druid Nature Power branches", () => { + it("only applies Grace of the Forest's conditional component while Nature Power is held", () => { + const grace = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 494937); + expect(grace).toMatchObject({ classId: "rom-druid", secondaryClassId: "rom-warden" }); + const withoutPower = romEffectsForCast(grace!, { ...empty, naturePower: 0 }); + const withPower = romEffectsForCast(grace!, { ...empty, naturePower: 1 }); + const bonus = (effects: readonly AbilityEffect[]) => effects.filter((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "resource" + && modifier.resource === "mana" && modifier.property === "maximum" + && modifier.operation === "percent" && modifier.value === 0.1)); + + expect(bonus(withoutPower)).toHaveLength(0); + expect(bonus(withPower)).toHaveLength(1); + expect(bonus(withPower)[0]).toMatchObject({ + kind: "apply-aura", + aura: { durationMs: 4_000 }, + }); + const healingBonuses = (effects: readonly AbilityEffect[]) => effects.filter((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "healing" + && modifier.direction === "done" && modifier.value === 0.03)); + expect(healingBonuses(withoutPower)).toHaveLength(1); + expect(healingBonuses(withPower)).toHaveLength(2); + expect(healingBonuses(withPower).find((effect) => effect.kind === "apply-aura" + && effect.aura.id === "rom-druid-grace-of-the-forest-powered-healing")) + .toMatchObject({ kind: "apply-aura", aura: { durationMs: 4_000 } }); + }); + + it("uses Essence Extraction's 10% mana cast cost once and restores four Nature Power", () => { + const essence = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 494373); + expect(essence).toBeDefined(); + expect(essence!.costs).toContainEqual({ resource: "mana", amount: 10, percentage: true }); + + const effects = cast(494373, 0); + expect(effects.filter((effect) => effect.kind === "resource-drain" && effect.resource === "mana")).toHaveLength(0); + expect(effects.filter((effect) => effect.kind === "resource" && effect.resource === "nature-power")) + .toEqual([{ kind: "resource", resource: "nature-power", amount: 4 }]); + }); + 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); @@ -37,7 +74,7 @@ describe("RoM Druid Nature Power branches", () => { 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" }, + { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" }, ]); }); @@ -55,6 +92,40 @@ describe("RoM Druid Nature Power branches", () => { expect(cast(493549, 1).filter((effect) => effect.kind === "damage")).toHaveLength(1); }); + it("selects one RuneWaker Seed modifier component for the current Nature Power branch", () => { + const seedBranches = [ + { spellId: 493546, baseComponentId: 503815, poweredComponentId: 620458, stat: "magical-defense" }, + { spellId: 493545, baseComponentId: 503814, poweredComponentId: 620457, stat: "armor" }, + ] as const; + + for (const { spellId, baseComponentId, poweredComponentId, stat } of seedBranches) { + const branchAuras = (effects: readonly AbilityEffect[]) => effects.filter((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === stat)); + const baseline = branchAuras(cast(spellId, 0)); + const powered = branchAuras(cast(spellId, 1)); + + expect(baseline).toHaveLength(1); + expect(baseline[0]).toMatchObject({ + kind: "apply-aura", + aura: { + durationMs: 20_000, + modifiers: [{ kind: "stat", stat, operation: "percent", value: -0.02 }], + }, + }); + expect(baseline[0]?.kind === "apply-aura" && baseline[0].aura.id).toContain(`-${baseComponentId}-`); + + expect(powered).toHaveLength(1); + expect(powered[0]).toMatchObject({ + kind: "apply-aura", + aura: { + durationMs: 20_000, + modifiers: [{ kind: "stat", stat, operation: "percent", value: -0.02 }], + }, + }); + expect(powered[0]?.kind === "apply-aura" && powered[0].aura.id).toContain(`-${poweredComponentId}-`); + } + }); + it("selects the powered Sandstorm and Earth Chain payloads without simultaneous alternatives", () => { const sandstormBase = cast(493542, 0); const sandstormPowered = cast(493542, 1); @@ -85,7 +156,7 @@ describe("RoM Druid Nature Power branches", () => { 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" }); + expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" }); }); it("selects Cross of Thorns' two-hit branch and consumes one point without an unknown refund chance", () => { @@ -97,7 +168,7 @@ describe("RoM Druid Nature Power branches", () => { 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" }]); + .toEqual([{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" }]); expect(empowered.some((effect) => effect.kind === "resource" && effect.resource === "nature-power")) .toBe(false); }); diff --git a/src/game/romDruidOverrides.ts b/src/game/romDruidOverrides.ts index fcd8c2b8..7ea6a6da 100644 --- a/src/game/romDruidOverrides.ts +++ b/src/game/romDruidOverrides.ts @@ -1,6 +1,9 @@ import type { AbilityEffect } from "./abilityCatalog"; import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; +export const ROM_DRUID_GREEN_GUARDIAN_TAG = "rom-druid-green-guardian"; +export const ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG = "rom-druid-mother-earth-protection"; + 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)); @@ -83,6 +86,26 @@ export function overrideRomDruidEffects( translated: readonly AbilityEffect[], ): readonly AbilityEffect[] | null { switch (row.sourceSkillId) { + case 493533: + case 493558: { // Both Mother Earth's Protection ranks can trigger Green Guardian on expiry. + let tagged = false; + return translated.map((effect) => { + if (tagged || effect.kind !== "apply-aura" || effect.aura.durationMs === null) return effect; + tagged = true; + return { + ...effect, + aura: { + ...effect.aura, + utilityTags: [...(effect.aura.utilityTags ?? []), ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG], + }, + }; + }); + } + case 494331: // Keep source HoT payloads on the learned passive for its expiry trigger. + return [ + passiveState(494331, row.name, ROM_DRUID_GREEN_GUARDIAN_TAG), + ...translated.filter((effect) => effect.kind === "hot"), + ]; case 493531: // Savage Blessing buffs a selected friendly target. return translated.map((effect) => effect.kind === "apply-aura" ? { ...effect, @@ -134,7 +157,7 @@ function hasTag(tags: readonly string[] | ReadonlySet, tag: string): boo } function naturePowerDrain(): AbilityEffect { - return { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }; + return { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" }; } function spendBeforeNaturePowerRefund(effects: readonly AbilityEffect[]): readonly AbilityEffect[] { @@ -144,6 +167,18 @@ function spendBeforeNaturePowerRefund(effects: readonly AbilityEffect[]): readon return [...effects.slice(0, refundIndex), naturePowerDrain(), ...effects.slice(refundIndex)]; } +function selectSeedModifierBranch( + effects: readonly AbilityEffect[], + componentIds: readonly [number, number], + selectedComponentId: number, +): readonly AbilityEffect[] { + return effects.filter((effect) => { + if (effect.kind !== "apply-aura") return true; + const sourceComponentId = componentIds.find((id) => effect.aura.id.includes(`-${id}-`)); + return sourceComponentId === undefined || sourceComponentId === selectedComponentId; + }); +} + /** Resolve Druid source branches that depend on current Nature Power or learned passive state. */ export function romDruidCastEffects( ability: import("./abilityCatalog").AbilityDefinition, @@ -161,6 +196,32 @@ export function romDruidCastEffects( }; switch (ability.dbcSpellId) { + case 494373: // Essence Extraction's 10% MP conversion is already represented as its cast cost. + return effects.filter((effect) => !(effect.kind === "resource-drain" && effect.resource === "mana")); + case 494937: { // Grace of the Forest only applies RuneWaker's second component while Nature Power is held. + if (ability.classId !== "rom-druid" || ability.secondaryClassId !== "rom-warden") return effects; + if (naturePower < 1) return effects.filter((effect) => effect.kind !== "apply-aura" + || !effect.aura.id.includes("-494937-505711-")); + const poweredHealingPresent = effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes("-494937-505711-") + && effect.aura.modifiers?.some((modifier) => modifier.kind === "healing" + && modifier.direction === "done" && modifier.operation === "percent" && modifier.value === 0.03)); + return poweredHealingPresent ? effects : [...effects, { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-druid-grace-of-the-forest-powered-healing", + name: `${ability.name}: Nature Power`, + disposition: "buff", + durationMs: 4_000, + modifiers: [{ kind: "healing", direction: "done", operation: "percent", value: 0.03 }], + }, + sourceEffectType: -1, + sourceAuraType: 149, + miscValue: 3, + triggerSpellId: 494937, + }]; + } 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; @@ -175,9 +236,14 @@ export function romDruidCastEffects( ...(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 493546: { // Buff1 is the base branch; Buff0 is the powered branch. + const selected = selectSeedModifierBranch(effects, [503815, 620458], empowered ? 620458 : 503815); + return empowered ? spendBeforeNaturePowerRefund(selected) : selected; + } + case 493545: { // Buff1 is the base branch; Buff0 is the powered branch. + const selected = selectSeedModifierBranch(effects, [503814, 620457], empowered ? 620457 : 503814); + return empowered ? spendBeforeNaturePowerRefund(selected) : selected; + } 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; diff --git a/src/game/romDruidRuntime.test.ts b/src/game/romDruidRuntime.test.ts index 904f00b3..f5514c49 100644 --- a/src/game/romDruidRuntime.test.ts +++ b/src/game/romDruidRuntime.test.ts @@ -2,7 +2,7 @@ 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 { equipmentAdjustedCombatAmount, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { installCombatClock } from "./combatClock"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { companionResourceMaximum } from "./combatActorAuthority"; @@ -22,7 +22,7 @@ function finishCast(now: number) { if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt); } -function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout") { +function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout" | "rom-mage") { useCombatStore.getState().initializeCharacter({ classId: "rom-druid", ...(secondaryClassId ? { secondaryClassId } : {}), @@ -37,9 +37,25 @@ function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout") { })); } +function prepareMageDruid() { + useCombatStore.getState().initializeCharacter({ + classId: "rom-mage", + secondaryClassId: "rom-druid", + raceId: "rom-elf", + level: 80, + }); + useCombatStore.setState((state) => ({ + health: 1, + resource: 1_000, + maxResource: 1_000, + resourcePools: { ...state.resourcePools, mana: 1_000 }, + maxResourcePools: { ...state.maxResourcePools, mana: 1_000 }, + })); +} + describe("RuneWaker Druid runtime", () => { beforeAll(async () => { - await preloadAbilityCatalogsForClasses(["rom-druid"]); + await preloadAbilityCatalogsForClasses(["rom-druid", "rom-mage"]); }); beforeEach(() => { @@ -136,6 +152,77 @@ describe("RuneWaker Druid runtime", () => { expect(companionResourceMaximum(companion, "nature-power")).toBe(15); }); + it("converts exactly 10% MP into four Nature Power with Essence Extraction", () => { + prepareDruid("rom-warden"); + const essence = ability(494373); + expect(essence.costs).toContainEqual({ resource: "mana", amount: 10, percentage: true }); + expect(useCombatStore.getState().resourcePools).toMatchObject({ mana: 1_000, "nature-power": 0 }); + + expect(useCombatStore.getState().castAbility(essence.id, { now: 1_000 }).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.mana).toBe(900); + expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(4); + }); + + it("applies Grace of the Forest's Nature Power bonus only when a point is held", () => { + prepareDruid("rom-warden"); + const grace = ability(494937); + expect(useCombatStore.getState().castAbility(grace.id, { now: 1_000 }).ok).toBe(true); + const baseAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID); + expect(baseAuras.some((aura) => aura.definition.modifiers?.some((modifier) => modifier.kind === "resource" + && modifier.resource === "mana" && modifier.property === "maximum" + && modifier.operation === "percent" && modifier.value === 0.1))).toBe(false); + expect(baseAuras.some((aura) => aura.definition.modifiers?.some((modifier) => modifier.kind === "healing" + && modifier.direction === "done" && modifier.operation === "percent" && modifier.value === 0.03))).toBe(true); + + prepareDruid("rom-warden"); + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, "nature-power": 1 }, + })); + expect(useCombatStore.getState().castAbility(grace.id, { now: 2_000 }).ok).toBe(true); + const poweredAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID); + expect(poweredAuras.find((aura) => aura.definition.modifiers?.some((modifier) => modifier.kind === "resource" + && modifier.resource === "mana" && modifier.property === "maximum" + && modifier.operation === "percent" && modifier.value === 0.1))?.expiresAt).toBe(6_000); + expect(poweredAuras.find((aura) => aura.definition.id === "rom-druid-grace-of-the-forest-powered-healing")?.expiresAt) + .toBe(6_000); + }); + + it("selects and applies the source Seed defense branch to its target", () => { + const branches = [ + { spellId: 493546, baseComponentId: 503815, poweredComponentId: 620458, stat: "magical-defense" }, + { spellId: 493545, baseComponentId: 503814, poweredComponentId: 620457, stat: "armor" }, + ] as const; + + branches.forEach(({ spellId, baseComponentId, poweredComponentId, stat }, index) => { + for (const { naturePower, componentId } of [ + { naturePower: 0, componentId: baseComponentId }, + { naturePower: 1, componentId: poweredComponentId }, + ]) { + const targetId = `${TARGET_ID}-seed-${spellId}-${naturePower}`; + prepareDruid(); + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, "nature-power": naturePower }, + })); + useCombatStore.getState().registerMob(targetId, { name: "Seed Target", maxHealth: 10_000 }); + setMobPosition(targetId, [5, 0, 0]); + + const seed = ability(spellId); + const now = 2_000 + index * 4_000 + naturePower * 1_000; + expect(useCombatStore.getState().castAbility(seed.id, { targetActorId: targetId, now }).ok).toBe(true); + expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(1); + const seedAura = useCombatStore.getState().auras.find((aura) => aura.targetId === targetId + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === stat)); + expect(seedAura).toMatchObject({ + expiresAt: now + 20_000, + definition: expect.objectContaining({ + id: expect.stringContaining(`-${componentId}-`), + modifiers: [expect.objectContaining({ kind: "stat", stat, operation: "percent", value: -0.02 })], + }), + }); + } + }); + }); + 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); @@ -185,4 +272,178 @@ describe("RuneWaker Druid runtime", () => { expect(buffs.length).toBeGreaterThanOrEqual(2); expect(buffs.every((aura) => aura.targetId === ally.id && aura.definition.disposition === "buff")).toBe(true); }); + + it("triggers both Green Guardian HoTs only after the caster's protection expires", () => { + prepareMageDruid(); + const state = useCombatStore.getState(); + const passive = ability(494331); + const protection = ability(493533); + expect(state.abilities.some((entry) => entry.id === passive.id && entry.passive)).toBe(true); + + const percentHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth !== undefined); + const flatHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth === undefined); + expect(percentHot).toMatchObject({ kind: "hot", percentMaxHealth: 0.02, ticks: 5, intervalMs: 2_000 }); + expect(flatHot).toMatchObject({ kind: "hot", basePoints: 39, pointsPerLevel: 9, ticks: 5, intervalMs: 2_000 }); + + const targetMaxHealth = state.maxHealth; + const protectionCast = useCombatStore.getState().castAbility(protection.id, { + targetActorId: null, + now: 1_000, + }); + expect(protectionCast.ok).toBe(true); + const taggedProtection = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes("rom-druid-mother-earth-protection") + )); + expect(taggedProtection?.expiresAt).toBe(6_000); + + const startingHealth = useCombatStore.getState().health; + useCombatStore.getState().tick(0, 5_999); + expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0); + expect(useCombatStore.getState().health).toBe(startingHealth); + useCombatStore.getState().tick(0, 6_000); + const triggered = useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id); + expect(triggered).toHaveLength(2); + expect(triggered.every((effect) => effect.kind === "hot" && effect.targetId === null + && effect.remainingTicks === 5 && effect.nextTickAt === 8_000)).toBe(true); + expect(useCombatStore.getState().health).toBe(startingHealth); + + const flatTickAmount = equipmentAdjustedCombatAmount( + flatHot as Extract, { kind: "hot" }>, + 80, + state.gearStats, + "rom-mage", + passive, + ); + expect(flatTickAmount).toBe(750); + const expectedPerTick = Math.round(targetMaxHealth * 0.02 + flatTickAmount); + const expectedTotal = expectedPerTick * 5; + let observedTicks = 0; + for (const now of [8_000, 10_000, 12_000, 14_000, 16_000]) { + useCombatStore.getState().tick(0, now); + observedTicks += 1; + expect(useCombatStore.getState().health - startingHealth).toBe(expectedPerTick * observedTicks); + } + expect(useCombatStore.getState().health - startingHealth).toBe(expectedTotal); + expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0); + }); + + it("does not trigger Green Guardian for another pair or another aura caster", () => { + prepareDruid("rom-mage"); + const protection = ability(493533); + const wrongPairPassive = useCombatStore.getState().abilities.find((entry) => entry.dbcSpellId === 494331); + expect(wrongPairPassive).toBeUndefined(); + expect(useCombatStore.getState().castAbility(protection.id, { + targetActorId: null, + now: 1_000, + }).ok).toBe(true); + useCombatStore.getState().tick(1, 6_000); + expect(useCombatStore.getState().effects.filter((effect) => effect.kind === "hot")).toHaveLength(0); + + prepareMageDruid(); + const passive = ability(494331); + expect(useCombatStore.getState().castAbility(protection.id, { + targetActorId: null, + now: 10_000, + }).ok).toBe(true); + const taggedProtection = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes("rom-druid-mother-earth-protection") + )); + expect(taggedProtection).toBeDefined(); + useCombatStore.setState((state) => ({ + auras: state.auras.map((aura) => aura.instanceId === taggedProtection!.instanceId + ? { ...aura, sourceId: "another-caster" } + : aura), + })); + useCombatStore.getState().tick(1, taggedProtection!.expiresAt!); + expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0); + }); + + it("heals the friendly recipient of Mother Earth's Protection when it expires", () => { + prepareMageDruid(); + usePartyStore.getState().initializeParty("green-guardian-recipient", 80, 1, "damage", 5, "rom"); + const recipient = usePartyStore.getState().members[0]!; + usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === recipient.id + ? { ...member, health: 1 } + : member) })); + registerPartyRuntimePosition(recipient.id, [4, 0, 0]); + useCombatStore.getState().synchronizeActors(); + + const passive = ability(494331); + const protection = ability(493533); + const percentHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth !== undefined); + const flatHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth === undefined); + const percentMaxHealth = percentHot?.kind === "hot" ? percentHot.percentMaxHealth ?? 0 : 0; + expect(percentMaxHealth).toBe(0.02); + expect(useCombatStore.getState().castAbility(protection.id, { + targetActorId: recipient.id, + now: 1_000, + }).ok).toBe(true); + const protectionAura = useCombatStore.getState().auras.find((aura) => ( + aura.sourceId === PLAYER_AURA_ENTITY_ID + && aura.targetId === recipient.id + && aura.definition.utilityTags?.includes("rom-druid-mother-earth-protection") + )); + expect(protectionAura?.expiresAt).toBe(6_000); + useCombatStore.getState().tick(0, protectionAura!.expiresAt!); + expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id + && effect.targetId === recipient.id)).toHaveLength(2); + + const targetMaxHealth = recipient.maxHealth; + const flatTickAmount = equipmentAdjustedCombatAmount( + flatHot as Extract, { kind: "hot" }>, + 80, + useCombatStore.getState().gearStats, + "rom-mage", + passive, + ); + const expectedPerTick = Math.round(targetMaxHealth * percentMaxHealth + flatTickAmount); + for (const now of [8_000, 10_000, 12_000, 14_000, 16_000]) { + const before = usePartyStore.getState().members.find((member) => member.id === recipient.id)!.health; + useCombatStore.getState().tick(0, now); + const after = usePartyStore.getState().members.find((member) => member.id === recipient.id)!.health; + expect(after - before).toBe(expectedPerTick); + } + }); + + it("uses a party Mage/Druid's Green Guardian when their protection expires", () => { + prepareMageDruid(); + const caster = createConfiguredPartyMember({ + name: "Mage Druid", + classId: "rom-mage", + secondaryClassId: "rom-druid", + specialization: "Mage", + specializationId: "rom-mage-druid", + talentTreeId: "rom-mage-druid", + role: "damage", + }, 80, 0, "green-guardian-companion"); + usePartyStore.getState().initializeParty("green-guardian-companion", 80, + useCombatStore.getState().health, "damage", 2, "rom"); + usePartyStore.setState({ members: [caster] }); + useCombatStore.getState().synchronizeActors(); + + const passive = ability(494331); + const protection = ability(493533); + const protectionEffect = protection.effects.find((effect) => ( + effect.kind === "apply-aura" + && effect.aura.utilityTags?.includes("rom-druid-mother-earth-protection") + )); + if (!protectionEffect || protectionEffect.kind !== "apply-aura") { + throw new Error("Mother Earth's Protection did not expose its tagged aura effect."); + } + expect(useCombatStore.getState().applyActorAura( + caster.id, + PLAYER_AURA_ENTITY_ID, + protection, + protectionEffect, + 1_000, + )).toBe(true); + + useCombatStore.getState().tick(0, 6_000); + const triggered = useCombatStore.getState().effects.filter((effect) => ( + effect.sourceAbilityId === passive.id && effect.sourceActorId === caster.id + )); + expect(triggered).toHaveLength(2); + expect(triggered.every((effect) => effect.kind === "hot" + && effect.targetId === null && effect.nextTickAt === 8_000)).toBe(true); + }); }); diff --git a/src/game/romKnightOverrides.test.ts b/src/game/romKnightOverrides.test.ts new file mode 100644 index 00000000..d4064731 --- /dev/null +++ b/src/game/romKnightOverrides.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { + romKnightRogueHolyStrikeCriticalRateIncreasePercent, + romKnightWarriorWhirlwindAdjustment, +} from "./romKnightOverrides"; + +describe("RoM Knight/Warrior Deadly Whirlwind", () => { + const whirlwind = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490133)!; + const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491639)!; + + it("applies the local 10-Rage and 20-second reductions to Warrior Whirlwind for the learned pair passive", () => { + expect(passive).toMatchObject({ + classId: "rom-knight", + secondaryClassId: "rom-warrior", + passive: true, + }); + expect(whirlwind).toMatchObject({ classId: "rom-warrior", source: "runewaker" }); + + expect(romKnightWarriorWhirlwindAdjustment( + "rom-knight", + "rom-warrior", + whirlwind, + [491639], + )).toEqual({ rageCostReduction: 10, cooldownReductionMs: 20_000 }); + }); + + it("does not apply without the exact pair, learned passive, source, or Whirlwind spell", () => { + const adjustments = [ + romKnightWarriorWhirlwindAdjustment("rom-warrior", "rom-knight", whirlwind, [491639]), + romKnightWarriorWhirlwindAdjustment("rom-knight", "rom-warrior", whirlwind, []), + romKnightWarriorWhirlwindAdjustment("rom-knight", "rom-warrior", { + ...whirlwind, + dbcSpellId: 490175, + }, [491639]), + romKnightWarriorWhirlwindAdjustment("rom-knight", "rom-warrior", { + ...whirlwind, + source: "core", + }, [491639]), + ]; + + expect(adjustments).toEqual(Array.from({ length: 4 }, () => ({ + rageCostReduction: 0, + cooldownReductionMs: 0, + }))); + }); +}); + +describe("RoM Knight/Rogue God's Sword", () => { + const holyStrike = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490148)!; + const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491621)!; + + it("provides the local 40% critical-rate increase only to Holy Strike for the learned pair passive", () => { + expect(passive).toMatchObject({ + classId: "rom-knight", + secondaryClassId: "rom-rogue", + passive: true, + }); + expect(holyStrike).toMatchObject({ classId: "rom-knight", source: "runewaker" }); + + expect(romKnightRogueHolyStrikeCriticalRateIncreasePercent( + "rom-knight", + "rom-rogue", + holyStrike, + [491621], + )).toBe(40); + }); + + it("does not apply God's Sword to other pairings, abilities, unlearned passives, or non-RuneWaker spells", () => { + const bonuses = [ + romKnightRogueHolyStrikeCriticalRateIncreasePercent("rom-rogue", "rom-knight", holyStrike, [491621]), + romKnightRogueHolyStrikeCriticalRateIncreasePercent("rom-knight", "rom-rogue", holyStrike, []), + romKnightRogueHolyStrikeCriticalRateIncreasePercent("rom-knight", "rom-rogue", { + ...holyStrike, + dbcSpellId: 490149, + }, [491621]), + romKnightRogueHolyStrikeCriticalRateIncreasePercent("rom-knight", "rom-rogue", { + ...holyStrike, + source: "core", + }, [491621]), + ]; + + expect(bonuses).toEqual([0, 0, 0, 0]); + }); +}); diff --git a/src/game/romKnightOverrides.ts b/src/game/romKnightOverrides.ts index 63e20c2d..648f8fe5 100644 --- a/src/game/romKnightOverrides.ts +++ b/src/game/romKnightOverrides.ts @@ -1,9 +1,69 @@ import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import type { AuraProcDefinition } from "./combatAuras"; 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"; +export const ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG = "rom-holy-lights-fury"; +export const ROM_KNIGHT_MASTER_OF_PARRY_TAG = "rom-knight-master-of-parry"; +export const ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC = "rom-knight-master-of-parry-block"; + +/** Shield of Valor reduces Holy Seal's remaining cooldown only on an active-aura parry. */ +export function romKnightShieldOfValorCooldownReductionMs(auraActive: boolean, parried: boolean): number { + return auraActive && parried ? 1_000 : 0; +} + +export interface RomKnightWarriorWhirlwindAdjustment { + readonly rageCostReduction: number; + readonly cooldownReductionMs: number; +} + +/** RuneWaker Knight/Warrior passive 491639 modifies only Warrior Whirlwind. */ +export function romKnightWarriorWhirlwindAdjustment( + characterClassId: string, + secondaryClassId: string | null | undefined, + ability: Pick, + learnedPassiveSpellIds: readonly number[], +): RomKnightWarriorWhirlwindAdjustment { + if (characterClassId !== "rom-knight" + || secondaryClassId !== "rom-warrior" + || ability.source !== "runewaker" + || ability.classId !== "rom-warrior" + || ability.dbcSpellId !== 490133 + || !learnedPassiveSpellIds.includes(491639)) { + return { rageCostReduction: 0, cooldownReductionMs: 0 }; + } + + // The local 491639 tooltip explicitly says 10 Rage and 20 seconds. + return { rageCostReduction: 10, cooldownReductionMs: 20_000 }; +} + +/** RuneWaker Knight/Rogue passive 491621 boosts only Holy Strike's critical rate. */ +export function romKnightRogueHolyStrikeCriticalRateIncreasePercent( + characterClassId: string, + secondaryClassId: string | null | undefined, + ability: Pick, + learnedPassiveSpellIds: readonly number[], +): number { + if (characterClassId !== "rom-knight" + || secondaryClassId !== "rom-rogue" + || ability.source !== "runewaker" + || ability.classId !== "rom-knight" + || ability.dbcSpellId !== 490148 + || !learnedPassiveSpellIds.includes(491621)) return 0; + + // The populated local tooltip specifies a 40% Holy Strike critical-rate increase. + return 40; +} + +const ROM_KNIGHT_MASTER_OF_PARRY_PROC: AuraProcDefinition = { + id: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, + triggers: "block", + chance: 1, + charges: 2, + action: { kind: "custom", id: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC }, +}; function divinePunishmentPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] { return [{ @@ -28,6 +88,31 @@ export function overrideRomKnightEffects( row: RomGeneratedAbilityLike, translated: readonly AbilityEffect[], ): readonly AbilityEffect[] | null { + if (row.sourceSkillId === 492935) { + const durationMs = Math.max(0, ...(row.source?.components ?? []).map((component) => ( + Math.max(0, Number(component.effectTime ?? 0)) * 1_000 + ))); + return translated.map((effect) => { + if (effect.kind !== "apply-aura") return effect; + if (effect.aura.modifiers?.some((modifier) => ( + modifier.kind === "stat" && modifier.stat === "parry" + ))) return { + ...effect, + aura: { + ...effect.aura, + utilityTags: [...(effect.aura.utilityTags ?? []), ROM_KNIGHT_MASTER_OF_PARRY_TAG], + procs: [...(effect.aura.procs ?? []), ROM_KNIGHT_MASTER_OF_PARRY_PROC], + }, + }; + // Source component 503192 drives a periodic trigger for the same 15s + // effect. Keep that hidden trigger aura on the component's lifetime too. + if (effect.aura.procs?.some((proc) => proc.id.startsWith("rom-proc-trigger-492935-503192"))) return { + ...effect, + aura: { ...effect.aura, durationMs: durationMs || effect.aura.durationMs }, + }; + return effect; + }); + } if (row.sourceSkillId === 491608 && row.passive) return divinePunishmentPassive(row); if (row.sourceSkillId === 499934 && row.passive) return [{ kind: "apply-aura", @@ -44,6 +129,21 @@ export function overrideRomKnightEffects( miscValue: 0, triggerSpellId: 499934, }]; + if (row.sourceSkillId === 491635 && row.passive) return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-knight-passive-491635", + name: row.name, + disposition: "buff", + durationMs: null, + utilityTags: [ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 491635, + }]; if (row.sourceSkillId === 490468) return translated.map((effect) => effect.kind === "apply-aura" ? { ...effect, aura: { @@ -60,6 +160,23 @@ export function romKnightCastEffects( effects: readonly AbilityEffect[], passiveTags: readonly string[] | ReadonlySet = [], ): readonly AbilityEffect[] { + const hasHolyLightsFury = Array.isArray(passiveTags) + ? passiveTags.includes(ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG) + : (passiveTags as ReadonlySet).has(ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG); + if (ability.source === "runewaker" + && ability.classId === "rom-knight" + && ability.dbcSpellId === 490149 + && hasHolyLightsFury) { + // RuneWaker's local tooltip gives a flat 3-second reduction to Holy Seal. + // The added damage rider has no numeric payload in this source snapshot. + return [...effects, { + kind: "cooldown-reset", + mode: "reduce", + abilityName: "Holy Seal", + amountMs: 3_000, + }]; + } + if (ability.source !== "runewaker" || ability.classId !== "rom-knight" || ability.dbcSpellId !== 490151 @@ -67,7 +184,7 @@ export function romKnightCastEffects( ? passiveTags.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG) : (passiveTags as ReadonlySet).has(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG))) return effects; - return effects.map((effect) => { + const increasedDamage = effects.map((effect) => { if (effect.kind !== "damage" && effect.kind !== "dot") return effect; return { ...effect, @@ -78,4 +195,25 @@ export function romKnightCastEffects( ...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * 1.01 }), }; }); + + // Generated passive 491608 includes component 502052: a ten-second, +1% + // Light-damage vulnerability. The generic modifier map treats type 225 as + // Light resistance, which would make the target take less damage despite + // the local tooltip explicitly saying it takes more Light damage. + return [...increasedDamage, { + kind: "apply-aura", + recipient: "ability-target", + aura: { + id: "rom-knight-divine-punishment-light-vulnerability", + name: "Divine Punishment", + disposition: "debuff", + durationMs: 10_000, + modifiers: [{ kind: "damage", direction: "taken", school: "holy", operation: "percent", value: 0.01 }], + dispelCategory: "magic", + }, + sourceEffectType: -1, + sourceAuraType: 225, + miscValue: 1, + triggerSpellId: 502052, + }]; } diff --git a/src/game/romKnightPeriodicRuntime.test.ts b/src/game/romKnightPeriodicRuntime.test.ts index d2e170bc..3545adb8 100644 --- a/src/game/romKnightPeriodicRuntime.test.ts +++ b/src/game/romKnightPeriodicRuntime.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { abilityById } from "./abilityCatalog"; +import { abilitiesForClass, abilityById } from "./abilityCatalog"; import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { ROM_KNIGHT_DIVINE_PUNISHMENT_TAG, ROM_KNIGHT_GODS_VENGEANCE_TAG, + ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG, ROM_KNIGHT_SHIELD_OF_VALOR_TAG, } from "./romKnightOverrides"; import { romEffectsForCast } from "./romCombatMechanics"; @@ -32,6 +35,7 @@ function godsVengeanceCastTime(wantProc: boolean): number { describe("RuneWaker Knight periodic skills", () => { beforeEach(() => { clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); useCombatStore.setState({ playerPosition: [0, 0, 0] }); }); @@ -105,6 +109,13 @@ describe("RuneWaker Knight periodic skills", () => { expect(useCombatStore.getState().comboTargetId).toBe("war-prayer-target"); expect(useCombatStore.getState().comboPoints).toBe(Math.min(tick, 4)); } + + const punishment = abilityById("rom-knight-general-0")!; + expect(useCombatStore.getState().castAbility(punishment.id, undefined, 20_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().comboPoints).toBe(0); + expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage" + && event.abilityId === punishment.id && event.targetActorId === "war-prayer-target" + && event.effectiveAmount > 0)).toBe(true); }); it("limits Divine Punishment's sourced 1% bonus to Punishment", () => { @@ -162,6 +173,56 @@ describe("RuneWaker Knight periodic skills", () => { })).toEqual(romEffectsForCast(abilityById("rom-knight-primary-1")!, context)); }); + it("makes Divine Punishment apply its sourced Light-damage vulnerability", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80, + }); + useCombatStore.getState().registerMob("divine-punishment-target", { + name: "Divine Punishment Target", maxHealth: 100_000, + }); + setMobPosition("divine-punishment-target", [3, 0, 0]); + useCombatStore.getState().selectMob("divine-punishment-target"); + + expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, 12_000)) + .toMatchObject({ ok: true }); + + const vulnerability = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === "divine-punishment-target" + && aura.definition.id === "rom-knight-divine-punishment-light-vulnerability" + )); + expect(vulnerability?.definition).toMatchObject({ + durationMs: 10_000, + modifiers: [{ kind: "damage", direction: "taken", school: "holy", operation: "percent", value: 0.01 }], + }); + expect(vulnerability?.expiresAt).toBe(22_000); + }); + + it("reduces Holy Seal cooldown by three seconds with Holy Light's Fury", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80, + }); + const state = useCombatStore.getState(); + const holySeal = state.abilities.find((ability) => ability.dbcSpellId === 490149)!; + const furyPassive = state.abilities.find((ability) => ability.dbcSpellId === 491635)!; + expect(furyPassive.passive).toBe(true); + expect(furyPassive.effects).toEqual([ + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG] }), + }), + ]); + + useCombatStore.getState().registerMob("holy-lights-fury-target", { + name: "Holy Light's Fury Target", maxHealth: 100_000, + }); + setMobPosition("holy-lights-fury-target", [3, 0, 0]); + useCombatStore.getState().selectMob("holy-lights-fury-target"); + expect(useCombatStore.getState().castAbility(holySeal.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().cooldowns[holySeal.id]).toBe(8_000); + }); + 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 }); @@ -246,6 +307,84 @@ describe("RuneWaker Knight periodic skills", () => { expect(useCombatStore.getState().cooldowns[holySeal.id]).toBe(24_000); }); + it("reduces only the party Knight's Holy Seal cooldown on an active Shield of Valor parry", () => { + const runIncomingCase = ( + suffix: string, + auraActive: boolean, + parryRating: number, + wantedOutcome: "parry" | "hit", + ) => { + const seed = `shield-of-valor-party-${suffix}`; + useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 }); + const knight = createConfiguredPartyMember({ + name: "Shield of Valor Knight", + classId: "rom-knight", + specialization: "Primary", + role: "tank", + }, 80, 0, seed); + const knightAbilities = abilitiesForClass("rom-knight"); + const holySeal = knightAbilities.find((ability) => ability.source === "runewaker" + && ability.dbcSpellId === 490_149)!; + const shieldOfValor = knightAbilities.find((ability) => ability.source === "runewaker" + && ability.dbcSpellId === 490_468)!; + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "tank", 2, "rom"); + usePartyStore.setState({ members: [{ + ...knight, + gearStats: { ...knight.gearStats, parryRating }, + cooldowns: { ...knight.cooldowns, [holySeal.id]: 25_000 }, + }] }); + registerPartyRuntimePosition(knight.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + + if (auraActive) { + const shieldAura = shieldOfValor.effects.find((effect) => effect.kind === "apply-aura"); + if (!shieldAura || shieldAura.kind !== "apply-aura") throw new Error("Shield of Valor has no aura row"); + useCombatStore.getState().applyActorAura(knight.id, knight.id, shieldOfValor, shieldAura, 1_000); + } + + const mobId = `shield-of-valor-party-attacker-${suffix}`; + useCombatStore.getState().registerMob(mobId, { + name: "Shield of Valor party attacker", + maxHealth: 100_000, + xpReward: 0, + attacks: [DEFAULT_ENEMY_ATTACK], + }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().damageMob(mobId, 1, 1_100, { + actorId: knight.id, + role: "tank", + }); + + let outcome: string | undefined; + for (let now = 1_450; now < 16_000; now += 100) { + useCombatStore.getState().advanceMobCombat(now); + const event = [...useCombatStore.getState().combatEvents].reverse().find((entry) => ( + entry.sourceActorId === mobId && entry.targetActorId === knight.id && entry.occurredAt === now + )); + if (event?.outcome === wantedOutcome) { + outcome = event.outcome; + break; + } + } + return { + outcome, + cooldown: useCombatStore.getState().actors[knight.id]?.cooldowns[holySeal.id], + }; + }; + + const activeParry = runIncomingCase("active-parry", true, 100_000, "parry"); + expect(activeParry.outcome).toBe("parry"); + expect(activeParry.cooldown).toBe(24_000); + + const inactiveParry = runIncomingCase("inactive-parry", false, 100_000, "parry"); + expect(inactiveParry.outcome).toBe("parry"); + expect(inactiveParry.cooldown).toBe(25_000); + + const activeHit = runIncomingCase("active-hit", true, 0, "hit"); + expect(activeHit.outcome).toBe("hit"); + expect(activeHit.cooldown).toBe(25_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 }); diff --git a/src/game/romKnightWhirlwind.integration.test.ts b/src/game/romKnightWhirlwind.integration.test.ts new file mode 100644 index 00000000..34079ad0 --- /dev/null +++ b/src/game/romKnightWhirlwind.integration.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForCharacter, type AbilityDefinition } from "./abilityCatalog"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; + +function playerWhirlwind(state: ReturnType): AbilityDefinition { + const ability = state.abilities.find((candidate) => candidate.dbcSpellId === 490133); + if (!ability) throw new Error("RuneWaker Warrior Whirlwind is missing from Knight/Warrior abilities."); + return ability; +} + +describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + }); + + it("reduces player Whirlwind to 40 Rage and a 10-second cooldown when Deadly Whirlwind is learned", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", + secondaryClassId: "rom-warrior", + raceId: "rom-human", + level: 80, + }); + const initial = useCombatStore.getState(); + const whirlwind = playerWhirlwind(initial); + expect(initial.abilities.find((ability) => ability.dbcSpellId === 491639)).toMatchObject({ + classId: "rom-knight", + secondaryClassId: "rom-warrior", + passive: true, + }); + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, rage: 40 }, + maxResourcePools: { ...state.maxResourcePools, rage: 100 }, + })); + useCombatStore.getState().registerMob("knight-whirlwind-target", { + name: "Whirlwind Target", + maxHealth: 100_000, + xpReward: 0, + }); + setMobPosition("knight-whirlwind-target", [3, 0, 0]); + useCombatStore.getState().selectMob("knight-whirlwind-target"); + + const result = useCombatStore.getState().castAbility(whirlwind.id, undefined, 1_000); + + expect(result).toMatchObject({ ok: true }); + const final = useCombatStore.getState(); + expect(final.resourcePools.rage).toBe(0); + expect(final.cooldowns[whirlwind.id]).toBe(11_000); + expect(final.combatEvents.some((event) => event.kind === "damage" + && event.abilityId === whirlwind.id && event.targetActorId === "knight-whirlwind-target" + && event.effectiveAmount > 0)).toBe(true); + }); + + it("uses the learned paired passive for companion Whirlwind cost and cooldown", () => { + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + const knightSeed = createConfiguredPartyMember({ + name: "Knight Warrior", + classId: "rom-knight", + secondaryClassId: "rom-warrior", + specialization: "Primary", + role: "damage", + }, 80, 0, "deadly-whirlwind-party"); + const abilities = abilitiesForCharacter("rom-knight", "rom-warrior"); + const whirlwind = abilities.find((ability) => ability.dbcSpellId === 490133)!; + const knight = { + ...knightSeed, + resourcePools: { ...knightSeed.resourcePools, rage: 40 }, + cooldowns: Object.fromEntries(abilities + .filter((ability) => ability.id !== whirlwind.id) + .map((ability) => [ability.id, 60_000])), + }; + usePartyStore.setState({ members: [knight], command: "attack" }); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(knight.id, [0, 0, 0]); + useCombatStore.getState().registerMob("party-knight-whirlwind-target", { + name: "Party Whirlwind Target", + maxHealth: 100_000, + xpReward: 0, + }); + // Keep the target inside Whirlwind's area but outside the companion's melee range + // so its autoattack cannot add Rage after the discounted spell is spent. + setMobPosition("party-knight-whirlwind-target", [10, 0, 0]); + useCombatStore.getState().damageMob("party-knight-whirlwind-target", 1, 100); + updatePartyRuntimeTargetVisibility(knight.id, "party-knight-whirlwind-target", true); + + advanceDungeonPartyCombat(1_000); + + const finalMember = usePartyStore.getState().members.find((member) => member.id === knight.id)!; + expect(finalMember.resourcePools.rage).toBe(0); + expect(finalMember.cooldowns[whirlwind.id]).toBe(11_000); + expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage" + && event.sourceActorId === knight.id && event.abilityId === whirlwind.id + && event.targetActorId === "party-knight-whirlwind-target" && event.effectiveAmount > 0)).toBe(true); + }); +}); diff --git a/src/game/romMageKnightLightOfDay.integration.test.ts b/src/game/romMageKnightLightOfDay.integration.test.ts new file mode 100644 index 00000000..98a41236 --- /dev/null +++ b/src/game/romMageKnightLightOfDay.integration.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { + ROM_MAGE_KNIGHT_LIGHT_OF_DAY, +} from "./romMageRuntime"; + +const TARGET_ID = "light-of-day-target"; +const ACTIVE_TAG = "rom-mage-knight-light-of-day-active"; + +function initializeMageKnight(): void { + useCombatStore.getState().initializeCharacter({ + classId: "rom-mage", + secondaryClassId: "rom-knight", + raceId: "rom-human", + level: 80, + }); +} + +function registerTarget(): void { + useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 }); + setMobPosition(TARGET_ID, [3, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function aura(actorId: string, tag: string, now: number) { + return useCombatStore.getState().auras.find((entry) => entry.targetId === actorId + && (entry.expiresAt === null || entry.expiresAt > now) + && entry.definition.utilityTags?.includes(tag)); +} + +describe("RoM Mage/Knight Light of Day", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + initializeMageKnight(); + }); + + it("stays suppressed at startup, triggers on both named hits, and observes its 20-second ICD", () => { + registerTarget(); + const holyLightStrike = abilityById("rom-mage-rom-knight-elite-15"); + const lightCharge = abilityById("rom-mage-rom-knight-elite-35"); + const passive = abilityById("rom-mage-rom-knight-elite-45"); + if (!holyLightStrike || !lightCharge || !passive) throw new Error("Missing local Mage/Knight Light of Day rows"); + expect([holyLightStrike.dbcSpellId, lightCharge.dbcSpellId]).toEqual([491_594, 492_634]); + expect(passive.dbcSpellId).toBe(ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId); + expect(passive.effects).toEqual([]); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 1_000)).toBeUndefined(); + + expect(useCombatStore.getState().castAbility(holyLightStrike.id, undefined, 1_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 3_100); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 3_100)?.expiresAt).toBe(18_100); + expect(aura(PLAYER_AGGRO_ID, ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag, 3_100)?.expiresAt).toBe(23_100); + + expect(useCombatStore.getState().castAbility(lightCharge.id, undefined, 7_000)).toMatchObject({ ok: true }); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 7_000)?.expiresAt).toBe(18_100); + + expect(useCombatStore.getState().castAbility(lightCharge.id, undefined, 24_000)).toMatchObject({ ok: true }); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 24_000)?.expiresAt).toBe(39_000); + expect(aura(PLAYER_AGGRO_ID, ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag, 24_000)?.expiresAt).toBe(44_000); + }); + + it("triggers for a landed party Mage/Knight hit with an independent caster ICD", () => { + const seed = "light-of-day-party-mage-knight"; + const member = createConfiguredPartyMember({ + name: "Light of Day Mage", + classId: "rom-mage", + secondaryClassId: "rom-knight", + specialization: "Primary", + role: "damage", + }, 80, 0, seed); + const holyLightStrike = abilityById("rom-mage-rom-knight-elite-15"); + if (!holyLightStrike) throw new Error("Missing local Mage/Knight Holy Light Strike row"); + + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom"); + usePartyStore.getState().setCommand("attack"); + usePartyStore.setState({ members: [{ + ...member, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...member.resourcePools, mana: 10_000 }, + activeCast: { + abilityId: holyLightStrike.id, + school: "holy", + mode: "cast", + startedAt: 500, + completesAt: 2_500, + originalDurationMs: 2_000, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: TARGET_ID, + }, + activeCastTargetId: TARGET_ID, + status: "attacking", + }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + registerTarget(); + useCombatStore.getState().damageMob(TARGET_ID, 1, 2_400); + updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); + useCombatStore.getState().synchronizeActors(); + + advanceDungeonPartyCombat(2_500); + + expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === member.id + && event.abilityId === holyLightStrike.id && event.outcome === "hit")).toBe(true); + expect(aura(member.id, ACTIVE_TAG, 2_500)?.expiresAt).toBe(17_500); + expect(aura(member.id, ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag, 2_500)?.expiresAt).toBe(22_500); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 2_500)).toBeUndefined(); + }); +}); diff --git a/src/game/romMageMechanics.test.ts b/src/game/romMageMechanics.test.ts index 9c3553b4..e736fd48 100644 --- a/src/game/romMageMechanics.test.ts +++ b/src/game/romMageMechanics.test.ts @@ -8,6 +8,7 @@ import { ROM_MAGE_WARLOCK_PASSIVE_TAGS, romMageWarlockCastEffects, } from "./romMageWarlockOverrides"; +import { overrideRomMageEffects } from "./romMageOverrides"; import { applyTalentModifierValue } from "./talentModifiers"; function initializeMage(secondaryClassId?: "rom-druid" | "rom-warden" | "rom-champion", level = 80): void { @@ -362,4 +363,109 @@ describe("RoM Mage mechanics", () => { expect(fireWard.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party")) .toBe(true); }); + + it("maps Elemental Weakness to all modeled resistances and only weakens its target for 30 seconds", () => { + const weakness = abilityById("rom-mage-primary-11")!; + const sourceAura = weakness.effects.find((effect) => effect.kind === "apply-aura"); + expect(sourceAura).toMatchObject({ + kind: "apply-aura", + recipient: "ability-target", + aura: expect.objectContaining({ durationMs: 30_000 }), + }); + if (!sourceAura || sourceAura.kind !== "apply-aura") throw new Error("Elemental Weakness aura missing"); + + const rankTwo = weakness.ranks?.find((rank) => rank.spellId === 497977); + expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "magical-defense" && modifier.value === -0.01))).toBe(true); + expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat.startsWith("resistance:")))).toBe(false); + + const armorModifier = { kind: "stat" as const, stat: "armor", operation: "flat" as const, value: -12 }; + const enrichedAura = { + ...sourceAura, + aura: { + ...sourceAura.aura, + modifiers: [ + { kind: "stat" as const, stat: "all-resistance", operation: "flat" as const, value: -30 }, + armorModifier, + ], + }, + }; + const resolved = overrideRomMageEffects({ + classId: "rom-mage", + sourceSkillId: 490247, + name: "Elemental Weakness", + description: "", + }, [enrichedAura]); + expect(resolved?.[0]).toMatchObject({ + kind: "apply-aura", + recipient: "ability-target", + aura: expect.objectContaining({ + durationMs: 30_000, + modifiers: [ + { kind: "stat", stat: "resistance:arcane", operation: "flat", value: -30 }, + { kind: "stat", stat: "resistance:fire", operation: "flat", value: -30 }, + { kind: "stat", stat: "resistance:frost", operation: "flat", value: -30 }, + { kind: "stat", stat: "resistance:nature", operation: "flat", value: -30 }, + { kind: "stat", stat: "resistance:shadow", operation: "flat", value: -30 }, + armorModifier, + ], + }), + }); + expect(overrideRomMageEffects({ + classId: "rom-priest", sourceSkillId: 490247, name: "Elemental Weakness", description: "", + }, [enrichedAura])).toBe(null); + expect(overrideRomMageEffects({ + classId: "rom-mage", sourceSkillId: 497977, name: "Elemental Weakness", description: "", + }, [enrichedAura])).toBe(null); + + const flameDamage = (useWeakness: boolean, waitForExpiry = false): number => { + initializeMage(undefined, 60); + const targetId = "elemental-weakness-resistance-dummy"; + useCombatStore.getState().registerMob(targetId, { + name: targetId, + maxHealth: 10_000_000, + resistances: { fire: 1_000 }, + }); + useCombatStore.getState().registerMob("elemental-weakness-untouched-dummy", { + name: "elemental-weakness-untouched-dummy", + maxHealth: 10_000_000, + resistances: { fire: 1_000 }, + }); + setMobPosition(targetId, [3, 0, 0]); + setMobPosition("elemental-weakness-untouched-dummy", [4, 0, 0]); + useCombatStore.getState().selectMob(targetId); + if (useWeakness) { + const result = useCombatStore.getState().castAbility(weakness.id, undefined, 1_000); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + } + const castAt = waitForExpiry ? 31_100 : 2_600; + if (waitForExpiry) useCombatStore.getState().tick(30.1, castAt); + const target = useCombatStore.getState().auras.find((aura) => aura.targetId === targetId + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "resistance:fire")); + expect(Boolean(target)).toBe(useWeakness && !waitForExpiry); + if (useWeakness && !waitForExpiry) expect(target?.expiresAt).toBe(31_000); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "elemental-weakness-untouched-dummy" + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat.startsWith("resistance:")))).toBe(false); + return Array.from({ length: 64 }, (_, index) => useCombatStore.getState().damageMob( + targetId, + 10_000, + castAt + index, + { actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: "rom-mage-primary-1" }, + "fire", + { attackKind: "spell", seed: `elemental-weakness-${index}`, abilityId: "rom-mage-primary-1" }, + ).amount).reduce((total, damage) => total + damage, 0); + }; + + const baselineDamage = flameDamage(false); + const weakenedDamage = flameDamage(true); + const baselineAfterWaitDamage = flameDamage(false, true); + const expiredDamage = flameDamage(true, true); + expect(weakenedDamage).toBeGreaterThan(baselineDamage); + expect(expiredDamage).toBeCloseTo(baselineAfterWaitDamage, 5); + }); }); diff --git a/src/game/romMageMeditativeCurrent.integration.test.ts b/src/game/romMageMeditativeCurrent.integration.test.ts new file mode 100644 index 00000000..1f7f235d --- /dev/null +++ b/src/game/romMageMeditativeCurrent.integration.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + advanceDungeonPartyCombat, + resetPartyRuntimeClock, +} from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { ROM_MAGE_MEDITATIVE_CURRENT } from "./romMageRuntime"; + +const TARGET_ID = "meditative-current-target"; + +function initializeMageWarrior(): void { + useCombatStore.getState().initializeCharacter({ + classId: "rom-mage", + secondaryClassId: "rom-warrior", + raceId: "rom-human", + level: 80, + }); +} + +function registerTarget(): void { + useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 }); + setMobPosition(TARGET_ID, [3, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function aura(actorId: string, tag: string, now: number) { + return useCombatStore.getState().auras.find((entry) => entry.targetId === actorId + && (entry.expiresAt === null || entry.expiresAt > now) + && entry.definition.utilityTags?.includes(tag)); +} + +describe("RoM Mage Meditative Current", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + initializeMageWarrior(); + }); + + it("triggers on landed Flame and Plasma Arrow hits, not Fireball, and observes its 30-second ICD", () => { + registerTarget(); + const fireball = abilityById("rom-mage-general-0"); + const flame = abilityById("rom-mage-primary-1"); + const plasmaArrow = abilityById("rom-mage-primary-52"); + const passive = abilityById("rom-mage-rom-warrior-elite-50"); + if (!fireball || !flame || !plasmaArrow) throw new Error("Missing local Mage trigger spell rows"); + expect([fireball.dbcSpellId, flame.dbcSpellId, plasmaArrow.dbcSpellId]).toEqual([490_204, 491_150, 491_169]); + expect(passive?.dbcSpellId).toBe(493_262); + expect(passive?.effects).toEqual([]); + + expect(useCombatStore.getState().castAbility(fireball.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(aura(PLAYER_AGGRO_ID, "rom-mage-meditative-current-active", 1_000)).toBeUndefined(); + + expect(useCombatStore.getState().castAbility(flame.id, undefined, 2_600)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 5_700); + expect(aura(PLAYER_AGGRO_ID, "rom-mage-meditative-current-active", 5_700)?.expiresAt).toBe(15_700); + expect(aura(PLAYER_AGGRO_ID, ROM_MAGE_MEDITATIVE_CURRENT.cooldownAuraTag, 5_700)?.expiresAt).toBe(35_700); + + expect(useCombatStore.getState().castAbility(plasmaArrow.id, undefined, 6_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 8_100); + expect(aura(PLAYER_AGGRO_ID, "rom-mage-meditative-current-active", 8_100)?.expiresAt).toBe(15_700); + }); + + it("applies a separate Meditative Current proc and ICD to a party Mage caster", () => { + const seed = "meditative-current-party-mage"; + const member = createConfiguredPartyMember({ + name: "Meditative Mage", + classId: "rom-mage", + secondaryClassId: "rom-warrior", + specialization: "Primary", + role: "damage", + }, 80, 0, seed); + const flame = abilityById("rom-mage-primary-1"); + if (!flame) throw new Error("Missing local Mage Flame row"); + + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom"); + usePartyStore.getState().setCommand("attack"); + usePartyStore.setState({ members: [{ + ...member, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...member.resourcePools, mana: 10_000 }, + activeCast: { + abilityId: flame.id, + school: "fire", + mode: "cast", + startedAt: 500, + completesAt: 3_500, + originalDurationMs: 3_000, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: TARGET_ID, + }, + activeCastTargetId: TARGET_ID, + status: "attacking", + }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + registerTarget(); + useCombatStore.getState().damageMob(TARGET_ID, 1, 3_400); + updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); + useCombatStore.getState().synchronizeActors(); + + advanceDungeonPartyCombat(3_500); + + expect(aura(member.id, "rom-mage-meditative-current-active", 3_500)?.expiresAt).toBe(13_500); + expect(aura(member.id, ROM_MAGE_MEDITATIVE_CURRENT.cooldownAuraTag, 3_500)?.expiresAt).toBe(33_500); + expect(aura(PLAYER_AGGRO_ID, "rom-mage-meditative-current-active", 3_500)).toBeUndefined(); + }); +}); diff --git a/src/game/romMageOverrides.ts b/src/game/romMageOverrides.ts new file mode 100644 index 00000000..533cfc13 --- /dev/null +++ b/src/game/romMageOverrides.ts @@ -0,0 +1,40 @@ +import type { AbilityEffect } from "./abilityCatalog"; +import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; + +// NPC spell mitigation models these five resistance fields. RuneWaker's +// Elemental Weakness component (500365) is an all-resistance −30 modifier; +// expand it so the debuff affects the same fields used by damage resolution. +const MODELED_MAGIC_RESISTANCES = ["arcane", "fire", "frost", "nature", "shadow"] as const; + +export function overrideRomMageEffects( + row: RomGeneratedAbilityLike, + translated: readonly AbilityEffect[], +): readonly AbilityEffect[] | null { + if (row.classId !== "rom-mage") return null; + + // Meditative Current is a hit-triggered passive, not an always-on haste + // aura. Its exact trigger and cooldown are applied by romMageRuntime. + if (row.sourceSkillId === 493262) return []; + // Light of Day is a landed-hit proc for Mage/Knight builds, not an always-on + // damage aura. Its trigger and internal cooldown are applied at hit time. + if (row.sourceSkillId === 493034) return []; + if (row.sourceSkillId !== 490247) return null; + + return translated.map((effect) => { + if (effect.kind !== "apply-aura") return effect; + const modifiers = effect.aura.modifiers ?? []; + if (!modifiers.some((modifier) => modifier.kind === "stat" && modifier.stat === "all-resistance")) { + return effect; + } + + return { + ...effect, + aura: { + ...effect.aura, + modifiers: modifiers.flatMap((modifier) => modifier.kind === "stat" && modifier.stat === "all-resistance" + ? MODELED_MAGIC_RESISTANCES.map((school) => ({ ...modifier, stat: `resistance:${school}` })) + : [modifier]), + }, + }; + }); +} diff --git a/src/game/romMageRuntime.test.ts b/src/game/romMageRuntime.test.ts new file mode 100644 index 00000000..f42a30de --- /dev/null +++ b/src/game/romMageRuntime.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { + ROM_MAGE_KNIGHT_LIGHT_OF_DAY, + ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT, + ROM_MAGE_MEDITATIVE_CURRENT, + ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT, + romMageKnightLightOfDayProc, + romMageKnightLightOfDayReadyAt, + romMageMeditativeCurrentReadyAt, + romMageMeditativeCurrentProc, +} from "./romMageRuntime"; + +const passiveIds = [ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId]; + +describe("RoM Mage runtime", () => { + it("grants Meditative Current only after landed Flame or Plasma Arrow hits with the passive learned", () => { + for (const dbcSpellId of ROM_MAGE_MEDITATIVE_CURRENT.triggerSpellIds) { + const result = romMageMeditativeCurrentProc( + "rom-mage", + { source: "runewaker", dbcSpellId }, + passiveIds, + true, + 1_000, + 1_000, + ); + + expect(result.nextReadyAt).toBe(31_000); + expect(result.effect).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + triggerSpellId: 493_262, + aura: { + id: "rom-mage-meditative-current", + durationMs: 10_000, + maxStacks: 1, + stackBehavior: "refresh", + modifiers: [{ + kind: "stat", + stat: "spell-haste", + operation: "flat", + value: 10, + }], + }, + }); + } + }); + + it("preserves readiness and rejects misses, other spells/classes, unlearned passives, and non-RuneWaker rows", () => { + const rejectedInputs = [ + { classId: "rom-mage", ability: { source: "runewaker", dbcSpellId: 491_150 }, passives: [], landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", ability: { source: "runewaker", dbcSpellId: 490_204 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", ability: { source: "runewaker", dbcSpellId: 491_169 }, passives: passiveIds, landed: false, readyAt: 100, now: 100 }, + { classId: "rom-priest", ability: { source: "runewaker", dbcSpellId: 491_169 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", ability: { source: "core", dbcSpellId: 491_169 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", ability: { source: "runewaker", dbcSpellId: 491_169 }, passives: passiveIds, landed: true, readyAt: 101, now: 100 }, + ] as const; + + for (const input of rejectedInputs) { + expect(romMageMeditativeCurrentProc( + input.classId, + input.ability, + input.passives, + input.landed, + input.readyAt, + input.now, + )).toEqual({ effect: null, nextReadyAt: input.readyAt }); + } + }); + + it("reads the active cooldown per caster from its marker aura", () => { + const aura = { + targetId: "mage-one", + expiresAt: 30_000, + definition: ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT.aura, + }; + expect(romMageMeditativeCurrentReadyAt([aura], "mage-one", 10_000)).toBe(30_000); + expect(romMageMeditativeCurrentReadyAt([aura], "mage-two", 10_000)).toBe(10_000); + expect(romMageMeditativeCurrentReadyAt([aura], "mage-one", 31_000)).toBe(31_000); + }); + + it("suppresses Light of Day's generic aura and grants its source values on either landed trigger", () => { + expect(abilityById("rom-mage-rom-knight-elite-45")?.effects).toEqual([]); + + for (const dbcSpellId of ROM_MAGE_KNIGHT_LIGHT_OF_DAY.triggerSpellIds) { + const result = romMageKnightLightOfDayProc( + "rom-mage", + "rom-knight", + { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId }, + [ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId], + true, + 4_000, + 4_000, + ); + + expect(result.nextReadyAt).toBe(24_000); + expect(result.effect).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + triggerSpellId: 493_034, + aura: { + id: "rom-mage-knight-light-of-day", + durationMs: 15_000, + maxStacks: 1, + stackBehavior: "refresh", + modifiers: [{ + kind: "damage", + direction: "dealt", + school: "holy", + operation: "percent", + value: 0.08, + }], + }, + }); + } + }); + + it("rejects Light of Day misses, other builds or spells, nonlocal rows, and hits on cooldown", () => { + const passiveIds = [ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId]; + const rejectedInputs = [ + { classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: [], landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 490_204 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: false, readyAt: 100, now: 100 }, + { classId: "rom-mage", secondaryClassId: "rom-warrior", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-warrior", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 }, + { classId: "rom-priest", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "core", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 100, now: 100 }, + { classId: "rom-mage", secondaryClassId: "rom-knight", ability: { classId: "rom-mage", secondaryClassId: "rom-knight", source: "runewaker", dbcSpellId: 491_594 }, passives: passiveIds, landed: true, readyAt: 101, now: 100 }, + ] as const; + + for (const input of rejectedInputs) { + expect(romMageKnightLightOfDayProc( + input.classId, + input.secondaryClassId, + input.ability, + input.passives, + input.landed, + input.readyAt, + input.now, + )).toEqual({ effect: null, nextReadyAt: input.readyAt }); + } + }); + + it("reads Light of Day's cooldown separately for each caster", () => { + const aura = { + targetId: "knight-mage-one", + expiresAt: 20_000, + definition: ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT.aura, + }; + expect(romMageKnightLightOfDayReadyAt([aura], "knight-mage-one", 5_000)).toBe(20_000); + expect(romMageKnightLightOfDayReadyAt([aura], "knight-mage-two", 5_000)).toBe(5_000); + expect(romMageKnightLightOfDayReadyAt([aura], "knight-mage-one", 20_000)).toBe(20_000); + }); +}); diff --git a/src/game/romMageRuntime.ts b/src/game/romMageRuntime.ts index 49bd910d..9543b5b3 100644 --- a/src/game/romMageRuntime.ts +++ b/src/game/romMageRuntime.ts @@ -1,8 +1,200 @@ -import type { AbilityDefinition } from "./abilityCatalog"; +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import type { ActiveAura } from "./combatAuras"; const ELECTROSTATIC_CHARGE_EXPERTISE_SPELL_ID = 498_871; const ELECTROSTATIC_CHARGE_SPELL_ID = 491_156; +export const ROM_MAGE_MEDITATIVE_CURRENT = Object.freeze({ + passiveSpellId: 493_262, + triggerSpellIds: [491_150, 491_169] as const, + durationMs: 10_000, + cooldownMs: 30_000, + spellHaste: 10, + cooldownAuraTag: "rom-mage-meditative-current-cooldown", +}); + +export const ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT: Extract = { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-mage-meditative-current-cooldown", + name: "Meditative Current cooldown", + disposition: "buff", + durationMs: ROM_MAGE_MEDITATIVE_CURRENT.cooldownMs, + hideFromAuraStrip: true, + utilityTags: [ROM_MAGE_MEDITATIVE_CURRENT.cooldownAuraTag], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId, +}; + +export const ROM_MAGE_KNIGHT_LIGHT_OF_DAY = Object.freeze({ + passiveSpellId: 493_034, + triggerSpellIds: [491_594, 492_634] as const, + durationMs: 15_000, + cooldownMs: 20_000, + lightDamagePercent: 8, + cooldownAuraTag: "rom-mage-knight-light-of-day-cooldown", +}); + +export const ROM_MAGE_KNIGHT_LIGHT_OF_DAY_COOLDOWN_EFFECT: Extract = { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-mage-knight-light-of-day-cooldown", + name: "Light of Day cooldown", + disposition: "buff", + durationMs: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownMs, + hideFromAuraStrip: true, + utilityTags: [ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId, +}; + +/** Find Light of Day's active internal cooldown marker for one caster. */ +export function romMageKnightLightOfDayReadyAt( + activeAuras: readonly Pick[], + casterId: string, + now: number, +): number { + const cooldown = activeAuras.find((aura) => aura.targetId === casterId + && aura.definition.utilityTags?.includes(ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownAuraTag) + && (aura.expiresAt === null || aura.expiresAt > now)); + return cooldown ? cooldown.expiresAt ?? Number.POSITIVE_INFINITY : now; +} + +export interface RomMageKnightLightOfDayResult { + readonly effect: AbilityEffect | null; + readonly nextReadyAt: number; +} + +/** + * RuneWaker Light of Day (493034): a landed Holy Light Strike (491594) or + * Light Charge (492634) hit grants +8% Holy damage for 15 seconds, with a + * 20-second internal cooldown. The effect only belongs to Mage/Knight builds. + */ +export function romMageKnightLightOfDayProc( + classId: string | null, + secondaryClassId: string | null | undefined, + ability: Pick, + learnedPassiveSpellIds: readonly number[], + landed: boolean, + readyAt: number, + now: number, +): RomMageKnightLightOfDayResult { + const eligible = classId === "rom-mage" + && secondaryClassId === "rom-knight" + && ability.classId === "rom-mage" + && ability.secondaryClassId === "rom-knight" + && ability.source === "runewaker" + && ROM_MAGE_KNIGHT_LIGHT_OF_DAY.triggerSpellIds.includes(ability.dbcSpellId as 491_594 | 492_634) + && learnedPassiveSpellIds.includes(ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId) + && landed + && now >= readyAt; + if (!eligible) return { effect: null, nextReadyAt: readyAt }; + + return { + effect: { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-mage-knight-light-of-day", + name: "Light of Day", + description: "A landed Holy Light Strike or Light Charge hit grants +8% Holy damage for 15 seconds. This effect has a 20-second internal cooldown.", + disposition: "buff", + durationMs: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.durationMs, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["rom-mage-knight-light-of-day-active"], + modifiers: [{ + kind: "damage", + direction: "dealt", + school: "holy", + operation: "percent", + value: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.lightDamagePercent / 100, + }], + }, + sourceEffectType: -1, + sourceAuraType: 49, + miscValue: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.lightDamagePercent, + triggerSpellId: ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId, + }, + nextReadyAt: now + ROM_MAGE_KNIGHT_LIGHT_OF_DAY.cooldownMs, + }; +} + +/** Find the active per-caster internal cooldown marker, if one exists. */ +export function romMageMeditativeCurrentReadyAt( + activeAuras: readonly Pick[], + casterId: string, + now: number, +): number { + const cooldown = activeAuras.find((aura) => aura.targetId === casterId + && aura.definition.utilityTags?.includes(ROM_MAGE_MEDITATIVE_CURRENT.cooldownAuraTag) + && (aura.expiresAt === null || aura.expiresAt > now)); + return cooldown ? cooldown.expiresAt ?? Number.POSITIVE_INFINITY : now; +} + +export interface RomMageMeditativeCurrentResult { + readonly effect: AbilityEffect | null; + readonly nextReadyAt: number; +} + +/** + * RuneWaker Meditative Current (493262): a landed Flame (491150) or Plasma + * Arrow (491169) hit grants +10 spell haste for 10 seconds, with a 30-second + * internal cooldown. Cooldown readiness is passed in by the combat event owner. + */ +export function romMageMeditativeCurrentProc( + classId: string | null, + ability: Pick, + learnedPassiveSpellIds: readonly number[], + landed: boolean, + readyAt: number, + now: number, +): RomMageMeditativeCurrentResult { + const eligible = classId === "rom-mage" + && ability.source === "runewaker" + && ROM_MAGE_MEDITATIVE_CURRENT.triggerSpellIds.some((spellId) => spellId === ability.dbcSpellId) + && learnedPassiveSpellIds.includes(ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId) + && landed + && now >= readyAt; + if (!eligible) return { effect: null, nextReadyAt: readyAt }; + + return { + effect: { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-mage-meditative-current", + name: "Meditative Current", + description: "A landed Flame or Plasma Arrow hit grants +10 spell haste for 10 seconds. This effect has a 30-second internal cooldown.", + disposition: "buff", + durationMs: ROM_MAGE_MEDITATIVE_CURRENT.durationMs, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["rom-mage-meditative-current-active"], + modifiers: [{ + kind: "stat", + stat: "spell-haste", + operation: "flat", + value: ROM_MAGE_MEDITATIVE_CURRENT.spellHaste, + }], + }, + sourceEffectType: -1, + sourceAuraType: 51, + miscValue: -ROM_MAGE_MEDITATIVE_CURRENT.spellHaste, + triggerSpellId: ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId, + }, + nextReadyAt: now + ROM_MAGE_MEDITATIVE_CURRENT.cooldownMs, + }; +} + /** Mage/Champion's level-45 passive shortens Electrostatic Charge by 30 seconds. */ export function romMageCooldownReductionMs( classId: string | null, diff --git a/src/game/romMageWarlockOverrides.ts b/src/game/romMageWarlockOverrides.ts index 8fc4911c..5dec6cce 100644 --- a/src/game/romMageWarlockOverrides.ts +++ b/src/game/romMageWarlockOverrides.ts @@ -272,7 +272,7 @@ export function romMageWarlockCriticalEffects( passiveTags: ReadonlySet, ): readonly AbilityEffect[] { if (!passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction) - || ![491150, 491170, 498755, 498756, 499812, 499851].includes(ability.dbcSpellId)) return []; + || ![491170, 498755, 498756, 499812, 499851].includes(ability.dbcSpellId)) return []; return [{ kind: "apply-aura", recipient: "ability-target", diff --git a/src/game/romMageWarlockPairValidation.test.ts b/src/game/romMageWarlockPairValidation.test.ts index 80b22184..b9d7e0ab 100644 --- a/src/game/romMageWarlockPairValidation.test.ts +++ b/src/game/romMageWarlockPairValidation.test.ts @@ -350,26 +350,26 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => { }); it("keeps Elemental Extraction's defense debuff on a critical-hit target", () => { - for (const id of ["rom-mage-primary-1", "rom-mage-primary-54"]) { - const attack = abilityById(id)!; - expect(romMageWarlockCriticalEffects(attack, new Set())).toEqual([]); - expect(romMageWarlockCriticalEffects(attack, - new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))).toEqual([ - expect.objectContaining({ - kind: "apply-aura", recipient: "ability-target", - aura: expect.objectContaining({ - disposition: "debuff", durationMs: 10_000, - modifiers: expect.arrayContaining([ - expect.objectContaining({ stat: "armor", value: -0.3 }), - expect.objectContaining({ stat: "magical-defense", value: -0.3 }), - ]), - }), + const passive = new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]); + expect(romMageWarlockCriticalEffects(abilityById("rom-mage-primary-1")!, passive)).toEqual([]); + + const electricExplosion = abilityById("rom-mage-primary-54")!; + expect(romMageWarlockCriticalEffects(electricExplosion, new Set())).toEqual([]); + expect(romMageWarlockCriticalEffects(electricExplosion, passive)).toEqual([ + expect.objectContaining({ + kind: "apply-aura", recipient: "ability-target", + aura: expect.objectContaining({ + disposition: "debuff", durationMs: 10_000, + modifiers: expect.arrayContaining([ + expect.objectContaining({ stat: "armor", value: -0.3 }), + expect.objectContaining({ stat: "magical-defense", value: -0.3 }), + ]), }), - ]); - } + }), + ]); }); - it("triggers Elemental Extraction's target debuff on a live critical Flame hit", () => { + it("triggers Elemental Extraction's target debuff on a live critical Electric Explosion hit", () => { initialize(79); registerTarget("critical-extraction-dummy"); useCombatStore.setState((state) => ({ @@ -386,7 +386,7 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => { now: 1_000, }).auras, })); - expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000)) + expect(useCombatStore.getState().castAbility("rom-mage-primary-54", undefined, 1_000)) .toMatchObject({ ok: true }); useCombatStore.getState().tick(0.1, 4_100); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "critical-extraction-dummy" @@ -448,19 +448,19 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => { expect((reduced.threat - 1) / (baseline.threat - 1)).toBeCloseTo(0.9, 5); }); - it("applies Elemental Extraction's defense reduction to the target's live magic mitigation", () => { + it("applies Elemental Extraction's defense reduction to Electric Explosion's live magic mitigation", () => { const targetId = "extraction-defense-dummy"; - const castFlame = (useDebuff: boolean) => { + const castElectricExplosion = (useDebuff: boolean) => { initialize(60); useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 100_000, - resistances: { fire: 1_000 }, + resistances: { nature: 1_400 }, }); setMobPosition(targetId, [3, 0, 0]); useCombatStore.getState().selectMob(targetId); if (useDebuff) { - const rider = romMageWarlockCriticalEffects(abilityById("rom-mage-primary-1")!, + const rider = romMageWarlockCriticalEffects(abilityById("rom-mage-primary-54")!, new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))[0]; expect(rider.kind).toBe("apply-aura"); if (rider.kind !== "apply-aura") throw new Error("Elemental Extraction debuff missing"); @@ -473,14 +473,14 @@ describe("accepted RoM Mage/Warlock dwarf pair", () => { }).auras, })); } - expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000)) + expect(useCombatStore.getState().castAbility("rom-mage-primary-54", undefined, 1_000)) .toMatchObject({ ok: true }); useCombatStore.getState().tick(0.1, 4_100); return useCombatStore.getState().mobs[targetId]!.maxHealth - useCombatStore.getState().mobs[targetId]!.health; }; - const baselineDamage = castFlame(false); - const reducedDefenseDamage = castFlame(true); + const baselineDamage = castElectricExplosion(false); + const reducedDefenseDamage = castElectricExplosion(true); expect(baselineDamage).toBeGreaterThan(0); expect(reducedDefenseDamage).toBeGreaterThan(baselineDamage); }); diff --git a/src/game/romMageWarriorRuntime.test.ts b/src/game/romMageWarriorRuntime.test.ts new file mode 100644 index 00000000..b992cbf4 --- /dev/null +++ b/src/game/romMageWarriorRuntime.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForCharacter, abilityById } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + advanceDungeonPartyCombat, + partyCastCanMoveDuringMeditationPath, + resetPartyRuntimeClock, +} from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { + ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, + ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, +} from "./romMageWarriorRuntime"; + +const TARGET_ID = "meditation-path-target"; + +function initializeMageWarrior(): void { + useCombatStore.getState().initializeCharacter({ + classId: "rom-mage", + secondaryClassId: "rom-warrior", + raceId: "rom-human", + level: 80, + }); + useCombatStore.setState((state) => ({ + resource: 1_000, + maxResource: 1_000, + resourcePools: { ...state.resourcePools, mana: 1_000 }, + maxResourcePools: { ...state.maxResourcePools, mana: 1_000 }, + })); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); +} + +function registerTarget(): void { + useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 }); + setMobPosition(TARGET_ID, [3, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function auraWithTag(tag: string, now: number) { + return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AGGRO_ID + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(tag)); +} + +describe("RoM Mage/Warrior Meditation Path", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + initializeMageWarrior(); + }); + + it.each([ + ["rom-mage-general-0", 490204, "Fireball"], + ["rom-mage-general-1", 490212, "Lightning"], + ["rom-mage-primary-4", 490242, "Discharge"], + ["rom-mage-primary-6", 490239, "Phoenix"], + ] as const)("grants 7 seconds of mobile casting after %s (%i, %s)", (abilityId, spellId, _name) => { + registerTarget(); + const state = useCombatStore.getState(); + expect(state.abilities.some((ability) => ability.dbcSpellId === 499588 && ability.passive)).toBe(true); + expect(abilityById(abilityId)?.dbcSpellId).toBe(spellId); + + expect(state.castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true }); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 1_000)?.expiresAt).toBe(8_000); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 1_000)?.expiresAt).toBe(16_000); + }); + + it("does not proc on Flame and does not retrigger during the 15-second lockout", () => { + registerTarget(); + const flame = abilityById("rom-mage-primary-1"); + const fireball = abilityById("rom-mage-general-0"); + if (!flame || !fireball) throw new Error("Missing local Mage ability rows"); + + expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 4_100); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 4_100)).toBeUndefined(); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 4_100)).toBeUndefined(); + + expect(useCombatStore.getState().castAbility(fireball.id, undefined, 6_000)).toMatchObject({ ok: true }); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 6_000)?.expiresAt).toBe(13_000); + expect(useCombatStore.getState().castAbility(fireball.id, undefined, 14_000)).toMatchObject({ ok: true }); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 14_000)).toBeUndefined(); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 14_000)?.expiresAt).toBe(21_000); + + expect(useCombatStore.getState().castAbility(fireball.id, undefined, 21_000)).toMatchObject({ ok: true }); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 21_000)?.expiresAt).toBe(28_000); + }); + + it("lets a cast started during Meditation Path continue after movement", () => { + registerTarget(); + const fireball = abilityById("rom-mage-general-0"); + const flame = abilityById("rom-mage-primary-1"); + if (!fireball || !flame) throw new Error("Missing local Mage ability rows"); + + expect(useCombatStore.getState().castAbility(fireball.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 1_000)).toBeDefined(); + expect(useCombatStore.getState().castAbility(flame.id, undefined, 2_600)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: flame.id, canCastWhileMoving: true }); + + useCombatStore.getState().setPlayerPosition([1, 0, 0]); + expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: flame.id, canCastWhileMoving: true }); + useCombatStore.getState().tick(0.1, 5_700); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "cast" + && event.abilityId === flame.id && event.outcome === "completed")).toBe(true); + expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 5_700)?.expiresAt).toBe(8_000); + }); + + it("grants the companion aura after a supported cast and preserves movement casting", () => { + const seed = "meditation-path-party"; + const partyMember = createConfiguredPartyMember({ + name: "Meditation Mage", + classId: "rom-mage", + secondaryClassId: "rom-warrior", + specialization: "Primary", + role: "damage", + }, 80, 0, seed); + const fireball = abilityById("rom-mage-general-0"); + const flame = abilityById("rom-mage-primary-1"); + if (!fireball || !flame) throw new Error("Missing local Mage ability rows"); + + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom"); + usePartyStore.getState().setCommand("attack"); + usePartyStore.setState({ members: [{ + ...partyMember, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...partyMember.resourcePools, mana: 10_000 }, + activeCast: { + abilityId: fireball.id, + school: "fire", + mode: "cast", + startedAt: 500, + completesAt: 1_000, + originalDurationMs: 500, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: TARGET_ID, + }, + activeCastTargetId: TARGET_ID, + status: "attacking", + }] }); + registerPartyRuntimePosition(partyMember.id, [0, 0, 0]); + registerTarget(); + useCombatStore.getState().damageMob(TARGET_ID, 1, 900); + updatePartyRuntimeTargetVisibility(partyMember.id, TARGET_ID, true); + useCombatStore.getState().synchronizeActors(); + + advanceDungeonPartyCombat(1_000); + const partyAuras = useCombatStore.getState().auras; + expect(partyAuras.find((aura) => aura.targetId === partyMember.id + && aura.definition.utilityTags?.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG))?.expiresAt).toBe(8_000); + expect(partyAuras.find((aura) => aura.targetId === partyMember.id + && aura.definition.utilityTags?.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG))?.expiresAt).toBe(16_000); + + const canMoveDuringCast = partyCastCanMoveDuringMeditationPath(partyMember, flame, 7_000); + expect(canMoveDuringCast).toBe(true); + usePartyStore.setState({ members: usePartyStore.getState().members.map((member) => member.id === partyMember.id + ? { ...member, activeCast: { + abilityId: flame.id, + school: "fire", + mode: "cast", + startedAt: 7_000, + completesAt: 10_000, + originalDurationMs: 3_000, + pushbackHits: 0, + canCastWhileMoving: canMoveDuringCast, + origin: [0, 0, 0], + targetId: TARGET_ID, + }, activeCastTargetId: TARGET_ID, status: "attacking" } + : member) }); + useCombatStore.getState().synchronizeActors(); + advanceDungeonPartyCombat(7_500); + updatePartyRuntimePosition(partyMember.id, 1, 0, 0); + advanceDungeonPartyCombat(10_000); + expect(usePartyStore.getState().members.find((member) => member.id === partyMember.id)?.activeCast).toBeNull(); + expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === partyMember.id + && event.abilityId === flame.id && event.outcome === "hit")).toBe(true); + }); +}); diff --git a/src/game/romMageWarriorRuntime.ts b/src/game/romMageWarriorRuntime.ts new file mode 100644 index 00000000..395365c3 --- /dev/null +++ b/src/game/romMageWarriorRuntime.ts @@ -0,0 +1,86 @@ +import type { ClassId } from "../app/characterCatalog"; +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import type { ActiveAura, AuraDefinition } from "./combatAuras"; + +export const ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID = 499588; +export const ROM_MAGE_WARRIOR_MEDITATION_PATH_TRIGGER_SPELL_IDS = new Set([490204, 490212, 490242, 490239]); +export const ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG = "rom-mage-warrior-meditation-path-active"; +export const ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG = "rom-mage-warrior-meditation-path-lockout"; + +const MEDITATION_PATH_ACTIVE_AURA: AuraDefinition = { + id: "rom:499588:mobile-casting", + name: "Meditation Path", + disposition: "buff", + durationMs: 7_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG], +}; + +const MEDITATION_PATH_LOCKOUT_AURA: AuraDefinition = { + id: "rom:499588:retrigger-lockout", + name: "Meditation Path lockout", + disposition: "buff", + durationMs: 15_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG], +}; + +function auraEffect(aura: AuraDefinition): Extract { + return { + kind: "apply-aura", + recipient: "caster", + aura, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID, + }; +} + +export const ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT = auraEffect(MEDITATION_PATH_ACTIVE_AURA); +export const ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT = auraEffect(MEDITATION_PATH_LOCKOUT_AURA); + +export function romMageWarriorMeditationPathCanProc( + classId: ClassId, + secondaryClassId: ClassId | null | undefined, + hasPassive: boolean, + ability: AbilityDefinition, +): boolean { + return classId === "rom-mage" + && secondaryClassId === "rom-warrior" + && hasPassive + && ability.source === "runewaker" + && ROM_MAGE_WARRIOR_MEDITATION_PATH_TRIGGER_SPELL_IDS.has(ability.dbcSpellId); +} + +export function romMageWarriorMeditationPathHasState( + auras: readonly ActiveAura[], + actorId: string, + tag: string, + now: number, +): boolean { + return auras.some((aura) => aura.targetId === actorId + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(tag)); +} + +export function romMageWarriorMeditationPathAllowsMovement( + classId: ClassId, + secondaryClassId: ClassId | null | undefined, + hasPassive: boolean, + auras: readonly ActiveAura[], + actorId: string, + now: number, +): boolean { + return classId === "rom-mage" + && secondaryClassId === "rom-warrior" + && hasPassive + && romMageWarriorMeditationPathHasState( + auras, + actorId, + ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, + now, + ); +} diff --git a/src/game/romPriestChampionMechanics.test.ts b/src/game/romPriestChampionMechanics.test.ts new file mode 100644 index 00000000..ac00e6e8 --- /dev/null +++ b/src/game/romPriestChampionMechanics.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { applyAura } from "./combatAuras"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { romEffectsForCast } from "./romCombatMechanics"; + +const righteousExplosion = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499836)!; +const castContext = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 1_000, + targetHealthPercent: 1, +}; + +function finishCast(now: number) { + const cast = useCombatStore.getState().activeCast; + if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt); +} + +describe("RoM Champion/Priest Righteous Explosion", () => { + beforeEach(() => { + useCombatStore.setState({ auras: [], activeCast: null, combatEvents: [] }); + }); + + it("cleanses every modeled harmful category from the whole party", () => { + const effects = romEffectsForCast(righteousExplosion, castContext); + expect(effects).toContainEqual(expect.objectContaining({ + kind: "dispel", + mode: "cleanse", + relationship: "friendly", + categories: ["magic", "curse", "poison", "disease", "enrage"], + maxCount: 99, + scope: "party", + })); + }); + + it("removes more than one harmful effect from each party member", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-champion", secondaryClassId: "rom-priest", raceId: "rom-dwarf", level: 70, + }); + usePartyStore.getState().initializeParty( + "rom-champion-priest-righteous-explosion", 70, useCombatStore.getState().health, "tank", 2, "rom", + ); + + const memberIds = usePartyStore.getState().members.map((member) => member.id); + let auras = useCombatStore.getState().auras; + for (const targetId of [PLAYER_AURA_ENTITY_ID, ...memberIds]) { + for (const [id, category] of [["magic", "magic"], ["curse", "curse"], ["enrage", "enrage"]] as const) { + auras = applyAura(auras, { + definition: { + id: `righteous-explosion-${id}`, + name: `Test ${id}`, + disposition: "debuff", + dispelCategory: category, + durationMs: 30_000, + }, + sourceId: "test:enemy", + targetId, + now: 1_000, + }).auras; + } + auras = applyAura(auras, { + definition: { + id: "righteous-explosion-buff", + name: "Unrelated buff", + disposition: "buff", + durationMs: 30_000, + }, + sourceId: "test:ally", + targetId, + now: 1_000, + }).auras; + } + useCombatStore.setState({ auras }); + + expect(useCombatStore.getState().castAbility(righteousExplosion.id, undefined, 1_000)).toMatchObject({ ok: true }); + finishCast(1_000); + + const after = useCombatStore.getState().auras; + for (const targetId of [PLAYER_AURA_ENTITY_ID, ...memberIds]) { + expect(after.filter((aura) => aura.targetId === targetId && aura.definition.id.startsWith("righteous-explosion-")) + .map((aura) => aura.definition.id)).toEqual(["righteous-explosion-buff"]); + } + }); +}); diff --git a/src/game/romPriestChampionOverrides.ts b/src/game/romPriestChampionOverrides.ts index 8ee0c163..154553b6 100644 --- a/src/game/romPriestChampionOverrides.ts +++ b/src/game/romPriestChampionOverrides.ts @@ -1,4 +1,27 @@ -import type { AbilityDefinition } from "./abilityCatalog"; +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; + +export const ROM_PRIEST_CHAMPION_REGENERATE_PROC_ID = "rom-priest-protection-rune-seal-reset"; +export const ROM_PRIEST_CHAMPION_REGENERATE_PROC_ACTION_ID = "rom-priest-protection-rune-seal-reset-heart-rune"; +export const ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID = "rom-priest-protection-rune-seal-regenerate"; +export const ROM_PRIEST_CHAMPION_REGENERATE_PROC_CHANCE = 0.08; + +const ROM_PRIEST_REGENERATE_ABILITY_ID = "rom-priest-general-2"; + +const PROTECTION_RUNE_SEAL_REGENERATE_PROC_AURA = { + id: ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID, + name: "Protection Rune Seal: Regenerate", + disposition: "buff" as const, + // Keep the exact 20-second final HoT tick eligible before aura expiry runs. + durationMs: 20_001, + hideFromAuraStrip: true, + procs: [{ + id: ROM_PRIEST_CHAMPION_REGENERATE_PROC_ID, + triggers: "periodic" as const, + abilityIds: [ROM_PRIEST_REGENERATE_ABILITY_ID], + chance: ROM_PRIEST_CHAMPION_REGENERATE_PROC_CHANCE, + action: { kind: "custom" as const, id: ROM_PRIEST_CHAMPION_REGENERATE_PROC_ACTION_ID }, + }], +}; /** Infused Light Chain removes Chain of Light's base cooldown for Priest/Champion. */ export function romPriestChampionCooldownOverride( @@ -11,3 +34,34 @@ export function romPriestChampionCooldownOverride( || !learnedPassiveSourceIds.includes(498610)) return null; return 0; } + +/** Righteous Explosion removes every modeled harmful effect from the party. */ +export function romPriestChampionCastEffects( + ability: AbilityDefinition, + effects: readonly AbilityEffect[], + learnedPassiveSpellIds: readonly number[] = [], +): readonly AbilityEffect[] { + if (ability.source === "runewaker" + && ability.classId === "rom-priest" + && ability.dbcSpellId === 490269 + && learnedPassiveSpellIds.includes(498613)) { + return [{ + kind: "apply-aura", + aura: PROTECTION_RUNE_SEAL_REGENERATE_PROC_AURA, + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 498613, + }, ...effects]; + } + + if (ability.source !== "runewaker" + || ability.classId !== "rom-champion" + || ability.secondaryClassId !== "rom-priest" + || ability.dbcSpellId !== 499836) return effects; + + return effects.map((effect) => effect.kind === "dispel" && effect.mode === "cleanse" + ? { ...effect, maxCount: 99, scope: "party" } + : effect); +} diff --git a/src/game/romPriestChampionProtectionRune.test.ts b/src/game/romPriestChampionProtectionRune.test.ts new file mode 100644 index 00000000..39548ff7 --- /dev/null +++ b/src/game/romPriestChampionProtectionRune.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCombatStore } from "./combatStore"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID, ROM_PRIEST_CHAMPION_REGENERATE_PROC_CHANCE } from "./romPriestChampionOverrides"; + +describe("RoM Priest/Champion Protection Rune Seal", () => { + beforeEach(() => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-human", level: 30, role: "healer", + }); + }); + + it("resets Heart Rune Energy on an 8% Regenerate tick proc", () => { + const regenerate = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490269)!; + const heartRuneEnergy = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498612)!; + const initial = useCombatStore.getState(); + useCombatStore.setState({ cooldowns: { ...initial.cooldowns, [heartRuneEnergy.id]: 60_000 } }); + + expect(useCombatStore.getState().castAbility(regenerate.id, undefined, 1_000)).toMatchObject({ ok: true }); + const activeProcAura = useCombatStore.getState().auras.find((aura) => ( + aura.definition.id === ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID + )); + expect(activeProcAura?.definition.procs?.[0]).toMatchObject({ + chance: ROM_PRIEST_CHAMPION_REGENERATE_PROC_CHANCE, + abilityIds: [regenerate.id], + triggers: "periodic", + }); + + // The deterministic 8% roll succeeds on Regenerate's first periodic tick. + useCombatStore.getState().tick(0, 3_000); + expect(useCombatStore.getState().cooldowns[heartRuneEnergy.id]).toBe(0); + + // Include the final 20-second HoT tick when the exact expiry time is processed. + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [heartRuneEnergy.id]: 60_000 }, + })); + useCombatStore.getState().tick(0, 21_000); + expect(useCombatStore.getState().cooldowns[heartRuneEnergy.id]).toBe(0); + }); + + it("does not activate the reset proc when Protection Rune Seal is not unlocked", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-human", level: 24, role: "healer", + }); + const regenerate = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490269)!; + expect(useCombatStore.getState().castAbility(regenerate.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.id === ROM_PRIEST_CHAMPION_REGENERATE_PROC_AURA_ID + ))).toBe(false); + }); +}); diff --git a/src/game/romPriestGrandDispel.test.ts b/src/game/romPriestGrandDispel.test.ts new file mode 100644 index 00000000..7a3302d2 --- /dev/null +++ b/src/game/romPriestGrandDispel.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { ROM_ELITE_ABILITIES } from "./romAbilityCatalog"; +import { romEffectsForCast } from "./romCombatMechanics"; + +const grandDispel = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 492928)!; +const purify = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 491588)!; +const grandDispelTag = "rom-priest-mage-grand-dispel"; +const mechanics = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 1_000, + targetHealthPercent: 1, +}; + +function cleanseCategories(effects: ReturnType): readonly string[] | undefined { + const dispel = effects.find((effect) => effect.kind === "dispel"); + return dispel?.kind === "dispel" ? dispel.categories : undefined; +} + +describe("RoM Mage/Priest Grand Dispel", () => { + it("exposes its source passive marker and keeps Purify curse-only without it", () => { + expect(grandDispel.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ utilityTags: [grandDispelTag] }), + }), + ])); + expect(purify).toMatchObject({ classId: "rom-mage", secondaryClassId: "rom-priest" }); + expect(cleanseCategories(romEffectsForCast(purify, mechanics))).toEqual(["curse"]); + }); + + it("adds only modeled harmful cleanse categories for the matching pair with Grand Dispel", () => { + const withPassive = romEffectsForCast(purify, { + ...mechanics, + passiveTags: [grandDispelTag], + }); + expect(withPassive).toContainEqual(expect.objectContaining({ + kind: "dispel", + mode: "cleanse", + relationship: "friendly", + categories: ["magic", "curse", "poison", "disease"], + maxCount: 1, + })); + + const wrongPair = romEffectsForCast({ ...purify, secondaryClassId: "rom-knight" }, { + ...mechanics, + passiveTags: [grandDispelTag], + }); + expect(cleanseCategories(wrongPair)).toEqual(["curse"]); + }); +}); diff --git a/src/game/romPriestKnightOverrides.ts b/src/game/romPriestKnightOverrides.ts index b977c4f5..7aae828f 100644 --- a/src/game/romPriestKnightOverrides.ts +++ b/src/game/romPriestKnightOverrides.ts @@ -1,5 +1,31 @@ import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; +import { romPriestChampionCastEffects } from "./romPriestChampionOverrides"; + +export const ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG = "rom-priest-warrior-battle-monk-stance"; + +const BATTLE_MONK_STANCE_GATED_SKILLS = new Set([491362, 491363, 491364, 492635, 493038]); +export const ROM_PRIEST_KNIGHT_HOLY_POWER_HEAL_USES_TAG = "rom-priest-knight-holy-power-heal-uses"; + +export function romPriestWarriorStanceRequired( + ability: Pick, +): boolean { + return ability.source === "runewaker" + && ability.classId === "rom-priest" + && ability.secondaryClassId === "rom-warrior" + && BATTLE_MONK_STANCE_GATED_SKILLS.has(ability.dbcSpellId); +} + +/** Player and companion cast gates share the source-specific Priest/Warrior condition. */ +export function romPriestWarriorStanceUseAllowed( + ability: Pick, + activeAuraTags: readonly string[] | ReadonlySet, +): boolean { + if (!romPriestWarriorStanceRequired(ability)) return true; + return Array.isArray(activeAuraTags) + ? activeAuraTags.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG) + : (activeAuraTags as ReadonlySet).has(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG); +} function passiveState(sourceId: number, name: string, tag: string): readonly AbilityEffect[] { return [{ @@ -24,6 +50,33 @@ export function overrideRomPriestKnightEffects( row: RomGeneratedAbilityLike, translated: readonly AbilityEffect[], ): readonly AbilityEffect[] | null { + // These RuneWaker party buffs are self-targeted in their source rows, and + // their tooltip wording does not match the generic party-scope detector. + // Preserve the locally translated components and apply their auras to the + // party named by the source descriptions. + const partyBuff = (row.classId === "rom-priest" && row.secondaryClassId === undefined && row.sourceSkillId === 490298) + || (row.classId === "rom-priest" && row.secondaryClassId === "rom-scout" && row.sourceSkillId === 491323) + || (row.classId === "rom-priest" && row.secondaryClassId === "rom-rogue" && row.sourceSkillId === 492932); + if (partyBuff) { + return translated.map((effect) => effect.kind === "apply-aura" && (effect.aura.modifiers?.length ?? 0) > 0 + ? { ...effect, scope: "party" as const } + : effect); + } + + if (row.classId === "rom-priest" && row.secondaryClassId === "rom-warrior" && row.sourceSkillId === 491600) { + return translated.map((effect) => effect.kind === "apply-aura" && effect.recipient === "caster" + ? { + ...effect, + aura: { + ...effect.aura, + // All native Battle Monk stance components have a 900-second duration. + durationMs: 900_000, + utilityTags: [...(effect.aura.utilityTags ?? []), ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG], + }, + } + : effect); + } + switch (row.sourceSkillId) { case 492355: // Enhanced Grace of Life: Grace of Life also raises maximum MP. return passiveState(492355, row.name, "rom-enhanced-grace-of-life"); @@ -35,6 +88,8 @@ export function overrideRomPriestKnightEffects( 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 492928: // Grand Dispel: Purify also removes harmful effects, beyond curses. + return passiveState(492928, row.name, "rom-priest-mage-grand-dispel"); 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. @@ -157,6 +212,18 @@ export function romPriestKnightCastEffects( learnedPassiveSpellIds: readonly number[] = [], ): readonly AbilityEffect[] { if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects; + const priestChampionEffects = romPriestChampionCastEffects(ability, effects, learnedPassiveSpellIds); + if (priestChampionEffects !== effects) return priestChampionEffects; + if (ability.dbcSpellId === 491588 + && ability.classId === "rom-mage" + && ability.secondaryClassId === "rom-priest" + && hasTag(passiveTags, "rom-priest-mage-grand-dispel")) { + // Purify's base tooltip/source row allows curse removal. Grand Dispel + // broadens the same one-effect friendly cleanse to harmful status types. + return effects.map((effect) => effect.kind === "dispel" && effect.mode === "cleanse" + ? { ...effect, categories: ["magic", "curse", "poison", "disease"] } + : effect); + } if (ability.dbcSpellId === 490261 && learnedPassiveSpellIds.includes(493039) && friendlyTargetHealthPercent < 0.3) { @@ -184,6 +251,19 @@ export function romPriestKnightCastEffects( }, ...effects]; } switch (ability.dbcSpellId) { + case 493044: // Holy Power expires after three successful casts of Heal. + return effects.map((effect) => effect.kind === "apply-aura" && effect.recipient === "caster" + ? { + ...effect, + aura: { + ...effect.aura, + maxStacks: 3, + initialStacks: 3, + stackBehavior: "replace", + utilityTags: [...(effect.aura.utilityTags ?? []), ROM_PRIEST_KNIGHT_HOLY_POWER_HEAL_USES_TAG], + }, + } + : effect); 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; return [...effects, { diff --git a/src/game/romPriestKnightPairValidation.test.ts b/src/game/romPriestKnightPairValidation.test.ts index 2db293a9..72e9f961 100644 --- a/src/game/romPriestKnightPairValidation.test.ts +++ b/src/game/romPriestKnightPairValidation.test.ts @@ -1,13 +1,15 @@ import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForCharacter, isAbilityUnlocked, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog"; import { PLAYER_AGGRO_ID } from "./aggro"; -import { useCombatStore } from "./combatStore"; +import { characterCombatDefender, useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { commitPartyAbilityUse } from "./partyAbilityAi"; +import { applyAura as applyCombatAura } from "./combatAuras"; import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog"; import { romPriestKnightAdditionalAbilities, romPriestKnightCastEffects } from "./romPriestKnightOverrides"; +import { ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, ROM_KNIGHT_MASTER_OF_PARRY_TAG } from "./romKnightOverrides"; const PAIRED_SKILLS = [ ["rom-priest", "rom-knight", 15, 492355, "Enhanced Grace of Life", "passive"], @@ -500,4 +502,132 @@ describe("accepted RoM Priest/Knight and Knight/Scout pairs", () => { && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "parry"))).toBe(true); }); + + it("shares 40% of one incoming party hit, then leaves later hits with the ally", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 60, role: "tank", + }); + usePartyStore.getState().initializeParty("knight-redirect-hit", 60, useCombatStore.getState().health, "tank", 2, "rom"); + const member = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(member.id, [3, 0, 0]); + usePartyStore.getState().selectMember(member.id); + + expect(useCombatStore.getState().castAbility("rom-knight-rom-scout-elite-50", undefined, 1_000)) + .toMatchObject({ ok: true }); + const redirectAura = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id + && aura.definition.utilityTags?.includes("rom-disregard-danger-one-hit")); + expect(redirectAura?.definition.redirectDamagePercent).toBe(0.4); + expect(redirectAura?.expiresAt).toBe(11_000); + + useCombatStore.getState().registerMob("disregard-danger-attacker", { + name: "Disregard Danger attacker", maxHealth: 200, xpReward: 0, + }); + setMobPosition("disregard-danger-attacker", [5, 0, 0]); + useCombatStore.getState().damageMob("disregard-danger-attacker", 5, 1_000, { + actorId: member.id, + role: "tank", + }); + + const playerBeforeFirstHit = useCombatStore.getState().health; + const memberBeforeFirstHit = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health; + expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0); + const playerLossOnFirstHit = playerBeforeFirstHit - useCombatStore.getState().health; + const memberLossOnFirstHit = memberBeforeFirstHit + - usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health; + const firstHitDamage = playerLossOnFirstHit + memberLossOnFirstHit; + expect(playerLossOnFirstHit).toBeGreaterThan(0); + expect(memberLossOnFirstHit).toBeGreaterThan(0); + expect(playerLossOnFirstHit).toBe(Math.round(firstHitDamage * 0.4)); + expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === redirectAura?.instanceId)).toBe(false); + + const playerBeforeSecondHit = useCombatStore.getState().health; + const memberBeforeSecondHit = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health; + expect(useCombatStore.getState().advanceMobCombat(4_000)).toBeGreaterThan(0); + expect(useCombatStore.getState().health).toBe(playerBeforeSecondHit); + expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health) + .toBeLessThan(memberBeforeSecondHit); + }); + + it("keeps Master of Parry through one block and removes it after the second", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 60, role: "tank", + }); + useCombatStore.setState((state) => ({ + resource: 100, + resourcePools: { ...state.resourcePools, rage: 100 }, + })); + const castResult = useCombatStore.getState().castAbility("rom-knight-rom-warrior-elite-40", undefined, 1_000); + expect(castResult.ok, JSON.stringify(castResult)).toBe(true); + + const masterOfParry = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes(ROM_KNIGHT_MASTER_OF_PARRY_TAG) + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "parry") + )); + expect(masterOfParry?.definition.durationMs).toBe(15_000); + expect(masterOfParry?.expiresAt).toBe(16_000); + expect(masterOfParry?.definition.modifiers).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "stat", stat: "parry", operation: "flat", value: 5_000 }), + ])); + expect(masterOfParry?.procStates).toEqual(expect.arrayContaining([ + expect.objectContaining({ procId: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, remainingCharges: 2 }), + ])); + const timedTriggerAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.procs?.some((proc) => ( + proc.action.kind === "custom" + && proc.action.id === "rom-trigger-skill" + && proc.action.data?.spellId === 493094 + )) + )); + expect(timedTriggerAura?.definition.durationMs).toBe(15_000); + expect(timedTriggerAura?.expiresAt).toBe(16_000); + + // Make each incoming melee attack a deterministic successful block so the + // test exercises the same block event used by normal combat. + const forcedBlockAuras = applyCombatAura(useCombatStore.getState().auras, { + definition: { + id: "test-forced-block", + name: "Test forced block", + disposition: "buff", + durationMs: null, + modifiers: [ + { kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 1_000 }, + { kind: "stat", stat: "incoming-critical-strike", operation: "flat", value: -1_000 }, + { kind: "stat", stat: "dodge", operation: "flat", value: -1_000 }, + { kind: "stat", stat: "parry", operation: "flat", value: -10_000 }, + { kind: "stat", stat: "block-chance", operation: "flat", value: 1_000 }, + ], + }, + sourceId: PLAYER_AGGRO_ID, + targetId: PLAYER_AGGRO_ID, + now: 1_000, + }).auras; + useCombatStore.setState({ auras: [...forcedBlockAuras] }); + const forcedDefender = characterCombatDefender(useCombatStore.getState()); + expect(forcedDefender.dodgePercent).toBe(0); + expect(forcedDefender.parryPercent).toBe(0); + expect(forcedDefender.blockPercent).toBeGreaterThan(100); + useCombatStore.getState().registerMob("master-of-parry-attacker", { + name: "Master of Parry attacker", maxHealth: 200, xpReward: 0, + }); + setMobPosition("master-of-parry-attacker", [3, 0, 0]); + useCombatStore.getState().selectMob("master-of-parry-attacker"); + useCombatStore.getState().damageMob("master-of-parry-attacker", 5, 1_000); + expect(useCombatStore.getState().mobs["master-of-parry-attacker"]).toMatchObject({ engaged: true }); + + useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]); + const firstMobState = useCombatStore.getState().mobs["master-of-parry-attacker"]; + expect(firstMobState?.attackRevision, JSON.stringify(firstMobState)).toBe(1); + expect(useCombatStore.getState().lastCombatResult?.blocked).toBe(true); + expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === masterOfParry?.instanceId) + ?.procStates).toEqual(expect.arrayContaining([ + expect.objectContaining({ procId: ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC, remainingCharges: 1 }), + ])); + expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === masterOfParry?.instanceId)).toBe(true); + + useCombatStore.getState().advanceMobCombat(4_000, [0, 0, 0]); + expect(useCombatStore.getState().mobs["master-of-parry-attacker"]?.attackRevision).toBe(2); + expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === masterOfParry?.instanceId)).toBe(false); + }); }); diff --git a/src/game/romPriestKnightRuntimeEffects.test.ts b/src/game/romPriestKnightRuntimeEffects.test.ts index 748a84a7..e4c514b5 100644 --- a/src/game/romPriestKnightRuntimeEffects.test.ts +++ b/src/game/romPriestKnightRuntimeEffects.test.ts @@ -128,6 +128,46 @@ describe("RoM Knight/Priest conditional combat payloads", () => { && event.abilityId === "rom-knight-rom-priest-elite-20")).toHaveLength(1); }); + it("expires Priest/Knight Holy Power after the third successful Heal cast", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 45, role: "healer", + }); + usePartyStore.getState().initializeParty( + "priest-knight-holy-power", 45, useCombatStore.getState().health, "healer", 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]); + usePartyStore.getState().selectMember(ally.id); + + expect(useCombatStore.getState().castAbility("rom-priest-rom-knight-elite-45", undefined, 1_000)) + .toMatchObject({ ok: true }); + const taggedHolyPowerAuras = () => useCombatStore.getState().auras.filter((aura) => ( + aura.targetId === PLAYER_AGGRO_ID && aura.definition.utilityTags?.includes("rom-priest-knight-holy-power-heal-uses") + )); + expect(taggedHolyPowerAuras().length).toBeGreaterThan(0); + expect(taggedHolyPowerAuras().every((aura) => aura.stacks === 3)).toBe(true); + const holyPowerModifiers = taggedHolyPowerAuras().flatMap((aura) => aura.definition.modifiers ?? []); + expect(holyPowerModifiers).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "stat", stat: "wisdom", operation: "flat", value: 60 }), + expect.objectContaining({ kind: "stat", stat: "wisdom", operation: "percent", value: 0.01 }), + ])); + + let now = 2_600; + for (const expectedUsesRemaining of [2, 1, 0]) { + expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, now)).toMatchObject({ ok: true }); + const cast = useCombatStore.getState().activeCast; + if (cast) { + useCombatStore.getState().tick(Math.max(0.01, (cast.completesAt - now) / 1_000), cast.completesAt); + now = cast.completesAt + 100; + } else { + now += 1_600; + } + expect(taggedHolyPowerAuras().every((aura) => aura.stacks === expectedUsesRemaining)).toBe(true); + if (expectedUsesRemaining === 0) expect(taggedHolyPowerAuras()).toHaveLength(0); + } + }); + 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", diff --git a/src/game/romPriestMageEnlightenment.integration.test.ts b/src/game/romPriestMageEnlightenment.integration.test.ts new file mode 100644 index 00000000..f8c87169 --- /dev/null +++ b/src/game/romPriestMageEnlightenment.integration.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { ROM_PRIEST_MAGE_ENLIGHTENMENT } from "./romPriestMageRuntime"; + +const ACTIVE_TAG = "rom-priest-mage-enlightenment-active"; + +function initializePriestMage(): void { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", + secondaryClassId: "rom-mage", + raceId: "rom-human", + level: 45, + role: "healer", + }); +} + +function aura(actorId: string, tag: string, now: number) { + return useCombatStore.getState().auras.find((entry) => entry.targetId === actorId + && (entry.expiresAt === null || entry.expiresAt > now) + && entry.definition.utilityTags?.includes(tag)); +} + +describe("RoM Priest/Mage Enlightenment integration", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + initializePriestMage(); + usePartyStore.getState().initializeParty( + "priest-mage-enlightenment-test", 45, useCombatStore.getState().health, "healer", 2, "rom", + ); + }); + + it("triggers only on completed Heal and respects the caster's 20-second cooldown", () => { + const heal = abilityById("rom-priest-primary-2"); + const wrongSpell = abilityById("rom-priest-primary-4"); + const passive = abilityById("rom-priest-rom-mage-elite-40"); + if (!heal || !wrongSpell || !passive) throw new Error("Missing local Priest/Mage Enlightenment rows"); + expect([heal.dbcSpellId, wrongSpell.dbcSpellId, passive.dbcSpellId]).toEqual([490_261, 491_165, 492_933]); + expect(passive.effects).toEqual([]); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 1_000)).toBeUndefined(); + expect(aura(PLAYER_AGGRO_ID, ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, 1_000)).toBeUndefined(); + + useCombatStore.getState().damagePlayer(100); + expect(useCombatStore.getState().castAbility(heal.id, undefined, 1_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 3_100); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 3_100)?.expiresAt).toBe(11_100); + expect(aura(PLAYER_AGGRO_ID, ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, 3_100)?.expiresAt) + .toBe(23_100); + + useCombatStore.getState().registerMob("enlightenment-wrong-spell-target", { + name: "Enlightenment wrong-spell target", maxHealth: 100_000, xpReward: 0, + }); + setMobPosition("enlightenment-wrong-spell-target", [3, 0, 0]); + useCombatStore.getState().selectMob("enlightenment-wrong-spell-target"); + expect(useCombatStore.getState().castAbility(wrongSpell.id, undefined, 5_000)).toMatchObject({ ok: true }); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 5_000)?.expiresAt).toBe(11_100); + + expect(useCombatStore.getState().castAbility(heal.id, undefined, 7_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 9_100); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 9_100)?.expiresAt).toBe(11_100); + expect(aura(PLAYER_AGGRO_ID, ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, 9_100)?.expiresAt) + .toBe(23_100); + + expect(useCombatStore.getState().castAbility(heal.id, undefined, 24_000)).toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 26_100); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 26_100)?.expiresAt).toBe(34_100); + }); + + it("triggers after a party Priest/Mage finishes Heal and keeps the aura on that caster", () => { + const seed = "priest-mage-enlightenment-party"; + const healer = createConfiguredPartyMember({ + name: "Enlightenment Priest", + classId: "rom-priest", + secondaryClassId: "rom-mage", + specialization: "Primary", + role: "healer", + }, 45, 0, seed); + const heal = abilityById("rom-priest-primary-2"); + const passive = abilityById("rom-priest-rom-mage-elite-40"); + if (!heal || !passive) throw new Error("Missing local Priest/Mage Heal or Enlightenment rows"); + usePartyStore.getState().initializeParty(seed, 45, useCombatStore.getState().health, "healer", 2, "rom"); + usePartyStore.setState({ members: [{ + ...healer, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...healer.resourcePools, mana: 10_000 }, + activeCast: { + abilityId: heal.id, + school: "holy", + mode: "cast", + startedAt: 500, + completesAt: 2_500, + originalDurationMs: 2_000, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: healer.id, + }, + activeCastTargetId: healer.id, + status: "healing", + }] }); + registerPartyRuntimePosition(healer.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + expect(passive.effects).toEqual([]); + expect(aura(healer.id, ACTIVE_TAG, 1_000)).toBeUndefined(); + + advanceDungeonPartyCombat(2_500); + + expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === healer.id + && event.abilityId === heal.id && event.kind === "healing")).toBe(true); + expect(aura(healer.id, ACTIVE_TAG, 2_500)?.expiresAt).toBe(10_500); + expect(aura(healer.id, ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, 2_500)?.expiresAt).toBe(22_500); + expect(aura(PLAYER_AGGRO_ID, ACTIVE_TAG, 2_500)).toBeUndefined(); + }); +}); diff --git a/src/game/romPriestMageRuntime.test.ts b/src/game/romPriestMageRuntime.test.ts new file mode 100644 index 00000000..e8c83c56 --- /dev/null +++ b/src/game/romPriestMageRuntime.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { + ROM_PRIEST_MAGE_ENLIGHTENMENT, + ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT, + romPriestMageEnlightenmentProc, + romPriestMageEnlightenmentReadyAt, +} from "./romPriestMageRuntime"; + +const heal = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490_261)!; +const learnedPassive = [ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId]; + +describe("RoM Priest/Mage Enlightenment runtime", () => { + it("grants the source-defined Wisdom aura after completing Heal", () => { + expect(ROM_PRIEST_MAGE_ENLIGHTENMENT).toMatchObject({ + passiveSpellId: 492_933, + triggerSpellId: 490_261, + wisdomComponentId: 503_197, + cooldownComponentId: 506_532, + wisdomPercent: 1, + durationMs: 8_000, + cooldownMs: 20_000, + }); + const result = romPriestMageEnlightenmentProc( + "rom-priest", + "rom-mage", + heal, + learnedPassive, + true, + 2_000, + 2_000, + ); + + expect(result.nextReadyAt).toBe(22_000); + expect(result.effect).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + triggerSpellId: 492_933, + aura: { + id: "rom-priest-mage-enlightenment", + durationMs: 8_000, + maxStacks: 1, + stackBehavior: "refresh", + modifiers: [{ + kind: "stat", + stat: "wisdom", + operation: "percent", + value: 0.01, + }], + }, + }); + expect(ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT).toMatchObject({ + recipient: "caster", + triggerSpellId: 492_933, + aura: { + durationMs: 20_000, + hideFromAuraStrip: true, + utilityTags: [ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag], + }, + }); + }); + + it("keeps the per-caster cooldown independent and ready at expiration", () => { + const activeAuras = [ + { + targetId: "priest-a", + expiresAt: 12_000, + definition: { + id: "enlightenment-cooldown-test", + name: "Enlightenment cooldown test", + disposition: "buff" as const, + durationMs: ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownMs, + utilityTags: [ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag], + }, + }, + ]; + expect(romPriestMageEnlightenmentReadyAt(activeAuras, "priest-a", 5_000)).toBe(12_000); + expect(romPriestMageEnlightenmentReadyAt(activeAuras, "priest-b", 5_000)).toBe(5_000); + expect(romPriestMageEnlightenmentReadyAt(activeAuras, "priest-a", 12_000)).toBe(12_000); + }); + + it("rejects other builds, spells, sources, unlearned passives, incomplete casts, and cooldown casts", () => { + const rejected = [ + romPriestMageEnlightenmentProc("rom-mage", "rom-priest", heal, learnedPassive, true, 0, 1), + romPriestMageEnlightenmentProc("rom-priest", undefined, heal, learnedPassive, true, 0, 1), + romPriestMageEnlightenmentProc("rom-priest", "rom-mage", { ...heal, dbcSpellId: 490_204 }, learnedPassive, true, 0, 1), + romPriestMageEnlightenmentProc("rom-priest", "rom-mage", { ...heal, source: "core" }, learnedPassive, true, 0, 1), + romPriestMageEnlightenmentProc("rom-priest", "rom-mage", heal, [], true, 0, 1), + romPriestMageEnlightenmentProc("rom-priest", "rom-mage", heal, learnedPassive, false, 0, 1), + romPriestMageEnlightenmentProc("rom-priest", "rom-mage", heal, learnedPassive, true, 5_000, 4_999), + ]; + for (const result of rejected) { + expect(result.effect).toBeNull(); + expect(result.nextReadyAt).toBeGreaterThanOrEqual(0); + } + }); + + it("suppresses the generated passive aura while keeping Heal available", () => { + const passive = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-rom-mage-elite-40"); + expect(passive?.effects).toEqual([]); + expect(passive?.ranks?.[0]?.effects).toEqual([]); + expect(heal).toMatchObject({ classId: "rom-priest", dbcSpellId: 490_261, source: "runewaker" }); + }); +}); diff --git a/src/game/romPriestMageRuntime.ts b/src/game/romPriestMageRuntime.ts new file mode 100644 index 00000000..a6883f5c --- /dev/null +++ b/src/game/romPriestMageRuntime.ts @@ -0,0 +1,114 @@ +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import type { ActiveAura } from "./combatAuras"; +import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; + +export const ROM_PRIEST_MAGE_ENLIGHTENMENT = Object.freeze({ + passiveSpellId: 492_933, + triggerSpellId: 490_261, + wisdomComponentId: 503_197, + cooldownComponentId: 506_532, + wisdomPercent: 1, + durationMs: 8_000, + cooldownMs: 20_000, + cooldownAuraTag: "rom-priest-mage-enlightenment-cooldown", +}); + +export const ROM_PRIEST_MAGE_ENLIGHTENMENT_COOLDOWN_EFFECT: Extract = { + kind: "apply-aura", + recipient: "caster", + aura: { + id: ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag, + name: "Enlightenment cooldown", + disposition: "buff", + durationMs: ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownMs, + hideFromAuraStrip: true, + utilityTags: [ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId, +}; + +/** Enlightenment is triggered by the Priest/Mage Heal cast, not a permanent passive aura. */ +export function overrideRomPriestMageEffects( + row: RomGeneratedAbilityLike, + _translated: readonly AbilityEffect[], +): readonly AbilityEffect[] | null { + return row.classId === "rom-priest" + && row.secondaryClassId === "rom-mage" + && row.sourceSkillId === ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId + ? [] + : null; +} + +/** Find the active internal cooldown marker for one caster. */ +export function romPriestMageEnlightenmentReadyAt( + activeAuras: readonly Pick[], + casterId: string, + now: number, +): number { + const cooldown = activeAuras.find((aura) => aura.targetId === casterId + && aura.definition.utilityTags?.includes(ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownAuraTag) + && (aura.expiresAt === null || aura.expiresAt > now)); + return cooldown ? cooldown.expiresAt ?? Number.POSITIVE_INFINITY : now; +} + +export interface RomPriestMageEnlightenmentResult { + readonly effect: AbilityEffect | null; + readonly nextReadyAt: number; +} + +/** + * RuneWaker Enlightenment (492933): completing Heal (490261) while the + * Priest/Mage passive is learned grants +1% Wisdom for 8 seconds, with a + * 20-second internal cooldown. The source says "when you use Heal," so a + * completed cast is the trigger; hit or target health is not required. + */ +export function romPriestMageEnlightenmentProc( + classId: string | null, + secondaryClassId: string | null | undefined, + ability: Pick, + learnedPassiveSpellIds: readonly number[], + castCompleted: boolean, + readyAt: number, + now: number, +): RomPriestMageEnlightenmentResult { + const eligible = classId === "rom-priest" + && secondaryClassId === "rom-mage" + && ability.classId === "rom-priest" + && ability.source === "runewaker" + && ability.dbcSpellId === ROM_PRIEST_MAGE_ENLIGHTENMENT.triggerSpellId + && learnedPassiveSpellIds.includes(ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId) + && castCompleted + && now >= readyAt; + if (!eligible) return { effect: null, nextReadyAt: readyAt }; + + return { + effect: { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-priest-mage-enlightenment", + name: "Enlightenment", + description: "Completing Heal grants +1% Wisdom for 8 seconds. This effect has a 20-second internal cooldown.", + disposition: "buff", + durationMs: ROM_PRIEST_MAGE_ENLIGHTENMENT.durationMs, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["rom-priest-mage-enlightenment-active"], + modifiers: [{ + kind: "stat", + stat: "wisdom", + operation: "percent", + value: ROM_PRIEST_MAGE_ENLIGHTENMENT.wisdomPercent / 100, + }], + }, + sourceEffectType: -1, + sourceAuraType: 49, + miscValue: ROM_PRIEST_MAGE_ENLIGHTENMENT.wisdomPercent, + triggerSpellId: ROM_PRIEST_MAGE_ENLIGHTENMENT.passiveSpellId, + }, + nextReadyAt: now + ROM_PRIEST_MAGE_ENLIGHTENMENT.cooldownMs, + }; +} diff --git a/src/game/romPriestPartyScopes.test.ts b/src/game/romPriestPartyScopes.test.ts new file mode 100644 index 00000000..f500e3d9 --- /dev/null +++ b/src/game/romPriestPartyScopes.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { abilitiesForCharacter, type AbilityDefinition } from "./abilityCatalog"; + +function ability( + primary: "rom-priest", + secondary: "rom-scout" | "rom-rogue" | null, + sourceId: number, +): AbilityDefinition { + const match = abilitiesForCharacter(primary, secondary).find((candidate) => candidate.dbcSpellId === sourceId); + if (!match) throw new Error(`Missing Priest ability ${sourceId}`); + return match; +} + +describe("RoM Priest party buff scopes", () => { + it.each([ + [null, 490298, "Blessed Spring Water"], + ["rom-scout", 491323, "Embrace of the Water Spirit"], + ["rom-rogue", 492932, "Shadow Fury"], + ] as const)("applies %s to its source-described party", (secondary, sourceId, name) => { + const buff = ability("rom-priest", secondary, sourceId); + expect(buff.name).toBe(name); + + const auras = buff.effects.filter((effect) => effect.kind === "apply-aura"); + const buffs = auras.filter((effect) => effect.kind === "apply-aura" && (effect.aura.modifiers?.length ?? 0) > 0); + expect(buffs.length).toBeGreaterThan(0); + expect(buffs.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true); + expect(buffs.every((effect) => effect.kind === "apply-aura" && effect.recipient === "caster")).toBe(true); + expect(buffs.every((effect) => effect.kind === "apply-aura" && effect.aura.durationMs === 900_000)).toBe(true); + // The Scout pairing also has an independent periodic trigger state; it remains caster-only. + expect(auras.filter((effect) => effect.kind === "apply-aura" && effect.aura.procs?.length).every( + (effect) => effect.kind === "apply-aura" && effect.scope !== "party", + )).toBe(true); + }); +}); diff --git a/src/game/romPriestTooltips.ts b/src/game/romPriestTooltips.ts index f1ca3aa4..04366bba 100644 --- a/src/game/romPriestTooltips.ts +++ b/src/game/romPriestTooltips.ts @@ -33,7 +33,7 @@ const PRIEST_DESCRIPTIONS: Readonly> = { 491366: "Increases your healing and reduces mana costs while lowering magical attack for 15 minutes.", 492639: "Protects a friendly target from Fear for 10 seconds.", 492934: "Sacrifices some of your health to heal a friendly party member.", - 493044: "Increases your Wisdom for 20 seconds or until you cast Heal three times.", + 493044: "Increases your Wisdom by (current Wisdom + 60) × 1% + 60 for 20 seconds or until you cast Heal three times.", 499968: "Passive: Splendor Ripple makes your Devotion Halo heal its target for additional health.", 499957: "Passive: while Devotion Halo is active, increases your healing done.", }; diff --git a/src/game/romPriestWarlockCleanse.test.ts b/src/game/romPriestWarlockCleanse.test.ts new file mode 100644 index 00000000..92c82719 --- /dev/null +++ b/src/game/romPriestWarlockCleanse.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { applyAura } from "./combatAuras"; +import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog"; +import { romEffectsForCast } from "./romCombatMechanics"; +import { useCombatStore } from "./combatStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; + +const cleanse = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491175 + && ability.classId === "rom-priest")!; +const soulCleansing = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 498771)!; +const soulCleansingTag = "rom-priest-warlock-soul-cleansing-ceremony"; +const mechanics = { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 1_000, + targetHealthPercent: 1, +}; + +describe("RoM Priest/Warlock Soul Cleansing Ceremony", () => { + beforeEach(() => { + clearPartyRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + it("keeps Cleanse curse-free without the passive and adds curse removal for Priest/Warlock", () => { + expect(soulCleansing.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + aura: expect.objectContaining({ utilityTags: [soulCleansingTag] }), + }), + ])); + expect(cleanse.effects).toContainEqual(expect.objectContaining({ + kind: "dispel", + mode: "cleanse", + categories: ["magic", "poison", "disease"], + maxCount: 1, + })); + + const withPassive = romEffectsForCast(cleanse, { + ...mechanics, + passiveTags: [soulCleansingTag], + }); + expect(withPassive).toContainEqual(expect.objectContaining({ + kind: "dispel", + mode: "cleanse", + relationship: "friendly", + categories: ["magic", "poison", "disease", "curse"], + maxCount: 1, + })); + + const wrongClass = romEffectsForCast({ ...cleanse, classId: "rom-mage" }, { + ...mechanics, + passiveTags: [soulCleansingTag], + }); + expect(wrongClass).toContainEqual(expect.objectContaining({ + kind: "dispel", + categories: ["magic", "poison", "disease"], + })); + }); + + it("removes a cursed status from the selected ally when the passive is active", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", + secondaryClassId: "rom-warlock", + raceId: "rom-human", + level: 35, + }); + expect(useCombatStore.getState().abilities).toContainEqual(expect.objectContaining({ + dbcSpellId: 498771, + passive: true, + unlockLevel: 30, + })); + usePartyStore.getState().initializeParty( + "rom-priest-warlock-soul-cleansing", + 35, + useCombatStore.getState().health, + "healer", + 2, + "rom", + ); + const ally = usePartyStore.getState().members[0]!; + registerPartyRuntimePosition(ally.id, [3, 0, 0]); + usePartyStore.getState().selectMember(ally.id); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes(soulCleansingTag) + ))).toBe(true); + + const curse = { + id: "test:rom-priest-warlock-curse", + name: "Test Curse", + disposition: "debuff" as const, + dispelCategory: "curse" as const, + durationMs: 60_000, + }; + const applied = applyAura(useCombatStore.getState().auras, { + definition: curse, + sourceId: "test:enemy", + targetId: ally.id, + now: 1_000, + }); + useCombatStore.setState({ auras: [...applied.auras] }); + + expect(useCombatStore.getState().castAbility(cleanse.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === curse.id + && aura.targetId === ally.id)).toBe(false); + }); +}); diff --git a/src/game/romPriestWarlockOverrides.ts b/src/game/romPriestWarlockOverrides.ts index 406fbc4b..7aee8248 100644 --- a/src/game/romPriestWarlockOverrides.ts +++ b/src/game/romPriestWarlockOverrides.ts @@ -1,15 +1,61 @@ import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +const SOUL_CLEANSING_TAG = "rom-priest-warlock-soul-cleansing-ceremony"; + +/** Source-specific Priest/Warlock passive and cleanse mechanics. */ +export function overrideRomPriestWarlockEffects( + row: { readonly sourceSkillId: number; readonly name: string; readonly classId?: string; readonly secondaryClassId?: string }, + translated: readonly AbilityEffect[], +): readonly AbilityEffect[] | null { + if (row.sourceSkillId === 498771 + && row.classId === "rom-priest" + && row.secondaryClassId === "rom-warlock") { + return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-priest-warlock-soul-cleansing-ceremony", + name: row.name, + disposition: "buff", + durationMs: null, + utilityTags: [SOUL_CLEANSING_TAG], + }, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 498771, + }]; + } + + if (row.sourceSkillId === 491175 && row.classId === "rom-priest") { + // Soul Cleansing Ceremony explicitly adds cursed states to Cleanse. Keep + // the base cleanse on the other modeled harmful categories so the + // passive has a real, pair-specific effect. + return translated.map((effect) => effect.kind === "dispel" && effect.mode === "cleanse" + ? { ...effect, categories: ["magic", "poison", "disease"] } + : effect); + } + + return null; +} + /** Select local Prayer of Tribulation buff stacks from its tooltip HP gates. */ export function romPriestWarlockCastEffects( ability: AbilityDefinition, effects: readonly AbilityEffect[], friendlyTargetHealthPercent: number, + passiveTags: readonly string[] | ReadonlySet = [], ): readonly AbilityEffect[] { - if (ability.source !== "runewaker" - || ability.classId !== "rom-priest" - || ability.secondaryClassId !== "rom-warlock" - || ability.dbcSpellId !== 498770) return effects; + if (ability.source !== "runewaker" || ability.classId !== "rom-priest") return effects; + const hasSoulCleansing = Array.isArray(passiveTags) + ? passiveTags.includes(SOUL_CLEANSING_TAG) + : (passiveTags as ReadonlySet).has(SOUL_CLEANSING_TAG); + if (ability.dbcSpellId === 491175 && hasSoulCleansing) { + return effects.map((effect) => effect.kind === "dispel" && effect.mode === "cleanse" + ? { ...effect, categories: [...(effect.categories ?? []), "curse"] } + : effect); + } + if (ability.secondaryClassId !== "rom-warlock" || ability.dbcSpellId !== 498770) return effects; const buff = effects.find((effect): effect is Extract => ( effect.kind === "apply-aura" diff --git a/src/game/romPriestWarriorStance.test.ts b/src/game/romPriestWarriorStance.test.ts new file mode 100644 index 00000000..3cff2fc7 --- /dev/null +++ b/src/game/romPriestWarriorStance.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { AbilityDefinition } from "./abilityCatalog"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { + overrideRomPriestKnightEffects, + ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG, + romPriestWarriorStanceUseAllowed, +} from "./romPriestKnightOverrides"; + +function ability(sourceId: number): AbilityDefinition { + const found = ROM_ALL_ABILITIES.find((row) => row.dbcSpellId === sourceId + && row.classId === "rom-priest" && row.secondaryClassId === "rom-warrior"); + if (!found) throw new Error(`Missing Priest/Warrior source skill ${sourceId}`); + return found; +} + +describe("RoM Priest/Warrior Battle Monk stance requirements", () => { + it("tags the generated stance aura so cast gates can detect it", () => { + const stance = ability(491600); + const tagged = overrideRomPriestKnightEffects({ + classId: stance.classId, + secondaryClassId: stance.secondaryClassId, + sourceSkillId: stance.dbcSpellId, + name: stance.name, + description: stance.description, + }, stance.effects); + + expect(tagged).not.toBeNull(); + expect(tagged!.some((effect) => effect.kind === "apply-aura" + && effect.recipient === "caster" + && effect.aura.utilityTags?.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG))).toBe(true); + }); + + it.each([491362, 491363, 491364, 492635, 493038])( + "requires Battle Monk Stance for generated skill %i", + (sourceId) => { + const skill = ability(sourceId); + expect(romPriestWarriorStanceUseAllowed(skill, [])).toBe(false); + expect(romPriestWarriorStanceUseAllowed(skill, [ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG])).toBe(true); + expect(romPriestWarriorStanceUseAllowed(skill, new Set([ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG]))).toBe(true); + }, + ); + + it("does not apply the source-specific gate to other abilities", () => { + const skill = ability(491362); + expect(romPriestWarriorStanceUseAllowed({ ...skill, dbcSpellId: 490290 }, [])).toBe(true); + expect(romPriestWarriorStanceUseAllowed({ ...skill, secondaryClassId: "rom-knight" }, [])).toBe(true); + expect(romPriestWarriorStanceUseAllowed({ ...skill, source: "core" }, [])).toBe(true); + }); +}); diff --git a/src/game/romPriestWarriorStanceRuntime.test.ts b/src/game/romPriestWarriorStanceRuntime.test.ts new file mode 100644 index 00000000..0df888e0 --- /dev/null +++ b/src/game/romPriestWarriorStanceRuntime.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForCharacter } from "./abilityCatalog"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { partyAbilityIsReady } from "./partyAbilityAi"; +import { + partyAbilityTargetIsValid, +} from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { + ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG, +} from "./romPriestKnightOverrides"; + +const TARGET_ID = "rom-priest-warrior-stance-target"; +const STANCE_SPELL_ID = 491600; +const STANCE_GATED_SPELL_IDS = [491362, 491363, 491364, 492635, 493038] as const; + +function sourceAbility(spellId: number) { + const found = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId + && candidate.classId === "rom-priest" + && candidate.secondaryClassId === "rom-warrior"); + if (!found) throw new Error(`Missing Priest/Warrior ability ${spellId}`); + return found; +} + +function preparePlayer(): void { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", + secondaryClassId: "rom-warrior", + raceId: "rom-human", + level: 80, + }); + useCombatStore.setState((state) => ({ + resource: 1_000, + maxResource: 1_000, + resourcePools: { ...state.resourcePools, mana: 1_000, rage: 1_000 }, + maxResourcePools: { ...state.maxResourcePools, mana: 1_000, rage: 1_000 }, + })); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); +} + +function registerTarget(): void { + useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 }); + setMobPosition(TARGET_ID, [3, 0, 0]); + useCombatStore.getState().damageMob(TARGET_ID, 1, 500); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function activeStanceFor(actorId: string, now: number): boolean { + const stance = sourceAbility(STANCE_SPELL_ID); + const effect = stance.effects.find((candidate) => candidate.kind === "apply-aura" + && candidate.recipient === "caster"); + if (!effect || effect.kind !== "apply-aura") throw new Error("Battle Monk Stance has no caster aura"); + return useCombatStore.getState().applyActorAura(actorId, actorId, stance, effect, now); +} + +function companionFor(abilityId: string) { + const seed = `rom-priest-warrior-stance:${abilityId}`; + const member = createConfiguredPartyMember({ + name: "Battle Monk", + classId: "rom-priest", + secondaryClassId: "rom-warrior", + specialization: "Primary", + role: "damage", + }, 80, 0, seed); + const pairAbilities = abilitiesForCharacter("rom-priest", "rom-warrior"); + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom"); + const configuredMember = { + ...member, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...member.resourcePools, mana: 10_000, rage: 10_000 }, + cooldowns: Object.fromEntries(pairAbilities.map((ability) => [ability.id, ability.id === abilityId ? 0 : 60_000])), + globalCooldownEndsAt: 0, + nextActionAt: 0, + status: "attacking" as const, + }; + usePartyStore.setState({ + members: [configuredMember], + command: "attack", + activeMobId: TARGET_ID, + }); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + return configuredMember; +} + +describe("RoM Priest/Warrior Battle Monk stance runtime gate", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + usePartyStore.setState({ members: [], activeMobId: null, objectiveBossId: null, memberTargetIds: {} }); + preparePlayer(); + }); + + it.each(STANCE_GATED_SPELL_IDS)("rejects player cast %i without Battle Monk Stance", (spellId) => { + registerTarget(); + const ability = sourceAbility(spellId); + const result = useCombatStore.getState().castAbility(ability.id, { + ...(ability.target === "hostile" ? { targetActorId: TARGET_ID } : {}), + now: 1_000, + }); + + expect(result).toMatchObject({ ok: false, reason: "condition-not-met" }); + }); + + it.each(STANCE_GATED_SPELL_IDS)("accepts player cast %i while Battle Monk Stance is active", (spellId) => { + registerTarget(); + const stance = sourceAbility(STANCE_SPELL_ID); + const ability = sourceAbility(spellId); + expect(useCombatStore.getState().castAbility(stance.id, { now: 1_000 }).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG))).toBe(true); + + const result = useCombatStore.getState().castAbility(ability.id, { + ...(ability.target === "hostile" ? { targetActorId: TARGET_ID } : {}), + now: 2_600, + }); + expect(result.ok).toBe(true); + }); + + it("rejects a player cast after the timed stance aura expires", () => { + registerTarget(); + const stance = sourceAbility(STANCE_SPELL_ID); + const ability = sourceAbility(491362); + expect(useCombatStore.getState().castAbility(stance.id, { now: 1_000 }).ok).toBe(true); + const stanceAura = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG)); + expect(stanceAura?.expiresAt).not.toBeNull(); + const afterExpiry = (stanceAura?.expiresAt ?? 1_000) + 1; + + expect(useCombatStore.getState().castAbility(ability.id, { + targetActorId: TARGET_ID, + now: afterExpiry, + })).toMatchObject({ ok: false, reason: "condition-not-met" }); + }); + + it.each(STANCE_GATED_SPELL_IDS)("gates companion eligibility for %i by the active timed stance", (spellId) => { + registerTarget(); + const ability = sourceAbility(spellId); + const member = companionFor(ability.id); + const targetId = ability.target === "hostile" ? TARGET_ID : member.id; + + expect(partyAbilityIsReady(member, ability, 1_000)).toBe(false); + expect(partyAbilityTargetIsValid(member, ability, targetId, undefined, 1_000)).toBe(false); + expect(activeStanceFor(member.id, 500)).toBe(true); + expect(partyAbilityIsReady(member, ability, 1_000)).toBe(true); + expect(partyAbilityTargetIsValid(member, ability, targetId, undefined, 1_000)).toBe(true); + + const stanceAura = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id + && aura.definition.utilityTags?.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG)); + expect(stanceAura?.expiresAt).not.toBeNull(); + expect(partyAbilityIsReady(member, ability, (stanceAura?.expiresAt ?? 1_000) + 1)).toBe(false); + expect(partyAbilityTargetIsValid(member, ability, targetId, undefined, (stanceAura?.expiresAt ?? 1_000) + 1)).toBe(false); + }); + + it("does not apply the runtime stance gate to Priest/Knight", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-priest", + secondaryClassId: "rom-knight", + raceId: "rom-human", + level: 80, + }); + registerTarget(); + const warriorAbility = sourceAbility(491362); + + expect(useCombatStore.getState().castAbility(warriorAbility.id, { + targetActorId: TARGET_ID, + now: 1_000, + })).toMatchObject({ ok: false, reason: "wrong-class" }); + }); +}); diff --git a/src/game/romRogueChampionNerveGear.integration.test.ts b/src/game/romRogueChampionNerveGear.integration.test.ts new file mode 100644 index 00000000..d38c3341 --- /dev/null +++ b/src/game/romRogueChampionNerveGear.integration.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { abilitiesForCharacter, abilityById } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { + advanceDungeonPartyCombat, + resetPartyRuntimeClock, +} from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { + ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID, + ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG, +} from "./romRogueChampionNerveGearRuntime"; + +const ELITE_ABILITY_ID = "rom-rogue-rom-champion-elite-30"; + +function nerveGearAura(actorId: string) { + return useCombatStore.getState().auras.find((aura) => ( + aura.sourceId === actorId + && aura.targetId === actorId + && aura.definition.id === ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID + )); +} + +describe("RoM Rogue/Champion Nerve Gear cast integration", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "mage", level: 50, xp: 0 }); + usePartyStore.getState().initializeParty("rom-rogue-champion-nerve-gear-tests", 50, + useCombatStore.getState().health); + }); + + it("grants one stack to a learned player Rogue/Champion after one elite cast", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-champion", level: 50, xp: 0, + }); + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, energy: 100, rage: 100 }, + maxResourcePools: { ...state.maxResourcePools, energy: 100, rage: 100 }, + })); + const ability = abilityById(ELITE_ABILITY_ID)!; + const marker = abilityById("rom-rogue-rom-champion-elite-50")!.effects.find((effect) => ( + effect.kind === "apply-aura" && effect.aura.utilityTags?.includes(ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG) + )); + expect(marker).toBeDefined(); + expect(nerveGearAura(PLAYER_AGGRO_ID)).toBeUndefined(); + + const result = useCombatStore.getState().castAbility(ability.id, undefined, 1_000); + + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + expect(nerveGearAura(PLAYER_AGGRO_ID)).toMatchObject({ + appliedAt: 1_000, + expiresAt: 6_000, + stacks: 1, + definition: { id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID }, + }); + }); + + it("grants one stack to the party Rogue/Champion at cast scope for an area skill", () => { + const seed = "rom-rogue-champion-nerve-gear-party"; + const member = createConfiguredPartyMember({ + name: "Rogue companion", + classId: "rom-rogue", + secondaryClassId: "rom-champion", + specialization: "Primary", + role: "damage", + }, 50, 0, seed); + const selectedAbility = abilityById(ELITE_ABILITY_ID)!; + const lockedCooldowns = Object.fromEntries(abilitiesForCharacter("rom-rogue", "rom-champion") + .filter((ability) => ability.id !== selectedAbility.id) + .map((ability) => [ability.id, 50_000])); + usePartyStore.getState().initializeParty(seed, 50, useCombatStore.getState().health, "damage", 1, "rom"); + usePartyStore.setState({ members: [{ + ...member, + nextActionAt: 100, + status: "attacking", + resource: 100, + resourcePools: { ...member.resourcePools, energy: 100, rage: 100 }, + cooldowns: lockedCooldowns, + }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + const primaryTargetId = "nerve-gear-party-target-a"; + const secondaryTargetId = "nerve-gear-party-target-b"; + for (const targetId of [primaryTargetId, secondaryTargetId]) { + useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 10_000, xpReward: 0 }); + setMobPosition(targetId, [2, 0, 0]); + useCombatStore.getState().damageMob(targetId, 1, 100); + updatePartyRuntimeTargetVisibility(member.id, targetId, true); + } + useCombatStore.getState().selectMob(primaryTargetId); + usePartyStore.setState((party) => ({ + command: "attack", + activeMobId: primaryTargetId, + members: party.members.map((entry) => ( + entry.id === member.id ? { ...entry, globalCooldownEndsAt: 0, nextActionAt: 0 } : entry + )), + })); + + advanceDungeonPartyCombat(1_000); + expect(nerveGearAura(member.id)).toMatchObject({ + appliedAt: 1_000, + expiresAt: 6_000, + stacks: 1, + definition: { id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID }, + }); + }); +}); diff --git a/src/game/romRogueChampionNerveGearRuntime.test.ts b/src/game/romRogueChampionNerveGearRuntime.test.ts new file mode 100644 index 00000000..71748765 --- /dev/null +++ b/src/game/romRogueChampionNerveGearRuntime.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { abilityById } from "./abilityCatalog"; +import { applyAura as applyCombatAura, type ActiveAura } from "./combatAuras"; +import { + ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID, + ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS, + ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG, + ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS, + romRogueChampionNerveGearEffect, + romRogueChampionNerveGearShouldTrigger, +} from "./romRogueChampionNerveGearRuntime"; + +describe("RoM Rogue/Champion Nerve Gear", () => { + const passive = abilityById("rom-rogue-rom-champion-elite-50")!; + const elite = abilityById("rom-rogue-rom-champion-elite-25")!; + const primaryAbility = abilityById("rom-rogue-primary-1")!; + + it("requires the learned Rogue/Champion passive and a non-passive RuneWaker elite skill", () => { + expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-champion", elite, true)).toBe(true); + expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-champion", elite, false)).toBe(false); + expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-warrior", elite, true)).toBe(false); + expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-champion", primaryAbility, true)).toBe(false); + expect(romRogueChampionNerveGearShouldTrigger("rom-rogue", "rom-champion", passive, true)).toBe(false); + expect(romRogueChampionNerveGearShouldTrigger("rom-champion", "rom-rogue", elite, true)).toBe(false); + }); + + it("keeps the learned passive as a marker instead of an unconditional stat aura", () => { + const marker = passive.effects.find((effect) => effect.kind === "apply-aura"); + expect(marker?.kind).toBe("apply-aura"); + if (marker?.kind !== "apply-aura") throw new Error("Missing Nerve Gear passive marker."); + expect(marker.aura.utilityTags).toContain(ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG); + expect(marker.aura.durationMs).toBeNull(); + expect(marker.aura.modifiers).toBeUndefined(); + }); + + it("uses the local 5-second, five-stack attack-speed and dodge payload", () => { + const effect = romRogueChampionNerveGearEffect(); + expect(effect).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + aura: { + id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID, + durationMs: ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS, + maxStacks: ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS, + stackBehavior: "add", + modifiers: [ + { kind: "stat", stat: "attack-speed", operation: "flat", value: 5 }, + { kind: "stat", stat: "dodge", operation: "flat", value: 2 }, + ], + }, + }); + }); + + it("adds up to five stacks and refreshes the five-second duration", () => { + const effect = romRogueChampionNerveGearEffect(); + if (effect.kind !== "apply-aura") throw new Error("Missing Nerve Gear aura."); + let auras: readonly ActiveAura[] = []; + for (let stack = 1; stack <= ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS + 2; stack += 1) { + auras = applyCombatAura(auras, { + definition: effect.aura, + sourceId: "rogue-actor", + targetId: "rogue-actor", + now: stack * 1_000, + }).auras; + } + expect(auras).toHaveLength(1); + expect(auras[0]).toMatchObject({ + stacks: ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS, + appliedAt: 7_000, + expiresAt: 7_000 + ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS, + definition: { id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID }, + }); + }); +}); diff --git a/src/game/romRogueChampionNerveGearRuntime.ts b/src/game/romRogueChampionNerveGearRuntime.ts new file mode 100644 index 00000000..4c2f947a --- /dev/null +++ b/src/game/romRogueChampionNerveGearRuntime.ts @@ -0,0 +1,47 @@ +import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; + +export const ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID = 498839; +export const ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG = "rom-rogue-champion-nerve-gear"; +export const ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID = "rom-rogue-champion-nerve-gear-active"; +export const ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS = 5_000; +export const ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS = 5; + +export function romRogueChampionNerveGearShouldTrigger( + classId: string, + secondaryClassId: string | null | undefined, + ability: Pick, + passiveLearned: boolean, +): boolean { + return classId === "rom-rogue" + && secondaryClassId === "rom-champion" + && passiveLearned + && ability.classId === "rom-rogue" + && ability.secondaryClassId === "rom-champion" + && ability.source === "runewaker" + && ability.romSkillGroup === "elite" + && ability.passive !== true; +} + +/** RuneWaker child 621652: +5 attack speed and +2 dodge for 5s, stacking to five. */ +export function romRogueChampionNerveGearEffect(): Extract { + return { + kind: "apply-aura", + recipient: "caster", + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID, + aura: { + id: ROM_ROGUE_CHAMPION_NERVE_GEAR_AURA_ID, + name: "Nerve Gear", + disposition: "buff", + durationMs: ROM_ROGUE_CHAMPION_NERVE_GEAR_DURATION_MS, + maxStacks: ROM_ROGUE_CHAMPION_NERVE_GEAR_MAX_STACKS, + stackBehavior: "add", + modifiers: [ + { kind: "stat", stat: "attack-speed", operation: "flat", value: 5 }, + { kind: "stat", stat: "dodge", operation: "flat", value: 2 }, + ], + }, + }; +} diff --git a/src/game/romRogueMechanics.test.ts b/src/game/romRogueMechanics.test.ts index 9904b08c..b6b6a33b 100644 --- a/src/game/romRogueMechanics.test.ts +++ b/src/game/romRogueMechanics.test.ts @@ -7,16 +7,75 @@ import { abilityRequiresOutOfCombat } from "./abilityRules"; import { romEffectsForCast } from "./romCombatMechanics"; import { PLAYER_AGGRO_ID } from "./aggro"; import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { resolvedEquipmentLoadout } from "./heirloomEquipment"; import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment"; +import { combatAttackTable } from "./combatResults"; +import { usePartyStore } from "./partyStore"; import { ROM_ROGUE_DAGGER_MASTERY_TAG, + ROM_ROGUE_INSTANT_SHADOW_READY_TAG, ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID, ROM_ROGUE_TREASURE_HUNTER_TAG, romRogueDaggerDamageMultiplier, + romRogueEnchantedThrowShouldProc, romRogueShadowWalkerAdjustment, romRogueTreasureHunterLootChance, } from "./romRogueMechanics"; +import { + ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG, + ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY, + ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG, + romRogueMageDayOfRainBaseMissPercent, + romScoutRogueProcActionSpec, +} from "./romScoutRogueOverrides"; + +const SINISTER_METHODS_BUFF_ID = "rom-pair-sinister-methods-critical"; + +function prepareSinisterMethodsTarget( + abilityId: "rom-rogue-primary-1" | "rom-rogue-primary-4" | "rom-rogue-primary-2", + mobLevel = 80, + criticalStrikeRating = 0, +) { + const behindTarget = abilityId === "rom-rogue-primary-4"; + const dagger = behindTarget ? testWeapon("dagger", "rogue-sinister-methods-dagger") : null; + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", + secondaryClassId: "rom-priest", + raceId: "rom-human", + level: 80, + ...(dagger ? { inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } } } : {}), + }); + useCombatStore.setState((state) => ({ + playerPosition: behindTarget ? [0, 0, -2] : [0, 0, 0], + gearStats: { + ...state.gearStats, + hitRating: mobLevel >= 200 ? 0 : 1_000_000, + criticalStrikeRating, + }, + resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 }, + })); + const targetId = "rogue-sinister-methods-target"; + useCombatStore.getState().registerMob(targetId, { + name: "Sinister Methods target", + maxHealth: 100_000, + level: mobLevel, + xpReward: 0, + }); + setMobPosition(targetId, behindTarget ? [0, 0, 0] : [0, 0, 2]); + useCombatStore.getState().selectMob(targetId); + return targetId; +} + +function sinisterMethodsBuff() { + return useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AGGRO_ID && aura.definition.id === SINISTER_METHODS_BUFF_ID + )); +} + +function castRogueAbilityAtTarget(abilityId: string, targetActorId: string, now: number) { + return useCombatStore.getState().castAbility(abilityId, { targetActorId, now }); +} function effectAuras(abilityId: string) { return abilityById(abilityId)!.effects.filter((effect) => effect.kind === "apply-aura"); @@ -71,6 +130,160 @@ describe("RoM Rogue source mechanics", () => { expect(abilityById("rom-rogue-primary-12")!.effects).toEqual([]); }); + it("learns Sinister Methods as a proc marker without an always-on critical aura", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80, + }); + const passive = abilityById("rom-rogue-rom-priest-elite-45")!; + const [marker] = effectAuras(passive.id); + expect(marker?.kind === "apply-aura" && marker.aura.utilityTags) + .toContain(ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG); + expect(marker?.kind === "apply-aura" && marker.aura.procs?.[0]?.triggers).toEqual(["hit", "crit"]); + expect(marker?.kind === "apply-aura" && marker.aura.modifiers).toBeUndefined(); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === SINISTER_METHODS_BUFF_ID)).toBe(false); + + useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes(ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG) + ))).toBe(false); + }); + + it.each([ + ["Low Blow", "rom-rogue-primary-1"], + ["Blind Spot", "rom-rogue-primary-4"], + ] as const)("procs Sinister Methods after a landed %s", (_name, abilityId) => { + const targetId = prepareSinisterMethodsTarget(abilityId); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === SINISTER_METHODS_BUFF_ID)).toBe(false); + const castResult = castRogueAbilityAtTarget(abilityId, targetId, 1_000); + expect(castResult, JSON.stringify(castResult)).toMatchObject({ ok: true }); + const hit = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === abilityId + )); + expect(hit?.outcome).not.toBe("miss"); + expect(sinisterMethodsBuff()).toMatchObject({ + appliedAt: 1_000, + expiresAt: 11_000, + definition: { + modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }], + }, + }); + expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(21_000); + }); + + it("also procs Sinister Methods on a landed critical hit", () => { + const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1", 80, 1_000_000); + const castResult = castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000); + expect(castResult, JSON.stringify(castResult)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === "rom-rogue-primary-1" + ))?.outcome).toBe("crit"); + expect(sinisterMethodsBuff()?.appliedAt).toBe(1_000); + }); + + it("does not proc or start its cooldown on a miss or another ability", () => { + const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1", 200); + expect(castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000).ok).toBe(true); + expect(useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === "rom-rogue-primary-1" + ))?.outcome).toBe("miss"); + expect(sinisterMethodsBuff()).toBeUndefined(); + expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBeUndefined(); + + const otherTargetId = prepareSinisterMethodsTarget("rom-rogue-primary-2"); + expect(castRogueAbilityAtTarget("rom-rogue-primary-2", otherTargetId, 3_000).ok).toBe(true); + expect(sinisterMethodsBuff()).toBeUndefined(); + expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBeUndefined(); + + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, hitRating: 1_000_000 }, + resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 }, + })); + expect(castRogueAbilityAtTarget("rom-rogue-primary-1", otherTargetId, 5_000).ok).toBe(true); + expect(sinisterMethodsBuff()?.appliedAt).toBe(5_000); + }); + + it("honors Sinister Methods duration and 20-second cooldown expiry", () => { + const targetId = prepareSinisterMethodsTarget("rom-rogue-primary-1"); + expect(castRogueAbilityAtTarget("rom-rogue-primary-1", targetId, 1_000).ok).toBe(true); + expect(sinisterMethodsBuff()?.expiresAt).toBe(11_000); + + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 }, + })); + expect(castRogueAbilityAtTarget("rom-rogue-primary-4", targetId, 12_000).ok).toBe(true); + expect(sinisterMethodsBuff()).toMatchObject({ appliedAt: 1_000, expiresAt: 11_000 }); + expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(21_000); + + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy ?? 100 }, + })); + expect(castRogueAbilityAtTarget("rom-rogue-primary-4", targetId, 21_000).ok).toBe(true); + expect(sinisterMethodsBuff()).toMatchObject({ appliedAt: 21_000, expiresAt: 31_000 }); + expect(useCombatStore.getState().cooldowns[ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY]).toBe(41_000); + }); + + it("arms Instant Shadow on a successful Rogue/Mage dodge and consumes it for its three-second concealment", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80, + }); + const ability = abilityById("rom-rogue-rom-mage-elite-60")!; + useCombatStore.getState().resetSettings(); + usePartyStore.getState().initializeParty("rom-instant-shadow-tests", 1, useCombatStore.getState().health); + resetCombatSpatialProvider(); + const beforeDodge = useCombatStore.getState().castAbility(ability.id, undefined, 1_000); + expect(beforeDodge).toMatchObject({ ok: false, reason: "condition-not-met" }); + + useCombatStore.getState().registerMob("rogue-instant-shadow-attacker", { + name: "Instant Shadow attacker", maxHealth: 100_000, xpReward: 0, attacks: [DEFAULT_ENEMY_ATTACK], + }); + setMobPosition("rogue-instant-shadow-attacker", [0, 0, 1]); + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, dodgeRating: 1_000_000 }, + resource: state.maxResource, + resourcePools: { + ...state.resourcePools, + energy: state.maxResourcePools.energy, + mana: state.maxResourcePools.mana, + }, + })); + useCombatStore.getState().damageMob("rogue-instant-shadow-attacker", 1, 1_100); + + let dodgedAt: number | null = null; + for (let now = 1_450; now < 20_000; now += 100) { + useCombatStore.getState().advanceMobCombat(now); + if (useCombatStore.getState().lastCombatResult?.outcome === "dodge") { + dodgedAt = now; + break; + } + } + expect(dodgedAt, JSON.stringify({ health: useCombatStore.getState().health, + lastCombatResult: useCombatStore.getState().lastCombatResult, + mob: useCombatStore.getState().mobs["rogue-instant-shadow-attacker"] })).not.toBeNull(); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG) + ))).toBe(true); + + const beforeCast = useCombatStore.getState(); + const manaCost = Math.round((beforeCast.maxResourcePools.mana ?? 0) * 0.06); + const castAt = dodgedAt! + 100; + expect(useCombatStore.getState().castAbility(ability.id, undefined, castAt)) + .toMatchObject({ ok: true, abilityId: ability.id }); + const afterCast = useCombatStore.getState(); + expect(afterCast.resourcePools.energy).toBe((beforeCast.resourcePools.energy ?? 0) - 15); + expect(afterCast.resourcePools.mana).toBe((beforeCast.resourcePools.mana ?? 0) - manaCost); + expect(afterCast.cooldowns[ability.id]).toBe(castAt + ability.cooldownMs); + expect(afterCast.auras.some((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes(ROM_ROGUE_INSTANT_SHADOW_READY_TAG) + ))).toBe(false); + expect(afterCast.auras.some((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes("stealth") + && aura.expiresAt === castAt + 3_000 + ))).toBe(true); + }); + it("applies Dagger Mastery only to the Rogue player's physical melee hits with a main-hand dagger", () => { const context = { classId: "rom-rogue", @@ -88,6 +301,56 @@ describe("RoM Rogue source mechanics", () => { expect(romRogueDaggerDamageMultiplier({ ...context, passiveTags: [] })).toBe(1); }); + it("adds the referenced Throw payload to landed Rogue/Mage normal attacks while Enchanted Throw is active", () => { + const simulateAutoAttacks = (enchanted: boolean) => { + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30, + }); + useCombatStore.getState().registerMob("enchanted-throw-target", { name: "Target", maxHealth: 1_000_000 }); + setMobPosition("enchanted-throw-target", [0, 0, 3]); + if (enchanted) { + expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-15", undefined, 1_000).ok).toBe(true); + } + useCombatStore.setState({ autoAttackTargetId: "enchanted-throw-target", nextAutoAttackAt: 1_500 }); + for (let now = 1_500; now <= 20_000; now += 250) { + useCombatStore.getState().tick(0.25, now); + if (useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === "rom-rogue-general-1" + ))) break; + } + return useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.targetActorId === "enchanted-throw-target" + )); + }; + + const enchantedHits = simulateAutoAttacks(true); + expect(enchantedHits.some((event) => event.abilityId === "rom-rogue-general-1" + && event.school === "physical" && event.effectiveAmount > 0)).toBe(true); + + const ordinaryHits = simulateAutoAttacks(false); + expect(ordinaryHits.some((event) => event.abilityId === undefined)).toBe(true); + expect(ordinaryHits.some((event) => event.abilityId === "rom-rogue-general-1")).toBe(false); + }); + + it("only qualifies landed Rogue/Mage player normal attacks for Enchanted Throw", () => { + const context = { + classId: "rom-rogue", + secondaryClassId: "rom-mage", + sourceActorId: "player", + playerActorId: "player", + attackKind: "melee", + isAutoAttack: true, + attackLanded: true, + enchantedThrowActive: true, + }; + expect(romRogueEnchantedThrowShouldProc(context)).toBe(true); + expect(romRogueEnchantedThrowShouldProc({ ...context, attackLanded: false })).toBe(false); + expect(romRogueEnchantedThrowShouldProc({ ...context, enchantedThrowActive: false })).toBe(false); + expect(romRogueEnchantedThrowShouldProc({ ...context, isAutoAttack: false })).toBe(false); + expect(romRogueEnchantedThrowShouldProc({ ...context, secondaryClassId: "rom-warrior" })).toBe(false); + }); + 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); @@ -114,6 +377,87 @@ describe("RoM Rogue source mechanics", () => { )).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 }); }); + it("gives Rogue/Mage Throw its source-backed one-second Day of Rain cooldown reduction", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30, + }); + useCombatStore.getState().registerMob("rogue-day-of-rain-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("rogue-day-of-rain-target", [0, 0, 1]); + useCombatStore.getState().selectMob("rogue-day-of-rain-target"); + + expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-30", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().cooldowns["rom-rogue-rom-mage-elite-30"]).toBe(26_000); + expect(useCombatStore.getState().castAbility("rom-rogue-general-1", undefined, 2_600).ok).toBe(true); + expect(useCombatStore.getState().cooldowns["rom-rogue-rom-mage-elite-30"]).toBe(25_000); + }); + + it("applies Day of Rain's exact 50% miss rate only while Enchanted Throw is active", () => { + const dayOfRain = abilityById("rom-rogue-rom-mage-elite-30")!; + const beforeBuff = romRogueMageDayOfRainBaseMissPercent( + "rom-rogue", "rom-mage", dayOfRain.dbcSpellId, [], 6, + ); + const withBuff = romRogueMageDayOfRainBaseMissPercent( + "rom-rogue", "rom-mage", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG], 6, + ); + expect(beforeBuff).toBeNull(); + expect(withBuff).toBe(56); + expect(combatAttackTable({ + amount: 100, + attackKind: "ranged", + damageSchool: "physical", + attacker: { level: 80, hitPercent: 6, criticalChancePercent: 0 }, + defender: { level: 80, dodgePercent: 0, parryPercent: 0, blockPercent: 0, criticalAvoidancePercent: 0 }, + rules: { baseMissChancePercent: withBuff! }, + }).missPercent).toBe(50); + expect(romRogueMageDayOfRainBaseMissPercent( + "rom-rogue", "rom-warrior", dayOfRain.dbcSpellId, [ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG], 6, + )).toBeNull(); + }); + + it("uses the Enchanted Throw hit penalty in Day of Rain's resolved combat result", () => { + const outcomeFor = (now: number, enchantedThrow: boolean): string | undefined => { + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 30, + }); + useCombatStore.getState().registerMob("rogue-day-rain-roll-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("rogue-day-rain-roll-target", [0, 0, 1]); + useCombatStore.getState().selectMob("rogue-day-rain-roll-target"); + if (enchantedThrow) { + expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-15", undefined, 1_000).ok).toBe(true); + } + expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-30", undefined, now).ok).toBe(true); + return useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === "rom-rogue-rom-mage-elite-30" + ))?.outcome; + }; + + let foundEnchantedOnlyMiss = false; + for (let now = 3_000; now < 3_100; now += 1) { + const ordinaryOutcome = outcomeFor(now, false); + const enchantedOutcome = outcomeFor(now, true); + if (ordinaryOutcome !== "miss" && ordinaryOutcome !== undefined && enchantedOutcome === "miss") { + foundEnchantedOnlyMiss = true; + break; + } + } + expect(foundEnchantedOnlyMiss).toBe(true); + }); + + it("marks the active Rogue/Mage Enchanted Throw buff for its Day of Rain hit-rate rider", () => { + const enchantedThrow = abilityById("rom-rogue-rom-mage-elite-15")!; + const enchantedAuras = enchantedThrow.effects.filter((effect) => effect.kind === "apply-aura"); + expect(enchantedAuras.length).toBeGreaterThan(0); + expect(enchantedAuras.every((effect) => !effect.aura.procs?.length)).toBe(true); + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", level: 15, + }); + expect(useCombatStore.getState().castAbility("rom-rogue-rom-mage-elite-15", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes(ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG) + ))).toBe(true); + }); + it("adds one bonus boss loot roll only while Treasure Hunter is active", () => { const bossKey = LOOT_CATALOG.bosses[0]!.bossKey; const lootCount = (passiveActive: boolean) => { diff --git a/src/game/romRogueMechanics.ts b/src/game/romRogueMechanics.ts index 3733ea31..ffebbc0d 100644 --- a/src/game/romRogueMechanics.ts +++ b/src/game/romRogueMechanics.ts @@ -1,6 +1,29 @@ 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 const ROM_ROGUE_INSTANT_SHADOW_READY_TAG = "rom-rogue-instant-shadow-ready"; + +export interface RomRogueEnchantedThrowContext { + readonly classId: string; + readonly secondaryClassId: string | null | undefined; + readonly sourceActorId: string; + readonly playerActorId: string; + readonly attackKind: string; + readonly isAutoAttack: boolean; + readonly attackLanded: boolean; + readonly enchantedThrowActive: boolean; +} + +/** Enchanted Throw only follows a landed Rogue/Mage normal attack while its buff is active. */ +export function romRogueEnchantedThrowShouldProc(context: RomRogueEnchantedThrowContext): boolean { + return context.classId === "rom-rogue" + && context.secondaryClassId === "rom-mage" + && context.sourceActorId === context.playerActorId + && context.attackKind === "melee" + && context.isAutoAttack + && context.attackLanded + && context.enchantedThrowActive; +} export interface RomRogueAbilityAdjustment { readonly allowsCombatUse: boolean; diff --git a/src/game/romRogueNumbingDagger.integration.test.ts b/src/game/romRogueNumbingDagger.integration.test.ts new file mode 100644 index 00000000..b98bdff7 --- /dev/null +++ b/src/game/romRogueNumbingDagger.integration.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCombatStore } from "./combatStore"; +import { abilityById } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + advanceDungeonPartyCombat, + resetPartyRuntimeClock, +} from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; + +const PLAYER_DEFENDER = { + level: 50, + armor: 0, + dodgePercent: 0, + parryPercent: 0, + blockPercent: 0, + blockValue: 0, +} as const; + +describe("RoM Rogue Numbing Dagger status durations", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ + classId: "rom-rogue", + secondaryClassId: "rom-warrior", + raceId: "rom-human", + level: 50, + }); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + function registerLivingTarget(id: string, characterDefender = false) { + useCombatStore.getState().registerMob(id, { + name: id, + serverEntry: 103946, + maxHealth: 1_000_000, + xpReward: 0, + }); + if (characterDefender) { + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + [id]: { ...state.mobs[id]!, characterDefender: PLAYER_DEFENDER }, + }, + })); + } + setMobPosition(id, [3, 0, 0]); + } + + function applyPlayerPoisonousAndSleep(targetId: string, completionAt: number): void { + expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000)) + .toMatchObject({ ok: true }); + useCombatStore.getState().tick(0.1, 2_100); + useCombatStore.getState().selectMob(targetId); + expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600)) + .toMatchObject({ ok: true }); + useCombatStore.getState().tick(1.1, completionAt); + } + + it("keeps the local ten-second sleep for player Rogue casts on NPCs", () => { + registerLivingTarget("numbing-npc"); + applyPlayerPoisonousAndSleep("numbing-npc", 3_700); + + expect(useCombatStore.getState().mobs["numbing-npc"]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "sleep", sourceAbilityId: "rom-rogue-primary-5", endsAt: 13_700 }), + ])); + }); + + it("uses the local three-second sleep for player Rogue casts on character defenders", () => { + registerLivingTarget("numbing-player", true); + applyPlayerPoisonousAndSleep("numbing-player", 3_700); + + expect(useCombatStore.getState().mobs["numbing-player"]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "sleep", sourceAbilityId: "rom-rogue-primary-5", endsAt: 6_700 }), + ])); + }); + + it("uses the same target-specific duration for a party Rogue cast", () => { + const member = createConfiguredPartyMember({ + name: "Rogue companion", + classId: "rom-rogue", + secondaryClassId: "rom-warrior", + specialization: "Primary", + role: "damage", + }, 50, 0, "numbing-dagger-party"); + const numbingDagger = abilityById("rom-rogue-primary-5"); + const poisonous = abilityById("rom-rogue-primary-6"); + const poisonousEffect = poisonous?.effects.find((effect) => effect.kind === "apply-aura"); + if (!numbingDagger || !poisonous || poisonousEffect?.kind !== "apply-aura") { + throw new Error("Missing RuneWaker Rogue Numbing Dagger or Poisonous source effect."); + } + + usePartyStore.getState().initializeParty( + "numbing-dagger-party", 50, useCombatStore.getState().health, "damage", 2, "rom", + ); + const targetId = "numbing-party-player"; + usePartyStore.setState({ members: [{ + ...member, + activeCast: { + abilityId: numbingDagger.id, + school: "physical", + mode: "cast", + startedAt: 1_000, + completesAt: 2_000, + originalDurationMs: 1_000, + pushbackHits: 0, + origin: [0, 0, 0], + targetId, + }, + activeCastTargetId: targetId, + status: "attacking", + }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + expect(useCombatStore.getState().applyActorAura( + member.id, member.id, poisonous, poisonousEffect, 1_000, + )).toBe(true); + + registerLivingTarget(targetId, true); + useCombatStore.getState().damageMob(targetId, 1, 1_000); + updatePartyRuntimeTargetVisibility(member.id, targetId, true); + + advanceDungeonPartyCombat(2_000); + + expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "sleep", + sourceAbilityId: numbingDagger.id, + sourceActorId: member.id, + endsAt: 5_000, + }), + ])); + }); + + it.each([ + ["low-level NPC status branch", "numbing-control-npc", false, 15_000], + ["low-level player status branch", "numbing-control-player", true, 8_000], + ] as const)("keeps Numbing Dagger target durations in the %s", (_label, targetId, characterDefender, endsAt) => { + registerLivingTarget(targetId, characterDefender); + + expect(useCombatStore.getState().controlMob( + targetId, + "rom-rogue-primary-5", + "sleep", + 9_000, + 5_000, + true, + )).toBe(true); + + expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "sleep", sourceAbilityId: "rom-rogue-primary-5", endsAt }), + ])); + }); +}); diff --git a/src/game/romRogueNumbingDaggerRuntime.test.ts b/src/game/romRogueNumbingDaggerRuntime.test.ts new file mode 100644 index 00000000..2860883f --- /dev/null +++ b/src/game/romRogueNumbingDaggerRuntime.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import generated from "./romPlayerCatalog.generated.json"; +import { + ROM_ROGUE_NUMBING_DAGGER_CREATURE_COMPONENT_ID, + ROM_ROGUE_NUMBING_DAGGER_PLAYER_COMPONENT_ID, + ROM_ROGUE_NUMBING_DAGGER_SKILL_ID, + romRogueNumbingDaggerStatusForTarget, +} from "./romRogueNumbingDaggerRuntime"; + +describe("RoM Rogue Numbing Dagger duration", () => { + it("keeps RuneWaker's separate creature and player control durations", () => { + const skill = generated.skills.find((row) => row.sourceSkillId === ROM_ROGUE_NUMBING_DAGGER_SKILL_ID + && row.classId === "rom-rogue" && row.secondaryClassId === undefined); + expect(skill?.source?.components).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: ROM_ROGUE_NUMBING_DAGGER_CREATURE_COMPONENT_ID, effectTime: 10 }), + expect.objectContaining({ id: ROM_ROGUE_NUMBING_DAGGER_PLAYER_COMPONENT_ID, effectTime: 3 }), + ])); + expect(romRogueNumbingDaggerStatusForTarget("npc")).toEqual({ + kind: "status", + status: "sleep", + durationMs: 10_000, + maxTargets: 1, + breakOnDamage: true, + }); + expect(romRogueNumbingDaggerStatusForTarget("player")).toEqual({ + kind: "status", + status: "sleep", + durationMs: 3_000, + maxTargets: 1, + breakOnDamage: true, + }); + }); +}); diff --git a/src/game/romRogueNumbingDaggerRuntime.ts b/src/game/romRogueNumbingDaggerRuntime.ts new file mode 100644 index 00000000..de9ac487 --- /dev/null +++ b/src/game/romRogueNumbingDaggerRuntime.ts @@ -0,0 +1,16 @@ +export const ROM_ROGUE_NUMBING_DAGGER_SKILL_ID = 490347; +export const ROM_ROGUE_NUMBING_DAGGER_CREATURE_COMPONENT_ID = 500737; +export const ROM_ROGUE_NUMBING_DAGGER_PLAYER_COMPONENT_ID = 503046; + +export type RomRogueNumbingDaggerTargetKind = "npc" | "player"; + +/** RuneWaker gives Numbing Dagger separate sleep payloads for NPCs and players. */ +export function romRogueNumbingDaggerStatusForTarget(targetKind: RomRogueNumbingDaggerTargetKind) { + return { + kind: "status" as const, + status: "sleep" as const, + durationMs: targetKind === "player" ? 3_000 : 10_000, + maxTargets: 1, + breakOnDamage: true, + }; +} diff --git a/src/game/romRoguePoison.integration.test.ts b/src/game/romRoguePoison.integration.test.ts new file mode 100644 index 00000000..e128d8e4 --- /dev/null +++ b/src/game/romRoguePoison.integration.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { abilityById } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { + advanceDungeonPartyCombat, + resetPartyRuntimeClock, +} from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { + ROM_ROGUE_POISON_AURA_TAG, + ROM_ROGUE_POISON_TRIGGER_SPELL_ID, +} from "./romRoguePoisonRuntime"; + +function stableUnit(seed: string): number { + let hash = 2166136261; + for (const character of seed) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function attackTime(seedForTime: (now: number) => string, shouldProc: boolean, startAt = 3_000): number { + for (let now = startAt; now < 100_000; now += 1) { + if ((stableUnit(seedForTime(now)) < 0.5) === shouldProc) return now; + } + throw new Error("Could not find a seeded Poison proc time."); +} + +function poisonedAuraFor(sourceId: string, targetId: string) { + return useCombatStore.getState().auras.find((aura) => ( + aura.sourceId === sourceId + && aura.targetId === targetId + && aura.definition.id === `rom-rogue-poisoned:${ROM_ROGUE_POISON_TRIGGER_SPELL_ID}` + )); +} + +describe("RoM Rogue Poison attack integration", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "mage", level: 50, xp: 0 }); + usePartyStore.getState().initializeParty("rom-rogue-poison-tests", 50, useCombatStore.getState().health); + }); + + function preparePlayerRogue(targetId: string): void { + useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", level: 50, xp: 0 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 1_000_000, xpReward: 0 }); + setMobPosition(targetId, [3, 0, 0]); + expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000).ok).toBe(true); + useCombatStore.getState().tick(0.1, 2_100); + } + + it.each([true, false])("player landed normal autos use the 50%% trigger roll (proc=%s)", (shouldProc) => { + const targetId = `poison-player-${shouldProc}`; + preparePlayerRogue(targetId); + const now = attackTime( + (attackAt) => `${PLAYER_AGGRO_ID}:auto:${targetId}:${attackAt}:rom-rogue-poison`, + shouldProc, + ); + useCombatStore.setState({ autoAttackTargetId: targetId, nextAutoAttackAt: now }); + + useCombatStore.getState().tick(0.1, now); + + expect(useCombatStore.getState().mobs[targetId]?.health).toBeLessThan(1_000_000); + expect(poisonedAuraFor(PLAYER_AGGRO_ID, targetId)?.stacks ?? 0).toBe(shouldProc ? 1 : 0); + }); + + it.each([true, false])("party landed normal autos use the 50%% trigger roll (proc=%s)", (shouldProc) => { + const targetId = `poison-party-${shouldProc}`; + const seed = `rom-rogue-poison-party-${shouldProc}`; + const member = createConfiguredPartyMember({ + name: "Rogue companion", + classId: "rom-rogue", + secondaryClassId: "rom-warrior", + specialization: "Primary", + role: "damage", + }, 50, 0, seed); + const poisonous = abilityById("rom-rogue-primary-6")!; + const poisonousEffect = poisonous.effects.find((effect) => effect.kind === "apply-aura"); + if (poisonousEffect?.kind !== "apply-aura") throw new Error("Missing RuneWaker Poisonous aura effect."); + + usePartyStore.getState().initializeParty(seed, 50, useCombatStore.getState().health, "damage", 1, "rom"); + usePartyStore.setState({ members: [{ ...member, nextActionAt: 100_000, status: "attacking" }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 1_000_000, xpReward: 0 }); + setMobPosition(targetId, [2, 0, 0]); + useCombatStore.getState().damageMob(targetId, 1, 100); + updatePartyRuntimeTargetVisibility(member.id, targetId, true); + expect(useCombatStore.getState().applyActorAura( + member.id, member.id, poisonous, poisonousEffect, 100, + )).toBe(true); + const now = attackTime( + (attackAt) => `${member.id}:${targetId}:party-auto-attack:${attackAt}:rom-rogue-poison`, + shouldProc, + ); + + advanceDungeonPartyCombat(now); + + expect(useCombatStore.getState().mobs[targetId]?.health).toBeLessThan(1_000_000); + expect(poisonedAuraFor(member.id, targetId)?.stacks ?? 0).toBe(shouldProc ? 1 : 0); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_AURA_TAG) + ))).toBe(true); + }); + + it("adds stacks independently for player and party Rogues on the same mob", () => { + const targetId = "poison-two-rogues"; + preparePlayerRogue(targetId); + const playerTime = attackTime( + (now) => `${PLAYER_AGGRO_ID}:auto:${targetId}:${now}:rom-rogue-poison`, + true, + ); + useCombatStore.setState({ autoAttackTargetId: targetId, nextAutoAttackAt: playerTime }); + useCombatStore.getState().tick(0.1, playerTime); + + const seed = "rom-rogue-poison-two-rogues"; + const member = createConfiguredPartyMember({ + name: "Rogue companion", + classId: "rom-rogue", + secondaryClassId: "rom-warrior", + specialization: "Primary", + role: "damage", + }, 50, 0, seed); + const poisonous = abilityById("rom-rogue-primary-6")!; + const poisonousEffect = poisonous.effects.find((effect) => effect.kind === "apply-aura"); + if (poisonousEffect?.kind !== "apply-aura") throw new Error("Missing RuneWaker Poisonous aura effect."); + usePartyStore.getState().initializeParty(seed, 50, useCombatStore.getState().health, "damage", 1, "rom"); + usePartyStore.setState({ members: [{ ...member, nextActionAt: 100_000, status: "attacking" }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + updatePartyRuntimeTargetVisibility(member.id, targetId, true); + expect(useCombatStore.getState().applyActorAura(member.id, member.id, poisonous, poisonousEffect, playerTime)) + .toBe(true); + const partyTime = attackTime( + (now) => `${member.id}:${targetId}:party-auto-attack:${now}:rom-rogue-poison`, + true, + playerTime + 1, + ); + + advanceDungeonPartyCombat(partyTime); + + expect(poisonedAuraFor(PLAYER_AGGRO_ID, targetId)?.stacks).toBe(1); + expect(poisonedAuraFor(member.id, targetId)?.stacks).toBe(1); + }); +}); diff --git a/src/game/romRoguePoisonRuntime.test.ts b/src/game/romRoguePoisonRuntime.test.ts new file mode 100644 index 00000000..e73b1ca6 --- /dev/null +++ b/src/game/romRoguePoisonRuntime.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { applyAura, resolveDamageValue, type AuraDefinition } from "./combatAuras"; +import { abilityById } from "./abilityCatalog"; +import generated from "./romPlayerCatalog.generated.json"; +import { + ROM_ROGUE_POISON_AURA_TAG, + ROM_ROGUE_POISON_COMPONENT_ID, + ROM_ROGUE_POISON_DEBUFF_DURATION_MS, + ROM_ROGUE_POISON_MAX_STACKS, + ROM_ROGUE_POISON_PROC_CHANCE, + ROM_ROGUE_POISON_SKILL_ID, + ROM_ROGUE_POISON_TRIGGER_SPELL_ID, + romRoguePoisonApplicationForAttack, +} from "./romRoguePoisonRuntime"; + +const baseContext = { + classId: "rom-rogue", + sourceActorId: "party-rogue-1", + targetActorId: "mob-1", + attackKind: "melee" as const, + isNormalAttack: true, + attackLanded: true, + poisonActive: true, + roll: 0.4999, +}; + +describe("RoM Rogue Poison", () => { + it("keeps the RuneWaker-triggered attack poison values and only borrows the missing duration", () => { + const sourceRow = generated.skills.find((row) => row.sourceSkillId === ROM_ROGUE_POISON_SKILL_ID + && row.classId === "rom-rogue"); + expect(sourceRow?.source?.components?.[0]).toMatchObject({ + id: ROM_ROGUE_POISON_COMPONENT_ID, + effectTime: 900, + onAttackTrigger: { rate: 50, rank: 0, id: ROM_ROGUE_POISON_TRIGGER_SPELL_ID }, + }); + const skill = abilityById("rom-rogue-primary-6"); + expect(skill?.dbcSpellId).toBe(ROM_ROGUE_POISON_SKILL_ID); + expect(skill?.description).toContain("normal attacks have a 50% chance"); + expect(skill?.description).toContain("damage is lowered by 2%"); + expect(skill?.description).toContain("stacked four times"); + const selfBuff = skill?.effects.find((effect) => effect.kind === "apply-aura"); + expect(selfBuff?.kind === "apply-aura" && selfBuff.aura.utilityTags).toContain(ROM_ROGUE_POISON_AURA_TAG); + expect(selfBuff?.kind === "apply-aura" && selfBuff.aura.durationMs).toBe(900_000); + expect(ROM_ROGUE_POISON_TRIGGER_SPELL_ID).toBe(490357); + expect(ROM_ROGUE_POISON_PROC_CHANCE).toBe(0.5); + expect(ROM_ROGUE_POISON_DEBUFF_DURATION_MS).toBe(900_000); + }); + + it("applies one two-percent source-owned poison stack on a qualifying normal hit", () => { + const application = romRoguePoisonApplicationForAttack(baseContext); + expect(application).toMatchObject({ + sourceId: "party-rogue-1", + targetId: "mob-1", + stacks: 1, + definition: { + durationMs: 900_000, + maxStacks: ROM_ROGUE_POISON_MAX_STACKS, + stackBehavior: "add", + uniqueBySource: true, + dispelCategory: "poison", + modifiers: [{ kind: "damage", direction: "dealt", operation: "percent", value: -0.02, perStack: true }], + }, + }); + }); + + it.each(["player", "party-rogue-1"])("supports the learned Rogue owner %s", (sourceActorId) => { + expect(romRoguePoisonApplicationForAttack({ ...baseContext, sourceActorId })?.sourceId).toBe(sourceActorId); + }); + + it("allows a landed ranged normal attack to use the same source trigger", () => { + expect(romRoguePoisonApplicationForAttack({ ...baseContext, attackKind: "ranged" })).not.toBeNull(); + }); + + it.each([ + ["miss", { attackLanded: false }], + ["spell", { attackKind: "spell" as const, isNormalAttack: false }], + ["ability hit", { isNormalAttack: false }], + ["inactive Poison", { poisonActive: false }], + ["another primary class", { classId: "rom-warrior" }], + ["threshold roll", { roll: ROM_ROGUE_POISON_PROC_CHANCE }], + ["invalid roll", { roll: Number.NaN }], + ])("does not apply for a %s", (_reason, overrides) => { + expect(romRoguePoisonApplicationForAttack({ ...baseContext, ...overrides })).toBeNull(); + }); + + it("caps this Rogue's target debuff at four stacks and keeps a second Rogue independent", () => { + const first = romRoguePoisonApplicationForAttack(baseContext)!; + let auras = [] as ReturnType["auras"]; + for (let index = 0; index < 6; index += 1) { + auras = applyAura(auras, { + definition: first.definition, + sourceId: first.sourceId, + targetId: first.targetId, + now: index * 1_000, + }).auras; + } + expect(auras.find((aura) => aura.sourceId === "party-rogue-1")?.stacks).toBe(4); + + const second = romRoguePoisonApplicationForAttack({ + ...baseContext, + sourceActorId: "party-rogue-2", + roll: 0.1, + })!; + auras = applyAura(auras, { + definition: second.definition, + sourceId: second.sourceId, + targetId: second.targetId, + now: 7_000, + }).auras; + expect(auras.filter((aura) => aura.targetId === "mob-1").map((aura) => [aura.sourceId, aura.stacks])) + .toEqual([["party-rogue-1", 4], ["party-rogue-2", 1]]); + const debuff = auras.find((aura) => aura.sourceId === "party-rogue-2")?.definition as AuraDefinition; + expect(debuff.modifiers?.[0]).toMatchObject({ value: -0.02, perStack: true }); + }); + + it("reduces target damage by two percent per stack for melee, ranged, and spell attacks", () => { + const application = romRoguePoisonApplicationForAttack(baseContext)!; + let auras = [] as ReturnType["auras"]; + for (let index = 0; index < ROM_ROGUE_POISON_MAX_STACKS; index += 1) { + auras = applyAura(auras, { + definition: application.definition, + sourceId: application.sourceId, + targetId: application.targetId, + now: index * 1_000, + }).auras; + } + + for (const context of [ + { attackKind: "melee", school: "physical" }, + { attackKind: "ranged", school: "physical" }, + { attackKind: "spell", school: "nature" }, + { attackKind: "spell", school: "shadow" }, + ] as const) { + expect(resolveDamageValue(100, auras, { direction: "dealt", ...context })).toBe(92); + } + }); +}); diff --git a/src/game/romRoguePoisonRuntime.ts b/src/game/romRoguePoisonRuntime.ts new file mode 100644 index 00000000..053c4ecd --- /dev/null +++ b/src/game/romRoguePoisonRuntime.ts @@ -0,0 +1,72 @@ +import type { AuraDefinition, AttackKind } from "./combatAuras"; + +export const ROM_ROGUE_POISON_SKILL_ID = 490329; +export const ROM_ROGUE_POISON_COMPONENT_ID = 500715; +export const ROM_ROGUE_POISON_TRIGGER_SPELL_ID = 490357; +export const ROM_ROGUE_POISON_AURA_TAG = "rom-poisonous"; +export const ROM_ROGUE_POISON_PROC_CHANCE = 0.5; +export const ROM_ROGUE_POISON_MAX_STACKS = 4; +export const ROM_ROGUE_POISON_DEBUFF_DURATION_MS = 900_000; + +export interface RomRoguePoisonAttackContext { + readonly classId: string; + readonly sourceActorId: string; + readonly targetActorId: string; + readonly attackKind: AttackKind; + readonly isNormalAttack: boolean; + readonly attackLanded: boolean; + readonly poisonActive: boolean; + readonly roll: number; +} + +export interface RomRoguePoisonApplication { + readonly sourceId: string; + readonly targetId: string; + readonly definition: AuraDefinition; + readonly stacks: 1; +} + +/** + * RuneWaker Poison (490329/500715) has a 50% on-normal-attack trigger for + * hidden child 490357. Its local tooltip supplies the 2% damage reduction and + * four-stack cap; the child duration is absent locally, so 900 seconds uses + * only the current CoA entry's duration detail. + */ +export function romRoguePoisonApplicationForAttack( + context: RomRoguePoisonAttackContext, +): RomRoguePoisonApplication | null { + if (context.classId !== "rom-rogue" + || !context.sourceActorId + || !context.targetActorId + || context.sourceActorId === context.targetActorId + || context.attackKind === "spell" + || !context.isNormalAttack + || !context.attackLanded + || !context.poisonActive + || !Number.isFinite(context.roll) + || context.roll < 0 + || context.roll >= ROM_ROGUE_POISON_PROC_CHANCE) return null; + + return { + sourceId: context.sourceActorId, + targetId: context.targetActorId, + stacks: 1, + definition: { + id: `rom-rogue-poisoned:${ROM_ROGUE_POISON_TRIGGER_SPELL_ID}`, + name: "Poisoned", + disposition: "debuff", + durationMs: ROM_ROGUE_POISON_DEBUFF_DURATION_MS, + maxStacks: ROM_ROGUE_POISON_MAX_STACKS, + stackBehavior: "add", + uniqueBySource: true, + dispelCategory: "poison", + modifiers: [{ + kind: "damage", + direction: "dealt", + operation: "percent", + value: -0.02, + perStack: true, + }], + }, + }; +} diff --git a/src/game/romScoutMechanics.test.ts b/src/game/romScoutMechanics.test.ts index bcbafd8a..e7d97bf8 100644 --- a/src/game/romScoutMechanics.test.ts +++ b/src/game/romScoutMechanics.test.ts @@ -1,14 +1,23 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { abilityById } from "./abilityCatalog"; +import { abilitiesForCharacter, abilityById } from "./abilityCatalog"; import { PLAYER_AGGRO_ID } from "./aggro"; +import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment"; +import { resolvedEquipmentLoadout } from "./heirloomEquipment"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; import { resolveDamageValue, resolveStatValue } from "./combatAuras"; -import { useCombatStore } from "./combatStore"; +import { romAbilityNeedsRangedWeapon } from "./romCombatMechanics"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { romScoutRogueProcActionSpec } from "./romScoutRogueOverrides"; describe("RoM Scout ranged weapon masteries", () => { beforeEach(() => { clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); }); it.each(["bow", "crossbow"] as const)( @@ -104,4 +113,287 @@ describe("RoM Scout ranged weapon masteries", () => { expect.objectContaining({ kind: "root", endsAt: 7_600, sourceAbilityId: "rom-scout-general-3" }), ])); }); + + it.each([ + ["rom-scout", "rom-warrior", 25, "rom-enhanced-throat-attack"], + ["rom-rogue", "rom-scout", 20, "rom-slit-throat"], + ] as const)("applies the local four-second Throat Attack silence for %s/%s", ( + classId, secondaryClassId, level, passiveTag, + ) => { + useCombatStore.getState().initializeCharacter({ + classId, secondaryClassId, raceId: "rom-human", level, + }); + useCombatStore.getState().registerMob("throat-silence-target", { name: "Target", level: 1, maxHealth: 100_000 }); + setMobPosition("throat-silence-target", [3, 0, 0]); + useCombatStore.getState().selectMob("throat-silence-target"); + + const throatAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490428); + expect(throatAttack).toBeDefined(); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(passiveTag))).toBe(true); + expect(useCombatStore.getState().castAbility(throatAttack!.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true); + expect(useCombatStore.getState().mobs["throat-silence-target"]?.statuses).toContainEqual( + expect.objectContaining({ kind: "silence", endsAt: 5_000, sourceAbilityId: throatAttack!.id }), + ); + }); + + it("limits the paired Throat Attack silence proc to Throat Attack hits", () => { + expect(romScoutRogueProcActionSpec("rom-enhanced-throat-attack", 490428, false)) + .toMatchObject({ kind: "status", status: "silence", durationMs: 4_000, provenance: "source" }); + expect(romScoutRogueProcActionSpec("rom-slit-throat", 490428, false)) + .toMatchObject({ kind: "status", status: "silence", durationMs: 4_000, provenance: "source" }); + expect(romScoutRogueProcActionSpec("rom-slit-throat", 490423, false)).toBeNull(); + }); + + it("reduces Snipe's cast by one second only with Scout/Priest Sniper", () => { + const bow = { + instanceId: "scout-sniper-bow", + itemId: 1, + itemKey: "scout-sniper-bow", + name: "Test bow", + quality: "uncommon" as const, + inventorySlot: "ranged", + armorType: null, + weaponType: "bow" as const, + itemLevel: 1, + sourceItemLevel: 1, + stats: EMPTY_ITEM_STATS, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "test", + acquiredAt: 1, + }; + const prepareSnipe = (secondaryClassId?: "rom-priest", targetId = "sniper-target") => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", + ...(secondaryClassId ? { secondaryClassId } : {}), + raceId: "rom-human", + level: 25, + inventory: [bow], + equipment: { player: { ranged: bow.instanceId } }, + }); + useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 100_000 }); + setMobPosition(targetId, [3, 0, 0]); + useCombatStore.getState().selectMob(targetId); + }; + + prepareSnipe("rom-priest"); + expect(useCombatStore.getState().castAbility("rom-scout-primary-7", undefined, 1_000)).toMatchObject({ ok: true }); + const withSniper = useCombatStore.getState().activeCast?.originalDurationMs; + expect(withSniper).toBeGreaterThan(0); + + prepareSnipe(undefined, "sniper-base-target"); + expect(useCombatStore.getState().castAbility("rom-scout-primary-7", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().activeCast?.originalDurationMs).toBe(withSniper! + 1_000); + }); + + it("marks all sourced Scout projectile abilities as requiring a ranged weapon", () => { + for (const [abilityId, sourceSpellId] of [ + ["rom-scout-general-0", 490423], // Shot + ["rom-scout-general-2", 491292], // Vampire Arrows + ["rom-scout-general-3", 490420], // Joint Blow + ["rom-scout-primary-1", 491128], // Wind Arrows + ["rom-scout-primary-57", 492589], // Autoshot + ] as const) { + const ability = abilityById(abilityId); + expect(ability?.dbcSpellId, abilityId).toBe(sourceSpellId); + expect(romAbilityNeedsRangedWeapon(ability!), abilityId).toBe(true); + } + }); + + it("leaves Blood Arrow and Accurate Targeting outside the ranged weapon requirement", () => { + const bloodArrow = abilitiesForCharacter("rom-scout", null).find((candidate) => candidate.dbcSpellId === 490434); + const accurateTargeting = abilitiesForCharacter("rom-scout", "rom-warrior").find((candidate) => candidate.dbcSpellId === 493006); + expect(bloodArrow).toBeDefined(); + expect(accurateTargeting).toBeDefined(); + expect(romAbilityNeedsRangedWeapon(bloodArrow!)).toBe(false); + expect(romAbilityNeedsRangedWeapon(accurateTargeting!)).toBe(false); + }); + + it("rejects Scout secondary projectile skills without a ranged weapon for the player", () => { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", + secondaryClassId: "rom-scout", + raceId: "rom-human", + level: 80, + inventory: [], + equipment: { player: {} }, + }); + useCombatStore.getState().registerMob("ranged-weapon-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("ranged-weapon-target", [3, 0, 0]); + useCombatStore.getState().selectMob("ranged-weapon-target"); + useCombatStore.setState((state) => ({ + resource: 1_000, + maxResource: 1_000, + resourcePools: { ...state.resourcePools, focus: 1_000 }, + maxResourcePools: { ...state.maxResourcePools, focus: 1_000 }, + })); + const player = useCombatStore.getState(); + expect(player.abilities.map((ability) => ability.id)).toEqual(expect.arrayContaining([ + "rom-scout-general-0", "rom-scout-general-2", "rom-scout-general-3", + ])); + expect(resolvedEquipmentLoadout(player.equipment, player.inventory, { + ownerId: PLAYER_EQUIPMENT_OWNER_ID, + ownerKind: "player", + classId: player.classId, + secondaryClassId: player.secondaryClassId, + specializationId: player.specializationId, + role: player.gearRole, + level: player.level, + }).ranged).toBeUndefined(); + + for (const abilityId of [ + "rom-scout-general-0", // Shot (490423) + "rom-scout-general-2", // Vampire Arrows (491292) + "rom-scout-general-3", // Joint Blow (490420) + ]) { + expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000), abilityId) + .toMatchObject({ ok: false, reason: "condition-not-met" }); + } + }); + + it("does not let a Scout secondary companion cast Shot without a ranged weapon", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 80 }); + const player = useCombatStore.getState(); + usePartyStore.getState().initializeParty("scout-companion-ranged-guard", 80, player.health, "damage"); + const scout = createConfiguredPartyMember({ + name: "Warrior Scout companion", + classId: "rom-warrior", + secondaryClassId: "rom-scout", + raceId: "rom-human", + specialization: "Primary", + role: "damage", + }, 80, 0, "scout-companion-ranged-guard"); + const shot = abilitiesForCharacter("rom-warrior", "rom-scout").find((ability) => ability.dbcSpellId === 490423); + if (!shot) throw new Error("Missing Scout Shot"); + const cooldowns = Object.fromEntries(abilitiesForCharacter("rom-warrior", "rom-scout").map((ability) => [ability.id, 60_000])); + usePartyStore.setState({ members: [{ + ...scout, + resource: scout.maxResource, + resourcePools: { ...scout.resourcePools, focus: scout.maxResource }, + cooldowns: { ...cooldowns, [shot.id]: 0 }, + }] }); + const combat = useCombatStore.getState(); + expect(resolvedEquipmentLoadout(combat.equipment, combat.inventory, { + ownerId: scout.id, + ownerKind: "party", + classId: scout.classId, + secondaryClassId: scout.secondaryClassId, + specializationId: scout.specializationId, + role: scout.role, + level: scout.level, + }).ranged).toBeUndefined(); + registerPartyRuntimePosition(scout.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().registerMob("scout-companion-target", { name: "Target", maxHealth: 100_000 }); + setMobPosition("scout-companion-target", [3, 0, 0]); + useCombatStore.getState().damageMob("scout-companion-target", 1, 100); + useCombatStore.getState().selectMob("scout-companion-target"); + updatePartyRuntimeTargetVisibility(scout.id, "scout-companion-target", true); + + advanceDungeonPartyCombat(1_000); + + expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === scout.id + && event.abilityId === shot.id)).toBe(false); + }); +}); + +describe("RoM Warrior/Scout Calm", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + }); + + function prepareWarriorScoutTarget(targetId: string, targetLevel: number) { + useCombatStore.getState().initializeCharacter({ + classId: "rom-warrior", + secondaryClassId: "rom-scout", + raceId: "rom-human", + level: 80, + }); + useCombatStore.setState((state) => ({ + resource: 1_000, + maxResource: 1_000, + resourcePools: { ...state.resourcePools, focus: 1_000, rage: 1_000 }, + maxResourcePools: { ...state.maxResourcePools, focus: 1_000, rage: 1_000 }, + })); + useCombatStore.getState().registerMob(targetId, { + name: targetId, + level: targetLevel, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(targetId, [3, 0, 0]); + useCombatStore.getState().selectMob(targetId); + const skullBreaker = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491328); + if (!skullBreaker) throw new Error("Missing Warrior/Scout Skull Breaker"); + return skullBreaker; + } + + function calmAura() { + return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("rom-warrior-scout-calm")); + } + + it("grants 20 physical hit for 15 seconds when Skull Breaker misses", () => { + const skullBreaker = prepareWarriorScoutTarget("calm-miss-target", 255); + const player = useCombatStore.getState(); + const basePhysicalHit = resolveStatValue(0, player.auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit"); + expect(player.abilities.some((ability) => ability.dbcSpellId === 498032 && ability.passive)).toBe(true); + + expect(player.castAbility(skullBreaker.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().lastCombatResult?.landed).toBe(false); + const aura = calmAura(); + expect(aura?.expiresAt).toBe(16_000); + expect(resolveStatValue(0, useCombatStore.getState().auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit")) + .toBe(basePhysicalHit + 20); + + useCombatStore.getState().tick(0, 16_001); + expect(calmAura()).toBeUndefined(); + expect(resolveStatValue(0, useCombatStore.getState().auras.filter((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID), "physical-hit")) + .toBe(basePhysicalHit); + }); + + it("does not grant Calm when Skull Breaker lands", () => { + const skullBreaker = prepareWarriorScoutTarget("calm-landed-target", 1); + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + "calm-landed-target": { + ...state.mobs["calm-landed-target"]!, + characterDefender: { + level: 1, + armor: 0, + dodgePercent: 0, + parryPercent: 0, + blockPercent: 0, + blockValue: 0, + }, + }, + }, + })); + + expect(useCombatStore.getState().castAbility(skullBreaker.id, undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true); + expect(calmAura()).toBeUndefined(); + }); + + it("does not grant Calm for another failed Warrior attack or without the Warrior/Scout pair", () => { + const skullBreaker = prepareWarriorScoutTarget("calm-other-ability-target", 255); + const probingAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491133); + if (!probingAttack) throw new Error("Missing Probing Attack"); + expect(useCombatStore.getState().castAbility(probingAttack.id, undefined, 3_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().lastCombatResult?.landed).toBe(false); + expect(calmAura()).toBeUndefined(); + + useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 80 }); + useCombatStore.getState().registerMob("calm-wrong-pair-target", { name: "Target", maxHealth: 1_000_000 }); + setMobPosition("calm-wrong-pair-target", [3, 0, 0]); + useCombatStore.getState().selectMob("calm-wrong-pair-target"); + expect(useCombatStore.getState().castAbility(skullBreaker.id, undefined, 1_000)) + .toMatchObject({ ok: false, reason: "wrong-class" }); + expect(calmAura()).toBeUndefined(); + }); }); diff --git a/src/game/romScoutPriestDrumsOfWar.integration.test.ts b/src/game/romScoutPriestDrumsOfWar.integration.test.ts new file mode 100644 index 00000000..6a239c16 --- /dev/null +++ b/src/game/romScoutPriestDrumsOfWar.integration.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForCharacter, abilityById } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + advanceDungeonPartyCombat, + resetPartyRuntimeClock, +} from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { + ROM_SCOUT_PRIEST_DRUMS_OF_WAR, + ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA, +} from "./romScoutPriestOverrides"; + +const TARGET_ID = "drums-of-war-target"; + +function initializeScoutPriest(): void { + useCombatStore.getState().initializeCharacter({ + classId: "rom-scout", + secondaryClassId: "rom-priest", + raceId: "rom-human", + level: 80, + }); +} + +function registerTarget(): void { + useCombatStore.getState().registerMob(TARGET_ID, { + name: TARGET_ID, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [3, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function shot() { + const ability = useCombatStore.getState().abilities.find((candidate) => ( + candidate.source === "runewaker" && candidate.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId + )); + if (!ability) throw new Error("RuneWaker Shot (490423) is missing from Scout/Priest abilities."); + return ability; +} + +function landPlayerShot(now: number, seed: string) { + const ability = shot(); + return useCombatStore.getState().damageMob( + TARGET_ID, + 100, + now, + { actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: ability.id }, + "physical", + { + attackKind: "ranged", + abilityId: ability.id, + seed, + rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false }, + }, + ); +} + +function activeDrumsAura(actorId: string, now: number) { + return useCombatStore.getState().auras.find((aura) => ( + aura.targetId === actorId + && aura.sourceId === PLAYER_AGGRO_ID + && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id + && (aura.expiresAt === null || aura.expiresAt > now) + )); +} + +describe("RoM Scout/Priest Drums of War integration", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + initializeScoutPriest(); + }); + + it("applies the local critical-stat aura after a landed Shot and respects its per-caster ICD", () => { + registerTarget(); + const passive = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 493010); + expect(passive).toMatchObject({ classId: "rom-scout", secondaryClassId: "rom-priest", passive: true }); + + const first = landPlayerShot(1_000, "drums-player-first"); + expect(first.combatResult?.landed).toBe(true); + expect(activeDrumsAura(PLAYER_AGGRO_ID, 1_000)).toMatchObject({ + expiresAt: 9_000, + definition: { + modifiers: [ + { kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }, + { kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 20 }, + ], + }, + }); + expect(useCombatStore.getState().cooldowns[`${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${PLAYER_AGGRO_ID}`]) + .toBe(16_000); + + landPlayerShot(5_000, "drums-player-on-icd"); + expect(activeDrumsAura(PLAYER_AGGRO_ID, 5_000)?.expiresAt).toBe(9_000); + + landPlayerShot(16_000, "drums-player-ready-again"); + expect(activeDrumsAura(PLAYER_AGGRO_ID, 16_000)?.expiresAt).toBe(24_000); + }); + + it("applies only to living party members within 50 yards of the caster", () => { + const near = createConfiguredPartyMember({ + name: "Near Ally", + classId: "priest", + specialization: "Discipline", + role: "healer", + }, 80, 0, "drums-near-party-member"); + const far = createConfiguredPartyMember({ + name: "Far Ally", + classId: "warrior", + specialization: "Arms", + role: "damage", + }, 80, 1, "drums-far-party-member"); + usePartyStore.setState({ members: [near, far] }); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(near.id, [40, 0, 0]); + registerPartyRuntimePosition(far.id, [51, 0, 0]); + registerTarget(); + + landPlayerShot(1_000, "drums-player-party-radius"); + + expect(activeDrumsAura(PLAYER_AGGRO_ID, 1_000)).toBeDefined(); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === near.id + && aura.sourceId === PLAYER_AGGRO_ID + && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === far.id + && aura.sourceId === PLAYER_AGGRO_ID + && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)).toBe(false); + }); + + it("passes party Shot through the ranged hit-resolution path before proc application", () => { + registerTarget(); + const playerHit = landPlayerShot(1_000, "drums-player-independent-icd"); + expect(playerHit.combatResult?.landed).toBe(true); + + const seed = "drums-party-scout"; + const member = createConfiguredPartyMember({ + name: "Scout Priest", + classId: "rom-scout", + secondaryClassId: "rom-priest", + specialization: "Primary", + role: "damage", + }, 80, 0, seed); + const partyShot = abilitiesForCharacter(member.classId, member.secondaryClassId) + .find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId); + if (!partyShot) throw new Error("RuneWaker Shot (490423) is missing from party Scout/Priest abilities."); + const passive = abilitiesForCharacter(member.classId, member.secondaryClassId) + .find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId); + if (!passive) throw new Error("Drums of War (493010) is missing from party Scout/Priest abilities."); + expect(abilityById(partyShot.id)?.dbcSpellId).toBe(490_423); + + usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom"); + usePartyStore.getState().setCommand("attack"); + usePartyStore.setState({ members: [{ + ...member, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...member.resourcePools, "nature-power": 10_000, mana: 10_000 }, + activeCast: { + abilityId: partyShot.id, + school: "physical", + mode: "cast", + startedAt: 500, + completesAt: 3_500, + originalDurationMs: 3_000, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: TARGET_ID, + }, + activeCastTargetId: TARGET_ID, + status: "attacking", + }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + registerTarget(); + useCombatStore.getState().damageMob(TARGET_ID, 1, 3_400); + updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); + useCombatStore.getState().synchronizeActors(); + + advanceDungeonPartyCombat(3_500); + + expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === member.id + && aura.targetId === member.id + && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id + && aura.expiresAt === 11_500)).toBe(true); + expect(useCombatStore.getState().cooldowns[`${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${member.id}`]) + .toBe(18_500); + expect(activeDrumsAura(PLAYER_AGGRO_ID, 3_500)?.expiresAt).toBe(9_000); + }); +}); diff --git a/src/game/romScoutPriestOverrides.test.ts b/src/game/romScoutPriestOverrides.test.ts new file mode 100644 index 00000000..596d940a --- /dev/null +++ b/src/game/romScoutPriestOverrides.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; +import { + ROM_SCOUT_PRIEST_DRUMS_OF_WAR, + romScoutPriestDrumsOfWarProc, +} from "./romScoutPriestOverrides"; + +describe("RoM Scout/Priest Drums of War", () => { + const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493010)!; + const shot = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490423)!; + + it("uses the local 8-second +20 physical/magic critical stats and 15-second cooldown on a landed Shot", () => { + expect(passive).toMatchObject({ + classId: "rom-scout", + secondaryClassId: "rom-priest", + passive: true, + }); + expect(shot).toMatchObject({ classId: "rom-scout", source: "runewaker" }); + expect(ROM_SCOUT_PRIEST_DRUMS_OF_WAR).toMatchObject({ + passiveSpellId: 493010, + triggerSpellId: 490423, + internalCooldownMs: 15_000, + durationMs: 8_000, + modifiers: [ + { kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }, + { kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 20 }, + ], + }); + + expect(romScoutPriestDrumsOfWarProc( + "rom-scout", + "rom-priest", + shot, + [493010], + true, + 15_000, + 15_000, + )).toEqual({ + internalCooldownMs: 15_000, + durationMs: 8_000, + radius: 50, + scope: "party", + modifiers: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.modifiers, + }); + }); + + it("rejects other class pairs, other shots, missed hits, unlearned passives, and active cooldown", () => { + const blocked = [ + romScoutPriestDrumsOfWarProc("rom-priest", "rom-scout", shot, [493010], true, 20_000, 0), + romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", shot, [], true, 20_000, 0), + romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", { + ...shot, + dbcSpellId: 490424, + }, [493010], true, 20_000, 0), + romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", shot, [493010], false, 20_000, 0), + romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", shot, [493010], true, 14_999, 15_000), + romScoutPriestDrumsOfWarProc("rom-scout", "rom-priest", { + ...shot, + source: "core", + }, [493010], true, 20_000, 0), + ]; + + expect(blocked).toEqual(Array.from({ length: 6 }, () => null)); + }); +}); diff --git a/src/game/romScoutPriestOverrides.ts b/src/game/romScoutPriestOverrides.ts new file mode 100644 index 00000000..8f73612e --- /dev/null +++ b/src/game/romScoutPriestOverrides.ts @@ -0,0 +1,66 @@ +import type { AbilityDefinition } from "./abilityCatalog"; +import type { AuraDefinition, AuraModifier } from "./combatAuras"; + +/** RuneWaker's local payload for Scout/Priest passive 493010. */ +export const ROM_SCOUT_PRIEST_DRUMS_OF_WAR = Object.freeze({ + passiveSpellId: 493010, + triggerSpellId: 490423, + internalCooldownMs: 15_000, + durationMs: 8_000, + radius: 50, + modifiers: [ + { kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 20 }, + { kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 20 }, + ] satisfies readonly AuraModifier[], + cooldownKey: "rom-scout-priest:drums-of-war", +}); + +export const ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA: AuraDefinition = Object.freeze({ + id: "rom-scout-priest:drums-of-war", + name: "Drums of War", + description: "Increases physical and magical critical strike by 20 for 8 seconds.", + disposition: "buff", + durationMs: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.durationMs, + stackBehavior: "replace", + uniqueBySource: true, + modifiers: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.modifiers, +}); + +export interface RomScoutPriestDrumsOfWarProc { + readonly internalCooldownMs: number; + readonly durationMs: number; + /** The RuneWaker tooltip says nearby party; 50 is from the CoA range field. */ + readonly radius: number; + readonly scope: "party"; + readonly modifiers: readonly AuraModifier[]; +} + +/** Select Drums of War's exact sourced payload after a landed Shot when its ICD is ready. */ +export function romScoutPriestDrumsOfWarProc( + characterClassId: string, + secondaryClassId: string | null | undefined, + ability: Pick, + learnedPassiveSpellIds: readonly number[], + landed: boolean, + now: number, + readyAt: number, + attackKind: "melee" | "ranged" | "spell" = "ranged", +): RomScoutPriestDrumsOfWarProc | null { + if (characterClassId !== "rom-scout" + || secondaryClassId !== "rom-priest" + || ability.source !== "runewaker" + || ability.classId !== "rom-scout" + || ability.dbcSpellId !== ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId + || attackKind !== "ranged" + || !learnedPassiveSpellIds.includes(ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId) + || !landed + || now < readyAt) return null; + + return { + internalCooldownMs: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.internalCooldownMs, + durationMs: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.durationMs, + radius: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.radius, + scope: "party", + modifiers: ROM_SCOUT_PRIEST_DRUMS_OF_WAR.modifiers, + }; +} diff --git a/src/game/romScoutRogueOverrides.ts b/src/game/romScoutRogueOverrides.ts index 94b6b9e6..20e5fd9f 100644 --- a/src/game/romScoutRogueOverrides.ts +++ b/src/game/romScoutRogueOverrides.ts @@ -5,6 +5,15 @@ import { ROM_ROGUE_DAGGER_MASTERY_TAG, ROM_ROGUE_TREASURE_HUNTER_TAG, } from "./romRogueMechanics"; +import { + ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG, + ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID, +} from "./romRogueChampionNerveGearRuntime"; + +export const ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG = "rom-491178-enchanted-throw"; +export const ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG = "rom-rogue-priest-sinister-methods"; +export const ROM_ROGUE_PRIEST_SINISTER_METHODS_COOLDOWN_KEY = "rom-rogue-priest-sinister-methods"; +export const ROM_SCOUT_PRIEST_SNIPER_TAG = "rom-scout-priest-sniper"; function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] { return effects.filter((effect) => effect.kind === "damage").slice(0, 1); @@ -56,14 +65,18 @@ function customProc( sourceId: number, trigger: "hit" | "crit", actionId: string, - options: { readonly chance?: number; readonly internalCooldownMs?: number } = {}, + options: { + readonly chance?: number; + readonly internalCooldownMs?: number; + readonly data?: Readonly>; + } = {}, ): AuraProcDefinition { return { id: `rom-pair-proc-${sourceId}`, triggers: trigger === "hit" ? ["hit", "crit"] : trigger, chance: options.chance ?? 1, ...(options.internalCooldownMs ? { internalCooldownMs: options.internalCooldownMs } : {}), - action: { kind: "custom", id: actionId }, + action: { kind: "custom", id: actionId, ...(options.data ? { data: options.data } : {}) }, }; } @@ -91,8 +104,8 @@ function timedCasterAura( } /** - * Source-specific translations for the accepted Scout/Rogue and Rogue/Warrior - * pairings. Passive tags and custom proc IDs are consumed by combat-store + * Source-specific translations for the accepted Scout paired-class + * mechanics. Passive tags and custom proc IDs are consumed by combat-store * transitions; they intentionally do not apply unrelated generic stat buffs. */ export function overrideRomScoutRogueEffects( @@ -100,6 +113,28 @@ export function overrideRomScoutRogueEffects( translated: readonly AbilityEffect[], ): readonly AbilityEffect[] | null { switch (row.sourceSkillId) { + case 497206: { // Dodge arms the usable spell; its child applies the three-second concealment. + if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-mage") return null; + const concealment = row.source?.components?.find((component) => component.id === 502597); + const durationMs = Math.max(1_000, Math.round((concealment?.effectTime ?? 3) * 1_000)); + return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-rogue:instant-shadow", + name: "Instant Shadow", + disposition: "buff", + durationMs, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: ["stealth"], + }, + sourceEffectType: row.source?.sourceEffectType ?? -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 502597, + }]; + } case 490504: return taggedRoguePassive(490504, "Dagger Mastery", ROM_ROGUE_DAGGER_MASTERY_TAG); case 490316: @@ -109,6 +144,46 @@ export function overrideRomScoutRogueEffects( // is no distinct RoM off-hand hit yet, so do not turn either value into a // generic player melee modifier. return []; + case 493020: { // Successful Low Blow/Blind Spot hits trigger the local 10s crit buff. + if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-priest") return null; + const buff = row.source?.components?.find((component) => component.id === 503265); + const criticalModifier = buff?.abilityModifiers?.find((modifier) => modifier.type === 18); + const cooldown = row.source?.components?.find((component) => component.id === 503300); + const durationMs = Math.max(0, Math.round((buff?.effectTime ?? 0) * 1_000)); + const cooldownMs = Math.max(0, Math.round((cooldown?.effectTime ?? 0) * 1_000)); + const proc = criticalModifier && durationMs > 0 && cooldownMs > 0 + ? customProc(493020, "hit", "rom-sinister-methods", { + data: { criticalStrike: criticalModifier.value, durationMs, cooldownMs }, + }) + : undefined; + return taggedPassive(493020, "Sinister Methods", ROM_ROGUE_PRIEST_SINISTER_METHODS_TAG, proc); + } + case ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID: + if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-champion") return null; + // The timed child only triggers after an elite skill use. Keep a learned + // marker, not its generic aura, so party passive loading cannot grant the + // attack-speed/dodge payload on an unrelated cast. + return taggedPassive( + ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID, + "Nerve Gear", + ROM_ROGUE_CHAMPION_NERVE_GEAR_MARKER_TAG, + ); + case 491178: + if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-mage") return null; + // Hidden child 491185 has no generated payload. The auto-attack runtime + // applies its referenced Throw (490309) damage explicitly, so keep the + // tag and avoid also registering the untranslated generic child proc. + if (translated.some((effect) => effect.kind === "apply-aura")) { + return translated.map((effect) => effect.kind === "apply-aura" ? { + ...effect, + aura: { + ...effect.aura, + procs: [], + utilityTags: [...(effect.aura.utilityTags ?? []), ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG], + }, + } : effect); + } + return [...translated, ...taggedRoguePassive(491178, "Enchanted Throw", ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG)]; case 491552: if (row.classId !== "rom-rogue") return null; return translated.map((effect) => effect.kind === "apply-aura" ? { @@ -164,6 +239,17 @@ export function overrideRomScoutRogueEffects( case 493006: return taggedPassive(493006, "Accurate Targeting", "rom-accurate-targeting", customProc(493006, "hit", "rom-accurate-targeting", { internalCooldownMs: 30_000 })); + case 491489: + if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-warrior") return null; + return taggedPassive(491489, "Enhanced Throat Attack", "rom-enhanced-throat-attack", + customProc(491489, "hit", "rom-enhanced-throat-attack")); + case 491532: + if (row.classId !== "rom-rogue" || row.secondaryClassId !== "rom-scout") return null; + return taggedPassive(491532, "Slit Throat", "rom-slit-throat", + customProc(491532, "hit", "rom-slit-throat")); + case 492344: + if (row.classId !== "rom-scout" || row.secondaryClassId !== "rom-priest") return null; + return taggedPassive(492344, "Sniper", ROM_SCOUT_PRIEST_SNIPER_TAG); case 493007: return taggedPassive(493007, "Deadly Counterattack", "rom-deadly-counterattack", customProc(493007, "hit", "rom-deadly-counterattack", { internalCooldownMs: 20_000 })); @@ -301,8 +387,14 @@ export function romScoutRogueCastEffects( breakOnDamage: true, }]; } - if (ability.dbcSpellId === 490309 && passives.has("rom-throwing-mastery")) { - return [...effects, { kind: "status", status: "slow", durationMs: 5_000, magnitude: 0.4 }]; + if (ability.dbcSpellId === 490309) { + return [ + ...effects, + ...(passives.has("rom-throwing-mastery") + ? [{ kind: "status" as const, status: "slow" as const, durationMs: 5_000, magnitude: 0.4 }] + : []), + { kind: "cooldown-reset", mode: "reduce", abilityName: "Day of Rain", amountMs: 1_000 }, + ]; } if (ability.dbcSpellId === 492621) { const hasSourcePoison = target.has("rom-deadly-poison") || target.has("rom-501690"); @@ -316,6 +408,26 @@ export function romScoutRogueCastEffects( return effects; } +/** Day of Rain has an exact 50% miss chance while Enchanted Throw is active. */ +export function romRogueMageDayOfRainBaseMissPercent( + classId: string, + secondaryClassId: string | null | undefined, + abilitySpellId: number | undefined, + activeAuraTags: readonly string[], + attackerHitPercent: number, +): number | null { + if (classId !== "rom-rogue" + || secondaryClassId !== "rom-mage" + || abilitySpellId !== 491183 + || !activeAuraTags.includes(ROM_ROGUE_MAGE_ENCHANTED_THROW_TAG)) return null; + const hitPercent = Number.isFinite(attackerHitPercent) + ? Math.max(0, Math.min(100, attackerHitPercent)) + : 0; + // combatAttackTable subtracts attacker hit rating from base miss chance. + // Offset it here so the tooltip's 50% rate remains exact at any hit rating. + return 50 + hitPercent; +} + /** 499481/622993 adds a hit only to targets already carrying 622992's poison. */ export function romScoutRogueWhirlwindBonusEffect( ability: AbilityDefinition, @@ -333,6 +445,7 @@ export type RomScoutRogueProcActionSpec = readonly recipient: "caster" | "target"; readonly aura: AuraDefinition; readonly provenance: "source" | "hybrid-estimate"; + readonly cooldownMs?: number; } | { readonly kind: "dot"; @@ -352,6 +465,13 @@ export type RomScoutRogueProcActionSpec = readonly recipient: "target"; readonly effect: Extract; readonly provenance: "source" | "hybrid-estimate"; + } + | { + readonly kind: "status"; + readonly recipient: "target"; + readonly status: "silence"; + readonly durationMs: number; + readonly provenance: "source"; }; /** @@ -364,8 +484,37 @@ export function romScoutRogueProcActionSpec( actionId: string, triggeringSourceSkillId: number, casterPoisonous: boolean, + data: Readonly> = {}, ): RomScoutRogueProcActionSpec | null { switch (actionId) { + case "rom-enhanced-throat-attack": + case "rom-slit-throat": + if (triggeringSourceSkillId !== 490428) return null; + return { kind: "status", recipient: "target", status: "silence", durationMs: 4_000, provenance: "source" }; + case "rom-sinister-methods": { + if (![490323, 490311].includes(triggeringSourceSkillId)) return null; + const criticalStrike = data.criticalStrike; + const durationMs = data.durationMs; + const cooldownMs = data.cooldownMs; + if (typeof criticalStrike !== "number" || !Number.isFinite(criticalStrike) + || typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0 + || typeof cooldownMs !== "number" || !Number.isFinite(cooldownMs) || cooldownMs <= 0) return null; + return { + kind: "aura", + recipient: "caster", + aura: { + id: "rom-pair-sinister-methods-critical", + name: "Sinister Methods", + disposition: "buff", + durationMs, + maxStacks: 1, + stackBehavior: "refresh", + modifiers: [{ kind: "stat", stat: "physical-critical-strike", operation: "flat", value: criticalStrike }], + }, + cooldownMs, + provenance: "source", + }; + } case "rom-accurate-targeting": { if (triggeringSourceSkillId !== 490423) return null; return { diff --git a/src/game/romWardenOverrides.ts b/src/game/romWardenOverrides.ts index 48334755..785fb8ca 100644 --- a/src/game/romWardenOverrides.ts +++ b/src/game/romWardenOverrides.ts @@ -1,9 +1,45 @@ import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import { weaponDps, type WeaponStats } from "./itemStats"; +import { runewakerOrgPower } from "./runewakerCombatFormula"; +export const ROM_WARDEN_BRIAR_SHIELD_TAG = "rom-briar-shield"; 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_TOUGH_OAK_TAG = "rom-warden-tough-oak"; +export const ROM_WARDEN_OAK_BLESSING_TAG = "rom-warden-oak-blessing"; export const ROM_WARDEN_DAMAGE_TRANSFER_FRACTION = 0.3; +/** RuneWaker component 503959's level-scaled physical rebound coefficient. */ +export function romWardenBriarShieldReflectFraction(characterLevel: number): number { + return Math.abs(runewakerOrgPower(-0.01, characterLevel, 8)); +} + +/** The local tooltip defines the rebound basis as main-hand weapon DPS. */ +export function romWardenBriarShieldReflectDamage( + characterLevel: number, + mainHandWeapon: WeaponStats | null, +): number { + return mainHandWeapon + ? Math.max(0, Math.round(weaponDps(mainHandWeapon) * romWardenBriarShieldReflectFraction(characterLevel))) + : 0; +} + +export function romWardenBriarShieldShouldReflect(input: { + readonly auraActive: boolean; + readonly landed: boolean; + readonly school: string; + readonly attackKind: "melee" | "ranged" | "spell"; + readonly withinRange: boolean; +}): boolean { + return input.auraActive + && input.landed + && input.school === "physical" + && input.attackKind !== "spell" + && input.withinRange; +} + +const ROM_WARDEN_TOUGH_OAK_COOLDOWN_SPELLS = new Set([493382, 493385]); + const classPair = (ability: AbilityDefinition, primary: string, secondary: string): boolean => ( ability.classId === primary && ability.secondaryClassId === secondary ); @@ -41,6 +77,51 @@ export function romWardenCastEffects( ): readonly AbilityEffect[] { if (ability.source !== "runewaker") return effects; + if (ability.classId === "rom-warden" + && ability.dbcSpellId !== undefined + && ROM_WARDEN_TOUGH_OAK_COOLDOWN_SPELLS.has(ability.dbcSpellId) + && passiveTags.includes(ROM_WARDEN_TOUGH_OAK_TAG)) { + return [...effects, { + kind: "cooldown-reset", + mode: "reduce", + abilityName: ability.name, + amountMs: 1_000, + }]; + } + + if (ability.classId === "rom-warden" + && ability.dbcSpellId === 491668 + && passiveTags.includes(ROM_WARDEN_OAK_BLESSING_TAG)) { + // Warden/Mage's local Oak Blessing component 620679 is conditional on + // Heart of the Oak: +5% physical attack and 1% maximum HP every 3s for + // 60s. The generated passive itself is only a marker so it cannot apply + // this rider outside the parent buff's lifetime. + return [...effects, + { + kind: "apply-aura", + recipient: "caster", + aura: { + id: "rom-warden-oak-blessing-attack", + name: "Oak Blessing", + disposition: "buff", + durationMs: 60_000, + modifiers: [{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.05 }], + }, + sourceEffectType: -1, + sourceAuraType: 134, + miscValue: 5, + triggerSpellId: 491668, + }, + { + kind: "hot", + coefficient: 0, + percentMaxHealth: 0.01, + ticks: 20, + intervalMs: 3_000, + }, + ]; + } + 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. @@ -108,7 +189,7 @@ export function romWardenCastEffects( if (naturePower < 1) return [heals[0]!]; return [ ...heals.slice(0, 2), - { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }, + { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" }, ]; } @@ -128,7 +209,7 @@ export function romWardenCastEffects( coefficientPerLevel: 0, bonusPowerCoefficient: 0, }, - { kind: "resource-drain", amount: points, burn: false, resource: "nature-power" }, + { kind: "resource-drain", amount: points, burn: false, resource: "nature-power", recipient: "caster" }, ]; } diff --git a/src/game/romWardenRuntime.test.ts b/src/game/romWardenRuntime.test.ts index 54037056..5ff06921 100644 --- a/src/game/romWardenRuntime.test.ts +++ b/src/game/romWardenRuntime.test.ts @@ -8,9 +8,15 @@ import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { usePartyStore } from "./partyStore"; import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics"; import { + ROM_WARDEN_BRIAR_SHIELD_TAG, ROM_WARDEN_DAMAGE_TRANSFER_FRACTION, ROM_WARDEN_DAMAGE_TRANSFER_TAG, + ROM_WARDEN_OAK_BLESSING_TAG, + ROM_WARDEN_TOUGH_OAK_TAG, ROM_WARDEN_WILL_ATTACK_TAG, + romWardenBriarShieldReflectDamage, + romWardenBriarShieldReflectFraction, + romWardenBriarShieldShouldReflect, } from "./romWardenOverrides"; const emptyContext: RomCastMechanics = { @@ -30,7 +36,7 @@ function effectsFor(spellId: number, context: Partial = {}) { function initializeRomCharacter( classId: "rom-warden" | "rom-warrior" | "rom-druid", - secondaryClassId?: "rom-warden" | "rom-warrior", + secondaryClassId?: "rom-warden" | "rom-warrior" | "rom-mage", ) { useCombatStore.getState().initializeCharacter({ classId, @@ -53,6 +59,140 @@ describe("RoM Warden and Warden-linked source behaviors", () => { useCombatStore.setState({ playerPosition: [0, 0, 0] }); }); + it("casts Briar Shield as its source-backed defense aura without an immediate damage hit", () => { + initializeRomCharacter("rom-warden"); + const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 493642)!; + expect(ability.effects).toHaveLength(1); + expect(ability.effects[0]).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + aura: { + durationMs: 900_000, + modifiers: [{ kind: "stat", stat: "armor", operation: "percent", value: 0.05 }], + utilityTags: expect.arrayContaining([ROM_WARDEN_BRIAR_SHIELD_TAG]), + }, + }); + expect(ability.effects.some((effect) => effect.kind === "damage")).toBe(false); + + const mobId = "briar-shield-cast-target"; + useCombatStore.getState().registerMob(mobId, { name: "Briar Shield target", maxHealth: 100_000 }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().selectMob(mobId); + const now = 10_000; + expect(useCombatStore.getState().castAbility(ability.id, undefined, now)).toMatchObject({ ok: true }); + + const state = useCombatStore.getState(); + expect(state.mobs[mobId]?.health).toBe(100_000); + expect(state.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_WARDEN_BRIAR_SHIELD_TAG))).toMatchObject({ + appliedAt: now, + expiresAt: now + 900_000, + definition: { + modifiers: [{ kind: "stat", stat: "armor", operation: "percent", value: 0.05 }], + }, + }); + }); + + it("maps Briar Shield's reflected component to source-level main-hand DPS", () => { + const weapon = { damageMin: 100, damageMax: 120, damageSchool: "physical" as const, speedMs: 2_000 }; + expect(romWardenBriarShieldReflectFraction(80)).toBeCloseTo(0.074); + expect(romWardenBriarShieldReflectFraction(105)).toBeCloseTo(0.094); + expect(romWardenBriarShieldReflectDamage(80, weapon)).toBe(4); + expect(romWardenBriarShieldReflectDamage(105, weapon)).toBe(5); + expect(romWardenBriarShieldReflectDamage(80, null)).toBe(0); + + const validPhysicalHit = { + auraActive: true, + landed: true, + school: "physical", + attackKind: "melee" as const, + withinRange: true, + }; + expect(romWardenBriarShieldShouldReflect(validPhysicalHit)).toBe(true); + expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, attackKind: "ranged" })).toBe(true); + expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, school: "fire" })).toBe(false); + expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, attackKind: "spell" })).toBe(false); + expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, landed: false })).toBe(false); + expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, auraActive: false })).toBe(false); + expect(romWardenBriarShieldShouldReflect({ ...validPhysicalHit, withinRange: false })).toBe(false); + }); + + it("reflects landed physical projectiles, but not magic or attacks beyond 50 range", () => { + const reflectedFrom = (school: "physical" | "fire", attackerDistance: number) => { + clearMobRuntimeRegistry(); + initializeRomCharacter("rom-warden"); + const weapon = { damageMin: 100, damageMax: 120, damageSchool: "physical" as const, speedMs: 2_000 }; + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, meleeWeapon: weapon }, + })); + const mobId = `briar-reflect-${school}-${attackerDistance}`; + useCombatStore.getState().registerMob(mobId, { + name: "Briar Shield attacker", + maxHealth: 100_000, + level: 100, + attacks: [{ + ...DEFAULT_ENEMY_ATTACK, + id: `briar-reflect-${school}`, + delivery: "projectile", + school, + range: 100, + initialCooldownMs: [0, 0], + }], + }); + setMobPosition(mobId, [attackerDistance, 0, 0]); + useCombatStore.getState().selectMob(mobId); + expect(useCombatStore.getState().castAbility("rom-warden-general-1", undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().castAbility("rom-warden-primary-11", undefined, 3_000)) + .toMatchObject({ ok: true }); + useCombatStore.getState().damageMob(mobId, 1, 3_000); + const healthBeforeIncomingHit = useCombatStore.getState().mobs[mobId]!.health; + const incomingDamage = useCombatStore.getState().advanceMobCombat(3_450, [0, 0, 0]); + const healthAfterIncomingHit = useCombatStore.getState().mobs[mobId]!.health; + return { incomingDamage, reflectedDamage: healthBeforeIncomingHit - healthAfterIncomingHit }; + }; + + const physicalHit = reflectedFrom("physical", 2); + expect(physicalHit.incomingDamage).toBeGreaterThan(0); + expect(physicalHit.reflectedDamage).toBe(4); + const magicHit = reflectedFrom("fire", 2); + expect(magicHit.incomingDamage).toBeGreaterThan(0); + expect(magicHit.reflectedDamage).toBe(0); + const outOfRangePhysicalHit = reflectedFrom("physical", 60); + expect(outOfRangePhysicalHit.incomingDamage).toBeGreaterThan(0); + expect(outOfRangePhysicalHit.reflectedDamage).toBe(0); + }); + + it("uses Briar Shield's defense aura against physical enemy attacks", () => { + const incomingDamage = (withBriarShield: boolean) => { + clearMobRuntimeRegistry(); + initializeRomCharacter("rom-warden", "rom-warrior"); + const mobId = "briar-shield-defense-attacker"; + useCombatStore.getState().registerMob(mobId, { + name: "Briar Shield attacker", + maxHealth: 100_000, + level: 100, + attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "briar-shield-physical-swing", delivery: "projectile", initialCooldownMs: [0, 0] }], + }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().selectMob(mobId); + if (withBriarShield) { + expect(useCombatStore.getState().castAbility("rom-warden-general-1", undefined, 1_000)) + .toMatchObject({ ok: true }); + } + expect(useCombatStore.getState().castAbility("rom-warden-primary-11", undefined, 3_000)) + .toMatchObject({ ok: true }); + useCombatStore.getState().damageMob(mobId, 1, 3_000); + return useCombatStore.getState().advanceMobCombat(3_450, [0, 0, 0]); + }; + + const withoutBriarShield = incomingDamage(false); + const withBriarShield = incomingDamage(true); + expect(withoutBriarShield).toBeGreaterThan(0); + expect(withBriarShield).toBeGreaterThan(0); + expect(withBriarShield).toBeLessThan(withoutBriarShield); + }); + 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"); @@ -122,6 +262,66 @@ describe("RoM Warden and Warden-linked source behaviors", () => { expect(reducedDamage).toBeCloseTo(normalDamage * 0.9, 0); }); + it("reduces Warden Punch and Sepal Stab cooldowns by one second with Tough Oak", () => { + expect(effectsFor(493382).some((effect) => effect.kind === "cooldown-reset")).toBe(false); + initializeRomCharacter("rom-warden", "rom-mage"); + const state = useCombatStore.getState(); + const toughOak = state.abilities.find((ability) => ability.dbcSpellId === 494207); + expect(toughOak).toMatchObject({ passive: true, secondaryClassId: "rom-mage" }); + expect(state.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_WARDEN_TOUGH_OAK_TAG))).toBe(true); + + const mobId = "tough-oak-target"; + useCombatStore.getState().registerMob(mobId, { name: "Tough Oak target", maxHealth: 100_000 }); + setMobPosition(mobId, [3, 0, 0]); + useCombatStore.getState().selectMob(mobId); + + expect(useCombatStore.getState().castAbility("rom-warden-primary-2", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().cooldowns["rom-warden-primary-2"]).toBe(5_000); + expect(useCombatStore.getState().castAbility("rom-warden-primary-14", undefined, 2_500).ok).toBe(true); + expect(useCombatStore.getState().cooldowns["rom-warden-primary-14"]).toBe(6_500); + }); + + it("activates Oak Blessing's attack and periodic-healing riders only with Heart of the Oak", () => { + initializeRomCharacter("rom-warden", "rom-mage"); + const passive = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 494011)!; + expect(passive).toMatchObject({ passive: true, secondaryClassId: "rom-mage" }); + expect(passive.effects).toHaveLength(1); + expect(passive.effects[0]).toMatchObject({ + kind: "apply-aura", + aura: { utilityTags: [ROM_WARDEN_OAK_BLESSING_TAG] }, + }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_WARDEN_OAK_BLESSING_TAG))).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "attack-power"))).toBe(false); + + const now = 10_000; + expect(useCombatStore.getState().castAbility("rom-warden-primary-10", undefined, now)).toMatchObject({ ok: true }); + const stateAfterCast = useCombatStore.getState(); + expect(stateAfterCast.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" + && modifier.stat === "attack-power"))).toMatchObject({ + expiresAt: now + 60_000, + definition: { + modifiers: [{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.05 }], + }, + }); + expect(stateAfterCast.effects.find((effect) => effect.kind === "hot" + && effect.sourceAbilityId === "rom-warden-primary-10")).toMatchObject({ + remainingTicks: 20, + intervalMs: 3_000, + nextTickAt: now + 3_000, + percentMaxHealth: 0.01, + }); + + const healthBefore = stateAfterCast.maxHealth - 200; + useCombatStore.setState({ health: healthBefore }); + useCombatStore.getState().tick(3, now + 3_000); + expect(useCombatStore.getState().health).toBe(healthBefore + Math.round(stateAfterCast.maxHealth * 0.01)); + }); + it("keeps Nature Crystal active and makes its owner immune while blocking attack skills", () => { initializeRomCharacter("rom-warden"); const now = Date.now(); @@ -271,7 +471,7 @@ describe("RoM Warden and Warden-linked source behaviors", () => { 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" }); + expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" }); }); it("scales Gift Pulse by spent Nature Power and targets nearby party members", () => { @@ -281,7 +481,7 @@ describe("RoM Warden and Warden-linked source behaviors", () => { 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" }); + .toEqual({ kind: "resource-drain", amount: 3, burn: false, resource: "nature-power", recipient: "caster" }); expect(effectsFor(499936, { naturePower: 20 }).find((effect) => effect.kind === "heal")) .toMatchObject({ percentMaxHealth: 1 }); expect(ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499936)) diff --git a/src/game/romWarlockOverrides.ts b/src/game/romWarlockOverrides.ts index 16ef3a52..54ad2ac5 100644 --- a/src/game/romWarlockOverrides.ts +++ b/src/game/romWarlockOverrides.ts @@ -147,9 +147,24 @@ export function romWarlockCastEffects( effects: readonly AbilityEffect[], targetTags: readonly string[], learnedPassiveSpellIds: readonly number[] = [], + targetHealthPercent = 1, ): readonly AbilityEffect[] { if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return effects; + if (ability.dbcSpellId === 498544) { + return effects.flatMap((effect): AbilityEffect[] => { + if (effect.kind !== "apply-aura" || effect.sourceAuraType !== 192 || effect.miscValue !== 5) { + return [effect]; + } + if (targetHealthPercent >= 0.2) return []; + return [{ + ...effect, + recipient: "caster", + aura: { ...effect.aura, disposition: "buff" }, + }]; + }); + } + if (ability.dbcSpellId === 498549 && learnedPassiveSpellIds.includes(498648)) { // Mind Shield (498648) reduces Defense Net's sourced five-second aura by one second. diff --git a/src/game/romWarlockRuntime.test.ts b/src/game/romWarlockRuntime.test.ts index a95469f5..0cbe3f5f 100644 --- a/src/game/romWarlockRuntime.test.ts +++ b/src/game/romWarlockRuntime.test.ts @@ -3,9 +3,14 @@ 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 { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; -import { usePartyStore } from "./partyStore"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { romEffectsForCast } from "./romCombatMechanics"; import { @@ -65,9 +70,130 @@ describe("RuneWaker Warlock runtime", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); useCombatStore.setState({ playerPosition: [0, 0, 0] }); }); + it("gives the caster Ruthless Judgment's source bonus only below 20% target health", () => { + const ruthlessJudgment = ability(498544); + const effectsFor = (targetHealthPercent: number) => romEffectsForCast(ruthlessJudgment, { + sealStacks: 0, + bleedCount: 0, + charged: false, + mainHandDagger: false, + casterMaxHealth: 10_000, + targetHealthPercent, + }); + const bonusAuras = (targetHealthPercent: number) => effectsFor(targetHealthPercent).filter((effect) => ( + effect.kind === "apply-aura" && effect.sourceAuraType === 192 && effect.miscValue === 5 + )); + const below = bonusAuras(0.199); + expect(below).toHaveLength(1); + expect(below[0]).toMatchObject({ + kind: "apply-aura", + recipient: "caster", + aura: { + disposition: "buff", + durationMs: 20_000, + modifiers: [expect.objectContaining({ + kind: "damage", + direction: "dealt", + attackKind: "spell", + operation: "percent", + value: 0.05, + })], + }, + }); + expect(bonusAuras(0.2)).toHaveLength(0); + expect(bonusAuras(0.8)).toHaveLength(0); + + const sourceDamage = ruthlessJudgment.effects.find((effect) => effect.kind === "damage"); + expect(effectsFor(0.199).find((effect) => effect.kind === "damage")).toEqual(sourceDamage); + expect(effectsFor(0.2).find((effect) => effect.kind === "damage")).toEqual(sourceDamage); + }); + + it("applies Ruthless Judgment's low-health bonus to the player caster", () => { + prepareWarlock("rom-priest"); + registerTarget(); + useCombatStore.getState().damageMob(TARGET_ID, 81_000, 900); + + const ruthlessJudgment = ability(498544); + expect(useCombatStore.getState().castAbility(ruthlessJudgment.id, { + targetActorId: TARGET_ID, + now: 1_000, + }).ok).toBe(true); + const completesAt = useCombatStore.getState().activeCast?.completesAt; + expect(completesAt).toBeDefined(); + finishCast(1_000); + + const bonusAura = useCombatStore.getState().auras.find((aura) => ( + aura.sourceId === PLAYER_AURA_ENTITY_ID + && aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.name.startsWith("Ruthless Judgment") + )); + expect(bonusAura).toMatchObject({ + definition: { + disposition: "buff", + modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.05 })], + }, + }); + expect(bonusAura?.expiresAt).toBe(completesAt! + 20_000); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === TARGET_ID && aura.definition.name.startsWith("Ruthless Judgment") + ))).toBe(false); + }); + + it("applies Ruthless Judgment's low-health bonus to a companion Warlock caster", () => { + useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 }); + usePartyStore.getState().initializeParty("rom-warlock-ruthless-companion", 80, + useCombatStore.getState().health, "damage", 2, "rom"); + const warlock = createConfiguredPartyMember({ + name: "Ruthless Companion", + classId: "rom-warlock", + secondaryClassId: "rom-priest", + raceId: "rom-dwarf", + specialization: "Warlock", + role: "damage", + }, 80, 0, "rom-warlock-ruthless-companion"); + const ruthlessJudgment = ability(498544); + const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest"); + usePartyStore.setState({ + command: "attack", + members: [{ + ...warlock, + cooldowns: Object.fromEntries(pairAbilities + .filter((candidate) => candidate.id !== ruthlessJudgment.id) + .map((candidate) => [candidate.id, 100_000])), + resourcePools: { ...warlock.resourcePools, focus: 100 }, + resource: 100, + }], + }); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(warlock.id, [0, 0, 0]); + registerTarget(); + useCombatStore.getState().damageMob(TARGET_ID, 81_000, 900); + updatePartyRuntimeTargetVisibility(warlock.id, TARGET_ID, true); + + advanceDungeonPartyCombat(1_000); + advanceDungeonPartyCombat(3_100); + + const bonusAura = useCombatStore.getState().auras.find((aura) => ( + aura.sourceId === warlock.id + && aura.targetId === warlock.id + && aura.definition.name.startsWith("Ruthless Judgment") + )); + expect(bonusAura).toMatchObject({ + expiresAt: 23_100, + definition: { + disposition: "buff", + modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.05 })], + }, + }); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === TARGET_ID && aura.definition.name.startsWith("Ruthless Judgment") + ))).toBe(false); + }); + it("converts the RoM Warlock/Warrior Rage pool to psi at 35:1", () => { prepareWarlock("rom-warrior"); useCombatStore.setState((state) => ({ diff --git a/src/game/romWarriorOverrides.test.ts b/src/game/romWarriorOverrides.test.ts index d9d37986..4c1c12ae 100644 --- a/src/game/romWarriorOverrides.test.ts +++ b/src/game/romWarriorOverrides.test.ts @@ -1,12 +1,17 @@ import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import type { AbilityEffect } from "./abilityCatalog"; -import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { overrideRomWarriorEffects } from "./romWarriorOverrides"; -import { useCombatStore } from "./combatStore"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { usePartyStore } from "./partyStore"; +import { + ROM_WARRIOR_BRIAR_TECHNIQUE_AURA, + ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG, + ROM_WARRIOR_BRIAR_TECHNIQUE_TAG, +} from "./romWarriorOverrides"; describe("RoM Warrior/Priest Defender's Roar party scope", () => { beforeEach(() => { @@ -112,3 +117,68 @@ describe("RoM Warrior/Priest Defender's Roar party scope", () => { )))).toBe(true); }); }); + +describe("RoM Druid/Warrior Briar Technique", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.setState({ playerPosition: [0, 0, 0] }); + }); + + it("stacks the local bonuses only after landed Natural Attack or Heart Piercing hits", () => { + const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499955)!; + const naturalAttack = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494016)!; + const heartPiercing = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494216)!; + expect(passive).toMatchObject({ passive: true, classId: "rom-druid", secondaryClassId: "rom-warrior" }); + expect(passive.effects[0]).toMatchObject({ + kind: "apply-aura", + aura: { utilityTags: [ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG] }, + }); + for (const ability of [naturalAttack, heartPiercing]) { + expect(ability.effects.some((effect) => effect.kind === "apply-aura" + && effect.aura.id.includes("-623080-"))).toBe(false); + } + expect(ROM_WARRIOR_BRIAR_TECHNIQUE_AURA).toMatchObject({ + durationMs: 15_000, + maxStacks: 5, + stackBehavior: "add", + modifiers: [ + { kind: "stat", stat: "attack-power", operation: "percent", value: 0.03, perStack: true }, + { kind: "stat", stat: "wisdom", operation: "percent", value: 0.03, perStack: true }, + ], + }); + + useCombatStore.getState().initializeCharacter({ + classId: "rom-druid", + secondaryClassId: "rom-warrior", + raceId: "rom-elf", + level: 80, + }); + useCombatStore.setState((state) => ({ + resourcePools: { ...state.resourcePools, mana: 1_000, rage: 1_000 }, + maxResourcePools: { ...state.maxResourcePools, mana: 1_000, rage: 1_000 }, + })); + const mobId = "briar-technique-target"; + useCombatStore.getState().registerMob(mobId, { name: "Briar Technique target", level: 1, maxHealth: 1_000_000 }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().selectMob(mobId); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG))).toBe(true); + + const casts = [ + [naturalAttack.id, 1_000, 1], + [heartPiercing.id, 2_500, 2], + [naturalAttack.id, 5_000, 3], + [naturalAttack.id, 10_000, 4], + [naturalAttack.id, 14_000, 5], + [heartPiercing.id, 15_500, 5], + ] as const; + for (const [abilityId, now, expectedStacks] of casts) { + expect(useCombatStore.getState().castAbility(abilityId, undefined, now)).toMatchObject({ ok: true }); + const aura = useCombatStore.getState().auras.find((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID + && entry.definition.utilityTags?.includes(ROM_WARRIOR_BRIAR_TECHNIQUE_TAG)); + expect(aura?.stacks).toBe(expectedStacks); + expect(aura?.expiresAt).toBe(now + 15_000); + } + }); +}); diff --git a/src/game/romWarriorOverrides.ts b/src/game/romWarriorOverrides.ts index 9eba1207..6f495dcc 100644 --- a/src/game/romWarriorOverrides.ts +++ b/src/game/romWarriorOverrides.ts @@ -1,6 +1,24 @@ import type { AbilityEffect } from "./abilityCatalog"; +import type { AuraDefinition } from "./combatAuras"; import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; +export const ROM_WARRIOR_BRIAR_TECHNIQUE_TAG = "rom-druid-warrior-briar-technique"; +export const ROM_WARRIOR_BRIAR_TECHNIQUE_PASSIVE_TAG = "rom-druid-warrior-briar-technique-learned"; +export const ROM_WARRIOR_BRIAR_TECHNIQUE_TRIGGER_SPELL_IDS = new Set([494016, 494216]); +export const ROM_WARRIOR_BRIAR_TECHNIQUE_AURA: AuraDefinition = { + id: "rom-druid-warrior-briar-technique", + name: "Briar Technique", + disposition: "buff", + durationMs: 15_000, + maxStacks: 5, + stackBehavior: "add", + utilityTags: [ROM_WARRIOR_BRIAR_TECHNIQUE_TAG], + modifiers: [ + { kind: "stat", stat: "attack-power", operation: "percent", value: 0.03, perStack: true }, + { kind: "stat", stat: "wisdom", operation: "percent", value: 0.03, perStack: true }, + ], +}; + type RomWarriorGeneratedRow = RomGeneratedAbilityLike & { readonly classId?: string; readonly secondaryClassId?: string; diff --git a/src/game/talentModifiers.test.ts b/src/game/talentModifiers.test.ts index 7bf78813..82d365b0 100644 --- a/src/game/talentModifiers.test.ts +++ b/src/game/talentModifiers.test.ts @@ -27,6 +27,35 @@ describe("talent runtime modifiers", () => { expect(applyTalentModifierValue(100, maxRank, "damage-percent", { tags: ["cast-time", "holy"] })).toBe(105); }); + it("keeps Arcane Potency's next-spell crit bonus out of passive modifiers", () => { + for (const rank of [1, 2]) { + const modifiers = talentModifiersForRanks({ "talent-1725": rank }, "mage"); + expect(modifiers.filter((modifier) => modifier.kind === "critical-chance-percent")).toEqual([]); + } + }); + + it("keeps Molten Fury's target-health bonus out of unconditional scalar modifiers", () => { + for (const rank of [1, 2]) { + const modifiers = talentModifiersForRanks({ "talent-1732": rank }, "mage"); + expect(modifiers.filter((modifier) => modifier.kind === "damage-percent")).toEqual([]); + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: "Fireball", + tags: ["damage", "fire"], + })).toBe(100); + } + }); + + it("keeps Master of Elements' passive crit modifier single-counted", () => { + const modifiers = talentModifiersForRanks({ "talent-1639": 3 }, "mage"); + expect(modifiers.filter((modifier) => modifier.kind === "critical-chance-percent")).toEqual([ + expect.objectContaining({ + amount: 3, + sourceTalentId: "talent-1639", + sourceTalentName: "Master of Elements", + }), + ]); + }); + it("parses common passive stat, cost, cast-time, cooldown, and mitigation categories", () => { expect(parseTalentModifiers("Increases your maximum health by 10%.")).toContainEqual( expect.objectContaining({ kind: "max-health-percent", amount: 10 }), @@ -67,6 +96,40 @@ describe("talent runtime modifiers", () => { ])); }); + it("applies Spell Warding to direct and periodic magic damage, but not physical damage", () => { + const spellWarding = parseTalentModifiers( + "Reduces all direct and periodic magic damage taken by 3%.", + ).map((modifier) => ({ + ...modifier, + sourceTalentId: "test:spell-warding", + sourceTalentName: "Spell Warding", + rank: 3, + description: "Reduces all direct and periodic magic damage taken by 3%.", + })); + const generatedMaxRank = talentModifiersForRanks({ "talent-411": 3 }, "priest"); + + expect(spellWarding).toContainEqual(expect.objectContaining({ + kind: "damage-taken-percent", + amount: -3, + scope: "all direct and periodic magic damage taken", + })); + expect(generatedMaxRank).toContainEqual(expect.objectContaining({ + kind: "damage-taken-percent", + amount: -3, + sourceTalentId: "talent-411", + rank: 3, + })); + expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", { + tags: ["magic", "shadow"], + })).toBe(97); + expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", { + tags: ["magic", "fire", "periodic"], + })).toBe(97); + expect(applyTalentModifierValue(100, spellWarding, "damage-taken-percent", { + tags: ["physical"], + })).toBe(100); + }); + 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"); @@ -110,6 +173,32 @@ describe("talent runtime modifiers", () => { expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000); }); + it("scopes WoW Mage Impact's rank-scaled damage bonus to Fire Blast and Meteor", () => { + const impact = TALENT_NODES.find((node) => node.id === "talent-30")!; + + for (const rank of [1, 2, 3]) { + const modifiers = talentModifiersForRanks({ [impact.id]: rank }, "mage"); + const expectedBonus = rank * 3; + expect(modifiers).toContainEqual(expect.objectContaining({ + kind: "damage-percent", + amount: expectedBonus, + scope: "damage of your Fire Blast and Meteor abilities", + })); + for (const abilityName of ["Fire Blast", "Meteor"]) { + expect( + applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName, tags: ["fire"] }), + `${abilityName} at Impact rank ${rank}`, + ).toBeCloseTo(100 + expectedBonus); + } + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: "Fireball", tags: ["fire"], + })).toBe(100); + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: "Frostbolt", tags: ["frost"], + })).toBe(100); + } + }); + 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: "" })); @@ -166,4 +255,23 @@ describe("talent runtime modifiers", () => { tags: ["damage", "physical"], })).toBe(100); }); + + it("applies Mage Flame Throwing's generated flat range only to damaging spells", () => { + for (const [rank, bonus] of [[1, 2], [2, 4], [3, 6]] as const) { + const modifiers = talentModifiersForRanks({ "talent-28": rank }, "mage"); + expect(applyTalentModifierValue(30, modifiers, "range-flat-yards", { + abilityName: "Fireball", + tags: ["cast-time", "damage", "fire"], + })).toBe(30 + bonus); + expect(applyTalentModifierValue(30, modifiers, "range-flat-yards", { + abilityName: "Flash Heal", + tags: ["cast-time", "healing", "holy"], + })).toBe(30); + expect(applyTalentModifierValue(30, talentModifiersForRanks({ "talent-28": rank }, "priest"), + "range-flat-yards", { + abilityName: "Smite", + tags: ["cast-time", "damage", "holy"], + })).toBe(30); + } + }); }); diff --git a/src/game/talentModifiers.ts b/src/game/talentModifiers.ts index d37a446a..8db2ea92 100644 --- a/src/game/talentModifiers.ts +++ b/src/game/talentModifiers.ts @@ -4,6 +4,9 @@ import { type RuntimeTalentRanks as TalentRanks, } from "./talentRuntimeCatalog"; import { talentBalanceOverride } from "./balanceTuning"; +import { wow335MageFlameThrowingProfile } from "./wow335MageFlameThrowingRuntime"; +import { wow335DruidTalentModifierOverrides } from "./wow335DruidTalentRuntime"; +import { wow335ShamanConvectionModifiersForRank } from "./wow335ShamanTalentRuntime"; export type TalentModifierKind = | "damage-percent" @@ -21,6 +24,7 @@ export type TalentModifierKind = | "cooldown-percent" | "cooldown-flat-ms" | "armor-percent" + | "range-flat-yards" | "attribute-percent" | "range-percent" | "duration-percent" @@ -30,7 +34,7 @@ export interface TalentModifier { readonly kind: TalentModifierKind; /** Signed amount: increases are positive and reductions are negative. */ readonly amount: number; - readonly unit: "percent" | "milliseconds"; + readonly unit: "percent" | "milliseconds" | "yards"; readonly scope: string; readonly attribute?: "agility" | "intellect" | "spirit" | "stamina" | "strength"; readonly sourceTalentId: string; @@ -43,6 +47,9 @@ interface ParsedModifier extends Omit { + if (classId === "druid" && active.talentId === "talent-799") { + // Do not parse Tiger's Fury and Enrage's conditional bonuses as global + // damage dealt/taken modifiers. Their temporary aura states are applied + // by the Druid cast runtime. + return wow335DruidTalentModifierOverrides(active.talentId, active.rank, active.description); + } + // Elemental Oath's generated rank text repeats one crit bonus for the + // owner and the party. CombatStore applies the rank once to each spell + // attack/heal source, so the broad scalar parser must not apply it again. + if (classId === "shaman" && active.talentId === "talent-2049") return []; + if (classId === "shaman" && active.talentId === "talent-564") { + return wow335ShamanConvectionModifiersForRank(active.rank).map((modifier) => ({ + ...modifier, + sourceTalentId: active.talentId, + sourceTalentName: active.talentName, + rank: active.rank, + description: active.description, + })); + } + if (classId === "mage" && active.talentId === "talent-28") { + const amount = wow335MageFlameThrowingProfile(active.rank).damagingSpellRangeBonusYards; + return amount > 0 ? [{ + kind: "range-flat-yards" as const, + amount, + unit: "yards" as const, + scope: "your damaging spells", + sourceTalentId: active.talentId, + sourceTalentName: active.talentName, + rank: active.rank, + description: active.description, + }] : []; + } + // Molten Fury is active only while the individual target is below 35% + // health. The target-health predicate is applied by the Mage damage path, + // so keep its generated rank amount out of always-on scalar modifiers. + if (classId === "mage" && active.talentId === "talent-1732") return []; if (active.talentName === "Backlash") { return [{ kind: "critical-chance-percent" as const, @@ -186,6 +251,20 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): description: active.description, }]; } + if (active.talentName === "Backdraft") { + const shadowburnDamage = active.description.match(/increases the damage of your Shadowburn by (\d+(?:\.\d+)?)%/i); + if (!shadowburnDamage) return []; + return [{ + kind: "damage-percent" as const, + amount: Number(shadowburnDamage[1]), + unit: "percent" as const, + scope: "damage of your Shadowburn", + 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 @@ -193,7 +272,28 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): if (active.talentName === "Test of Faith") return []; const override = talentBalanceOverride(active.talentId); const scale = (override?.effectScalePercent ?? 100) / 100; + const druidOverrides = classId === "druid" + ? wow335DruidTalentModifierOverrides(active.talentId, active.rank, active.description) + : []; + const overriddenKinds = new Set(druidOverrides.map((modifier) => modifier.kind)); const parsed = parseTalentModifiers(active.description) + .filter((modifier) => !overriddenKinds.has(modifier.kind)) + // Empowered Fire's generated tooltip names the three affected spells, + // but this effect belongs to the caster. Combat applies it after + // resolving the source actor so party Mage ranks cannot leak from the + // player's scalar modifier list. + .filter((modifier) => !(classId === "mage" && active.talentId === "talent-1734" + && modifier.kind === "damage-percent")) + // Focused Will's healing-received rider only exists after taking a + // critical hit. Preserve the rank's passive spell-crit modifier above, + // while the conditional +3% healing stacks come from its runtime aura. + .filter((modifier) => !(classId === "priest" && active.talentId === "talent-1858" + && modifier.kind === "healing-percent")) + // Impact's passive bonus is restricted to Fire Blast and Meteor. The + // generated tooltip names those abilities without saying "your", so the + // general scope parser would otherwise treat the damage bonus as global. + .filter((modifier) => !(classId === "mage" && active.talentId === "talent-30" + && modifier.kind === "damage-percent")) // 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") @@ -232,6 +332,19 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): const unrelentingAssaultDamage = classId === "warrior" && active.talentName === "Unrelenting Assault" ? active.description.match(/increases the damage done by both abilities by (\d+(?:\.\d+)?)%/i) : null; + const mageImpactDamage = classId === "mage" && active.talentId === "talent-30" + ? active.description.match(/increases the damage of Fire Blast and Meteor by (\d+(?:\.\d+)?)%/i) + : null; + const explicitMageImpact = mageImpactDamage ? [{ + kind: "damage-percent" as const, + amount: Number(mageImpactDamage[1]) * scale, + unit: "percent" as const, + scope: "damage of your Fire Blast and Meteor abilities", + sourceTalentId: active.talentId, + sourceTalentName: active.talentName, + rank: active.rank, + description: active.description, + }] : []; const explicitScoped = unrelentingAssaultDamage ? [{ kind: "damage-percent" as const, amount: Number(unrelentingAssaultDamage[1]) * scale, @@ -255,7 +368,7 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): description: active.description, }]; }); - return [...parsed, ...explicitScoped, ...supplemental]; + return [...parsed, ...explicitMageImpact, ...explicitScoped, ...druidOverrides, ...supplemental]; }); } @@ -267,8 +380,11 @@ export interface TalentModifierContext { function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean { const scope = modifier.scope.toLowerCase(); const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase())); + if (scope.includes("damaging spell") && !tags.has("damage")) return false; if (scope.includes("instant") && !tags.has("instant")) return false; - if (scope.includes("periodic") && !tags.has("periodic")) return false; + const coversDirectAndPeriodic = /\bdirect and periodic\b/.test(scope); + if (scope.includes("periodic") && !coversDirectAndPeriodic && !tags.has("periodic")) return false; + if (scope.includes("magic damage") && !tags.has("magic")) return false; const schools = (["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) .filter((school) => scope.includes(school)); if (schools.length > 1) { diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index c2d856cd..0de6017c 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -27,6 +27,12 @@ import { } from "./roguePoison"; import { runtimeTalentNodeById } from "./talentRuntimeCatalog"; import { baseResourcesFor } from "./wotlkStats"; +import { + WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES, + wow335WarlockMetamorphosisAbility, +} from "./wow335WarlockMetamorphosisRuntime"; +import { WOW335_WARLOCK_SOUL_LINK_TAG } from "./wow335WarlockSoulLinkRuntime"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10; @@ -542,6 +548,8 @@ function namedAuraForEffect( id: `${auraId}:holy-shield`, triggers: "block", chance: 1, + // Holy Shield ranks 20925/20927/20928 carry procCharges=8 in local Spell.dbc. + charges: 8, action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" }, }); } @@ -646,8 +654,10 @@ function namedAuraForEffect( ...(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 + const controlImmunities = classId === "paladin" && abilityName === "Aura Mastery" + ? effect.miscValue === 3 ? ["silence"] as const + : effect.miscValue === 12 ? ["interrupt"] as const + : undefined : controlImmunitiesForAura(effect.auraType, effect.miscValue); return { id: auraId, @@ -878,7 +888,10 @@ function classScriptEffects( maxScalingLevel: rank.maxScalingLevel, }] : []; } - case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }]; + case "warrior:Charge": return [ + { kind: "movement", movement: "charge", toward: "target" }, + { kind: "status", status: "stun", durationMs: 1_500 }, + ]; case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }]; case "warrior:Devastate": return [ { @@ -969,15 +982,14 @@ function classScriptEffects( 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%.", + description: rank.description, 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. + // The generated row omits the starting damage bonus; leave the pet + // special-attack rider inert until that value and a special-hit event + // are both represented by the runtime. }, sourceEffectType: 6, sourceAuraType: 4, @@ -993,6 +1005,43 @@ function classScriptEffects( triggerSpellId: 60202, durationMs: 30_000, }]; + case "hunter:Freezing Trap": + case "hunter:Frost Trap": return [{ + kind: "utility", + action: "class-script", + effectType: 104, + auraType: 0, + miscValue: rank.effects.find((effect) => effect.effectType === 104)?.miscValue ?? 0, + triggerSpellId: 0, + durationMs: rank.durationMs, + }]; + case "hunter:Immolation Trap": { + const child = HUNTER_IMMOLATION_TRAP_CHILD_BY_RANK[rank.spellId]; + return [ + { + kind: "utility", + action: "class-script", + effectType: 105, + auraType: 0, + miscValue: rank.effects.find((effect) => effect.effectType === 105)?.miscValue ?? 0, + triggerSpellId: 0, + durationMs: rank.durationMs, + }, + ...(child ? [{ + kind: "dot" as const, + coefficient: 0, + basePoints: child.basePoints, + dieSides: child.dieSides, + rangedAttackPowerCoefficient: 0.02, + sourceLevel: Math.max(1, rank.sourceLevel || rank.level), + ...(rank.maxScalingLevel > 0 ? { maxScalingLevel: rank.maxScalingLevel } : {}), + school: "fire" as const, + ticks: 5, + intervalMs: 3_000, + tag: "hunter-immolation-trap", + }] : []), + ]; + } case "hunter:Explosive Trap": { const child = HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK[rank.spellId]; return child ? [{ @@ -1433,6 +1482,26 @@ function classScriptEffects( mode: "percent", amount: rank.effects.find((effect) => effect.effectType === 3)?.basePoints ?? -50, }]; + // Soul Link's populated talent row provides the gameplay percentage and + // demon restrictions; its active DBC spell row is only a dummy marker. + case "warlock:Soul Link": return [{ + kind: "apply-aura", + recipient: "caster", + aura: { + id: "wow335:warlock:soul-link", + name: abilityName, + description: "Shares 6% of incoming damage with your active, controlled demon within 30 yards.", + disposition: "buff", + durationMs: null, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: [WOW335_WARLOCK_SOUL_LINK_TAG], + }, + sourceEffectType: 3, + sourceAuraType: 0, + miscValue: 0, + triggerSpellId: 0, + }]; case "warlock:Life Tap": return [{ kind: "resource", resource: "mana", @@ -1573,6 +1642,20 @@ const HUNTER_EXPLOSIVE_TRAP_CHILD_BY_RANK: Readonly> = { + 13795: { basePoints: 21, dieSides: 1 }, + 14302: { basePoints: 43, dieSides: 1 }, + 14303: { basePoints: 68, dieSides: 1 }, + 14304: { basePoints: 102, dieSides: 1 }, + 14305: { basePoints: 138, dieSides: 1 }, + 27023: { basePoints: 197, dieSides: 1 }, + 49055: { basePoints: 308, dieSides: 1 }, + 49056: { basePoints: 377, dieSides: 1 }, +}; + export function hunterExplosiveTrapDirectScaling( spellId: number, sourceLevel = 0, @@ -2162,7 +2245,11 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef return healing ? { ...rank, effects: [...rank.effects, ...healing.effects] } : rank; }) : playerRanks; - const ranks = castRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name)); + const soulLinkTalentTooltip = classId === "warlock" && chain.name === "Soul Link" + ? WOW335_TALENT_NODES.find((node) => node.id === chain.talentNodeId)?.rankDescriptions[0] + : undefined; + const ranks = castRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name)) + .map((rank) => soulLinkTalentTooltip ? { ...rank, description: soulLinkTalentTooltip } : rank); const first = ranks[0]; const firstRaw = castRanks[0]; if (!first || !firstRaw) return null; @@ -2254,6 +2341,7 @@ function abilityNameTarget(classId: BaseClassId, abilityName: string): AbilityTa if (abilityName === "Soulshatter") return "self"; if (abilityName === "Desperate Prayer") return "self"; if (abilityName === "Lightwell") return "self"; + if (classId === "warlock" && abilityName === "Soul Link") return "self"; if (abilityName === "Dispel Magic") return "any"; if (abilityName === "Holy Shock") return "any"; if (abilityName === "Volley") return "hostile"; @@ -2272,13 +2360,21 @@ function classicCooldownGroup(classId: BaseClassId, abilityName: string): string } export const WOW335_ABILITIES_BY_CLASS: Readonly> = - Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => [ - classId, - data.classes[classId] + Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => { + const generated = data.classes[classId] .map((chain) => abilityDefinition(classId, chain)) - .filter((ability): ability is AbilityDefinition => Boolean(ability)) - .sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)), - ]))) as unknown as Readonly>; + .filter((ability): ability is AbilityDefinition => Boolean(ability)); + const specialized = classId === "warlock" + ? [ + ...generated.filter((ability) => ability.id !== "wow335-warlock-metamorphosis"), + wow335WarlockMetamorphosisAbility(), + ...WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES, + ] + : generated; + return [classId, specialized.sort((left, right) => ( + left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name) + ))]; + }))) as unknown as Readonly>; // These ground channels store their actual damage in same-name triggered // child spells. Resolve the child once so each channel pulse can use its diff --git a/src/game/wow335DeathKnightMarkOfBlood.test.ts b/src/game/wow335DeathKnightMarkOfBlood.test.ts new file mode 100644 index 00000000..a0311d90 --- /dev/null +++ b/src/game/wow335DeathKnightMarkOfBlood.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import type { OnlineSessionPlayer } from "../app/onlineSessionTypes"; +import { installCombatClock } from "./combatClock"; +import { createDeathKnightRunes } from "./combatActors"; +import { useCombatStore, PLAYER_AURA_ENTITY_ID, type CombatSummon } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { configureOnlineSessionRuntime, onlinePlayerActorId } from "./onlineSessionRuntime"; +import { + WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG, + WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, +} from "./wow335DeathKnightMarkOfBloodRuntime"; + +let now = 1_000; +let restoreClock: (() => void) | undefined; + +function markAbility() { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 49005); + if (!ability) throw new Error("Missing generated Death Knight Mark of Blood spell."); + return ability; +} + +function registerAttacker(id: string) { + useCombatStore.getState().registerMob(id, { + name: id, + maxHealth: 100_000, + xpReward: 0, + attacks: [{ + id: `${id}-strike`, + name: "Strike", + delivery: "melee", + target: "primary", + school: "physical", + animation: "attack", + range: 5, + cooldownMs: 1_000, + damageMultiplier: 0.01, + }], + }); + setMobPosition(id, [2, 0, 0]); +} + +function castMark() { + const ability = markAbility(); + const result = useCombatStore.getState().castAbility(ability.id, { now }); + expect(result.ok).toBe(true); + return ability; +} + +function remoteVictim(health = 9_000): OnlineSessionPlayer { + return { + accountId: "mark-remote-victim", + username: "Mark victim", + character: { + id: "mark-remote-character", + name: "Mark victim", + classId: "warrior", + categoryId: "wow", + raceId: "human", + gender: "female", + level: 80, + }, + role: "tank", + position: [0, 0, 0], + yaw: 0, + grounded: true, + health, + maxHealth: 10_000, + resource: 0, + maxResource: 100, + resourceName: "Rage", + selectedTargetId: null, + activeCast: null, + animationEvent: null, + equipment: [], + updatedAt: now, + }; +} + +describe("WoW Death Knight Mark of Blood combat integration", () => { + beforeEach(() => { + now = 1_000; + restoreClock = installCombatClock(() => now); + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); + usePartyStore.getState().initializeParty( + "wow335-death-knight-mark-of-blood", 80, useCombatStore.getState().health, "tank", 5, + ); + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: 0, + deathKnightRunes: createDeathKnightRunes(now), + talentRanks: { ...state.talentRanks, "talent-1949": 1 }, + resource: 100, + resourcePools: { "runic-power": 100 }, + })); + registerAttacker("mark-target"); + useCombatStore.getState().selectMob("mark-target"); + }); + + afterEach(() => { + restoreClock?.(); + restoreClock = undefined; + configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); + }); + + it("casts the generated talent spell with twenty charges and a twenty-second duration", () => { + castMark(); + const mark = useCombatStore.getState().auras.find((aura) => aura.targetId === "mark-target" + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); + + expect(mark).toMatchObject({ + sourceId: PLAYER_AURA_ENTITY_ID, + stacks: 20, + expiresAt: now + 20_000, + }); + expect(mark?.definition.maxStacks).toBe(20); + }); + + it("heals the player when the marked enemy lands an attack and consumes one charge", () => { + castMark(); + const maximumHealth = useCombatStore.getState().maxHealth; + const healthBefore = maximumHealth - 5_000; + useCombatStore.setState({ health: healthBefore }); + useCombatStore.getState().damageMob("mark-target", 1, now); + + useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); + + const state = useCombatStore.getState(); + const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage" + && event.sourceActorId === "mark-target" + && event.targetActorId === PLAYER_AURA_ENTITY_ID); + const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing" + && event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID + && event.targetActorId === PLAYER_AURA_ENTITY_ID); + const mark = state.auras.find((aura) => aura.targetId === "mark-target" + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); + + expect(damage?.effectiveAmount).toBeGreaterThan(0); + expect(healing).toMatchObject({ rawAmount: Math.round(maximumHealth * 0.04) }); + expect(healing?.effectiveAmount).toBe(healing?.rawAmount); + expect(state.health).toBe(healthBefore - damage!.effectiveAmount + healing!.effectiveAmount); + expect(mark?.stacks).toBe(19); + }); + + it("heals a party companion when the marked enemy lands an attack on them", () => { + const target = usePartyStore.getState().members[0]!; + const maxHealth = target.maxHealth; + const healthBefore = maxHealth - 1_000; + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === target.id + ? { ...member, health: healthBefore } + : member), + })); + registerPartyRuntimePosition(target.id, [0, 0, 0]); + castMark(); + useCombatStore.getState().damageMob("mark-target", 50_000, now, { + actorId: target.id, + role: target.role ?? "damage", + }); + + useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); + + const state = useCombatStore.getState(); + const memberAfter = usePartyStore.getState().members.find((member) => member.id === target.id)!; + const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage" + && event.sourceActorId === "mark-target" + && event.targetActorId === target.id); + const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing" + && event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID + && event.targetActorId === target.id); + const mark = state.auras.find((aura) => aura.targetId === "mark-target" + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); + + expect(damage?.effectiveAmount).toBeGreaterThan(0); + expect(healing).toMatchObject({ rawAmount: Math.round(maxHealth * 0.04) }); + expect(healing?.effectiveAmount).toBe(healing?.rawAmount); + expect(memberAfter.health).toBe(healthBefore - damage!.effectiveAmount + healing!.effectiveAmount); + expect(mark?.stacks).toBe(19); + }); + + it("heals a summoned companion from the marked enemy's damage event", () => { + const summon: CombatSummon = { + id: "mark-summon-victim", + sourceAbilityId: "test-summon", + creatureId: 0, + targetId: "mark-target", + expiresAt: now + 60_000, + kind: "summon", + name: "Companion", + ownerActorId: PLAYER_AURA_ENTITY_ID, + health: 9_000, + maxHealth: 10_000, + position: [0, 0, 0], + nextActionAt: 0, + pulseIntervalMs: 1_000, + attackDamage: 0, + abilityIds: [], + resources: {}, + cooldowns: {}, + globalCooldownEndsAt: 0, + cast: null, + controlledUntil: 0, + schoolLockedUntil: {}, + }; + useCombatStore.setState({ summons: [summon] }); + castMark(); + useCombatStore.getState().damageMob("mark-target", 50_000, now, { + actorId: summon.id, + role: "tank", + }); + + useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); + + const state = useCombatStore.getState(); + const summonAfter = state.summons.find((candidate) => candidate.id === summon.id)!; + const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage" + && event.sourceActorId === "mark-target" + && event.targetActorId === summon.id); + const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing" + && event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID + && event.targetActorId === summon.id); + const mark = state.auras.find((aura) => aura.targetId === "mark-target" + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); + + expect(damage?.effectiveAmount).toBeGreaterThan(0); + expect(healing).toMatchObject({ rawAmount: 400 }); + expect(summonAfter.health).toBe(summon.health - damage!.effectiveAmount + healing!.effectiveAmount); + expect(mark?.stacks).toBe(19); + }); + + it("heals an online player from the marked enemy's damage event", () => { + const victim = remoteVictim(); + const victimId = onlinePlayerActorId(victim.accountId); + configureOnlineSessionRuntime({ + active: true, + authority: true, + localAccountId: "mark-local", + players: [victim], + }); + registerPartyRuntimePosition(victimId, victim.position); + useCombatStore.getState().synchronizeActors(); + castMark(); + useCombatStore.getState().damageMob("mark-target", 50_000, now, { + actorId: victimId, + role: victim.role ?? "damage", + }); + + useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); + + const state = useCombatStore.getState(); + const victimAfter = state.actors[victimId]!; + const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage" + && event.sourceActorId === "mark-target" + && event.targetActorId === victimId); + const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing" + && event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID + && event.targetActorId === victimId); + const mark = state.auras.find((aura) => aura.targetId === "mark-target" + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); + + expect(damage?.effectiveAmount).toBeGreaterThan(0); + expect(healing).toMatchObject({ rawAmount: 400 }); + expect(victimAfter.health).toBe(victim.health - damage!.effectiveAmount + healing!.effectiveAmount); + expect(mark?.stacks).toBe(19); + }); +}); diff --git a/src/game/wow335DeathKnightMarkOfBloodRuntime.test.ts b/src/game/wow335DeathKnightMarkOfBloodRuntime.test.ts new file mode 100644 index 00000000..41b570ab --- /dev/null +++ b/src/game/wow335DeathKnightMarkOfBloodRuntime.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { applyAura } from "./combatAuras"; +import { + WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG, + WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, + wow335DeathKnightMarkOfBloodAuraDefinition, + wow335DeathKnightMarkOfBloodOnDamage, + wow335DeathKnightMarkOfBloodProfile, +} from "./wow335DeathKnightMarkOfBloodRuntime"; + +describe("WoW Death Knight Mark of Blood source runtime", () => { + it("reads heal percentage, duration, and hit cap from generated talent-1949", () => { + expect(wow335DeathKnightMarkOfBloodProfile()).toEqual({ + healingFraction: 0.04, + durationMs: 20_000, + hitCap: 20, + }); + }); + + it("configures the DBC aura with the generated twenty-hit cap and duration", () => { + const definition = wow335DeathKnightMarkOfBloodAuraDefinition({ + id: "wow335:death-knight:mark-of-blood", + name: "Mark of Blood", + disposition: "debuff", + durationMs: 0, + }); + const applied = applyAura([], { + definition, + sourceId: "dk-player", + targetId: "marked-enemy", + now: 1_000, + }).aura; + + expect(applied.definition.durationMs).toBe(20_000); + expect(applied.expiresAt).toBe(21_000); + expect(applied.definition.maxStacks).toBe(20); + expect(applied.stacks).toBe(20); + expect(applied.definition.utilityTags).toContain(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG); + }); + + it("heals for four percent of max health and consumes only landed-hit charges", () => { + const mark = applyAura([], { + definition: wow335DeathKnightMarkOfBloodAuraDefinition({ + id: "wow335:death-knight:mark-of-blood", + name: "Mark of Blood", + disposition: "debuff", + durationMs: 20_000, + }), + sourceId: "dk-player", + targetId: "marked-enemy", + now: 1_000, + }).aura; + + const miss = wow335DeathKnightMarkOfBloodOnDamage([mark], "marked-enemy", 9_000, 10_000, 2_000, false); + expect(miss.triggered).toBe(false); + expect(miss.auras).toEqual([mark]); + + const hit = wow335DeathKnightMarkOfBloodOnDamage([mark], "marked-enemy", 9_000, 10_000, 2_000, true); + expect(hit).toMatchObject({ + triggered: true, + healingAmount: 400, + sourceActorId: "dk-player", + abilityId: WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, + remainingHits: 19, + }); + expect(hit.auras[0]?.stacks).toBe(19); + }); + + it("removes the mark after its last hit, ignores expired marks, and consumes lethal-hit charges without reviving", () => { + const oneCharge = applyAura([], { + definition: { + ...wow335DeathKnightMarkOfBloodAuraDefinition({ + id: "wow335:death-knight:mark-of-blood", + name: "Mark of Blood", + disposition: "debuff", + durationMs: 20_000, + }), + initialStacks: 1, + }, + sourceId: "dk-player", + targetId: "marked-enemy", + now: 1_000, + stacks: 1, + }).aura; + + const finalHit = wow335DeathKnightMarkOfBloodOnDamage([oneCharge], "marked-enemy", 4_000, 5_000, 2_000, true); + expect(finalHit.healingAmount).toBe(200); + expect(finalHit.remainingHits).toBe(0); + expect(finalHit.auras).toEqual([]); + + const expired = wow335DeathKnightMarkOfBloodOnDamage([oneCharge], "marked-enemy", 4_000, 5_000, 21_000, true); + expect(expired.triggered).toBe(false); + expect(expired.auras).toEqual([oneCharge]); + + const lethal = wow335DeathKnightMarkOfBloodOnDamage([oneCharge], "marked-enemy", 0, 5_000, 2_000, true); + expect(lethal).toMatchObject({ triggered: true, healingAmount: 0, remainingHits: 0 }); + expect(lethal.auras).toEqual([]); + }); +}); diff --git a/src/game/wow335DeathKnightMarkOfBloodRuntime.ts b/src/game/wow335DeathKnightMarkOfBloodRuntime.ts new file mode 100644 index 00000000..34bb06ec --- /dev/null +++ b/src/game/wow335DeathKnightMarkOfBloodRuntime.ts @@ -0,0 +1,94 @@ +import { ActiveAura, AuraDefinition } from "./combatAuras"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export const WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG = "wow335-death-knight-mark-of-blood"; +export const WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_SPELL_ID = 49005; +export const WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID = "wow335-death-knight-mark-of-blood"; + +export interface Wow335DeathKnightMarkOfBloodProfile { + readonly healingFraction: number; + readonly durationMs: number; + readonly hitCap: number; +} + +export interface Wow335DeathKnightMarkOfBloodDamageResult { + readonly auras: readonly ActiveAura[]; + readonly triggered: boolean; + readonly healingAmount: number; + readonly sourceActorId?: string; + readonly abilityId?: string; + readonly remainingHits?: number; +} + +const MARK_OF_BLOOD_TALENT = WOW335_TALENT_NODES.find((node) => node.id === "talent-1949"); + +/** Read Mark of Blood's heal, duration, and charge cap from the local generated rank. */ +export function wow335DeathKnightMarkOfBloodProfile(rank = 1): Wow335DeathKnightMarkOfBloodProfile | null { + const normalizedRank = Math.trunc(rank); + const description = MARK_OF_BLOOD_TALENT?.rankDescriptions[normalizedRank - 1]; + const rankEffect = MARK_OF_BLOOD_TALENT?.rankEffects[normalizedRank - 1]; + if (!description || !rankEffect) return null; + + const healPercent = /healed for\s+(\d+(?:\.\d+)?)% of its maximum health/i.exec(description); + const durationSeconds = /Lasts for\s+(\d+(?:\.\d+)?) seconds/i.exec(description); + const healingFraction = healPercent ? Number(healPercent[1]) / 100 : 0; + const durationMs = durationSeconds ? Math.round(Number(durationSeconds[1]) * 1_000) : 0; + const hitCap = Math.floor(rankEffect.procCharges ?? 0); + if (!(healingFraction > 0) || !(durationMs > 0) || !(hitCap > 0)) return null; + + return { healingFraction, durationMs, hitCap }; +} + +/** Attach the generated Mark of Blood hit count and trigger tag to its DBC aura. */ +export function wow335DeathKnightMarkOfBloodAuraDefinition( + definition: AuraDefinition, +): AuraDefinition { + const profile = wow335DeathKnightMarkOfBloodProfile(); + if (!profile) return definition; + return { + ...definition, + durationMs: profile.durationMs, + maxStacks: profile.hitCap, + initialStacks: profile.hitCap, + stackBehavior: "replace", + utilityTags: [...new Set([...(definition.utilityTags ?? []), WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG])], + }; +} + +/** Consume one Mark charge for landed damage; only surviving targets can receive the heal. */ +export function wow335DeathKnightMarkOfBloodOnDamage( + auras: readonly ActiveAura[], + markedEnemyId: string, + targetHealthAfterDamage: number, + targetMaxHealth: number, + now: number, + landedDamage: boolean, +): Wow335DeathKnightMarkOfBloodDamageResult { + if (!landedDamage || !(targetMaxHealth > 0)) { + return { auras, triggered: false, healingAmount: 0 }; + } + const mark = auras.find((aura) => aura.targetId === markedEnemyId + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); + const profile = wow335DeathKnightMarkOfBloodProfile(); + if (!mark || !profile || mark.stacks <= 0) { + return { auras, triggered: false, healingAmount: 0 }; + } + + const remainingHits = mark.stacks - 1; + const nextAuras = remainingHits > 0 + ? auras.map((aura) => aura.instanceId === mark.instanceId + ? { ...aura, stacks: remainingHits } + : aura) + : auras.filter((aura) => aura.instanceId !== mark.instanceId); + return { + auras: nextAuras, + triggered: true, + healingAmount: targetHealthAfterDamage > 0 + ? Math.round(targetMaxHealth * profile.healingFraction) + : 0, + sourceActorId: mark.sourceId, + abilityId: WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, + remainingHits, + }; +} diff --git a/src/game/wow335DeathKnightRime.test.ts b/src/game/wow335DeathKnightRime.test.ts new file mode 100644 index 00000000..3e5e8410 --- /dev/null +++ b/src/game/wow335DeathKnightRime.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { installCombatClock } from "./combatClock"; +import { createDeathKnightRunes } from "./combatActors"; +import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { + WOW335_DEATH_KNIGHT_RIME_READY_TAG, + wow335DeathKnightRimeProcChance, +} from "./wow335DeathKnightRimeRuntime"; + +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; +} + +describe("WoW Death Knight Rime", () => { + beforeEach(() => { + restoreClock = installCombatClock(() => 1_000); + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); + usePartyStore.getState().initializeParty( + "wow335-death-knight-rime", 80, useCombatStore.getState().health, "tank", 5, + ); + useCombatStore.setState({ + resource: 100, + resourcePools: { "runic-power": 100 }, + talentRanks: { "talent-1989": 1, "talent-1992": 3 }, + }); + useCombatStore.getState().registerMob("rime-target", { + name: "Rime target", maxHealth: 1_000_000, xpReward: 0, + }); + setMobPosition("rime-target", [2, 0, 0]); + useCombatStore.getState().selectMob("rime-target"); + }); + + afterEach(() => { + restoreClock?.(); + restoreClock = undefined; + }); + + it("uses generated rank proc chances and makes the next Howling Blast cooldown- and rune-free", () => { + expect([0, 1, 2, 3].map(wow335DeathKnightRimeProcChance)).toEqual([0, 0.05, 0.1, 0.15]); + expect(talentModifiersForRanks({ "talent-1992": 3 }, "death-knight")).toContainEqual( + expect.objectContaining({ kind: "critical-chance-percent", amount: 15 }), + ); + + const obliterate = ability("Obliterate"); + const howlingBlast = ability("Howling Blast"); + let procAt = 0; + for (let attempt = 0; attempt < 100 && procAt === 0; attempt += 1) { + const now = 1_000 + attempt * 2_000; + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: 0, + deathKnightRunes: createDeathKnightRunes(now), + cooldowns: { ...state.cooldowns, [howlingBlast.id]: now + 8_000 }, + })); + const result = useCombatStore.getState().castAbility(obliterate.id, { now }); + const rimeReady = useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_RIME_READY_TAG) + )); + if (result.ok && rimeReady) procAt = now; + } + + expect(procAt).toBeGreaterThan(0); + expect(useCombatStore.getState().cooldowns[howlingBlast.id]).toBeLessThanOrEqual(procAt); + + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: procAt + 1_500, + deathKnightRunes: state.deathKnightRunes.map((rune) => ({ + ...rune, + readyAt: procAt + 60_000, + })), + })); + const runesBefore = useCombatStore.getState().deathKnightRunes; + const freeCast = useCombatStore.getState().castAbility(howlingBlast.id, { now: procAt + 2_000 }); + + expect(freeCast.ok).toBe(true); + expect(freeCast.damage).toBeGreaterThan(0); + expect(useCombatStore.getState().deathKnightRunes).toEqual(runesBefore); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_RIME_READY_TAG) + ))).toBe(false); + }); + + it("does not waive Howling Blast runes without an earned Rime proc", () => { + const howlingBlast = ability("Howling Blast"); + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: 0, + deathKnightRunes: state.deathKnightRunes.map((rune) => ({ ...rune, readyAt: 60_000 })), + })); + + const result = useCombatStore.getState().castAbility(howlingBlast.id, { now: 2_000 }); + + expect(result).toMatchObject({ ok: false, reason: "not-enough-runes" }); + }); +}); diff --git a/src/game/wow335DeathKnightRimeRuntime.ts b/src/game/wow335DeathKnightRimeRuntime.ts new file mode 100644 index 00000000..daefeeb1 --- /dev/null +++ b/src/game/wow335DeathKnightRimeRuntime.ts @@ -0,0 +1,17 @@ +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export const WOW335_DEATH_KNIGHT_RIME_READY_TAG = "wow335-death-knight-rime-ready"; + +const RIME_TALENT = WOW335_TALENT_NODES.find((node) => node.id === "talent-1992"); + +/** Return the locally generated rank chance for Rime's Obliterate proc. */ +export function wow335DeathKnightRimeProcChance(rank: number): number { + const normalizedRank = Math.max(0, Math.min(3, Math.trunc(rank))); + if (normalizedRank <= 0) return 0; + return Math.max(0, Math.min(1, (RIME_TALENT?.rankEffects[normalizedRank - 1]?.procChance ?? 0) / 100)); +} + +export function wow335DeathKnightRimeProcSucceeds(rank: number, roll: number): boolean { + const chance = wow335DeathKnightRimeProcChance(rank); + return chance > 0 && Number.isFinite(roll) && roll >= 0 && roll < chance; +} diff --git a/src/game/wow335DeathKnightScentOfBlood.integration.test.ts b/src/game/wow335DeathKnightScentOfBlood.integration.test.ts new file mode 100644 index 00000000..5f63d9bc --- /dev/null +++ b/src/game/wow335DeathKnightScentOfBlood.integration.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createDeathKnightRunes } from "./combatActors"; +import { installCombatClock } from "./combatClock"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { abilitiesForCharacter } from "./abilityCatalog"; +import { EMPTY_ITEM_STATS } from "./itemStats"; +import { + WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG, + WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID, +} from "./wow335DeathKnightScentOfBloodRuntime"; + +const TARGET_ID = "scent-of-blood-target"; +const PARTY_MEMBER_SEED = "scent-of-blood-party-member"; + +let now = 1_000; +let restoreClock: (() => void) | undefined; + +function registerTarget() { + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Scent of Blood target", + maxHealth: 1_000_000_000, + xpReward: 0, + attacks: [{ + id: "scent-of-blood-incoming-strike", + name: "Strike", + delivery: "melee", + target: "primary", + school: "physical", + animation: "attack", + range: 5, + cooldownMs: 1_000, + damageMultiplier: 0.00001, + }], + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function procIncomingForPlayer(): boolean { + const player = useCombatStore.getState(); + player.damageMob(TARGET_ID, 1, now); + for (let attempt = 0; attempt < 500; attempt += 1) { + now = 1_450 + attempt * 1_000; + useCombatStore.setState((state) => ({ health: state.maxHealth })); + useCombatStore.getState().advanceMobCombat(now, [0, 0, 0]); + if (useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG))) return true; + } + return false; +} + +function partyDeathKnight() { + const base = createConfiguredPartyMember({ + name: "Scent of Blood DK", + classId: "death-knight", + specialization: "Blood", + specializationId: "death-knight:blood", + talentTreeId: "death-knight-blood", + role: "tank", + }, 80, 0, PARTY_MEMBER_SEED); + const abilities = abilitiesForCharacter("death-knight", null); + const cooldowns = Object.fromEntries(abilities.map((ability) => [ability.id, now + 60_000])); + return { + ...base, + gearStats: EMPTY_ITEM_STATS, + talentRanks: { [WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID]: 3 }, + resourceType: "runic-power" as const, + resourceName: "Runic Power", + resource: 0, + maxResource: 100, + resourcePools: { "runic-power": 0 }, + runes: createDeathKnightRunes(now), + cooldowns, + globalCooldownEndsAt: now + 60_000, + activeCast: null, + activeCastTargetId: null, + nextActionAt: now + 60_000, + status: "attacking" as const, + }; +} + +describe("WoW Death Knight Scent of Blood combat integration", () => { + beforeEach(() => { + now = 1_000; + restoreClock = installCombatClock(() => now); + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); + usePartyStore.getState().initializeParty( + "wow335-death-knight-scent-of-blood", 80, useCombatStore.getState().health, "tank", 1, + ); + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: 0, + deathKnightRunes: createDeathKnightRunes(now), + talentRanks: { + ...state.talentRanks, + [WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID]: 3, + }, + resource: 0, + resourcePools: { ...state.resourcePools, "runic-power": 0 }, + })); + registerTarget(); + }); + + afterEach(() => { + restoreClock?.(); + restoreClock = undefined; + }); + + it("arms from a real incoming enemy hit and grants 10 Runic Power on the next landed player melee hit", () => { + expect(procIncomingForPlayer()).toBe(true); + const auraBefore = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG)); + expect(auraBefore?.stacks).toBe(3); + + let landed = false; + for (let attempt = 0; attempt < 20 && !landed; attempt += 1) { + now += 100; + useCombatStore.setState((state) => ({ + resource: 95, + resourcePools: { ...state.resourcePools, "runic-power": 95 }, + })); + const hit = useCombatStore.getState().damageMob(TARGET_ID, 1_000, now, { + actorId: PLAYER_AURA_ENTITY_ID, + role: "tank", + abilityId: "scent-test-melee-hit", + }, "physical", { + attackKind: "melee", + seed: `scent-test-melee-hit-${attempt}`, + abilityId: "scent-test-melee-hit", + }); + landed = hit.combatResult?.landed === true; + } + + expect(landed).toBe(true); + const state = useCombatStore.getState(); + const scentAura = state.auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG)); + expect(scentAura?.stacks).toBe(2); + expect(state.resourcePools["runic-power"]).toBe(100); + expect(state.combatEvents.at(-1)).toMatchObject({ + kind: "resource", + abilityId: "scent-test-melee-hit", + rawAmount: 10, + effectiveAmount: 5, + overheal: 5, + }); + }); + + it("arms a party Death Knight from an incoming attack and consumes a charge on its next landed auto-attack", () => { + useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80, xp: 0 }); + registerTarget(); + const member = partyDeathKnight(); + usePartyStore.setState((state) => ({ + command: "attack", + activeMobId: TARGET_ID, + members: [member], + })); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); + useCombatStore.getState().damageMob(TARGET_ID, 1, now, { + actorId: member.id, + role: "tank", + abilityId: "party-threat-setup", + }); + + let armed = false; + for (let attempt = 0; attempt < 500 && !armed; attempt += 1) { + now = 1_450 + attempt * 1_000; + useCombatStore.getState().advanceMobCombat(now, [100, 0, 0]); + armed = useCombatStore.getState().auras.some((aura) => aura.targetId === member.id + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG)); + } + expect(armed).toBe(true); + + let consumed = false; + for (let attempt = 0; attempt < 20 && !consumed; attempt += 1) { + now += 2_000; + const currentMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!; + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === member.id + ? { ...currentMember, globalCooldownEndsAt: now + 60_000, nextActionAt: now + 60_000 } + : candidate), + })); + advanceDungeonPartyCombat(now); + consumed = useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "resource" + && event.sourceActorId === member.id + && event.targetActorId === member.id + && event.abilityId === "party-auto-attack" + && event.effectiveAmount > 0 + && event.occurredAt === now + )); + } + expect(consumed).toBe(true); + expect(useCombatStore.getState().auras.find((aura) => aura.targetId === member.id + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG))?.stacks).toBe(2); + expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.resourcePools[ + "runic-power" + ]).toBeGreaterThan(0); + }); +}); diff --git a/src/game/wow335DeathKnightScentOfBloodRuntime.test.ts b/src/game/wow335DeathKnightScentOfBloodRuntime.test.ts new file mode 100644 index 00000000..38eac117 --- /dev/null +++ b/src/game/wow335DeathKnightScentOfBloodRuntime.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG, + wow335DeathKnightScentOfBloodOnIncomingEvent, + wow335DeathKnightScentOfBloodOnMeleeHit, + wow335DeathKnightScentOfBloodProfile, +} from "./wow335DeathKnightScentOfBloodRuntime"; + +describe("WoW Death Knight Scent of Blood source runtime", () => { + it("reads the generated 15% proc, rank hit counts, and 10 Runic Power per hit", () => { + expect([1, 2, 3].map(wow335DeathKnightScentOfBloodProfile)).toEqual([ + { procChance: 0.15, meleeHitCharges: 1, runicPowerPerHit: 10 }, + { procChance: 0.15, meleeHitCharges: 2, runicPowerPerHit: 10 }, + { procChance: 0.15, meleeHitCharges: 3, runicPowerPerHit: 10 }, + ]); + expect(wow335DeathKnightScentOfBloodProfile(0)).toBeNull(); + }); + + it("procs at the generated chance only after damage taken, a dodge, or a parry", () => { + for (const trigger of ["damage-taken", "dodge", "parry"] as const) { + const proc = wow335DeathKnightScentOfBloodOnIncomingEvent( + [], "dk-1", 3, trigger, 0.1499, 1_000, `event-${trigger}`, + ); + expect(proc.triggered).toBe(true); + expect(proc.chargesGranted).toBe(3); + expect(proc.auras[0]?.definition.utilityTags).toContain(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG); + expect(proc.auras[0]).toMatchObject({ + sourceId: "dk-1", + targetId: "dk-1", + stacks: 3, + expiresAt: 21_000, + }); + } + expect(wow335DeathKnightScentOfBloodOnIncomingEvent( + [], "dk-1", 3, "damage-taken", 0.15, 1_000, "outside-chance", + ).triggered).toBe(false); + expect(wow335DeathKnightScentOfBloodOnIncomingEvent( + [], "dk-1", 3, null, 0, 1_000, "unrelated-event", + ).triggered).toBe(false); + }); + + it("spends one charge per landed melee hit and grants no Runic Power for misses or expired charges", () => { + const armed = wow335DeathKnightScentOfBloodOnIncomingEvent( + [], "dk-1", 2, "parry", 0, 1_000, "parry-event", + ).auras; + const miss = wow335DeathKnightScentOfBloodOnMeleeHit(armed, "dk-1", 2, false, 2_000); + expect(miss).toEqual({ auras: armed, runicPowerGain: 0, remainingCharges: 0 }); + + const first = wow335DeathKnightScentOfBloodOnMeleeHit(armed, "dk-1", 2, true, 2_000); + expect(first).toMatchObject({ runicPowerGain: 10, remainingCharges: 1 }); + expect(first.auras[0]?.stacks).toBe(1); + const last = wow335DeathKnightScentOfBloodOnMeleeHit(first.auras, "dk-1", 2, true, 2_500); + expect(last).toMatchObject({ runicPowerGain: 10, remainingCharges: 0 }); + expect(last.auras).toEqual([]); + + const expired = wow335DeathKnightScentOfBloodOnMeleeHit(armed, "dk-1", 2, true, 21_001); + expect(expired).toMatchObject({ runicPowerGain: 0, remainingCharges: 0 }); + + const unlearned = wow335DeathKnightScentOfBloodOnMeleeHit(armed, "dk-1", 0, true, 2_000); + expect(unlearned.runicPowerGain).toBe(0); + expect(unlearned.auras).toEqual(armed); + }); +}); diff --git a/src/game/wow335DeathKnightScentOfBloodRuntime.ts b/src/game/wow335DeathKnightScentOfBloodRuntime.ts new file mode 100644 index 00000000..7d18f3a1 --- /dev/null +++ b/src/game/wow335DeathKnightScentOfBloodRuntime.ts @@ -0,0 +1,113 @@ +import { applyAura, type ActiveAura, type AuraDefinition } from "./combatAuras"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export const WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG = "wow335-death-knight-scent-of-blood"; +export const WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID = "talent-1948"; + +export type Wow335DeathKnightScentOfBloodTrigger = "damage-taken" | "dodge" | "parry"; + +export interface Wow335DeathKnightScentOfBloodProfile { + readonly procChance: number; + readonly meleeHitCharges: number; + readonly runicPowerPerHit: number; +} + +export interface Wow335DeathKnightScentOfBloodProcResult { + readonly auras: readonly ActiveAura[]; + readonly triggered: boolean; + readonly chargesGranted: number; +} + +export interface Wow335DeathKnightScentOfBloodHitResult { + readonly auras: readonly ActiveAura[]; + readonly runicPowerGain: number; + readonly remainingCharges: number; +} + +const SCENT_OF_BLOOD_TALENT = WOW335_TALENT_NODES.find( + (node) => node.id === WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID, +); + +/** Read proc chance, next-hit count, and Runic Power from generated WoW rank rows. */ +export function wow335DeathKnightScentOfBloodProfile(rank: number): Wow335DeathKnightScentOfBloodProfile | null { + const normalizedRank = Math.trunc(rank); + const rankEffect = SCENT_OF_BLOOD_TALENT?.rankEffects[normalizedRank - 1]; + const description = SCENT_OF_BLOOD_TALENT?.rankDescriptions[normalizedRank - 1]; + if (!rankEffect || !description) return null; + + const meleeHits = /causing your next(?:\s+(\d+))?\s+melee hit/i.exec(description); + const runicPower = /generate\s+(\d+)\s+runic power/i.exec(description); + const procChance = rankEffect.procChance / 100; + const meleeHitCharges = Number(meleeHits?.[1] ?? 1); + const runicPowerPerHit = Number(runicPower?.[1] ?? 0); + if (!(procChance > 0) || !(meleeHitCharges > 0) || !(runicPowerPerHit > 0)) return null; + return { procChance, meleeHitCharges, runicPowerPerHit }; +} + +/** Install one generated rank's charge batch after its exact source trigger. */ +export function wow335DeathKnightScentOfBloodOnIncomingEvent( + auras: readonly ActiveAura[], + sourceActorId: string, + rank: number, + trigger: Wow335DeathKnightScentOfBloodTrigger | null, + roll: number, + now: number, + eventId: string, +): Wow335DeathKnightScentOfBloodProcResult { + const profile = wow335DeathKnightScentOfBloodProfile(rank); + if (!profile || !trigger || !Number.isFinite(roll) || roll < 0 || roll >= profile.procChance) { + return { auras, triggered: false, chargesGranted: 0 }; + } + + const definition: AuraDefinition = { + // Keep each proc's source-authored hit charges as a separate batch. The + // generated talent links child 50421, whose stack/refresh row is not local. + id: `wow335:death-knight:scent-of-blood:${eventId}`, + name: "Scent of Blood", + description: `Your next ${profile.meleeHitCharges} melee hit${profile.meleeHitCharges === 1 ? "" : "s"} generate ${profile.runicPowerPerHit} Runic Power each.`, + disposition: "buff", + // The generated talent ranks link to child spell 50421, whose aura lasts 20 seconds. + durationMs: 20_000, + maxStacks: profile.meleeHitCharges, + initialStacks: profile.meleeHitCharges, + stackBehavior: "replace", + utilityTags: [WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG], + }; + const applied = applyAura(auras, { + definition, + sourceId: sourceActorId, + targetId: sourceActorId, + now, + }); + return { auras: applied.auras, triggered: true, chargesGranted: profile.meleeHitCharges }; +} + +/** Spend a single charge only on a landed direct melee hit. */ +export function wow335DeathKnightScentOfBloodOnMeleeHit( + auras: readonly ActiveAura[], + actorId: string, + rank: number, + landedMeleeHit: boolean, + now: number, +): Wow335DeathKnightScentOfBloodHitResult { + const profile = wow335DeathKnightScentOfBloodProfile(rank); + if (!profile || !landedMeleeHit) return { auras, runicPowerGain: 0, remainingCharges: 0 }; + const chargeAura = auras.find((aura) => aura.targetId === actorId + && aura.sourceId === actorId + && aura.stacks > 0 + && (aura.expiresAt === null || aura.expiresAt > now) + && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TAG)); + if (!chargeAura) return { auras, runicPowerGain: 0, remainingCharges: 0 }; + + const remainingCharges = chargeAura.stacks - 1; + const nextAuras = remainingCharges > 0 + ? auras.map((aura) => aura.instanceId === chargeAura.instanceId + ? { ...aura, stacks: remainingCharges } + : aura) + : auras.filter((aura) => aura.instanceId !== chargeAura.instanceId); + return { + auras: nextAuras, + runicPowerGain: profile.runicPowerPerHit, + remainingCharges, + }; +} diff --git a/src/game/wow335DeathKnightSuddenDoom.test.ts b/src/game/wow335DeathKnightSuddenDoom.test.ts new file mode 100644 index 00000000..65c7aa78 --- /dev/null +++ b/src/game/wow335DeathKnightSuddenDoom.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createDeathKnightRunes } from "./combatActors"; +import { installCombatClock } from "./combatClock"; +import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog"; +import { EMPTY_ITEM_STATS } from "./itemStats"; + +const TARGET_ID = "sudden-doom-target"; +const PARTY_SUDDEN_DOOM_SEED = "sudden-doom-party-member"; + +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 generated Death Knight ability: ${name}`); + return found; +} + +function registerTarget() { + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Sudden Doom target", + maxHealth: 1_000_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +describe("WoW Death Knight Sudden Doom combat integration", () => { + beforeEach(() => { + now = 1_000; + restoreClock = installCombatClock(() => now); + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); + usePartyStore.getState().initializeParty( + "wow335-death-knight-sudden-doom", 80, useCombatStore.getState().health, "tank", 5, + ); + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: 0, + deathKnightRunes: createDeathKnightRunes(now), + talentRanks: { ...state.talentRanks, "talent-1955": 3 }, + resource: 0, + resourcePools: { ...state.resourcePools, "runic-power": 0 }, + })); + registerTarget(); + }); + + afterEach(() => { + restoreClock?.(); + restoreClock = undefined; + }); + + it("launches one free Death Coil at the landed Blood Strike target", () => { + const bloodStrike = ability("Blood Strike"); + const deathCoil = ability("Death Coil"); + let procAt = 0; + + for (let attempt = 0; attempt < 500 && procAt === 0; attempt += 1) { + now = 1_000 + attempt * 2_000; + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: 0, + deathKnightRunes: createDeathKnightRunes(now), + cooldowns: { ...state.cooldowns, [bloodStrike.id]: 0 }, + resource: 0, + resourcePools: { ...state.resourcePools, "runic-power": 0 }, + })); + const cast = useCombatStore.getState().castAbility(bloodStrike.id, { + targetActorId: TARGET_ID, + now, + }); + const deathCoilHits = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" + && event.abilityId === deathCoil.id + && event.occurredAt === now + )); + if (cast.ok && deathCoilHits.length > 0) { + procAt = now; + expect(deathCoilHits).toHaveLength(1); + expect(deathCoilHits[0]?.targetActorId).toBe(TARGET_ID); + expect(deathCoilHits[0]?.sourceActorId).toBe(PLAYER_AURA_ENTITY_ID); + // Blood Strike adds 10 RP; a normal 40-RP Death Coil would have failed + // from the initial empty pool. Keeping that gain proves the proc is free. + expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(10); + } + } + + expect(procAt).toBeGreaterThan(0); + }); + + it("does not dispatch for an evading/missing hit result or an unrelated landed ability", () => { + const bloodStrike = ability("Blood Strike"); + const plagueStrike = ability("Plague Strike"); + const deathCoil = ability("Death Coil"); + const evadingNow = now; + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: 0, + deathKnightRunes: createDeathKnightRunes(evadingNow), + mobs: { + ...state.mobs, + [TARGET_ID]: { ...state.mobs[TARGET_ID]!, navigationEvadeUntil: evadingNow + 5_000 }, + }, + })); + useCombatStore.getState().castAbility(bloodStrike.id, { targetActorId: TARGET_ID, now: evadingNow }); + + now += 2_000; + useCombatStore.setState((state) => ({ + globalCooldownEndsAt: 0, + deathKnightRunes: createDeathKnightRunes(now), + mobs: { + ...state.mobs, + [TARGET_ID]: { ...state.mobs[TARGET_ID]!, navigationEvadeUntil: 0 }, + }, + })); + useCombatStore.getState().castAbility(plagueStrike.id, { targetActorId: TARGET_ID, now }); + + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === deathCoil.id + ))).toBe(false); + }); + + it("launches one free Death Coil for a party Death Knight after its landed Heart Strike", () => { + const dkAbilities = abilitiesForCharacter("death-knight", null); + const heartStrikeBase = dkAbilities.find((candidate) => candidate.name === "Heart Strike"); + const deathCoil = dkAbilities.find((candidate) => candidate.name === "Death Coil"); + if (!heartStrikeBase || !deathCoil) throw new Error("Missing generated party Death Knight abilities."); + const baseMember = createConfiguredPartyMember({ + name: "Sudden Doom DK", + classId: "death-knight", + specialization: "Blood", + specializationId: "death-knight:blood", + talentTreeId: "death-knight-blood", + role: "damage", + }, 80, 0, PARTY_SUDDEN_DOOM_SEED); + const talentRanks = { "talent-1955": 3, "talent-1957": 1 }; + const heartStrike = learnedAbilityForBuild(heartStrikeBase, 80, talentRanks); + if (!heartStrike) throw new Error("Party Death Knight Heart Strike was not learned at the configured talent rank."); + let procAt = 0; + + for (let attempt = 0; attempt < 500 && procAt === 0; attempt += 1) { + now = 1_000 + attempt * 2_000; + const blockedCooldowns = Object.fromEntries(dkAbilities + .filter((candidate) => candidate.id !== heartStrike.id) + .map((candidate) => [candidate.id, now + 60_000])); + const member = { + ...baseMember, + gearStats: EMPTY_ITEM_STATS, + talentRanks, + resourceType: "runic-power" as const, + resourceName: "Runic Power", + resource: 0, + maxResource: 100, + resourcePools: { "runic-power": 0 }, + runes: createDeathKnightRunes(now), + cooldowns: { ...blockedCooldowns, [heartStrike.id]: 0 }, + globalCooldownEndsAt: 0, + activeCast: null, + activeCastTargetId: null, + nextActionAt: 0, + status: "attacking" as const, + }; + usePartyStore.setState((state) => ({ + command: "attack", + activeMobId: TARGET_ID, + members: [member], + })); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().damageMob(TARGET_ID, 1, now); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); + advanceDungeonPartyCombat(now); + + const deathCoilHits = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" + && event.abilityId === deathCoil.id + && event.sourceActorId === member.id + && event.occurredAt === now + )); + if (deathCoilHits.length > 0) { + procAt = now; + expect(deathCoilHits).toHaveLength(1); + expect(deathCoilHits[0]?.targetActorId).toBe(TARGET_ID); + const finalMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!; + // Heart Strike grants 40 RP; the triggered Death Coil does not spend it. + expect(finalMember.resourcePools["runic-power"]).toBe(40); + } + } + + const partyAbilityIds = useCombatStore.getState().combatEvents + .filter((event) => event.sourceActorId === baseMember.id) + .map((event) => event.abilityId); + expect(partyAbilityIds).toContain(heartStrike.id); + expect(procAt).toBeGreaterThan(0); + }); +}); diff --git a/src/game/wow335DeathKnightSuddenDoomRuntime.test.ts b/src/game/wow335DeathKnightSuddenDoomRuntime.test.ts new file mode 100644 index 00000000..12df3087 --- /dev/null +++ b/src/game/wow335DeathKnightSuddenDoomRuntime.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID, + WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS, + wow335DeathKnightSuddenDoomTriggerForLandedHit, + wow335DeathKnightSuddenDoomProcChance, +} from "./wow335DeathKnightSuddenDoomRuntime"; + +describe("WoW Death Knight Sudden Doom source runtime", () => { + it("reads the generated 5/10/15% rank chances", () => { + expect([0, 1, 2, 3].map(wow335DeathKnightSuddenDoomProcChance)).toEqual([0, 0.05, 0.1, 0.15]); + }); + + it("returns a one-use free Death Coil instruction on an eligible landed hit at the generated chance", () => { + for (const triggerAbilityId of WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS) { + expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, triggerAbilityId, "target-1", true, 0)) + .toEqual({ + abilityId: WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID, + targetId: "target-1", + free: true, + }); + } + expect(wow335DeathKnightSuddenDoomTriggerForLandedHit( + 1, + WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS[0], + "target-1", + true, + 0.05, + )).toBeNull(); + expect(wow335DeathKnightSuddenDoomTriggerForLandedHit( + 3, + "wow335-death-knight-obliterate", + "target-1", + true, + 0, + )).toBeNull(); + }); + + it("does not proc on misses, targetless events, absent ranks, or invalid rolls", () => { + const bloodStrike = WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS[0]; + expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, bloodStrike, "target-1", false, 0)).toBeNull(); + expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, bloodStrike, null, true, 0)).toBeNull(); + expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(0, bloodStrike, "target-1", true, 0)).toBeNull(); + expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, bloodStrike, "target-1", true, -0.01)).toBeNull(); + expect(wow335DeathKnightSuddenDoomTriggerForLandedHit(3, bloodStrike, "target-1", true, Number.NaN)).toBeNull(); + }); +}); diff --git a/src/game/wow335DeathKnightSuddenDoomRuntime.ts b/src/game/wow335DeathKnightSuddenDoomRuntime.ts new file mode 100644 index 00000000..1e4ea3fd --- /dev/null +++ b/src/game/wow335DeathKnightSuddenDoomRuntime.ts @@ -0,0 +1,48 @@ +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +// These ability IDs are generated from the local 3.3.5a class catalog. +export const WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS = [ + "wow335-death-knight-blood-strike", + "wow335-death-knight-heart-strike", +] as const; +export const WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID = "wow335-death-knight-death-coil"; + +const SUDDEN_DOOM_TALENT = WOW335_TALENT_NODES.find((node) => node.id === "talent-1955"); +const SUDDEN_DOOM_TRIGGER_SET = new Set(WOW335_DEATH_KNIGHT_SUDDEN_DOOM_TRIGGER_ABILITY_IDS); + +export interface Wow335DeathKnightSuddenDoomTrigger { + readonly abilityId: typeof WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID; + readonly targetId: string; + /** The generated tooltip says the triggered Death Coil is free. */ + readonly free: true; +} + +/** Return the locally generated rank chance for Sudden Doom. */ +export function wow335DeathKnightSuddenDoomProcChance(rank: number): number { + const normalizedRank = Math.max(0, Math.min(3, Math.trunc(rank))); + if (normalizedRank <= 0) return 0; + return Math.max(0, Math.min(1, (SUDDEN_DOOM_TALENT?.rankEffects[normalizedRank - 1]?.procChance ?? 0) / 100)); +} + +/** Resolve one landed eligible attack into an immediate, one-use free Death Coil instruction. */ +export function wow335DeathKnightSuddenDoomTriggerForLandedHit( + rank: number, + triggeringAbilityId: string, + targetId: string | null, + landed: boolean, + roll: number, +): Wow335DeathKnightSuddenDoomTrigger | null { + const chance = wow335DeathKnightSuddenDoomProcChance(rank); + if (!landed + || !targetId + || !SUDDEN_DOOM_TRIGGER_SET.has(triggeringAbilityId) + || chance <= 0 + || !Number.isFinite(roll) + || roll < 0 + || roll >= chance) return null; + return { + abilityId: WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID, + targetId, + free: true, + }; +} diff --git a/src/game/wow335DruidFeralInstinct.test.ts b/src/game/wow335DruidFeralInstinct.test.ts new file mode 100644 index 00000000..8bedffe2 --- /dev/null +++ b/src/game/wow335DruidFeralInstinct.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { applyAura, resolveDamageValue } from "./combatAuras"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { talentModifiersForRanks, applyTalentModifierValue } from "./talentModifiers"; +import { + WOW335_DRUID_FERAL_INSTINCT_ENRAGE_AURA_TAG, + WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG, +} from "./wow335DruidTalentRuntime"; + +const TARGET_ID = "feral-instinct-target"; + +function abilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function setFeralRank(rank: number): void { + useCombatStore.setState((state) => { + const talentRanks = { ...state.talentRanks, "talent-799": rank }; + return { talentRanks, talentModifiers: talentModifiersForRanks(talentRanks, "druid") }; + }); +} + +describe("WoW Druid Feral Instinct", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["druid"]); + useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", level: 1, maxHealth: 1_000_000 }); + setMobPosition(TARGET_ID, [4, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { + ...state.resourcePools, + energy: 100, + rage: 100, + mana: state.maxResourcePools.mana, + }, + maxResourcePools: { ...state.maxResourcePools, energy: 100, rage: 100 }, + auras: [...applyAura(state.auras, { + definition: { + id: "feral-instinct-test-hit", + name: "Test Hit Chance", + disposition: "buff", + durationMs: null, + modifiers: [ + { kind: "stat", stat: "hit", operation: "flat", value: 100 }, + { kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 0 }, + ], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: 0, + }).auras], + })); + }); + + it("applies the rank bonus only to Swipe and Ravage, without unconditional damage or mitigation", () => { + for (const [rank, percent] of [[1, 10], [2, 20], [3, 30]] as const) { + const modifiers = talentModifiersForRanks({ "talent-799": rank }, "druid"); + expect(modifiers).toHaveLength(1); + expect(modifiers[0]).toMatchObject({ kind: "damage-percent", amount: percent }); + for (const name of ["Swipe (Bear)", "Swipe (Cat)", "Ravage"]) { + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: name, + tags: ["damage"], + })).toBeCloseTo(100 + percent); + } + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: "Claw", + tags: ["damage"], + })).toBe(100); + expect(applyTalentModifierValue(100, modifiers, "damage-taken-percent", { + tags: ["physical"], + })).toBe(100); + } + }); + + it("activates Tiger's Fury damage and Enrage mitigation only for their sourced durations", () => { + setFeralRank(3); + expect(useCombatStore.getState().castAbility(abilityId("Tiger's Fury"), { targetActorId: null, now: 1_000 }).ok).toBe(true); + expect(useCombatStore.getState().castAbility(abilityId("Enrage"), { targetActorId: null, now: 1_000 }).ok).toBe(true); + + const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID); + const tiger = auras.find((aura) => aura.definition.utilityTags?.includes(WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG)); + const enrage = auras.find((aura) => aura.definition.utilityTags?.includes(WOW335_DRUID_FERAL_INSTINCT_ENRAGE_AURA_TAG)); + expect(tiger?.expiresAt).toBe(7_000); + expect(enrage?.expiresAt).toBe(11_000); + expect(resolveDamageValue(1_000, [tiger!], { direction: "dealt", school: "physical", attackKind: "melee" })).toBe(1_030); + expect(resolveDamageValue(1_000, [enrage!], { direction: "taken", school: "physical", attackKind: "melee" })).toBe(970); + + setFeralRank(0); + expect(useCombatStore.getState().castAbility(abilityId("Tiger's Fury"), { targetActorId: null, now: 32_000 }).ok).toBe(true); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG) + && (aura.expiresAt === null || aura.expiresAt > 32_000) + ))).toBe(false); + }); + + it("reduces Tiger's Fury and Enrage cooldowns by one second on landed Swipe damage", () => { + setFeralRank(1); + const tigerId = abilityId("Tiger's Fury"); + const enrageId = abilityId("Enrage"); + useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [tigerId]: 20_000, [enrageId]: 20_000 } })); + + expect(useCombatStore.getState().castAbility(abilityId("Swipe (Bear)"), { + targetActorId: TARGET_ID, + now: 1_000, + }).ok).toBe(true); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.targetActorId === TARGET_ID && event.effectiveAmount > 0 + ))).toBe(true); + expect(useCombatStore.getState().cooldowns[tigerId]).toBe(19_000); + expect(useCombatStore.getState().cooldowns[enrageId]).toBe(19_000); + + useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [tigerId]: 30_000, [enrageId]: 30_000 } })); + expect(useCombatStore.getState().castAbility(abilityId("Claw"), { + targetActorId: TARGET_ID, + now: 3_000, + }).ok).toBe(true); + expect(useCombatStore.getState().cooldowns[tigerId]).toBe(30_000); + expect(useCombatStore.getState().cooldowns[enrageId]).toBe(30_000); + }); +}); diff --git a/src/game/wow335DruidProcRuntime.test.ts b/src/game/wow335DruidProcRuntime.test.ts index 63a2033e..f3d7c482 100644 --- a/src/game/wow335DruidProcRuntime.test.ts +++ b/src/game/wow335DruidProcRuntime.test.ts @@ -2,10 +2,11 @@ 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 { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { usePartyStore } from "./partyStore"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; const TARGET_ID = "druid-proc-target"; @@ -48,6 +49,50 @@ function castAndFinish(name: string, startedAt: number) { )).at(-1); } +function guaranteeDruidMeleeCrits(): void { + useCombatStore.setState((state) => ({ + auras: [...applyAura(state.auras, { + definition: { + id: "test-druid-guaranteed-melee-crit", + name: "Test Melee Critical Chance", + disposition: "buff", + durationMs: null, + modifiers: [ + { kind: "stat", stat: "hit", operation: "flat", value: 100 }, + { kind: "stat", stat: "physical-critical-strike", operation: "flat", value: 100 }, + { kind: "stat", stat: "spell-hit", operation: "flat", value: 100 }, + { kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 100 }, + ], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: 1_000, + }).auras], + mobs: { + ...state.mobs, + [TARGET_ID]: { + ...state.mobs[TARGET_ID]!, + characterDefender: { + level: 80, + armor: 0, + dodgePercent: 0, + parryPercent: 0, + blockPercent: 0, + blockValue: 0, + }, + }, + }, + })); +} + +function findDruidPrimalFurySeed(procTag: string, chance: number, shouldProc: boolean): string { + for (let index = 0; index < 100_000; index += 1) { + const seed = `druid-primal-fury:${procTag}:${index}`; + if ((unit(`${seed}:wow335-druid-primal-fury-${procTag}`) < chance) === shouldProc) return seed; + } + throw new Error(`No deterministic Primal Fury ${procTag} seed found.`); +} + describe("WoW Druid proc runtime", () => { beforeEach(async () => { clearMobRuntimeRegistry(); @@ -246,4 +291,311 @@ describe("WoW Druid proc runtime", () => { && aura.definition.utilityTags?.includes("wow335-druid-clearcasting") ))).toBe(true); }); + + it("stores 30% of an eligible critical heal and blooms on the target's next direct hit", () => { + const healingTouch = abilityId("Healing Touch"); + const startedAt = 1_000; + useCombatStore.setState((state) => ({ + health: 1, + talentRanks: { ...state.talentRanks, "talent-1922": 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 cast = useCombatStore.getState().activeCast; + expect(cast?.abilityId).toBe(healingTouch); + useCombatStore.getState().tick(0.1, cast!.completesAt); + + const heal = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "healing" && event.abilityId === healingTouch && event.occurredAt === cast!.completesAt + )); + expect(heal).toMatchObject({ critical: true, effectiveAmount: expect.any(Number) }); + const storedSeed = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-living-seed") + )); + expect(storedSeed?.expiresAt).toBe(cast!.completesAt + 15_000); + expect(storedSeed?.druidLivingSeedAmount).toBe(Math.round(heal!.effectiveAmount * 0.3)); + + const healthBeforeHit = useCombatStore.getState().health; + const damage = useCombatStore.getState().damagePlayer(100, "physical", 80, cast!.completesAt + 1); + expect(damage).toBeGreaterThan(0); + expect(useCombatStore.getState().health).toBeGreaterThan(healthBeforeHit - damage); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-living-seed") + ))).toBe(false); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "healing" && event.abilityId === "wow335:druid:living-seed" && event.effectiveAmount > 0 + ))).toBe(true); + }); + + it("expires an untriggered Living Seed after fifteen seconds", () => { + const healingTouch = abilityId("Healing Touch"); + const startedAt = 1_000; + useCombatStore.setState((state) => ({ + health: 1, + talentRanks: { ...state.talentRanks, "talent-1922": 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 cast = useCombatStore.getState().activeCast; + useCombatStore.getState().tick(0.1, cast!.completesAt); + const expiresAt = cast!.completesAt + 15_000; + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-living-seed") + ))).toBe(true); + + const healthBeforeHit = useCombatStore.getState().health; + const damage = useCombatStore.getState().damagePlayer(100, "physical", 80, expiresAt); + expect(damage).toBeGreaterThan(0); + expect(useCombatStore.getState().health).toBe(healthBeforeHit - damage); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-druid-living-seed") + ))).toBe(false); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "healing" && event.abilityId === "wow335:druid:living-seed" + ))).toBe(false); + }); + + it("blooms on a landed direct enemy hit to a party ally", () => { + const member = usePartyStore.getState().members[0]!; + const memberMaxHealth = 1_000; + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === member.id + ? { ...candidate, health: 100, maxHealth: memberMaxHealth } + : candidate), + })); + useCombatStore.setState((state) => ({ + actors: { + ...state.actors, + [member.id]: { ...state.actors[member.id]!, health: 100, maxHealth: memberMaxHealth, alive: true }, + }, + talentRanks: { ...state.talentRanks, "talent-1922": 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: 1_000, + }).auras, + ...applyAura(state.auras, { + definition: { + id: "test-druid-guaranteed-ally-hit", + name: "Test Incoming Hit Chance", + disposition: "buff", + durationMs: null, + modifiers: [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 100 }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: member.id, + now: 1_000, + }).auras], + })); + registerPartyRuntimePosition(member.id, [2, 0, 0]); + + const healingTouch = abilityId("Healing Touch"); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().selectFriendlyActor(member.id); + const cast = useCombatStore.getState().castAbility(healingTouch, { targetActorId: member.id, now: 1_000 }); + expect(cast.ok, JSON.stringify(cast)).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + useCombatStore.getState().tick(0.1, activeCast!.completesAt); + const storedSeed = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === member.id + && aura.definition.utilityTags?.includes("wow335-druid-living-seed") + )); + expect(storedSeed?.druidLivingSeedAmount).toBeGreaterThan(0); + + const mobId = "druid-living-seed-party-hit"; + const attackId = "druid-living-seed-party-swing"; + useCombatStore.getState().registerMob(mobId, { + name: "Party attacker", + level: 1, + maxHealth: 1_000, + xpReward: 0, + attacks: [{ + ...DEFAULT_ENEMY_ATTACK, + id: attackId, + initialCooldownMs: [0, 0], + repeatCooldownMs: [1_000, 1_000], + }], + }); + setMobPosition(mobId, [4, 0, 0]); + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + [mobId]: { + ...state.mobs[mobId]!, + engaged: true, + combatPhase: "attacking", + targetActorId: member.id, + threatByActor: { [member.id]: 100 }, + }, + }, + })); + useCombatStore.getState().advanceMobCombat(activeCast!.completesAt + 500); + + const enemyDamage = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.sourceActorId === mobId && event.targetActorId === member.id + )); + expect(enemyDamage).toMatchObject({ outcome: "hit" }); + const remainingSeed = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === member.id + && aura.definition.utilityTags?.includes("wow335-druid-living-seed") + )); + expect(remainingSeed).toBeUndefined(); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "healing" && event.abilityId === "wow335:druid:living-seed" + && event.targetActorId === member.id && event.effectiveAmount > 0 + ))).toBe(true); + }); + + it("grants five Rage on Bear and Dire Bear Form critical auto-attacks at Primal Fury's 50% rank", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-801": 1 }, + resourcePools: { ...state.resourcePools, rage: 0 }, + maxResourcePools: { ...state.maxResourcePools, rage: 100 }, + })); + guaranteeDruidMeleeCrits(); + const form = useCombatStore.getState().castAbility(abilityId("Bear Form"), { + targetActorId: null, + now: 1_000, + }); + expect(form.ok, JSON.stringify(form)).toBe(true); + + const procSeed = findDruidPrimalFurySeed("rage", 0.5, true); + const proc = useCombatStore.getState().damageMob(TARGET_ID, 100, 2_000, undefined, "physical", { + attackKind: "melee", + seed: procSeed, + rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false }, + }); + expect(proc.combatResult?.critical).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(5); + expect(useCombatStore.getState().combatEvents.at(-1)).toMatchObject({ + kind: "resource", + sourceActorId: PLAYER_AGGRO_ID, + targetActorId: PLAYER_AURA_ENTITY_ID, + abilityId: "wow335-druid-primal-fury", + rawAmount: 5, + effectiveAmount: 5, + }); + + const noProcSeed = findDruidPrimalFurySeed("rage", 0.5, false); + const noProc = useCombatStore.getState().damageMob(TARGET_ID, 100, 3_000, undefined, "physical", { + attackKind: "melee", + seed: noProcSeed, + rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false }, + }); + expect(noProc.combatResult?.critical).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(5); + + const direBearForm = useCombatStore.getState().castAbility(abilityId("Dire Bear Form"), { + targetActorId: null, + now: 4_000, + }); + expect(direBearForm.ok, JSON.stringify(direBearForm)).toBe(true); + const direBearProc = useCombatStore.getState().damageMob(TARGET_ID, 100, 5_000, undefined, "physical", { + attackKind: "melee", + seed: findDruidPrimalFurySeed("rage", 0.5, true), + rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false }, + }); + expect(direBearProc.combatResult?.critical).toBe(true); + expect(useCombatStore.getState().resourcePools.rage).toBe(10); + }); + + it("adds one capped combo point on a Cat Form combo-builder critical and ignores other hits", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-801": 2 }, + resourcePools: { ...state.resourcePools, rage: 0 }, + maxResourcePools: { ...state.maxResourcePools, rage: 100 }, + })); + guaranteeDruidMeleeCrits(); + const form = useCombatStore.getState().castAbility(abilityId("Cat Form"), { + targetActorId: null, + now: 1_000, + }); + expect(form.ok, JSON.stringify(form)).toBe(true); + + const claw = castAndFinish("Claw", 3_000); + expect(claw).toMatchObject({ critical: true, sourceActorId: PLAYER_AGGRO_ID, targetActorId: TARGET_ID }); + expect(useCombatStore.getState().comboPoints).toBe(2); + expect(useCombatStore.getState().comboTargetId).toBe(TARGET_ID); + expect(useCombatStore.getState().resourcePools.rage).toBe(0); + + useCombatStore.setState((state) => ({ + comboPoints: 4, + comboTargetId: TARGET_ID, + resourcePools: { + ...state.resourcePools, + energy: state.maxResourcePools.energy, + }, + })); + const cappedClaw = castAndFinish("Claw", 5_000); + expect(cappedClaw?.critical).toBe(true); + expect(useCombatStore.getState().comboPoints).toBe(5); + expect(useCombatStore.getState().comboTargetId).toBe(TARGET_ID); + + useCombatStore.setState((state) => ({ comboPoints: 0, comboTargetId: null })); + const clawId = abilityId("Claw"); + useCombatStore.getState().damageMob(TARGET_ID, 100, 7_000, undefined, "physical", { + attackKind: "melee", + seed: "druid-primal-fury-cat-auto-attack", + abilityId: clawId, + rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false }, + }); + expect(useCombatStore.getState().comboPoints).toBe(0); + expect(useCombatStore.getState().comboTargetId).toBeNull(); + + useCombatStore.setState((state) => ({ + comboPoints: 0, + comboTargetId: null, + resourcePools: { ...state.resourcePools, energy: state.maxResourcePools.energy }, + })); + const rake = castAndFinish("Rake", 8_000); + expect(rake).toMatchObject({ critical: true, sourceActorId: PLAYER_AGGRO_ID, targetActorId: TARGET_ID }); + expect(useCombatStore.getState().comboPoints).toBe(2); + useCombatStore.getState().tick(9, 17_000); + expect(useCombatStore.getState().comboPoints).toBe(2); + expect(useCombatStore.getState().comboTargetId).toBe(TARGET_ID); + + useCombatStore.setState((state) => ({ comboPoints: 0, comboTargetId: null })); + useCombatStore.getState().damageMob(TARGET_ID, 100, 18_000, undefined, "arcane", { + attackKind: "spell", + seed: "druid-primal-fury-cat-non-combo", + abilityId: abilityId("Moonfire"), + rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false }, + }); + expect(useCombatStore.getState().comboPoints).toBe(0); + expect(useCombatStore.getState().comboTargetId).toBeNull(); + }); }); diff --git a/src/game/wow335DruidTalentRuntime.test.ts b/src/game/wow335DruidTalentRuntime.test.ts new file mode 100644 index 00000000..058b2583 --- /dev/null +++ b/src/game/wow335DruidTalentRuntime.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers"; + +describe("WoW Druid talent modifier runtime", () => { + it.each([1, 2, 3])("scopes Savage Fury rank %i to its six generated abilities", (rank) => { + const modifiers = talentModifiersForRanks({ "talent-805": rank }, "druid"); + const savageFury = modifiers.filter((modifier) => modifier.sourceTalentId === "talent-805"); + const abilityNames = ["Claw", "Rake", "Shred", "Mangle (Cat)", "Mangle (Bear)", "Maul"]; + + expect(savageFury).toHaveLength(abilityNames.length); + for (const abilityName of abilityNames) { + expect(applyTalentModifierValue(100, savageFury, "damage-percent", { abilityName })) + .toBeCloseTo(100 + rank * 4); + } + for (const abilityName of ["Swipe (Bear)", "Moonfire"]) { + expect(applyTalentModifierValue(100, savageFury, "damage-percent", { abilityName })).toBe(100); + } + }); + + it("limits Nature's Majesty's rank-scaled cooldown reduction to Mass Entanglement", () => { + for (const [rank, expectedReduction] of [[1, 5_000], [2, 10_000]] as const) { + const modifiers = talentModifiersForRanks({ "talent-1822": rank }, "druid"); + + expect(applyTalentModifierValue( + 30_000, + modifiers, + "cooldown-flat-ms", + { abilityName: "Mass Entanglement" }, + )).toBe(30_000 - expectedReduction); + expect(applyTalentModifierValue( + 30_000, + modifiers, + "cooldown-flat-ms", + { abilityName: "Typhoon" }, + )).toBe(30_000); + } + }); +}); diff --git a/src/game/wow335DruidTalentRuntime.ts b/src/game/wow335DruidTalentRuntime.ts new file mode 100644 index 00000000..12e49c86 --- /dev/null +++ b/src/game/wow335DruidTalentRuntime.ts @@ -0,0 +1,120 @@ +import type { TalentModifier } from "./talentModifiers"; +import type { AuraDefinition } from "./combatAuras"; + +export const WOW335_DRUID_FERAL_INSTINCT_TALENT_ID = "talent-799"; +export const WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG = "wow335-druid-feral-instinct-tiger-active"; +export const WOW335_DRUID_FERAL_INSTINCT_ENRAGE_AURA_TAG = "wow335-druid-feral-instinct-enrage-active"; +export const WOW335_DRUID_FERAL_INSTINCT_ATTACK_NAMES = new Set([ + "Swipe (Bear)", + "Swipe (Cat)", + "Ravage", +]); + +const FERAL_INSTINCT_DAMAGE_PERCENT_BY_RANK = [0, 10, 20, 30] as const; + +export function wow335DruidFeralInstinctPercent(rank: number): number { + return FERAL_INSTINCT_DAMAGE_PERCENT_BY_RANK[Math.max(0, Math.min(3, Math.trunc(rank)))] ?? 0; +} + +export function wow335DruidFeralInstinctConditionalPercent(rank: number): number { + return Math.max(0, Math.min(3, Math.trunc(rank))); +} + +export function wow335DruidFeralInstinctTigerAura(rank: number, durationMs: number): AuraDefinition | null { + const percent = wow335DruidFeralInstinctConditionalPercent(rank); + if (percent <= 0 || durationMs <= 0) return null; + return { + id: "wow335-druid-feral-instinct-tiger-fury", + name: "Feral Instinct: Tiger's Fury", + description: `Increases all damage dealt by ${percent}% while Tiger's Fury is active.`, + disposition: "buff", + durationMs, + utilityTags: [WOW335_DRUID_FERAL_INSTINCT_TIGER_AURA_TAG], + modifiers: [{ kind: "damage", direction: "dealt", operation: "percent", value: percent / 100 }], + }; +} + +export function wow335DruidFeralInstinctEnrageAura(rank: number, durationMs: number): AuraDefinition | null { + const percent = wow335DruidFeralInstinctConditionalPercent(rank); + if (percent <= 0 || durationMs <= 0) return null; + return { + id: "wow335-druid-feral-instinct-enrage", + name: "Feral Instinct: Enrage", + description: `Reduces all damage taken by ${percent}% while Enrage is active.`, + disposition: "buff", + durationMs, + utilityTags: [WOW335_DRUID_FERAL_INSTINCT_ENRAGE_AURA_TAG], + modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -percent / 100 }], + }; +} + +/** + * Source-backed modifiers for WoW Druid talents whose generated tooltip needs + * narrower scoping than the generic talent description parser can express. + */ +export function wow335DruidTalentModifierOverrides( + talentId: string, + rank: number, + description: string, +): readonly TalentModifier[] { + if (talentId === WOW335_DRUID_FERAL_INSTINCT_TALENT_ID) { + const swipeAndRavageDamage = description.match(/increases\s+swipe\s+and\s+ravage\s+damage\s+by\s+(\d+(?:\.\d+)?)%/i); + if (!swipeAndRavageDamage) return []; + return [{ + kind: "damage-percent", + amount: Number(swipeAndRavageDamage[1]), + unit: "percent", + scope: "damage of your Swipe (Bear), Swipe (Cat), and Ravage abilities", + sourceTalentId: talentId, + sourceTalentName: "Feral Instinct", + rank, + description, + }]; + } + if (talentId === "talent-805") { + // The generic modifier parser stops at the first comma in Savage Fury's + // named list, so emit a precise scope for every ability from this rank's + // generated tooltip instead of applying its bonus only to Claw. + const damageBonus = description.match( + /increases the damage caused by your (.+?) abilities by (\d+(?:\.\d+)?)%/i, + ); + if (!damageBonus) return []; + const abilityNames = damageBonus[1]! + .replace(/\s+(?:and|or)\s+/gi, ",") + .split(",") + .map((name) => name.trim()) + .filter(Boolean); + const amount = Number(damageBonus[2]); + if (!Number.isFinite(amount) || amount <= 0) return []; + return abilityNames.map((name) => ({ + kind: "damage-percent", + amount, + unit: "percent", + scope: `damage of your ${name} ability`, + sourceTalentId: talentId, + sourceTalentName: "Savage Fury", + rank, + description, + })); + } + if (talentId !== "talent-1822") return []; + + // Nature's Majesty reduces only Mass Entanglement's cooldown. The generic + // parser recognizes the flat reduction but, without this ability scope, + // would apply it to every cooldown-bearing ability. + const cooldown = description.match( + /\breduces?\s+(?:the\s+)?cooldown\s+(?:of|for)\s+your\s+Mass Entanglement\s+by\s+(\d+(?:\.\d+)?)\s+seconds?\b/i, + ); + if (!cooldown) return []; + + return [{ + kind: "cooldown-flat-ms", + amount: -Number(cooldown[1]) * 1_000, + unit: "milliseconds", + scope: "cooldown of your Mass Entanglement ability", + sourceTalentId: talentId, + sourceTalentName: "Nature's Majesty", + rank, + description, + }]; +} diff --git a/src/game/wow335HunterLockAndLoadParty.test.ts b/src/game/wow335HunterLockAndLoadParty.test.ts new file mode 100644 index 00000000..a5e4c4d4 --- /dev/null +++ b/src/game/wow335HunterLockAndLoadParty.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { applyAura } from "./combatAuras"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG } from "./wow335HunterRuntime"; +import { partyAbilityCosts, partyAbilityIsReady } from "./partyAbilityAi"; + +describe("WoW Hunter Lock and Load companion runtime", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + await preloadAbilityCatalogsForClasses(["hunter"]); + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + usePartyStore.getState().initializeParty( + "hunter-lock-and-load-party", + 80, + useCombatStore.getState().health, + "damage", + 1, + ); + }); + + it("lets a companion spend two free Arcane Shots through its cooldown and mana checks", () => { + const arcaneShot = abilitiesForClass("hunter").find((ability) => ability.name === "Arcane Shot")!; + const hunterAbilities = abilitiesForClass("hunter"); + const hunterBase = createConfiguredPartyMember({ + name: "Survival Hunter", + classId: "hunter", + specialization: "Survival", + specializationId: "hunter:survival", + talentTreeId: "hunter-survival", + role: "damage", + talentRanks: { "talent-1306": 3 }, + }, 80, 0, "hunter-lock-and-load-party"); + const hunter = { + ...hunterBase, + resource: 0, + resourcePools: { ...hunterBase.resourcePools, mana: 0 }, + lastResourceSpentAt: {}, + cooldowns: Object.fromEntries(hunterAbilities.map((ability) => [ability.id, 60_000])), + globalCooldownEndsAt: 0, + nextActionAt: 0, + status: "ready" as const, + }; + usePartyStore.setState({ members: [hunter], command: "attack" }); + registerPartyRuntimePosition(hunter.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().registerMob("hunter-lock-and-load-party-target", { + name: "Training Target", maxHealth: 100_000, xpReward: 0, + }); + setMobPosition("hunter-lock-and-load-party-target", [0, 0, 12]); + useCombatStore.getState().damageMob("hunter-lock-and-load-party-target", 1, 500); + updatePartyRuntimeTargetVisibility(hunter.id, "hunter-lock-and-load-party-target", true); + useCombatStore.setState((state) => ({ + auras: applyAura(state.auras, { + definition: { + id: "wow335:hunter:lock-and-load-ready", + name: "Lock and Load", + disposition: "buff", + durationMs: 15_000, + maxStacks: 2, + initialStacks: 2, + stackBehavior: "replace", + utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], + }, + sourceId: hunter.id, + targetId: hunter.id, + now: 1_000, + }).auras, + })); + + expect(partyAbilityIsReady(hunter, arcaneShot, 1_000)).toBe(true); + expect(partyAbilityCosts(hunter, arcaneShot, 1_000)).toEqual(expect.arrayContaining([ + expect.objectContaining({ resource: "mana", amount: 0 }), + ])); + + advanceDungeonPartyCombat(1_000); + + expect(usePartyStore.getState().members[0]?.cooldowns[arcaneShot.id]).toBe(0); + expect(usePartyStore.getState().members[0]?.lastResourceSpentAt.mana).toBe(0); + expect(useCombatStore.getState().auras.find((aura) => ( + aura.targetId === hunter.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) + ))?.stacks).toBe(1); + + advanceDungeonPartyCombat(3_000); + + expect(usePartyStore.getState().members[0]?.cooldowns[arcaneShot.id]).toBe(0); + expect(usePartyStore.getState().members[0]?.lastResourceSpentAt.mana).toBe(0); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === hunter.id && aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) + ))).toBe(false); + }); + + it("creates a companion Freezing Arrow trap that procs the companion's talent", () => { + const hunter = createConfiguredPartyMember({ + name: "Survival Hunter", + classId: "hunter", + specialization: "Survival", + specializationId: "hunter:survival", + talentTreeId: "hunter-survival", + role: "damage", + talentRanks: { "talent-1306": 3 }, + }, 80, 0, "hunter-freezing-arrow-party"); + usePartyStore.setState({ members: [hunter] }); + registerPartyRuntimePosition(hunter.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().registerMob("hunter-freezing-arrow-party-target", { + name: "Training Target", maxHealth: 100_000, xpReward: 0, + }); + setMobPosition("hunter-freezing-arrow-party-target", [0, 0, 12]); + const freezingArrow = abilitiesForClass("hunter").find((ability) => ability.name === "Freezing Arrow")!; + const areaId = useCombatStore.getState().createActorAreaEffect( + hunter.id, freezingArrow, [0, 0, 0], 1_000, + ); + expect(areaId).toBeTruthy(); + setMobPosition("hunter-freezing-arrow-party-target", [0, 0, 1]); + + useCombatStore.getState().tick(1, 2_000); + + expect(useCombatStore.getState().mobs["hunter-freezing-arrow-party-target"]?.statuses) + .toEqual(expect.arrayContaining([expect.objectContaining({ kind: "freeze", sourceActorId: hunter.id })])); + expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ targetId: hunter.id, stacks: 2, expiresAt: 17_000 }), + ])); + }); +}); diff --git a/src/game/wow335HunterMechanics.test.ts b/src/game/wow335HunterMechanics.test.ts index 24627cf3..eb551a3c 100644 --- a/src/game/wow335HunterMechanics.test.ts +++ b/src/game/wow335HunterMechanics.test.ts @@ -1,13 +1,20 @@ import { beforeEach, describe, expect, it } from "vitest"; import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { PLAYER_AGGRO_ID } from "./aggro"; +import { applyAura } from "./combatAuras"; 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 { runtimeTalentNodeById } from "./talentRuntimeCatalog"; import { talentModifiersForRanks } from "./talentModifiers"; import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { + lockAndLoadRankMechanics, + WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG, + WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG, +} from "./wow335HunterRuntime"; import { usePartyStore } from "./partyStore"; describe("WoW 3.3.5 Hunter Readiness", () => { @@ -96,6 +103,15 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => { ); }); + it("reads Lock and Load chance, charges, and cooldown from each generated rank description", () => { + expect([1, 2, 3].map(lockAndLoadRankMechanics)).toEqual([ + expect.objectContaining({ trapProcChance: 0.33, periodicProcChance: 0.1, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }), + expect.objectContaining({ trapProcChance: 0.66, periodicProcChance: 0.2, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }), + expect.objectContaining({ trapProcChance: 1, periodicProcChance: 0.3, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }), + ]); + expect(lockAndLoadRankMechanics(0)).toBeNull(); + }); + it("resets Hunter cooldowns while leaving Bestial Wrath's cooldown untouched", () => { const readinessId = unlockHunterTalent("Readiness"); const bestialWrathId = unlockHunterTalent("Bestial Wrath"); @@ -156,6 +172,14 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => { 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"); + const beastWithin = useCombatStore.getState().abilities.find((candidate) => candidate.name === "The Beast Within"); + const beastWithinTalent = runtimeTalentNodeById("talent-1803"); + expect(beastWithinTalent).toBeDefined(); + expect(beastWithin).toMatchObject({ + talentNodeId: "talent-1803", + description: beastWithinTalent?.rankDescriptions[0], + }); + expect(beastWithin?.ranks?.map((rank) => rank.description)).toEqual(beastWithinTalent?.rankDescriptions); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1803": 2 }, })); @@ -177,10 +201,11 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => { 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.every((aura) => aura.expiresAt === 9_000)).toBe(true); + expect(beastWithinAuras.every((aura) => aura.definition.description === beastWithinTalent?.rankDescriptions[1])).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 }), + expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.5 }), + expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.15 }), ])); }); @@ -205,8 +230,14 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => { })); 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); + const beastWithinAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.id.includes(":34471:")); + expect(beastWithinAuras).toHaveLength(3); + expect(beastWithinAuras.every((aura) => aura.expiresAt === procAt + 6_000)).toBe(true); + expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.5 }), + expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.15 }), + ])); }); it("stores Intimidation on the pet and spends it on the pet's next melee hit", () => { @@ -278,7 +309,7 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => { ))).toBe(false); }); - it("does not consume Kill Command's special-attack charges on a generic pet melee swing", () => { + it("keeps Kill Command's pet aura inert on generic melee swings", () => { registerHunterMob("kill-command-target", [0, 0, 3]); const callPetId = hunterAbilityId("Call Pet"); const killCommandId = hunterAbilityId("Kill Command"); @@ -292,12 +323,14 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => { entry.definition.utilityTags?.includes("wow335-hunter-kill-command") )); expect(aura?.targetId).toBe(pet!.id); - expect(aura?.stacks).toBe(60); + expect(aura?.expiresAt).toBe(33_000); + expect(aura?.definition.maxStacks).toBeUndefined(); + expect(aura?.definition.initialStacks).toBeUndefined(); 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); + expect(useCombatStore.getState().auras.find((entry) => entry.instanceId === aura?.instanceId)?.expiresAt).toBe(33_000); }); it("arms Freezing Arrow at its ground point and freezes only an approaching target", () => { @@ -328,6 +361,206 @@ describe("WoW 3.3.5 Hunter pet mechanics", () => { expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0); }); + it("procs Lock and Load when Freezing Arrow successfully traps a target", () => { + registerHunterMob("lock-and-load-trap-target", [0, 0, 10]); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1306": 3 }, + })); + const freezingArrowId = hunterAbilityId("Freezing Arrow"); + expect(useCombatStore.getState().castAbility(freezingArrowId, { + origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000, + })).toMatchObject({ ok: true }); + + setMobPosition("lock-and-load-trap-target", [0, 0, 1]); + useCombatStore.getState().tick(1, 2_000); + + expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + targetId: PLAYER_AURA_ENTITY_ID, + stacks: 2, + expiresAt: 17_000, + definition: expect.objectContaining({ + utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG]), + }), + }), + expect.objectContaining({ + targetId: PLAYER_AURA_ENTITY_ID, + expiresAt: 17_000, + definition: expect.objectContaining({ + utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG]), + }), + }), + ])); + }); + + it("uses the generated periodic-damage chance for Black Arrow ticks", () => { + const targetId = "lock-and-load-black-arrow-target"; + registerHunterMob(targetId, [0, 0, 12]); + const blackArrowId = unlockHunterTalent("Black Arrow"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1306": 3 }, + })); + const firstProcTickAt = Array.from({ length: 60_000 }, (_, index) => 4_000 + index) + .find((tickAt) => hunterProcRoll( + `${PLAYER_AGGRO_ID}:${blackArrowId}:periodic:${targetId}:${tickAt}:wow335-hunter-lock-and-load`, + ) < 0.3); + expect(firstProcTickAt).toBeDefined(); + const castAt = firstProcTickAt! - 3_000; + expect(useCombatStore.getState().castAbility(blackArrowId, { targetActorId: targetId, now: castAt })) + .toMatchObject({ ok: true }); + + useCombatStore.getState().tick(1, firstProcTickAt!); + + expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + targetId: PLAYER_AURA_ENTITY_ID, + stacks: 2, + expiresAt: firstProcTickAt! + 15_000, + definition: expect.objectContaining({ + utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG]), + }), + }), + expect.objectContaining({ + targetId: PLAYER_AURA_ENTITY_ID, + expiresAt: firstProcTickAt! + 15_000, + definition: expect.objectContaining({ + utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG]), + }), + }), + ])); + }); + + it("makes the next two Arcane Shots free and cooldown-free, then restores normal costs", () => { + registerHunterMob("lock-and-load-shot-target", [0, 0, 12]); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1306": 3 }, + resource: 0, + resourcePools: { ...state.resourcePools, mana: 0 }, + })); + const arcaneShotId = hunterAbilityId("Arcane Shot"); + const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!; + const readyAura = { + kind: "apply-aura" as const, + recipient: "caster" as const, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + aura: { + id: "wow335:hunter:lock-and-load-ready", + name: "Lock and Load", + description: "Two Arcane Shot or Explosive Shot casts cost no mana and trigger no cooldown.", + disposition: "buff" as const, + durationMs: 15_000, + maxStacks: 2, + initialStacks: 2, + stackBehavior: "replace" as const, + utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], + }, + }; + useCombatStore.setState((state) => ({ + auras: applyAura(state.auras, { + definition: readyAura.aura, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: 1_000, + }).auras, + })); + + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [arcaneShotId]: 30_000 }, + })); + expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 1_000 })) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().resourcePools.mana).toBe(0); + expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(0); + expect(useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) + ))?.stacks).toBe(1); + + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [arcaneShotId]: 40_000 }, + })); + expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 3_000 })) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().resourcePools.mana).toBe(0); + expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(0); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) + ))).toBe(false); + + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [arcaneShotId]: 0 }, + })); + expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 5_000 })) + .toMatchObject({ ok: false, reason: "not-enough-resource" }); + }); + + it("does not consume Lock and Load when an eligible shot cannot find a target", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1306": 3 }, + resource: 0, + resourcePools: { ...state.resourcePools, mana: 0 }, + })); + const arcaneShotId = hunterAbilityId("Arcane Shot"); + const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!; + useCombatStore.setState((state) => ({ + auras: applyAura(state.auras, { + definition: { + id: "wow335:hunter:lock-and-load-ready", name: "Lock and Load", disposition: "buff", + durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace", + utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: 1_000, + }).auras, + })); + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [arcaneShotId]: 30_000 }, + })); + + expect(useCombatStore.getState().castAbility(arcaneShotId, { now: 1_000 })) + .toMatchObject({ ok: false, reason: "no-target" }); + expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(30_000); + expect(useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) + ))?.stacks).toBe(2); + }); + + it("spends a Lock and Load charge on a cast that misses", () => { + const targetId = "lock-and-load-miss-target"; + registerHunterMob(targetId, [0, 0, 12]); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1306": 3 }, + })); + const arcaneShotId = hunterAbilityId("Arcane Shot"); + const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!; + useCombatStore.setState((state) => ({ + auras: applyAura(state.auras, { + definition: { + id: "wow335:hunter:lock-and-load-ready", name: "Lock and Load", disposition: "buff", + durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace", + utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: 1_000, + }).auras, + })); + const missAt = Array.from({ length: 13_000 }, (_, index) => 3_000 + index).find((now) => ( + hunterProcRoll(`${PLAYER_AGGRO_ID}:${arcaneShot.id}:${targetId}:${now}:direct:outcome`) < 0.05 + )); + expect(missAt).toBeDefined(); + + expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: targetId, now: missAt! })) + .toMatchObject({ ok: true }); + expect(useCombatStore.getState().lastCombatResult?.outcome).toBe("miss"); + expect(useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) + ))?.stacks).toBe(1); + }); + 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 }); diff --git a/src/game/wow335HunterRuntime.ts b/src/game/wow335HunterRuntime.ts new file mode 100644 index 00000000..41d25512 --- /dev/null +++ b/src/game/wow335HunterRuntime.ts @@ -0,0 +1,188 @@ +import type { AbilityDefinition, AbilityEffect, AbilityRankDefinition } from "./abilityCatalog"; +import type { ActiveAura } from "./combatAuras"; +import { runtimeTalentNodeById } from "./talentRuntimeCatalog"; + +const THE_BEAST_WITHIN_NODE_ID = "talent-1803"; +export const WOW335_HUNTER_LOCK_AND_LOAD_TALENT_ID = "talent-1306"; +export const WOW335_HUNTER_ENTRAPMENT_TALENT_ID = "talent-1304"; +export const WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG = "wow335-hunter-lock-and-load-ready"; +export const WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG = "wow335-hunter-lock-and-load-icd"; + +export interface BeastWithinRankMechanics { + readonly description: string; + readonly procChance: number; + readonly additionalDamage: number; + readonly manaCostReduction: number; + readonly durationMs: number; +} + +export interface LockAndLoadRankMechanics { + readonly description: string; + readonly trapProcChance: number; + readonly periodicProcChance: number; + readonly freeCastCount: number; + readonly durationMs: number; + readonly internalCooldownMs: number; +} + +export interface EntrapmentRankMechanics { + readonly description: string; + readonly frostTrapSlowBonus: number; + readonly rootProcChance: number; + readonly rootDurationMs: number; +} + +function tooltipNumber(description: string, pattern: RegExp): number | null { + const match = description.match(pattern); + if (!match) return null; + const value = Number(match[1]); + return Number.isFinite(value) ? value : null; +} + +/** Read The Beast Within's mechanics from the generated talent rank text. */ +export function beastWithinRankMechanics(rank: number): BeastWithinRankMechanics | null { + const node = runtimeTalentNodeById(THE_BEAST_WITHIN_NODE_ID); + const description = node?.rankDescriptions[Math.trunc(rank) - 1]; + if (!description) return null; + + const procChance = tooltipNumber(description, /you have a (\d+(?:\.\d+)?)% chance/i); + const additionalDamage = tooltipNumber(description, /causing (\d+(?:\.\d+)?)% additional damage/i); + const manaCostReduction = tooltipNumber(description, /reducing mana costs of all spells by (\d+(?:\.\d+)?)%/i); + const durationSeconds = tooltipNumber(description, /for (\d+(?:\.\d+)?) seconds/i); + if (procChance === null || additionalDamage === null || manaCostReduction === null || durationSeconds === null) { + return null; + } + + return { + description, + procChance: Math.max(0, Math.min(1, procChance / 100)), + additionalDamage: additionalDamage / 100, + manaCostReduction: manaCostReduction / 100, + durationMs: Math.max(0, Math.round(durationSeconds * 1_000)), + }; +} + +/** Read Lock and Load's rank values from its generated tooltip text. */ +export function lockAndLoadRankMechanics(rank: number): LockAndLoadRankMechanics | null { + const node = runtimeTalentNodeById(WOW335_HUNTER_LOCK_AND_LOAD_TALENT_ID); + const description = node?.rankDescriptions[Math.trunc(rank) - 1]; + if (!description) return null; + + const trapProcPercent = tooltipNumber(description, /trapping a target[^.]*?has a (\d+(?:\.\d+)?)% chance/i); + const periodicProcPercent = tooltipNumber(description, /periodic damage[^.]*?has a (\d+(?:\.\d+)?)% to trigger/i); + const freeCastCount = tooltipNumber(description, /your next (\d+) Arcane Shot or Explosive Shot/i); + const cooldownSeconds = tooltipNumber(description, /this effect has a (\d+(?:\.\d+)?) sec cooldown/i); + if (trapProcPercent === null || periodicProcPercent === null || freeCastCount === null || cooldownSeconds === null) { + return null; + } + + const durationMs = Math.max(0, Math.round(cooldownSeconds * 1_000)); + return { + description, + trapProcChance: Math.max(0, Math.min(1, trapProcPercent / 100)), + periodicProcChance: Math.max(0, Math.min(1, periodicProcPercent / 100)), + freeCastCount: Math.max(0, Math.trunc(freeCastCount)), + durationMs, + internalCooldownMs: durationMs, + }; +} + +/** Read the authored Entrapment slow/root values from the generated talent tooltip. */ +export function entrapmentRankMechanics(rank: number): EntrapmentRankMechanics | null { + const normalizedRank = Math.trunc(rank); + const node = runtimeTalentNodeById(WOW335_HUNTER_ENTRAPMENT_TALENT_ID); + const description = node?.rankDescriptions[normalizedRank - 1]; + if (!description) return null; + + const slowPercent = tooltipNumber(description, /increases the slow effect of Frost Trap by (\d+(?:\.\d+)?)%/i); + const rootDurationSeconds = tooltipNumber(description, /preventing them from moving for (\d+(?:\.\d+)?) seconds/i); + if (slowPercent === null || rootDurationSeconds === null) return null; + + // The local Hunter DBC Entrapment rank rows carry 2/4/6 as their proc base + // points; their companion child aura supplies the root duration. + const rootProcChance = [0, 0.02, 0.04, 0.06][normalizedRank]; + if (rootProcChance === undefined) return null; + return { + description, + frostTrapSlowBonus: slowPercent / 100, + rootProcChance, + rootDurationMs: Math.max(0, Math.round(rootDurationSeconds * 1_000)), + }; +} + +/** Spell.dbc child-aura durations for Freezing Trap's three learned ranks. */ +export function freezingTrapDurationMs(spellId: number): number { + switch (spellId) { + case 1499: return 10_000; + case 14310: return 15_000; + case 14311: + case 60192: + case 60202: return 20_000; + default: return 20_000; + } +} + +export function consumeLockAndLoadCharge( + auras: readonly ActiveAura[], + auraInstanceId: string, +): readonly ActiveAura[] { + return auras.flatMap((aura) => aura.instanceId !== auraInstanceId + ? [aura] + : aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []); +} + +function applyBeastWithinRankMechanics( + effects: readonly AbilityEffect[], + mechanics: BeastWithinRankMechanics, +): readonly AbilityEffect[] { + return effects.map((effect) => { + if (effect.kind !== "apply-aura") return effect; + return { + ...effect, + aura: { + ...effect.aura, + description: mechanics.description, + durationMs: mechanics.durationMs, + modifiers: effect.aura.modifiers?.map((modifier) => { + if (modifier.kind === "resource" && modifier.property === "cost" && modifier.resource === "power") { + return { ...modifier, value: -mechanics.manaCostReduction }; + } + if (modifier.kind === "damage" && modifier.direction === "dealt") { + return { ...modifier, value: mechanics.additionalDamage }; + } + return modifier; + }), + }, + }; + }); +} + +/** Replace the duplicate DBC child-rank text and payload with the generated talent's actual ranks. */ +export function applyGeneratedBeastWithinTalent(ability: AbilityDefinition): AbilityDefinition { + if (ability.classId !== "hunter" || ability.name !== "The Beast Within") return ability; + const node = runtimeTalentNodeById(THE_BEAST_WITHIN_NODE_ID); + const sourceRank = ability.ranks?.[0]; + if (!node || !sourceRank || node.rankDescriptions.length === 0) return ability; + + const ranks: AbilityRankDefinition[] = []; + for (const [index, description] of node.rankDescriptions.entries()) { + const mechanics = beastWithinRankMechanics(index + 1); + if (!mechanics) return ability; + ranks.push({ + ...sourceRank, + rank: index + 1, + description, + effects: applyBeastWithinRankMechanics(sourceRank.effects, mechanics), + }); + } + + const firstRank = ranks[0]; + if (!firstRank) return ability; + return { + ...ability, + talentNodeId: node.id, + description: firstRank.description, + effects: firstRank.effects, + ranks, + }; +} diff --git a/src/game/wow335HunterTrapPartyRuntime.test.ts b/src/game/wow335HunterTrapPartyRuntime.test.ts new file mode 100644 index 00000000..9a4573c8 --- /dev/null +++ b/src/game/wow335HunterTrapPartyRuntime.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForClass, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { resetPartyRuntimeClock } from "./partyRuntime"; + +const EMPTY_TALENTS: Readonly> = {}; + +function setupHunter( + id: string, + level: number, + talentRanks: Readonly> = EMPTY_TALENTS, + rangedAttackPower = 0, +) { + const base = createConfiguredPartyMember({ + name: "Companion Hunter", + classId: "hunter", + specialization: "Survival", + specializationId: "hunter:survival", + talentTreeId: "hunter-survival", + role: "damage", + talentRanks, + }, level, 0, id); + const hunter = { + ...base, + gearStats: { ...base.gearStats, rangedAttackPower }, + globalCooldownEndsAt: 0, + nextActionAt: 0, + status: "ready" as const, + }; + usePartyStore.setState({ members: [hunter] }); + registerPartyRuntimePosition(hunter.id, [5, 0, 0]); + useCombatStore.getState().synchronizeActors(); + return hunter; +} + +function learnedHunterAbility(name: string, level: number, talentRanks: Readonly>) { + const ability = abilitiesForClass("hunter").find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + const learned = learnedAbilityForBuild(ability!, level, talentRanks); + expect(learned, `${name} should be available at level ${level}`).not.toBeNull(); + return learned!; +} + +function registerTarget(id: string, position: readonly [number, number, number]): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); + setMobPosition(id, position); +} + +function stableRoll(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 Hunter companion trap runtime", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + await preloadAbilityCatalogsForClasses(["hunter"]); + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + usePartyStore.getState().initializeParty("hunter-trap-party-runtime", 80, useCombatStore.getState().health); + }); + + it("places a companion Freezing Trap at the owner and uses that owner's learned rank on trigger", () => { + const hunter = setupHunter("freezing-trap-owner", 40); + const targetId = "freezing-trap-party-target"; + registerTarget(targetId, [9, 0, 0]); + const trap = learnedHunterAbility("Freezing Trap", hunter.level, hunter.talentRanks); + expect(trap.dbcSpellId).toBe(14310); + + const areaId = useCombatStore.getState().createActorAreaEffect(hunter.id, trap, [100, 0, 0], 1_000); + const placed = useCombatStore.getState().areaEffects.find((area) => area.id === areaId); + expect(placed).toMatchObject({ + sourceActorId: hunter.id, + sourceAbilityId: trap.id, + position: [5, 0, 0], + expiresAt: 31_000, + hunterTrap: { kind: "freezing", freezeDurationMs: 15_000 }, + }); + + setMobPosition(targetId, [6, 0, 0]); + useCombatStore.getState().tick(1, 2_000); + + expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "freeze", + sourceAbilityId: trap.id, + sourceActorId: hunter.id, + endsAt: 17_000, + breakOnDamage: true, + }), + ])); + }); + + it("activates a companion Frost Trap zone and uses the owner's Entrapment rank", () => { + const hunter = setupHunter("frost-trap-owner", 40, { "talent-1304": 3 }); + const targetId = "frost-trap-party-target"; + registerTarget(targetId, [9, 0, 0]); + const trap = learnedHunterAbility("Frost Trap", hunter.level, hunter.talentRanks); + + const areaId = useCombatStore.getState().createActorAreaEffect(hunter.id, trap, [100, 0, 0], 1_000); + expect(areaId).toBeTruthy(); + expect(useCombatStore.getState().areaEffects.find((area) => area.id === areaId)).toMatchObject({ + sourceActorId: hunter.id, + position: [5, 0, 0], + hunterTrap: { kind: "frost" }, + }); + + setMobPosition(targetId, [6, 0, 0]); + useCombatStore.getState().tick(1, 2_000); + let zone = useCombatStore.getState().areaEffects.find((area) => area.id === areaId); + expect(zone).toMatchObject({ radius: 10, expiresAt: 32_000, tickIntervalMs: 2_000, hunterTrap: { kind: "frost-zone" } }); + + useCombatStore.getState().tick(0, 4_000); + const zoneSlowSourceId = `hunter-trap-zone:${areaId}`; + expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "slow", sourceAbilityId: zoneSlowSourceId, magnitude: 0.65 }), + ])); + + zone = useCombatStore.getState().areaEffects.find((area) => area.id === areaId); + const rootAt = Array.from({ length: 100 }, (_, index) => zone!.nextTickAt + index * 2_000) + .find((pulseAt) => stableRoll( + `${hunter.id}:${areaId}:${targetId}:${pulseAt}:wow335-hunter-entrapment`, + ) < 0.06); + expect(rootAt).toBeDefined(); + useCombatStore.getState().tick(1, rootAt!); + + expect(useCombatStore.getState().mobs[targetId]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "root", sourceAbilityId: trap.id, sourceActorId: hunter.id, endsAt: rootAt! + 3_000 }), + ])); + }); + + it("snapshots companion Immolation Trap rank and ranged attack power when it triggers", () => { + const hunter = setupHunter("immolation-trap-owner", 46, EMPTY_TALENTS, 2_000); + const targetId = "immolation-trap-party-target"; + registerTarget(targetId, [9, 0, 0]); + const trap = learnedHunterAbility("Immolation Trap", hunter.level, hunter.talentRanks); + expect(trap.dbcSpellId).toBe(14304); + + const areaId = useCombatStore.getState().createActorAreaEffect(hunter.id, trap, [100, 0, 0], 1_000); + expect(useCombatStore.getState().areaEffects.find((area) => area.id === areaId)).toMatchObject({ + sourceActorId: hunter.id, + sourceAbilityId: trap.id, + position: [5, 0, 0], + expiresAt: 31_000, + hunterTrap: { kind: "immolation" }, + }); + + setMobPosition(targetId, [6, 0, 0]); + useCombatStore.getState().tick(1, 2_000); + const dot = useCombatStore.getState().effects.find((effect) => ( + effect.sourceActorId === hunter.id && effect.sourceAbilityId === trap.id && effect.tag === "hunter-immolation-trap" + )); + expect(dot).toMatchObject({ + kind: "dot", + sourceActorId: hunter.id, + sourceAbilityId: trap.id, + targetId, + remainingTicks: 5, + intervalMs: 3_000, + nextTickAt: 5_000, + }); + expect(dot?.fixedAmount).toBeGreaterThan(100); + + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === hunter.id + ? { ...member, gearStats: { ...member.gearStats, rangedAttackPower: 0 } } + : member), + })); + useCombatStore.getState().tick(1, 5_000); + + expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + occurredAt: 5_000, + kind: "damage", + sourceActorId: hunter.id, + abilityId: trap.id, + targetActorId: targetId, + rawAmount: dot?.fixedAmount, + }), + ])); + }); + + it("executes a companion Explosive Trap from the owner's rank and snapshots both damage payloads", () => { + const hunter = setupHunter("explosive-trap-owner", 44, EMPTY_TALENTS, 2_000); + const targetId = "explosive-trap-party-target"; + registerTarget(targetId, [9, 0, 0]); + const trap = learnedHunterAbility("Explosive Trap", hunter.level, hunter.talentRanks); + expect(trap.dbcSpellId).toBe(14316); + + const areaId = useCombatStore.getState().createActorAreaEffect(hunter.id, trap, [100, 0, 0], 1_000); + expect(useCombatStore.getState().areaEffects.find((area) => area.id === areaId)).toMatchObject({ + sourceActorId: hunter.id, + sourceAbilityId: trap.id, + position: [5, 0, 0], + hunterTrap: { kind: "explosive", directDamageScaling: expect.any(Object) }, + }); + + setMobPosition(targetId, [6, 0, 0]); + useCombatStore.getState().tick(1, 2_000); + const burningZone = useCombatStore.getState().areaEffects.find((area) => area.id === areaId); + const directHit = useCombatStore.getState().combatEvents.find((event) => ( + event.occurredAt === 2_000 && event.abilityId === trap.id && event.targetActorId === targetId + )); + expect(directHit?.sourceActorId).toBe(hunter.id); + expect(directHit?.rawAmount).toBeGreaterThan(100); + expect(burningZone).toMatchObject({ + sourceActorId: hunter.id, + radius: 10, + hunterTrap: undefined, + }); + expect(burningZone?.fixedDamagePerTick).toBeGreaterThan(0); + + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === hunter.id + ? { ...member, gearStats: { ...member.gearStats, rangedAttackPower: 0 } } + : member), + })); + useCombatStore.getState().tick(1, 4_000); + + expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + occurredAt: 4_000, + kind: "damage", + sourceActorId: hunter.id, + abilityId: trap.id, + targetActorId: targetId, + rawAmount: burningZone?.fixedDamagePerTick, + }), + ])); + }); +}); diff --git a/src/game/wow335HunterTrapRuntime.test.ts b/src/game/wow335HunterTrapRuntime.test.ts new file mode 100644 index 00000000..8d22c6ba --- /dev/null +++ b/src/game/wow335HunterTrapRuntime.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; + +function hunterAbilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function registerMob(id: string, position: readonly [number, number, number]): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); + setMobPosition(id, position); +} + +function stableRoll(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 Hunter trap runtime", () => { + 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-trap-runtime-tests", + 1, + useCombatStore.getState().health, + ); + }); + + it("arms Freezing Trap, freezes an entrant for its local child-spell duration, and breaks on damage", () => { + registerMob("freezing-trap-target", [0, 0, 3]); + const abilityId = hunterAbilityId("Freezing Trap"); + const cast = useCombatStore.getState().castAbility(abilityId, undefined, 1_000); + expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); + const placed = useCombatStore.getState().areaEffects[0]; + expect(placed?.hunterTrap?.kind).toBe("freezing"); + expect(placed?.position).toEqual([0, 0, 0]); + expect(useCombatStore.getState().mobs["freezing-trap-target"]?.statuses).toHaveLength(0); + + setMobPosition("freezing-trap-target", [0, 0, 1]); + useCombatStore.getState().tick(1, 2_000); + + const freeze = useCombatStore.getState().mobs["freezing-trap-target"]?.statuses.find((status) => ( + status.kind === "freeze" + )); + expect(freeze).toMatchObject({ + sourceAbilityId: abilityId, + sourceActorId: PLAYER_AGGRO_ID, + endsAt: 2_000 + (placed?.hunterTrap?.freezeDurationMs ?? 0), + breakOnDamage: true, + }); + expect(useCombatStore.getState().areaEffects.some((area) => area.id === placed?.id)).toBe(false); + + useCombatStore.getState().damageMob("freezing-trap-target", 1, 2_001); + expect(useCombatStore.getState().mobs["freezing-trap-target"]?.statuses.some((status) => ( + status.kind === "freeze" + ))).toBe(false); + }); + + it("activates Frost Trap as a 10yd slow zone and uses Entrapment rank data for the slow and root", () => { + registerMob("frost-trap-trigger", [0, 0, 3]); + registerMob("frost-trap-nearby", [0, 0, 5]); + registerMob("frost-trap-outside", [0, 0, 12]); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1304": 3 }, + })); + const abilityId = hunterAbilityId("Frost Trap"); + expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)) + .toMatchObject({ ok: true }); + + setMobPosition("frost-trap-trigger", [0, 0, 1]); + useCombatStore.getState().tick(1, 2_000); + let zone = useCombatStore.getState().areaEffects.find((area) => area.hunterTrap?.kind === "frost-zone"); + expect(zone).toMatchObject({ radius: 10, expiresAt: 32_000, tickIntervalMs: 2_000 }); + const seededTrapId = Array.from({ length: 1_000 }, (_, index) => `entrapment-test-zone-${index}`) + .find((id) => stableRoll( + `${PLAYER_AGGRO_ID}:${id}:frost-trap-trigger:${zone!.nextTickAt}:wow335-hunter-entrapment`, + ) < 0.06); + expect(seededTrapId).toBeDefined(); + useCombatStore.setState((state) => ({ + areaEffects: state.areaEffects.map((area) => area.id === zone?.id + ? { ...area, id: seededTrapId! } + : area), + })); + zone = useCombatStore.getState().areaEffects.find((area) => area.hunterTrap?.kind === "frost-zone"); + + useCombatStore.getState().tick(0, 2_001); + const zoneSlowSourceId = `hunter-trap-zone:${zone!.id}`; + expect(useCombatStore.getState().mobs["frost-trap-trigger"]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "slow", sourceAbilityId: zoneSlowSourceId, magnitude: 0.65 }), + ])); + expect(useCombatStore.getState().mobs["frost-trap-nearby"]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "slow", sourceAbilityId: zoneSlowSourceId, magnitude: 0.65 }), + ])); + expect(useCombatStore.getState().mobs["frost-trap-outside"]?.statuses.some((status) => ( + status.sourceAbilityId === zoneSlowSourceId + ))).toBe(false); + + const rootAt = Array.from({ length: 14 }, (_, index) => zone!.nextTickAt + index * 2_000) + .find((pulseAt) => stableRoll( + `${PLAYER_AGGRO_ID}:${zone!.id}:frost-trap-trigger:${pulseAt}:wow335-hunter-entrapment`, + ) < 0.06); + expect(rootAt).toBeDefined(); + useCombatStore.getState().tick(1, rootAt!); + expect(useCombatStore.getState().mobs["frost-trap-trigger"]?.statuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "root", sourceAbilityId: abilityId, endsAt: rootAt! + 3_000 }), + ])); + + setMobPosition("frost-trap-nearby", [0, 0, 12]); + useCombatStore.getState().tick(0, rootAt! + 1); + expect(useCombatStore.getState().mobs["frost-trap-nearby"]?.statuses.some((status) => ( + status.sourceAbilityId === zoneSlowSourceId + ))).toBe(false); + }); + + it("applies Immolation Trap's sourced five fire ticks with ranged attack power scaling on activation", () => { + registerMob("immolation-trap-target", [0, 0, 3]); + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, rangedAttackPower: 2_000 }, + })); + const abilityId = hunterAbilityId("Immolation Trap"); + expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)) + .toMatchObject({ ok: true }); + const before = useCombatStore.getState().mobs["immolation-trap-target"]!.health; + expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("immolation"); + + setMobPosition("immolation-trap-target", [0, 0, 1]); + useCombatStore.getState().tick(1, 2_000); + const dot = useCombatStore.getState().effects.find((effect) => ( + effect.sourceAbilityId === abilityId && effect.tag === "hunter-immolation-trap" + )); + expect(dot).toMatchObject({ + kind: "dot", + targetId: "immolation-trap-target", + sourceActorId: PLAYER_AGGRO_ID, + remainingTicks: 5, + intervalMs: 3_000, + nextTickAt: 5_000, + damageSchool: "fire", + }); + expect(dot?.fixedAmount).toBeGreaterThan(0); + expect(useCombatStore.getState().mobs["immolation-trap-target"]!.health).toBe(before); + + useCombatStore.getState().tick(1, 5_000); + expect(useCombatStore.getState().mobs["immolation-trap-target"]!.health).toBeLessThan(before); + expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + occurredAt: 5_000, + kind: "damage", + abilityId, + targetActorId: "immolation-trap-target", + school: "fire", + rawAmount: dot?.fixedAmount, + }), + ])); + expect(useCombatStore.getState().effects.find((effect) => effect.id === dot?.id)?.remainingTicks).toBe(4); + }); +}); diff --git a/src/game/wow335MageConditionalMechanics.test.ts b/src/game/wow335MageConditionalMechanics.test.ts new file mode 100644 index 00000000..b15f081d --- /dev/null +++ b/src/game/wow335MageConditionalMechanics.test.ts @@ -0,0 +1,547 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; +import { + PLAYER_AURA_ENTITY_ID, + useCombatStore, +} from "./combatStore"; +import { applyAura } from "./combatAuras"; +import { abilityTalentNodeId, abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { usePartyStore, createConfiguredPartyMember } from "./partyStore"; +import { EMPTY_ITEM_STATS } from "./itemStats"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { + WOW335_MAGE_ARCANE_CONCENTRATION_TAG, +} from "./wow335MageTalentRuntime"; +import { WOW335_MAGE_ARCANE_POTENCY_TAG } from "./wow335MageConditionalTalentRuntime"; + +const TARGET_ID = "mage-conditional-mechanics-target"; +const FIREBALL_ID = "wow335-mage-fireball"; +const LIVING_BOMB_ID = "wow335-mage-living-bomb"; + +function auraOn(targetId: string, tag: string): boolean { + return useCombatStore.getState().auras.some((aura) => aura.targetId === targetId + && aura.definition.utilityTags?.includes(tag)); +} + +function stableUnit(seed: string): number { + let hash = 2166136261; + for (const character of seed) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function seedBelow(chance: number, makeSeed: (index: number) => string): string { + for (let index = 0; index < 100_000; index += 1) { + const seed = makeSeed(index); + if (stableUnit(seed) < chance) return seed; + } + throw new Error("Could not find a deterministic proc seed."); +} + +async function resetMage(): Promise { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + await preloadAbilityCatalogsForClasses(["mage"]); + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-mage-conditional-tests", 80, + useCombatStore.getState().health, "damage"); + useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", maxHealth: 100_000, xpReward: 0 }); + setMobPosition(TARGET_ID, [4, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function makePartyMage(talentRanks: Readonly> = {}) { + const abilities = abilitiesForClass("mage"); + const member = createConfiguredPartyMember({ + name: "Test Mage", + classId: "mage", + specialization: "Fire", + specializationId: "mage:fire", + talentTreeId: "mage-fire", + talentRanks, + role: "damage", + }, 80, 0, "wow335-mage-conditional-party"); + const fireball = abilities.find((ability) => ability.name === "Fireball")!; + const lockedCooldowns = Object.fromEntries(abilities + .filter((ability) => ability.id !== fireball.id) + .map((ability) => [ability.id, 1_000_000])); + usePartyStore.setState({ members: [{ + ...member, + gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 1_000_000, spellPower: 5_000 }, + resourceType: "mana", + resourceName: "Mana", + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...member.resourcePools, mana: 10_000 }, + talentRanks, + cooldowns: lockedCooldowns, + nextActionAt: 0, + globalCooldownEndsAt: 0, + }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); + usePartyStore.getState().setCommand("attack"); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().damageMob(TARGET_ID, 1, 100); + return { memberId: member.id, fireball }; +} + +function applyTargetSlow(kind: "slow" | "attack-speed"): void { + useCombatStore.setState((state) => { + const target = state.mobs[TARGET_ID]!; + return { mobs: { + ...state.mobs, + [TARGET_ID]: { + ...target, + statuses: [...target.statuses, { + kind, + sourceAbilityId: "test-mage-slow", + endsAt: 20_000, + }], + }, + } }; + }); +} + +async function castPlayerFireballWithSlow(slowKind?: "slow" | "attack-speed"): Promise { + await resetMage(); + const talentRanks = { ...useCombatStore.getState().talentRanks, "talent-2222": 3 }; + useCombatStore.setState({ + talentRanks, + talentModifiers: talentModifiersForRanks(talentRanks, "mage"), + }); + if (slowKind) applyTargetSlow(slowKind); + expect(useCombatStore.getState().castAbility(FIREBALL_ID, undefined, 1_000).ok).toBe(true); + const cast = useCombatStore.getState().activeCast!; + useCombatStore.getState().tick(0.1, cast.completesAt); + return useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === FIREBALL_ID && event.targetActorId === TARGET_ID + ))?.rawAmount ?? 0; +} + +async function castPartyFireballWithSlow(slowKind?: "slow" | "attack-speed"): Promise { + await resetMage(); + const { memberId, fireball } = makePartyMage({ "talent-2222": 3 }); + if (slowKind) applyTargetSlow(slowKind); + advanceDungeonPartyCombat(1_000); + const cast = usePartyStore.getState().members[0].activeCast!; + expect(cast.abilityId).toBe(fireball.id); + advanceDungeonPartyCombat(cast.completesAt); + return useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.sourceActorId === memberId + && event.abilityId === fireball.id && event.targetActorId === TARGET_ID + ))?.rawAmount ?? 0; +} + +async function castPlayerMissilesWithSlow(slowKind?: "slow" | "attack-speed"): Promise { + await resetMage(); + const talentRanks = { ...useCombatStore.getState().talentRanks, "talent-2222": 3 }; + useCombatStore.setState({ + talentRanks, + talentModifiers: talentModifiersForRanks(talentRanks, "mage"), + }); + if (slowKind) applyTargetSlow(slowKind); + const missiles = useCombatStore.getState().abilities.find((ability) => ability.name === "Arcane Missiles")!; + expect(useCombatStore.getState().castAbility(missiles.id, undefined, 1_000).ok).toBe(true); + const cast = useCombatStore.getState().activeCast!; + useCombatStore.getState().tick(0.1, cast.completesAt); + return useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.abilityId === missiles.id && event.targetActorId === TARGET_ID + )).map((event) => event.rawAmount); +} + +async function castPartyMissilesWithSlow(slowKind?: "slow" | "attack-speed"): Promise { + await resetMage(); + const { memberId } = makePartyMage({ "talent-2222": 3 }); + const missiles = abilitiesForClass("mage").find((ability) => ability.name === "Arcane Missiles")!; + if (slowKind) applyTargetSlow(slowKind); + const member = usePartyStore.getState().members[0]; + const castDurationMs = missiles.castTimeMs; + usePartyStore.setState({ members: [{ + ...member, + activeCast: { + abilityId: missiles.id, + school: "arcane", + mode: "channel", + startedAt: 1_000, + completesAt: 1_000 + castDurationMs, + originalDurationMs: castDurationMs, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: TARGET_ID, + }, + activeCastTargetId: TARGET_ID, + status: "attacking", + cooldowns: {}, + }] }); + useCombatStore.getState().synchronizeActors(); + const cast = usePartyStore.getState().members[0].activeCast!; + expect(cast.abilityId).toBe(missiles.id); + expect(cast.mode).toBe("channel"); + advanceDungeonPartyCombat(cast.completesAt); + return useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.sourceActorId === memberId + && event.abilityId === missiles.id && event.targetActorId === TARGET_ID + )).map((event) => event.rawAmount); +} + +describe("WoW Mage conditional mechanics integration", () => { + beforeEach(resetMage); + + it("grants Arcane Potency from Presence of Mind, applies rank crit to the next spell, and consumes it on any spell", () => { + const abilities = useCombatStore.getState().abilities; + const presenceOfMind = abilities.find((ability) => ability.name === "Presence of Mind")!; + const intellect = abilities.find((ability) => ability.name === "Arcane Intellect")!; + const pomTalentId = abilityTalentNodeId(presenceOfMind)!; + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 }, + talentRanks: { ...state.talentRanks, [pomTalentId]: 1, "talent-1725": 2 }, + })); + + expect(useCombatStore.getState().castAbility(presenceOfMind.id, undefined, 1_000).ok).toBe(true); + expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true); + expect(useCombatStore.getState().castAbility(FIREBALL_ID, undefined, 2_000).ok).toBe(true); + // Presence of Mind makes Fireball resolve immediately; the AP bonus must + // reach that direct damage event without a lingering aura. + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false); + expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({ + kind: "damage", + abilityId: FIREBALL_ID, + targetActorId: TARGET_ID, + critical: true, + })); + expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBeGreaterThanOrEqual(30); + + useCombatStore.setState((state) => ({ + cooldowns: { ...state.cooldowns, [presenceOfMind.id]: 0 }, + globalCooldownEndsAt: 0, + })); + expect(useCombatStore.getState().castAbility(presenceOfMind.id, undefined, 20_000).ok).toBe(true); + expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true); + expect(useCombatStore.getState().castAbility(intellect.id, undefined, 22_000).ok).toBe(true); + expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false); + }); + + it("grants party Arcane Potency from Presence of Mind and consumes it on the next cast", () => { + const pom = abilitiesForClass("mage").find((ability) => ability.name === "Presence of Mind")!; + const pomTalentId = abilityTalentNodeId(pom)!; + const { memberId, fireball } = makePartyMage({ "talent-1725": 2, [pomTalentId]: 1 }); + const member = usePartyStore.getState().members[0]; + usePartyStore.setState({ members: [{ + ...member, + activeCast: { + abilityId: pom.id, + school: "arcane", + mode: "cast", + startedAt: 1_000, + completesAt: 1_000, + originalDurationMs: 0, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: memberId, + }, + activeCastTargetId: memberId, + status: "healing", + }] }); + useCombatStore.getState().synchronizeActors(); + advanceDungeonPartyCombat(1_000); + expect(auraOn(memberId, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true); + + usePartyStore.setState((state) => ({ + members: state.members.map((entry) => ({ ...entry, nextActionAt: 0 })), + })); + advanceDungeonPartyCombat(2_000); + const started = usePartyStore.getState().members[0].activeCast!; + expect(started.abilityId).toBe(fireball.id); + expect(started.arcanePotencyCriticalChanceBonusPercent).toBe(30); + expect(auraOn(memberId, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false); + advanceDungeonPartyCombat(started.completesAt); + expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({ + kind: "damage", + sourceActorId: memberId, + targetActorId: TARGET_ID, + abilityId: fireball.id, + critical: true, + })); + }); + + it("procs party Arcane Potency from Arcane Concentration Clearcasting", () => { + const { memberId } = makePartyMage({ "talent-75": 3, "talent-1725": 2 }); + const fireball = abilitiesForClass("mage").find((ability) => ability.name === "Fireball")!; + const expectedProcAt = 4_500; + let procTargetId = "mage-party-clearcasting-target-0"; + for (let index = 0; stableUnit( + `${memberId}:${fireball.id}:${procTargetId}:${expectedProcAt}:wow335-mage-arcane-concentration`, + ) >= 0.15; index += 1) { + procTargetId = `mage-party-clearcasting-target-${index + 1}`; + } + useCombatStore.getState().registerMob(procTargetId, { + name: "Clearcasting Target", maxHealth: 100_000, xpReward: 0, + }); + setMobPosition(procTargetId, [4, 0, 0]); + updatePartyRuntimeTargetVisibility(memberId, procTargetId, true); + useCombatStore.getState().damageMob(procTargetId, 1, 100); + useCombatStore.getState().selectMob(procTargetId); + + advanceDungeonPartyCombat(1_000); + const cast = usePartyStore.getState().members[0].activeCast!; + expect(cast.abilityId).toBe(fireball.id); + expect(cast.completesAt).toBe(expectedProcAt); + advanceDungeonPartyCombat(cast.completesAt); + expect(auraOn(memberId, WOW335_MAGE_ARCANE_CONCENTRATION_TAG)).toBe(true); + expect(auraOn(memberId, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true); + useCombatStore.setState((state) => ({ + auras: state.auras.filter((aura) => !(aura.targetId === procTargetId && aura.sourceId === memberId)), + effects: state.effects.filter((effect) => !(effect.targetId === procTargetId + && effect.sourceAbilityId === fireball.id)), + })); + usePartyStore.setState((state) => ({ members: state.members.map((member) => ({ + ...member, + nextActionAt: 0, + globalCooldownEndsAt: 0, + cooldowns: {}, + })) })); + useCombatStore.getState().synchronizeActors(); + advanceDungeonPartyCombat(cast.completesAt + 3_000); + const empoweredCast = usePartyStore.getState().members[0].activeCast!; + expect(empoweredCast.abilityId).toBe(fireball.id); + expect(empoweredCast.manaSpent).toBe(0); + expect(empoweredCast.arcanePotencyCriticalChanceBonusPercent).toBe(30); + expect(auraOn(memberId, WOW335_MAGE_ARCANE_CONCENTRATION_TAG)).toBe(false); + expect(auraOn(memberId, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false); + }); + + it("procs player Arcane Potency from Arcane Concentration Clearcasting and consumes both on a free cast", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-75": 3, "talent-1725": 2 }, + })); + const fireball = useCombatStore.getState().abilities.find((ability) => ability.id === FIREBALL_ID)!; + const seed = seedBelow(0.15, (index) => { + const candidate = "mage-arcane-concentration-seed-" + index; + return stableUnit(candidate + ":mage-arcane-concentration") < 0.15 + ? candidate + : "outside-proc-window-" + index; + }); + const hit = useCombatStore.getState().damageMob(TARGET_ID, 10, 1_000, undefined, "fire", { + attackKind: "spell", + seed, + abilityId: fireball.id, + rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false, canCrit: false }, + }); + expect(hit.amount).toBeGreaterThan(0); + expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_CONCENTRATION_TAG)).toBe(true); + expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(true); + + const intellect = useCombatStore.getState().abilities.find((ability) => ability.name === "Arcane Intellect")!; + const manaBefore = useCombatStore.getState().resourcePools.mana; + expect(useCombatStore.getState().castAbility(intellect.id, undefined, 2_000).ok).toBe(true); + expect(useCombatStore.getState().resourcePools.mana).toBe(manaBefore); + expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_CONCENTRATION_TAG)).toBe(false); + expect(auraOn(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_ARCANE_POTENCY_TAG)).toBe(false); + }); + + it("refunds 40% of actual Mana spent on a direct spell crit, even when the hit is fully absorbed", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1639": 3 }, + gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 }, + })); + const state = useCombatStore.getState(); + const manaBefore = state.resourcePools.mana!; + const absorb = applyAura(state.auras, { + definition: { + id: "mage-mechanic-fire-absorb", + name: "Test Fire Absorb", + disposition: "buff", + durationMs: null, + absorbs: [{ id: "fire", amount: 1_000_000, school: "fire" }], + }, + sourceId: TARGET_ID, + targetId: TARGET_ID, + now: 1_000, + }); + useCombatStore.setState({ auras: [...absorb.auras] }); + + expect(useCombatStore.getState().castAbility(FIREBALL_ID, undefined, 1_000).ok).toBe(true); + const cast = useCombatStore.getState().activeCast!; + const manaSpent = cast.manaSpent!; + expect(cast.manaSpent).toBe(manaSpent); + useCombatStore.getState().tick(0.1, cast.completesAt); + const damage = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === FIREBALL_ID && event.targetActorId === TARGET_ID + )); + expect(damage).toMatchObject({ critical: true, effectiveAmount: 0 }); + const refund = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "resource" && event.abilityId === FIREBALL_ID + && event.targetActorId === PLAYER_AURA_ENTITY_ID + )); + const expectedRefund = Math.round(manaSpent * 0.4); + expect(refund?.effectiveAmount).toBe(expectedRefund); + expect(useCombatStore.getState().resourcePools.mana).toBe(manaBefore - manaSpent + expectedRefund); + }); + + it("caps a player Master of Elements refund at maximum Mana", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1639": 3 }, + gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 }, + })); + expect(useCombatStore.getState().castAbility(FIREBALL_ID, undefined, 1_000).ok).toBe(true); + const cast = useCombatStore.getState().activeCast!; + const actualCost = cast.manaSpent!; + // Simulate Mana refilled during the cast while retaining its spend snapshot, + // so the refund request exceeds the available room and must be capped. + useCombatStore.setState((state) => ({ + resource: state.maxResourcePools.mana!, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! }, + activeCast: { ...cast, manaSpent: actualCost * 4 }, + })); + const maximumMana = useCombatStore.getState().maxResourcePools.mana!; + useCombatStore.getState().tick(0.1, cast.completesAt); + expect(useCombatStore.getState().resourcePools.mana).toBe(maximumMana); + const refund = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "resource" && event.abilityId === FIREBALL_ID + && event.targetActorId === PLAYER_AURA_ENTITY_ID + )); + expect(refund?.effectiveAmount).toBe(actualCost); + expect(refund?.overheal).toBeGreaterThan(0); + }); + + it("refunds party Mage Mana on direct crits and caps at maximum", () => { + const { fireball } = makePartyMage({ "talent-1639": 3 }); + advanceDungeonPartyCombat(1_000); + const cast = usePartyStore.getState().members[0].activeCast!; + expect(cast.abilityId).toBe(fireball.id); + expect(cast.manaSpent).toBeGreaterThan(0); + const spentMana = usePartyStore.getState().members[0].resourcePools.mana!; + advanceDungeonPartyCombat(cast.completesAt); + const refundedMana = usePartyStore.getState().members[0].resourcePools.mana!; + expect(refundedMana).toBeGreaterThan(spentMana); + + const current = usePartyStore.getState().members[0]; + const maxMana = current.maxResource; + const capCast = { + ...cast, + startedAt: 6_000, + completesAt: 9_500, + originalDurationMs: 3_500, + manaSpent: 10_000, + }; + usePartyStore.setState({ members: [{ + ...current, + resource: maxMana - 5, + resourcePools: { ...current.resourcePools, mana: maxMana - 5 }, + nextActionAt: 0, + globalCooldownEndsAt: 0, + activeCast: capCast, + activeCastTargetId: TARGET_ID, + }] }); + useCombatStore.getState().synchronizeActors(); + advanceDungeonPartyCombat(capCast.completesAt); + const cappedMember = usePartyStore.getState().members[0]; + expect(cappedMember.resourcePools.mana).toBe(maxMana); + expect(capCast.manaSpent * 0.4).toBeGreaterThan(5); + }); + + it("applies Empowered Fire scaling to Living Bomb ticks and its final explosion", () => { + const ability = useCombatStore.getState().abilities.find((entry) => entry.id === LIVING_BOMB_ID)!; + const talentId = abilityTalentNodeId(ability)!; + const baseDot = ability.effects.find((effect) => effect.kind === "dot")!; + expect(baseDot.kind).toBe("dot"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [talentId]: 1, "talent-1734": 3 }, + gearStats: { ...state.gearStats, spellPower: 5_000 }, + })); + useCombatStore.getState().registerMob("mage-conditional-mechanics-neighbor", { + name: "Neighbor", maxHealth: 100_000, xpReward: 0, + }); + setMobPosition("mage-conditional-mechanics-neighbor", [6, 0, 0]); + expect(useCombatStore.getState().castAbility(LIVING_BOMB_ID, undefined, 1_000).ok).toBe(true); + const dot = useCombatStore.getState().effects.find((effect) => ( + effect.kind === "dot" && effect.targetId === TARGET_ID && effect.sourceAbilityId === LIVING_BOMB_ID + )); + const originalBonusCoefficient = baseDot.kind === "dot" + ? baseDot.bonusPowerCoefficient ?? baseDot.coefficient + : 0; + expect(dot?.bonusPowerCoefficient).toBeCloseTo(originalBonusCoefficient * 1.2); + const neighborBefore = useCombatStore.getState().mobs["mage-conditional-mechanics-neighbor"].health; + useCombatStore.getState().tick(0, 13_000); + expect(useCombatStore.getState().mobs["mage-conditional-mechanics-neighbor"].health).toBeLessThan(neighborBefore); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === LIVING_BOMB_ID + && event.targetActorId === "mage-conditional-mechanics-neighbor" && event.occurredAt === 13_000 + ))).toBe(true); + }); + + it("does not refund Mana for periodic party channel crits", () => { + const { memberId } = makePartyMage({ "talent-1639": 3 }); + const missiles = abilitiesForClass("mage").find((ability) => ability.name === "Arcane Missiles")!; + const member = usePartyStore.getState().members[0]; + const activeCast = { + abilityId: missiles.id, + school: "arcane" as const, + mode: "channel" as const, + startedAt: 1_000, + completesAt: 3_000, + originalDurationMs: 2_000, + pushbackHits: 0, + origin: [0, 0, 0] as const, + targetId: TARGET_ID, + manaSpent: 500, + arcanePotencyCriticalChanceBonusPercent: 1_000, + }; + usePartyStore.setState({ members: [{ + ...member, + activeCast, + activeCastTargetId: TARGET_ID, + status: "attacking", + cooldowns: {}, + }] }); + useCombatStore.getState().synchronizeActors(); + const manaBefore = usePartyStore.getState().members[0].resourcePools.mana!; + advanceDungeonPartyCombat(3_000); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.sourceActorId === memberId + && event.abilityId === missiles.id && event.critical + ))).toBe(true); + const manaAfter = usePartyStore.getState().members[0].resourcePools.mana!; + // Includes the ordinary small regeneration applied by party runtime. + expect(manaAfter - manaBefore).toBeLessThan(100); + }); + + it("applies Torment the Weak to player Fireball only while a modeled slow is active", async () => { + const normal = await castPlayerFireballWithSlow(); + const movementSlowed = await castPlayerFireballWithSlow("slow"); + expect(normal).toBeGreaterThan(0); + expect(movementSlowed).toBe(Math.round(normal * 1.09)); + }); + + it("applies Torment the Weak to party Mage Fireball against movement- and attack-speed-slowed targets", async () => { + const normal = await castPartyFireballWithSlow(); + const movementSlowed = await castPartyFireballWithSlow("slow"); + const attackSlowed = await castPartyFireballWithSlow("attack-speed"); + expect(normal).toBeGreaterThan(0); + expect(movementSlowed).toBe(Math.round(normal * 1.09)); + expect(attackSlowed).toBe(Math.round(normal * 1.09)); + }); + + it("applies Torment the Weak to each player and party Arcane Missiles channel pulse", async () => { + const playerNormal = await castPlayerMissilesWithSlow(); + const playerSlowed = await castPlayerMissilesWithSlow("slow"); + expect(playerSlowed).toHaveLength(playerNormal.length); + expect(playerNormal.length).toBeGreaterThan(0); + expect(playerSlowed).toEqual(playerNormal.map((amount) => Math.round(amount * 1.09))); + + const partyNormal = await castPartyMissilesWithSlow(); + const partySlowed = await castPartyMissilesWithSlow("slow"); + expect(partySlowed).toHaveLength(partyNormal.length); + expect(partyNormal.length).toBeGreaterThan(0); + expect(partySlowed).toEqual(partyNormal.map((amount) => Math.round(amount * 1.09))); + }); +}); diff --git a/src/game/wow335MageConditionalTalentRuntime.test.ts b/src/game/wow335MageConditionalTalentRuntime.test.ts new file mode 100644 index 00000000..fbae47fb --- /dev/null +++ b/src/game/wow335MageConditionalTalentRuntime.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + WOW335_MAGE_ARCANE_POTENCY_AURA, + WOW335_MAGE_ARCANE_POTENCY_TAG, + wow335MageArcanePotencyCanProc, + wow335MageArcanePotencyCastProfile, + wow335MageArcanePotencyCriticalChanceBonusPercent, + wow335MageMasterOfElementsManaRefund, + wow335MageMasterOfElementsManaRefundPercent, +} from "./wow335MageConditionalTalentRuntime"; + +describe("WoW Mage conditional talent profiles", () => { + it("uses Arcane Potency's generated rank 1/2 critical bonuses and exact triggers", () => { + expect([0, 1, 2, 3, -1, Number.NaN].map(wow335MageArcanePotencyCriticalChanceBonusPercent)) + .toEqual([0, 15, 30, 30, 0, 0]); + expect(wow335MageArcanePotencyCanProc(1, "presence-of-mind")).toBe(true); + expect(wow335MageArcanePotencyCanProc(2, "arcane-concentration-clearcasting")).toBe(true); + expect(wow335MageArcanePotencyCanProc(2, "other")).toBe(false); + expect(wow335MageArcanePotencyCanProc(0, "presence-of-mind")).toBe(false); + }); + + it("does not invent an Arcane Potency expiration and consumes the charge on any cast", () => { + expect(WOW335_MAGE_ARCANE_POTENCY_AURA).toMatchObject({ + durationMs: null, + maxStacks: 1, + utilityTags: [WOW335_MAGE_ARCANE_POTENCY_TAG], + }); + expect(wow335MageArcanePotencyCastProfile(2, true, true)).toEqual({ + consumesCharge: true, + criticalChanceBonusPercent: 30, + }); + expect(wow335MageArcanePotencyCastProfile(2, true, false)).toEqual({ + consumesCharge: true, + criticalChanceBonusPercent: 0, + }); + expect(wow335MageArcanePotencyCastProfile(2, false, true)).toEqual({ + consumesCharge: false, + criticalChanceBonusPercent: 0, + }); + }); + + it("keeps Master of Elements' passive crit in the generated scalar parser and refund at local rank 3 only", () => { + expect([0, 1, 2, 3, 4].map(wow335MageMasterOfElementsManaRefundPercent)) + .toEqual([0, 0, 0, 40, 40]); + }); + + it("refunds 40% of Mana spent only for a rank-3 direct damage spell critical", () => { + expect(wow335MageMasterOfElementsManaRefund(3, 125, true, true)).toBe(50); + expect(wow335MageMasterOfElementsManaRefund(3, 125, false, true)).toBe(0); + expect(wow335MageMasterOfElementsManaRefund(3, 125, true, false)).toBe(0); + expect(wow335MageMasterOfElementsManaRefund(2, 125, true, true)).toBe(0); + expect(wow335MageMasterOfElementsManaRefund(3, 0, true, true)).toBe(0); + }); +}); diff --git a/src/game/wow335MageConditionalTalentRuntime.ts b/src/game/wow335MageConditionalTalentRuntime.ts new file mode 100644 index 00000000..c6043b43 --- /dev/null +++ b/src/game/wow335MageConditionalTalentRuntime.ts @@ -0,0 +1,93 @@ +import type { AuraDefinition } from "./combatAuras"; + +export const WOW335_MAGE_ARCANE_POTENCY_TALENT_ID = "talent-1725"; +export const WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID = "talent-1639"; +export const WOW335_MAGE_ARCANE_POTENCY_TAG = "wow335-mage-arcane-potency"; + +const ARCANE_POTENCY_CRIT_BONUS_PERCENT_BY_RANK = [0, 15, 30] as const; + +export type Wow335MageArcanePotencyTrigger = + | "presence-of-mind" + | "arcane-concentration-clearcasting" + | "other"; + +export interface Wow335MageArcanePotencyCastProfile { + readonly consumesCharge: boolean; + readonly criticalChanceBonusPercent: number; +} + +function normalizedRank(rank: number, maximum: number): number { + return Number.isFinite(rank) ? Math.max(0, Math.min(maximum, Math.trunc(rank))) : 0; +} + +/** Local talent rank rows 31571/31572 grant 15%/30% crit to the next damaging spell. */ +export function wow335MageArcanePotencyCriticalChanceBonusPercent(rank: number): number { + return ARCANE_POTENCY_CRIT_BONUS_PERCENT_BY_RANK[normalizedRank(rank, 2)] ?? 0; +} + +/** + * Arcane Potency is earned only from Presence of Mind or Arcane Concentration's + * Clearcasting proc. A different source of Clearcasting does not qualify. + */ +export function wow335MageArcanePotencyCanProc( + rank: number, + trigger: Wow335MageArcanePotencyTrigger, +): boolean { + return normalizedRank(rank, 2) > 0 && trigger !== "other"; +} + +/** + * The next damaging spell gains the rank's crit bonus. The tooltip consumes + * the buff on any spell cast, so callers consume whenever `auraActive` is true, + * even if the cast cannot deal damage or critically strike. + */ +export function wow335MageArcanePotencyCastProfile( + rank: number, + auraActive: boolean, + abilityDealsDirectDamage: boolean, +): Wow335MageArcanePotencyCastProfile { + return { + consumesCharge: auraActive, + criticalChanceBonusPercent: auraActive && abilityDealsDirectDamage + ? wow335MageArcanePotencyCriticalChanceBonusPercent(rank) + : 0, + }; +} + +export const WOW335_MAGE_ARCANE_POTENCY_AURA: AuraDefinition = { + id: "wow335:mage:arcane-potency", + name: "Arcane Potency", + description: "Your next damaging spell gains critical strike chance after Presence of Mind or Clearcasting.", + disposition: "buff", + // The generated row supplies no expiration; the aura is consumed on the next spell cast. + durationMs: null, + maxStacks: 1, + stackBehavior: "replace", + utilityTags: [WOW335_MAGE_ARCANE_POTENCY_TAG], +}; + +/** + * The passive +1/2/3% spell crit is already represented by generated rank + * descriptions in talentModifiers. Only rank 3 (spell 29076) has the + * executable local auraType 4 mana-refund effect; ranks 1/2 have effectType 0. + */ +export function wow335MageMasterOfElementsManaRefundPercent(rank: number): number { + return normalizedRank(rank, 3) === 3 ? 40 : 0; +} + +/** + * Refund 40% of the Mana actually spent by an eligible direct spell critical. + * “Direct” excludes periodic damage, including channel pulses and DoT ticks; + * callers supply false for those events. The caller supplies the resolved spend + * after cast discounts (for example, Clearcasting makes that spend zero). + */ +export function wow335MageMasterOfElementsManaRefund( + rank: number, + manaSpent: number, + directDamageSpell: boolean, + critical: boolean, +): number { + const refundPercent = wow335MageMasterOfElementsManaRefundPercent(rank); + if (refundPercent <= 0 || !directDamageSpell || !critical || !Number.isFinite(manaSpent)) return 0; + return Math.max(0, Math.round(Math.max(0, manaSpent) * refundPercent / 100)); +} diff --git a/src/game/wow335MageEmpoweredFireRuntime.test.ts b/src/game/wow335MageEmpoweredFireRuntime.test.ts new file mode 100644 index 00000000..eec25eb2 --- /dev/null +++ b/src/game/wow335MageEmpoweredFireRuntime.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import type { AbilityEffect } from "./abilityCatalog"; +import { + wow335MageEmpoweredFireBonusPercent, + wow335MageEmpoweredFireEffect, +} from "./wow335MageEmpoweredFireRuntime"; + +describe("WoW Mage Empowered Fire scaling", () => { + it("uses the generated talent's 7/14/20 percent rank values", () => { + expect([0, 1, 2, 3, 4, -1, Number.NaN].map(wow335MageEmpoweredFireBonusPercent)) + .toEqual([0, 7, 14, 20, 20, 0, 0]); + }); + + it("increases only the named spells' spell-power coefficient", () => { + const fireball = { + kind: "damage" as const, + coefficient: 0.123, + basePoints: 14, + dieSides: 9, + bonusPowerCoefficient: 0.123, + sourceLevel: 1, + }; + const result = wow335MageEmpoweredFireEffect("Fireball", fireball, 1); + + expect(result).toMatchObject({ + coefficient: 0.123, + basePoints: 14, + dieSides: 9, + bonusPowerCoefficient: 0.13161, + sourceLevel: 1, + }); + }); + + it("supports Living Bomb periodic scaling while leaving its base tick unchanged", () => { + const livingBomb = { + kind: "dot" as const, + coefficient: 0.2, + basePoints: 153, + dieSides: 1, + bonusPowerCoefficient: 0.2, + ticks: 4, + intervalMs: 3_000, + }; + expect(wow335MageEmpoweredFireEffect("Living Bomb", livingBomb, 3)).toMatchObject({ + coefficient: 0.2, + basePoints: 153, + dieSides: 1, + bonusPowerCoefficient: 0.24, + ticks: 4, + intervalMs: 3_000, + }); + }); + + it("keeps coefficient-only base damage intact and records only its extra power coefficient", () => { + const coefficientOnly: Extract = { + kind: "damage" as const, + coefficient: 0.4, + dieSides: 1, + }; + const result = wow335MageEmpoweredFireEffect("Living Bomb", coefficientOnly, 2); + expect(result).toMatchObject({ kind: "damage", coefficient: 0.4, dieSides: 1 }); + expect(result.bonusPowerCoefficient).toBeCloseTo(0.456); + }); + + it("does not touch base damage spells outside the generated scope", () => { + const pyroblast = { + kind: "damage" as const, + coefficient: 1.15, + basePoints: 141, + bonusPowerCoefficient: 1.15, + }; + const frostbolt = { ...pyroblast }; + const fireBlast = { ...pyroblast }; + + expect(wow335MageEmpoweredFireEffect("Pyroblast", pyroblast, 2).bonusPowerCoefficient).toBeCloseTo(1.311); + expect(wow335MageEmpoweredFireEffect("Frostbolt", frostbolt, 3)).toBe(frostbolt); + expect(wow335MageEmpoweredFireEffect("Fire Blast", fireBlast, 3)).toBe(fireBlast); + expect(wow335MageEmpoweredFireEffect("Fireball", { ...pyroblast, basePoints: undefined }, 3)) + .toMatchObject({ coefficient: 1.15, bonusPowerCoefficient: 1.38 }); + }); +}); diff --git a/src/game/wow335MageEmpoweredFireRuntime.ts b/src/game/wow335MageEmpoweredFireRuntime.ts new file mode 100644 index 00000000..b2d14291 --- /dev/null +++ b/src/game/wow335MageEmpoweredFireRuntime.ts @@ -0,0 +1,39 @@ +import type { AbilityEffect } from "./abilityCatalog"; + +export const WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID = "talent-1734"; + +export const WOW335_MAGE_EMPOWERED_FIRE_SPELLS: ReadonlySet = new Set([ + "Fireball", + "Pyroblast", + "Living Bomb", +]); +const EMPOWERED_FIRE_POWER_SCALING_PERCENT_BY_RANK = [0, 7, 14, 20] as const; + +export function wow335MageEmpoweredFireBonusPercent(rank: number): number { + const normalizedRank = Number.isFinite(rank) ? Math.max(0, Math.min(3, Math.trunc(rank))) : 0; + return EMPOWERED_FIRE_POWER_SCALING_PERCENT_BY_RANK[normalizedRank] ?? 0; +} + +/** + * Apply Empowered Fire to the spell-power portion of its named damage spells. + * DBC base points and random die ranges remain untouched. The source spells + * carry their spell-power coefficient separately in `bonusPowerCoefficient`. + */ +export function wow335MageEmpoweredFireEffect( + abilityName: string, + effect: T, + rank: number, +): T { + const bonusPercent = wow335MageEmpoweredFireBonusPercent(rank); + if (bonusPercent <= 0 + || !WOW335_MAGE_EMPOWERED_FIRE_SPELLS.has(abilityName) + || (effect.kind !== "damage" && effect.kind !== "dot")) return effect; + + const sourceCoefficient = effect.bonusPowerCoefficient ?? effect.coefficient; + if (!Number.isFinite(sourceCoefficient) || sourceCoefficient <= 0) return effect; + + return { + ...effect, + bonusPowerCoefficient: sourceCoefficient * (1 + bonusPercent / 100), + } as T; +} diff --git a/src/game/wow335MageFlameThrowingIntegration.test.ts b/src/game/wow335MageFlameThrowingIntegration.test.ts new file mode 100644 index 00000000..60a79699 --- /dev/null +++ b/src/game/wow335MageFlameThrowingIntegration.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { abilityAtLevel, abilitiesForClass } from "./abilityCatalog"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, +} from "./partyRuntimeRegistry"; +import { partyAbilityTargetIsValid } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { WOW335_MAGE_FLAME_THROWING_TALENT_ID } from "./wow335MageFlameThrowingRuntime"; + +const FLAME_THROWING_RANK_RANGE_CASES = [ + { rank: 1, bonus: 2 }, + { rank: 2, bonus: 4 }, + { rank: 3, bonus: 6 }, +] as const; + +function registerTarget(id: string, position: readonly [number, number, number], maxHealth = 1_000): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 }); + setMobPosition(id, position); +} + +describe("WoW Mage Flame Throwing integration", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("mage-flame-throwing-integration", 80, + useCombatStore.getState().health, "damage"); + }); + + it.each(FLAME_THROWING_RANK_RANGE_CASES)( + "extends player damaging-spell range by rank $rank's $bonus yards", + ({ rank, bonus }) => { + useCombatStore.setState((state) => { + const talentRanks = { ...state.talentRanks, [WOW335_MAGE_FLAME_THROWING_TALENT_ID]: rank }; + return { talentRanks, talentModifiers: talentModifiersForRanks(talentRanks, "mage") }; + }); + const playerTargetId = `flame-throwing-player-rank-${rank}`; + registerTarget(playerTargetId, [20 + bonus, 0, 0]); + useCombatStore.getState().selectMob(playerTargetId); + + expect(useCombatStore.getState().castAbility("mage-fire-blast", undefined, 1_000)).toMatchObject({ + ok: true, + abilityId: "mage-fire-blast", + }); + }, + ); + + it.each(FLAME_THROWING_RANK_RANGE_CASES)( + "extends companion damaging-spell range by rank $rank's $bonus yards", + ({ rank, bonus }) => { + const companion = createConfiguredPartyMember({ + name: "Flame Throwing Mage", + classId: "mage", + specialization: "Fire", + specializationId: "mage:fire", + talentTreeId: "mage-fire", + talentRanks: { [WOW335_MAGE_FLAME_THROWING_TALENT_ID]: rank }, + role: "damage", + }, 80, 0, `flame-throwing-party-rank-${rank}`); + registerPartyRuntimePosition(companion.id, [0, 0, 0]); + const companionTargetId = `flame-throwing-party-target-rank-${rank}`; + registerTarget(companionTargetId, [30 + bonus, 0, 0]); + const frostbolt = abilityAtLevel(abilitiesForClass("mage").find((ability) => ( + ability.id === "mage-frostbolt" + ))!, companion.level); + + expect(partyAbilityTargetIsValid(companion, frostbolt, companionTargetId, undefined, 1_000)).toBe(true); + }, + ); + + it("does not extend the range of a healing spell", () => { + const mageCompanion = createConfiguredPartyMember({ + name: "Flame Throwing Mage", + classId: "mage", + specialization: "Fire", + specializationId: "mage:fire", + talentTreeId: "mage-fire", + talentRanks: { [WOW335_MAGE_FLAME_THROWING_TALENT_ID]: 3 }, + role: "damage", + }, 80, 0, "flame-throwing-heal-range"); + registerPartyRuntimePosition(mageCompanion.id, [0, 0, 0]); + useCombatStore.getState().setPlayerPosition([42, 0, 0]); + useCombatStore.getState().synchronizeActors(); + const flashHeal = abilityAtLevel( + abilitiesForClass("priest").find((ability) => ability.id === "priest-flash-heal")!, + 80, + ); + + // Exercise the shared companion targeting check with the real generated + // Flash Heal definition. Flame Throwing must be scoped to damage spells. + expect(partyAbilityTargetIsValid( + mageCompanion, + flashHeal, + PLAYER_AGGRO_ID, + undefined, + 1_000, + )).toBe(false); + }); + + it("does not expand Frost Nova's AoE effect radius", () => { + useCombatStore.setState((state) => { + const talentRanks = { ...state.talentRanks, [WOW335_MAGE_FLAME_THROWING_TALENT_ID]: 3 }; + return { talentRanks, talentModifiers: talentModifiersForRanks(talentRanks, "mage") }; + }); + const targetId = "flame-throwing-frost-nova-radius"; + registerTarget(targetId, [15, 0, 0]); + const healthBefore = useCombatStore.getState().mobs[targetId].health; + + expect(useCombatStore.getState().castAbility("mage-frost-nova", undefined, 1_000)).toMatchObject({ ok: true }); + expect(useCombatStore.getState().mobs[targetId].health).toBe(healthBefore); + }); +}); diff --git a/src/game/wow335MageFlameThrowingRuntime.test.ts b/src/game/wow335MageFlameThrowingRuntime.test.ts new file mode 100644 index 00000000..715f426d --- /dev/null +++ b/src/game/wow335MageFlameThrowingRuntime.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { wow335MageFlameThrowingProfile } from "./wow335MageFlameThrowingRuntime"; + +describe("WoW Mage Flame Throwing generated profile", () => { + it("parses the generated +2/+4/+6 yard damaging-spell range ranks", () => { + expect([1, 2, 3].map((rank) => wow335MageFlameThrowingProfile(rank).damagingSpellRangeBonusYards)) + .toEqual([2, 4, 6]); + }); + + it("exposes the 20% Fireball cast-time rider and 12-second lockout only where the generated capstone link exists", () => { + expect([1, 2].map(wow335MageFlameThrowingProfile)).toEqual([ + expect.objectContaining({ + capstoneAvailable: false, + fireballCastTimeReductionPercent: 0, + fireballCastTimeLockoutMs: 0, + fireballLockoutStart: null, + }), + expect.objectContaining({ + capstoneAvailable: false, + fireballCastTimeReductionPercent: 0, + fireballCastTimeLockoutMs: 0, + fireballLockoutStart: null, + }), + ]); + expect(wow335MageFlameThrowingProfile(3)).toMatchObject({ + rank: 3, + damagingSpellRangeBonusYards: 6, + capstoneAvailable: true, + fireballCastTimeReductionPercent: 20, + fireballCastTimeLockoutMs: 12_000, + fireballLockoutStart: "unspecified", + }); + }); + + it("normalizes absent and out-of-range talent ranks", () => { + expect(wow335MageFlameThrowingProfile(0)).toMatchObject({ rank: 0, damagingSpellRangeBonusYards: 0 }); + expect(wow335MageFlameThrowingProfile(-2)).toMatchObject({ rank: 0, damagingSpellRangeBonusYards: 0 }); + expect(wow335MageFlameThrowingProfile(4)).toMatchObject({ rank: 3, capstoneAvailable: true }); + expect(wow335MageFlameThrowingProfile(Number.NaN)).toMatchObject({ rank: 0, capstoneAvailable: false }); + }); +}); diff --git a/src/game/wow335MageFlameThrowingRuntime.ts b/src/game/wow335MageFlameThrowingRuntime.ts new file mode 100644 index 00000000..3b4b6164 --- /dev/null +++ b/src/game/wow335MageFlameThrowingRuntime.ts @@ -0,0 +1,76 @@ +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export const WOW335_MAGE_FLAME_THROWING_TALENT_ID = "talent-28"; +const FLAME_THROWING_CAPSTONE_TRIGGER_SPELL_ID = 811100; + +const RANGE_BONUS_PATTERN = /range of your damaging spells by (\d+(?:\.\d+)?) yards/i; +const FIREBALL_CAST_REDUCTION_PATTERN = /cast time of your Fireball is reduced by (\d+(?:\.\d+)?)%/i; +const FIREBALL_LOCKOUT_PATTERN = /becomes inactive for (\d+(?:\.\d+)?) seconds after use/i; + +const flameThrowingTalent = WOW335_TALENT_NODES.find( + (talent) => talent.id === WOW335_MAGE_FLAME_THROWING_TALENT_ID, +); +const flameThrowingRanks = flameThrowingTalent?.rankDescriptions ?? []; +const flameThrowingRankEffects = flameThrowingTalent?.rankEffects ?? []; + +if (!flameThrowingTalent) { + throw new Error(`Generated Mage talent ${WOW335_MAGE_FLAME_THROWING_TALENT_ID} is missing`); +} + +export interface Wow335MageFlameThrowingProfile { + readonly rank: number; + readonly damagingSpellRangeBonusYards: number; + readonly capstoneAvailable: boolean; + readonly fireballCastTimeReductionPercent: number; + readonly fireballCastTimeLockoutMs: number; + /** Generated text says "after use" but does not identify which event starts the lockout. */ + readonly fireballLockoutStart: "unspecified" | null; +} + +function normalizedRank(rank: number): number { + return Number.isFinite(rank) ? Math.max(0, Math.min(flameThrowingRanks.length, Math.trunc(rank))) : 0; +} + +function descriptionValue(description: string, pattern: RegExp): number { + const value = description.match(pattern)?.[1]; + return value === undefined ? 0 : Number(value); +} + +/** + * Returns the generated Flame Throwing rank payload. The cast-time rider is + * considered available only for rank 3, whose generated rank effect links the + * capstone trigger spell. This describes the effect but does not choose which + * event starts its tooltip's 12-second "after use" lockout. + */ +export function wow335MageFlameThrowingProfile(rank: number): Wow335MageFlameThrowingProfile { + const normalized = normalizedRank(rank); + if (normalized === 0) { + return { + rank: 0, + damagingSpellRangeBonusYards: 0, + capstoneAvailable: false, + fireballCastTimeReductionPercent: 0, + fireballCastTimeLockoutMs: 0, + fireballLockoutStart: null, + }; + } + + const description = flameThrowingRanks[normalized - 1] ?? ""; + const rankData = flameThrowingRankEffects[normalized - 1]; + const capstoneAvailable = rankData?.effects.some( + (effect) => effect.triggerSpellId === FLAME_THROWING_CAPSTONE_TRIGGER_SPELL_ID, + ) ?? false; + + return { + rank: normalized, + damagingSpellRangeBonusYards: descriptionValue(description, RANGE_BONUS_PATTERN), + capstoneAvailable, + fireballCastTimeReductionPercent: capstoneAvailable + ? descriptionValue(description, FIREBALL_CAST_REDUCTION_PATTERN) + : 0, + fireballCastTimeLockoutMs: capstoneAvailable + ? descriptionValue(description, FIREBALL_LOCKOUT_PATTERN) * 1000 + : 0, + fireballLockoutStart: capstoneAvailable ? "unspecified" : null, + }; +} diff --git a/src/game/wow335MageFrostbiteRuntime.test.ts b/src/game/wow335MageFrostbiteRuntime.test.ts new file mode 100644 index 00000000..005ba0d1 --- /dev/null +++ b/src/game/wow335MageFrostbiteRuntime.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + WOW335_MAGE_FROSTBITE_FREEZE_DURATION_MS, + wow335MageFrostbiteProcDurationMs, +} from "./wow335MageFrostbiteRuntime"; + +describe("WoW Mage Frostbite profile", () => { + it("uses the generated 5/10/15% ranks and four-second duration", () => { + expect(wow335MageFrostbiteProcDurationMs(1, "frost", true, 1, 0.049)) + .toBe(WOW335_MAGE_FROSTBITE_FREEZE_DURATION_MS); + expect(wow335MageFrostbiteProcDurationMs(1, "frost", true, 1, 0.05)).toBe(0); + expect(wow335MageFrostbiteProcDurationMs(2, "frost", true, 1, 0.099)) + .toBe(WOW335_MAGE_FROSTBITE_FREEZE_DURATION_MS); + expect(wow335MageFrostbiteProcDurationMs(2, "frost", true, 1, 0.1)).toBe(0); + expect(wow335MageFrostbiteProcDurationMs(3, "frost", true, 1, 0.149)) + .toBe(WOW335_MAGE_FROSTBITE_FREEZE_DURATION_MS); + expect(wow335MageFrostbiteProcDurationMs(3, "frost", true, 1, 0.15)).toBe(0); + }); + + it("requires landed, positive Frost damage and keeps untrained ranks inactive", () => { + expect(wow335MageFrostbiteProcDurationMs(0, "frost", true, 1, 0)).toBe(0); + expect(wow335MageFrostbiteProcDurationMs(3, "fire", true, 1, 0)).toBe(0); + expect(wow335MageFrostbiteProcDurationMs(3, "frost", false, 1, 0)).toBe(0); + expect(wow335MageFrostbiteProcDurationMs(3, "frost", true, 0, 0)).toBe(0); + expect(wow335MageFrostbiteProcDurationMs(3, "frost", true, 1, Number.NaN)).toBe(0); + }); +}); diff --git a/src/game/wow335MageFrostbiteRuntime.ts b/src/game/wow335MageFrostbiteRuntime.ts new file mode 100644 index 00000000..c7424bdf --- /dev/null +++ b/src/game/wow335MageFrostbiteRuntime.ts @@ -0,0 +1,26 @@ +export const WOW335_MAGE_FROSTBITE_TALENT_ID = "talent-38"; +export const WOW335_MAGE_FROSTBITE_FREEZE_DURATION_MS = 4_000; + +/** + * Returns Frostbite's generated freeze duration for a qualifying Frost spell + * hit. The supplied roll is a deterministic unit interval value from the + * caller's combat seed. + */ +export function wow335MageFrostbiteProcDurationMs( + rank: number, + school: string, + landed: boolean, + damage: number, + roll: number, +): number { + const safeRank = Math.min(3, Math.max(0, Math.trunc(rank))); + const chance = safeRank / 20; + return school === "frost" + && landed + && damage > 0 + && Number.isFinite(roll) + && roll >= 0 + && roll < chance + ? WOW335_MAGE_FROSTBITE_FREEZE_DURATION_MS + : 0; +} diff --git a/src/game/wow335MageImprovedCounterspellRuntime.test.ts b/src/game/wow335MageImprovedCounterspellRuntime.test.ts new file mode 100644 index 00000000..5c41b2f2 --- /dev/null +++ b/src/game/wow335MageImprovedCounterspellRuntime.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + PLAYER_AURA_ENTITY_ID, + useCombatStore, +} from "./combatStore"; +import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { EMPTY_ITEM_STATS } from "./itemStats"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; +import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers"; +import { + WOW335_MAGE_IMPROVED_COUNTERSPELL_SILENCE_DURATION_MS, + wow335MageImprovedCounterspellCooldownReductionMs, + wow335MageImprovedCounterspellSilenceDurationMs, +} from "./wow335MageImprovedCounterspellRuntime"; + +const TARGET_ID = "mage-improved-counterspell-target"; +const TALENT_ID = "talent-88"; +const NOW = 1_000; + +function setTargetCasting(): void { + useCombatStore.setState((state) => { + const target = state.mobs[TARGET_ID]!; + return { mobs: { + ...state.mobs, + [TARGET_ID]: { + ...target, + activeCast: { + attackId: "test-caster-spell", + name: "Test Caster Spell", + spellId: 99_001, + school: "fire", + interruptible: true, + targetActorId: PLAYER_AURA_ENTITY_ID, + sourcePosition: [4, 0, 0], + targetPosition: [0, 0, 0], + sourceYaw: 0, + startedAt: NOW - 500, + completesAt: NOW + 5_000, + }, + }, + } }; + }); +} + +async function resetMage(): Promise { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + await preloadAbilityCatalogsForClasses(["mage"]); + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-mage-counterspell-tests", 80, + useCombatStore.getState().health, "damage"); + useCombatStore.getState().registerMob(TARGET_ID, { name: "Caster", maxHealth: 100_000, xpReward: 0 }); + setMobPosition(TARGET_ID, [4, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function setPlayerTalentRank(rank: number): void { + const talentRanks = { ...useCombatStore.getState().talentRanks, [TALENT_ID]: rank }; + useCombatStore.setState({ + talentRanks, + talentModifiers: talentModifiersForRanks(talentRanks, "mage"), + }); +} + +function targetHasMageSilence(sourceAbilityId: string): boolean { + return useCombatStore.getState().mobs[TARGET_ID]!.statuses.some((status) => ( + status.kind === "silence" + && status.sourceAbilityId === sourceAbilityId + && status.endsAt === NOW + WOW335_MAGE_IMPROVED_COUNTERSPELL_SILENCE_DURATION_MS + )); +} + +describe("WoW Mage Improved Counterspell", () => { + beforeEach(resetMage); + + it("uses generated cooldown reductions only for Counterspell and silence duration at every rank", () => { + for (const [rank, reduction] of [[1, 4_000], [2, 7_000], [3, 10_000]] as const) { + expect(wow335MageImprovedCounterspellCooldownReductionMs(rank, "Counterspell")).toBe(reduction); + expect(wow335MageImprovedCounterspellSilenceDurationMs(rank, "Counterspell")) + .toBe(WOW335_MAGE_IMPROVED_COUNTERSPELL_SILENCE_DURATION_MS); + const modifiers = talentModifiersForRanks({ [TALENT_ID]: rank }, "mage"); + expect(applyTalentModifierValue(24_000, modifiers, "cooldown-flat-ms", { + abilityName: "Counterspell", + })).toBe(24_000 - reduction); + expect(applyTalentModifierValue(24_000, modifiers, "cooldown-flat-ms", { + abilityName: "Fire Blast", + })).toBe(24_000); + } + expect(wow335MageImprovedCounterspellCooldownReductionMs(3, "Fire Blast")).toBe(0); + expect(wow335MageImprovedCounterspellSilenceDurationMs(0, "Counterspell")).toBe(0); + }); + + it("silences a player Counterspell target only when it interrupts an active cast", async () => { + setPlayerTalentRank(3); + const counterspell = useCombatStore.getState().abilities.find((ability) => ability.name === "Counterspell")!; + setTargetCasting(); + expect(useCombatStore.getState().castAbility(counterspell.id, { + origin: [0, 0, 0], + now: NOW, + targetActorId: TARGET_ID, + }).ok).toBe(true); + expect(useCombatStore.getState().mobs[TARGET_ID]!.activeCast).toBeNull(); + expect(targetHasMageSilence(counterspell.id)).toBe(true); + expect(useCombatStore.getState().cooldowns[counterspell.id]).toBe( + NOW + counterspell.cooldownMs - 10_000, + ); + + await resetMage(); + setPlayerTalentRank(3); + const idleTargetCast = useCombatStore.getState().abilities.find((ability) => ability.name === "Counterspell")!; + expect(useCombatStore.getState().castAbility(idleTargetCast.id, { + origin: [0, 0, 0], + now: NOW, + targetActorId: TARGET_ID, + }).ok).toBe(true); + expect(useCombatStore.getState().mobs[TARGET_ID]!.statuses.some((status) => status.kind === "silence")).toBe(false); + }); + + it("party Counterspell applies the generated silence and rank cooldown reduction", async () => { + const rank = 2; + const talentRanks = { [TALENT_ID]: rank }; + const abilities = abilitiesForClass("mage"); + const counterspell = abilities.find((ability) => ability.name === "Counterspell")!; + const member = createConfiguredPartyMember({ + name: "Counterspell Mage", + classId: "mage", + specialization: "Fire", + specializationId: "mage:fire", + talentTreeId: "mage-fire", + talentRanks, + role: "damage", + }, 80, 0, "wow335-mage-counterspell-party"); + usePartyStore.setState({ members: [{ + ...member, + gearStats: EMPTY_ITEM_STATS, + cooldowns: Object.fromEntries(abilities.filter((ability) => ability.id !== counterspell.id) + .map((ability) => [ability.id, 1_000_000])), + nextActionAt: 0, + globalCooldownEndsAt: 0, + }] }); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); + usePartyStore.getState().setCommand("attack"); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().damageMob(TARGET_ID, 1, 100); + setTargetCasting(); + + advanceDungeonPartyCombat(NOW); + + expect(useCombatStore.getState().mobs[TARGET_ID]!.activeCast).toBeNull(); + expect(targetHasMageSilence(counterspell.id)).toBe(true); + expect(usePartyStore.getState().members[0].cooldowns[counterspell.id]).toBe( + NOW + counterspell.cooldownMs - 7_000, + ); + }); +}); diff --git a/src/game/wow335MageImprovedCounterspellRuntime.ts b/src/game/wow335MageImprovedCounterspellRuntime.ts new file mode 100644 index 00000000..f994226d --- /dev/null +++ b/src/game/wow335MageImprovedCounterspellRuntime.ts @@ -0,0 +1,22 @@ +export const WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID = "talent-88"; +export const WOW335_MAGE_IMPROVED_COUNTERSPELL_SILENCE_DURATION_MS = 2_000; + +const COUNTERSPELL_COOLDOWN_REDUCTION_MS_BY_RANK = [0, 4_000, 7_000, 10_000] as const; + +export function wow335MageImprovedCounterspellCooldownReductionMs( + rank: number, + abilityName: string, +): number { + if (abilityName !== "Counterspell") return 0; + const normalizedRank = Math.max(0, Math.min(3, Math.trunc(rank))); + return COUNTERSPELL_COOLDOWN_REDUCTION_MS_BY_RANK[normalizedRank] ?? 0; +} + +export function wow335MageImprovedCounterspellSilenceDurationMs( + rank: number, + abilityName: string, +): number { + return abilityName === "Counterspell" && rank > 0 + ? WOW335_MAGE_IMPROVED_COUNTERSPELL_SILENCE_DURATION_MS + : 0; +} diff --git a/src/game/wow335MageMissileBarrageRuntime.test.ts b/src/game/wow335MageMissileBarrageRuntime.test.ts new file mode 100644 index 00000000..7d571bd5 --- /dev/null +++ b/src/game/wow335MageMissileBarrageRuntime.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { + WOW335_MAGE_MISSILE_BARRAGE_AURA, + WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_CONSUMER_SPELL_NAMES, + WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_TRIGGER_SPELL_NAMES, + WOW335_MAGE_MISSILE_BARRAGE_TAG, + WOW335_MAGE_MISSILE_BARRAGE_TOOLTIP_CONSUMER_SPELL_NAMES, + WOW335_MAGE_MISSILE_BARRAGE_TOOLTIP_TRIGGER_SPELL_NAMES, + WOW335_MAGE_MISSILE_BARRAGE_UNAVAILABLE_CONSUMER_SPELL_NAMES, + WOW335_MAGE_MISSILE_BARRAGE_UNAVAILABLE_TRIGGER_SPELL_NAMES, + WOW335_MAGE_MISSILE_BARRAGE_TALENT_ID, + wow335MageMissileBarrageConsumerEligible, + wow335MageMissileBarrageProfileForRank, + wow335MageMissileBarrageReachableProcChancePercentForSpell, + wow335MageMissileBarrageTooltipProcChancePercentForSpell, +} from "./wow335MageMissileBarrageRuntime"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +describe("WoW 3.3.5 Mage Missile Barrage profile", () => { + it("uses the generated per-rank chance groups instead of the generic rank proc chance", () => { + const talent = WOW335_TALENT_NODES.find((candidate) => candidate.id === WOW335_MAGE_MISSILE_BARRAGE_TALENT_ID); + expect(talent).toMatchObject({ + name: "Missile Barrage", + rankSpellIds: [44404, 54486, 54488], + rankDescriptions: expect.arrayContaining([expect.any(String)]), + }); + expect(talent!.rankDescriptions).toHaveLength(3); + + const expectedByRank = [ + { primary: 14, secondary: 7, orb: 33 }, + { primary: 27, secondary: 14, orb: 66 }, + { primary: 40, secondary: 20, orb: 100 }, + ]; + const groups = [ + ["Arcane Blast", "Starfire", "Starsurge"], + ["Arcane Barrage", "Fireball", "Frostbolt", "Slow", "Moonfire"], + ["Arcane Orb"], + ]; + + expectedByRank.forEach((expected, index) => { + const rank = index + 1; + for (const spellName of groups[0]!) { + expect(wow335MageMissileBarrageTooltipProcChancePercentForSpell(rank, spellName)).toBe(expected.primary); + } + for (const spellName of groups[1]!) { + expect(wow335MageMissileBarrageTooltipProcChancePercentForSpell(rank, spellName)).toBe(expected.secondary); + } + expect(wow335MageMissileBarrageTooltipProcChancePercentForSpell(rank, "Arcane Orb")).toBe(expected.orb); + }); + + // The existing generated DBC rank procChance values are 7/14/20; those + // values must not flatten the custom primary-trigger and Arcane Orb groups. + expect(talent!.rankEffects.map((effect) => effect.procChance)).toEqual([7, 14, 20]); + expect(wow335MageMissileBarrageTooltipProcChancePercentForSpell(3, "Arcane Blast")).toBe(40); + expect(wow335MageMissileBarrageTooltipProcChancePercentForSpell(3, "Arcane Orb")).toBe(100); + }); + + it("separates tooltip-listed trigger and consumer spells from current Mage catalog rows", () => { + const mageAbilityNames = new Set(WOW335_ABILITIES_BY_CLASS.mage.map((ability) => ability.name)); + expect([...WOW335_MAGE_MISSILE_BARRAGE_TOOLTIP_TRIGGER_SPELL_NAMES].sort()).toEqual([ + "Arcane Barrage", + "Arcane Blast", + "Arcane Orb", + "Fireball", + "Frostbolt", + "Moonfire", + "Slow", + "Starfire", + "Starsurge", + ]); + expect([...WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_TRIGGER_SPELL_NAMES].sort()).toEqual([ + "Arcane Barrage", + "Arcane Blast", + "Fireball", + "Frostbolt", + "Slow", + ]); + expect([...WOW335_MAGE_MISSILE_BARRAGE_UNAVAILABLE_TRIGGER_SPELL_NAMES].sort()).toEqual([ + "Arcane Orb", + "Moonfire", + "Starfire", + "Starsurge", + ]); + expect([...WOW335_MAGE_MISSILE_BARRAGE_TOOLTIP_CONSUMER_SPELL_NAMES]).toEqual(["Arcane Missiles"]); + expect([...WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_CONSUMER_SPELL_NAMES]).toEqual(["Arcane Missiles"]); + expect([...WOW335_MAGE_MISSILE_BARRAGE_UNAVAILABLE_CONSUMER_SPELL_NAMES]).toEqual([]); + + for (const name of WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_TRIGGER_SPELL_NAMES) { + expect(mageAbilityNames.has(name), name).toBe(true); + } + for (const name of WOW335_MAGE_MISSILE_BARRAGE_UNAVAILABLE_TRIGGER_SPELL_NAMES) { + expect(mageAbilityNames.has(name), name).toBe(false); + } + expect(wow335MageMissileBarrageConsumerEligible("Arcane Missiles")).toBe(true); + expect(wow335MageMissileBarrageConsumerEligible("Fireball")).toBe(false); + expect(wow335MageMissileBarrageConsumerEligible("Arcane Orb")).toBe(false); + }); + + it("does not expose proc chances for unavailable spells or spells omitted by the generated tooltip", () => { + expect(wow335MageMissileBarrageReachableProcChancePercentForSpell(3, "Arcane Blast")).toBe(40); + expect(wow335MageMissileBarrageReachableProcChancePercentForSpell(3, "Fireball")).toBe(20); + expect(wow335MageMissileBarrageReachableProcChancePercentForSpell(3, "Arcane Orb")).toBe(0); + expect(wow335MageMissileBarrageTooltipProcChancePercentForSpell(3, "Frostfire Bolt")).toBe(0); + expect(wow335MageMissileBarrageReachableProcChancePercentForSpell(3, "Frostfire Bolt")).toBe(0); + expect(WOW335_MAGE_MISSILE_BARRAGE_TOOLTIP_TRIGGER_SPELL_NAMES.has("Frostfire Bolt")).toBe(false); + }); + + it("normalizes ranks and exposes the missing duration and generated effect magnitudes", () => { + expect(wow335MageMissileBarrageProfileForRank(0)).toMatchObject({ + rank: 0, + auraDurationMs: 15_000, + channelDurationMultiplier: 0.5, + missileIntervalMultiplier: 0.5, + manaCostReductionPercent: 100, + tooltipProcChancePercentBySpellName: {}, + }); + expect(wow335MageMissileBarrageProfileForRank(1.9).rank).toBe(1); + expect(wow335MageMissileBarrageProfileForRank(-2).rank).toBe(0); + expect(wow335MageMissileBarrageProfileForRank(9).rank).toBe(3); + expect(wow335MageMissileBarrageProfileForRank(Number.NaN).rank).toBe(0); + expect(wow335MageMissileBarrageProfileForRank(Number.POSITIVE_INFINITY).rank).toBe(0); + expect(WOW335_MAGE_MISSILE_BARRAGE_AURA).toMatchObject({ + id: "wow335:mage:missile-barrage", + name: "Missile Barrage", + durationMs: 15_000, + maxStacks: 1, + utilityTags: [WOW335_MAGE_MISSILE_BARRAGE_TAG], + }); + }); +}); diff --git a/src/game/wow335MageMissileBarrageRuntime.ts b/src/game/wow335MageMissileBarrageRuntime.ts new file mode 100644 index 00000000..e90ad1ad --- /dev/null +++ b/src/game/wow335MageMissileBarrageRuntime.ts @@ -0,0 +1,138 @@ +import type { AuraDefinition } from "./combatAuras"; +import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export const WOW335_MAGE_MISSILE_BARRAGE_TALENT_ID = "talent-2209"; +export const WOW335_MAGE_MISSILE_BARRAGE_TAG = "wow335-mage-missile-barrage"; + +const MISSILE_BARRAGE_DURATION_MS = 15_000; +const CHANNEL_DURATION_MULTIPLIER = 0.5; +const MISSILE_INTERVAL_MULTIPLIER = 0.5; +const MANA_COST_REDUCTION_PERCENT = 100; + +const MISSILE_BARRAGE_TOOLTIP_TRIGGER_GROUPS = [ + ["Arcane Blast", "Starfire", "Starsurge"], + ["Arcane Barrage", "Fireball", "Frostbolt", "Slow", "Moonfire"], + ["Arcane Orb"], +] as const; + +const MISSILE_BARRAGE_TOOLTIP_CONSUMERS = ["Arcane Missiles"] as const; + +const missileBarrageTalent = WOW335_TALENT_NODES.find((talent) => ( + talent.id === WOW335_MAGE_MISSILE_BARRAGE_TALENT_ID +)); + +function normalizedRank(rank: number): number { + return Math.max(0, Math.min(3, Math.trunc(Number.isFinite(rank) ? rank : 0))); +} + +function tooltipTriggerChancePercentByName(description: string | undefined): Readonly> { + const chances: Record = {}; + if (!description) return chances; + + for (const sentence of description.split(".")) { + const match = sentence.trim().match(/^(.*?)\s+(?:have|has) a\s+(\d+(?:\.\d+)?)% chance\b/i); + if (!match) continue; + + const listedNames = match[1]!.replace(/\s+and\s+/gi, ",").split(","); + for (const listedName of listedNames) { + const name = listedName.trim(); + if (name) chances[name] = Number(match[2]); + } + } + return chances; +} + +const tooltipTriggerSpellNames = new Set( + MISSILE_BARRAGE_TOOLTIP_TRIGGER_GROUPS.flat(), +); +const mageAbilityNames = new Set(WOW335_ABILITIES_BY_CLASS.mage.map((ability) => ability.name)); +const tooltipConsumerSpellNames = new Set(MISSILE_BARRAGE_TOOLTIP_CONSUMERS); + +/** Trigger names present in the generated talent tooltip, including currently unavailable Mage casts. */ +export const WOW335_MAGE_MISSILE_BARRAGE_TOOLTIP_TRIGGER_SPELL_NAMES: ReadonlySet = tooltipTriggerSpellNames; + +/** Trigger names that also have a current generated Mage ability row. */ +export const WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_TRIGGER_SPELL_NAMES: ReadonlySet = new Set( + [...tooltipTriggerSpellNames].filter((name) => mageAbilityNames.has(name)), +); + +/** Tooltip triggers with no current generated Mage ability row. */ +export const WOW335_MAGE_MISSILE_BARRAGE_UNAVAILABLE_TRIGGER_SPELL_NAMES: ReadonlySet = new Set( + [...tooltipTriggerSpellNames].filter((name) => !mageAbilityNames.has(name)), +); + +/** Consumer names present in the generated talent tooltip. */ +export const WOW335_MAGE_MISSILE_BARRAGE_TOOLTIP_CONSUMER_SPELL_NAMES: ReadonlySet = tooltipConsumerSpellNames; + +/** Consumers that also have a current generated Mage ability row. */ +export const WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_CONSUMER_SPELL_NAMES: ReadonlySet = new Set( + [...tooltipConsumerSpellNames].filter((name) => mageAbilityNames.has(name)), +); + +/** Tooltip consumers with no current generated Mage ability row. */ +export const WOW335_MAGE_MISSILE_BARRAGE_UNAVAILABLE_CONSUMER_SPELL_NAMES: ReadonlySet = new Set( + [...tooltipConsumerSpellNames].filter((name) => !mageAbilityNames.has(name)), +); + +export const WOW335_MAGE_MISSILE_BARRAGE_AURA: AuraDefinition = { + id: "wow335:mage:missile-barrage", + name: "Missile Barrage", + description: "Your next Arcane Missiles within 15 seconds has half channel and missile fire time and costs no Mana.", + disposition: "buff", + // The generated talent rank row links child 44401 but omits its aura duration. + // WotLKDB's 3.3.5a child spell lists the missing duration as 15 seconds. + durationMs: MISSILE_BARRAGE_DURATION_MS, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [WOW335_MAGE_MISSILE_BARRAGE_TAG], +}; + +export interface Wow335MageMissileBarrageProfile { + readonly rank: number; + readonly auraDurationMs: number; + readonly channelDurationMultiplier: number; + readonly missileIntervalMultiplier: number; + readonly manaCostReductionPercent: number; + /** Per-spell percentages parsed from this rank's generated tooltip. */ + readonly tooltipProcChancePercentBySpellName: Readonly>; +} + +/** + * Reads each rank's distinct trigger chances from the generated talent tooltip. + * The rank spell's generic DBC procChance only matches the tooltip's middle group. + */ +export function wow335MageMissileBarrageProfileForRank(rank: number): Wow335MageMissileBarrageProfile { + const normalized = normalizedRank(rank); + const description = normalized > 0 ? missileBarrageTalent?.rankDescriptions[normalized - 1] : undefined; + return { + rank: normalized, + auraDurationMs: MISSILE_BARRAGE_DURATION_MS, + channelDurationMultiplier: CHANNEL_DURATION_MULTIPLIER, + missileIntervalMultiplier: MISSILE_INTERVAL_MULTIPLIER, + manaCostReductionPercent: MANA_COST_REDUCTION_PERCENT, + tooltipProcChancePercentBySpellName: tooltipTriggerChancePercentByName(description), + }; +} + +/** Returns the generated tooltip chance, including names without a current Mage cast row. */ +export function wow335MageMissileBarrageTooltipProcChancePercentForSpell( + rank: number, + abilityName: string, +): number { + return wow335MageMissileBarrageProfileForRank(rank).tooltipProcChancePercentBySpellName[abilityName] ?? 0; +} + +/** Returns a proc chance only for spell names with a current generated Mage ability row. */ +export function wow335MageMissileBarrageReachableProcChancePercentForSpell( + rank: number, + abilityName: string, +): number { + if (!WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_TRIGGER_SPELL_NAMES.has(abilityName)) return 0; + return wow335MageMissileBarrageTooltipProcChancePercentForSpell(rank, abilityName); +} + +/** True for generated-tooltip consumers that can currently be cast as a Mage. */ +export function wow335MageMissileBarrageConsumerEligible(abilityName: string): boolean { + return WOW335_MAGE_MISSILE_BARRAGE_REACHABLE_CONSUMER_SPELL_NAMES.has(abilityName); +} diff --git a/src/game/wow335MageMoltenFury.integration.test.ts b/src/game/wow335MageMoltenFury.integration.test.ts new file mode 100644 index 00000000..29ea192d --- /dev/null +++ b/src/game/wow335MageMoltenFury.integration.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCombatStore } from "./combatStore"; +import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + advanceDungeonPartyCombat, + resetPartyRuntimeClock, +} from "./partyRuntime"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { partyAbilityAmount } from "./partyAbilityAi"; +import { + createConfiguredPartyMember, + usePartyStore, +} from "./partyStore"; +import { EMPTY_ITEM_STATS } from "./itemStats"; + +const TARGET_ID = "mage-molten-fury-target"; +const TARGET_MAX_HEALTH = 100_000; +const FIREBALL_BASE_DAMAGE = 100; + +function setPlayerTalentRanks(talentRanks: Readonly>): void { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, ...talentRanks }, + })); +} + +function registerTarget(health = TARGET_MAX_HEALTH): void { + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Molten Fury target", + maxHealth: TARGET_MAX_HEALTH, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + [TARGET_ID]: { ...state.mobs[TARGET_ID]!, health, dead: false }, + }, + })); +} + +function initializePartyMage(talentRanks: Readonly>, seed: string) { + const mage = createConfiguredPartyMember({ + name: "Molten Fury Mage", + classId: "mage", + specialization: "Fire", + specializationId: "mage:fire", + talentTreeId: "mage-fire", + talentRanks, + role: "damage", + }, 80, 0, seed); + usePartyStore.getState().initializeParty( + seed, + 80, + useCombatStore.getState().health, + "damage", + 2, + ); + usePartyStore.setState({ + members: [{ + ...mage, + gearStats: EMPTY_ITEM_STATS, + resource: 10_000, + maxResource: 10_000, + resourcePools: { ...mage.resourcePools, mana: 10_000 }, + }], + }); + registerPartyRuntimePosition(mage.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + return usePartyStore.getState().members[0]!; +} + +function registerPartyTarget(mageId: string, health: number): void { + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Molten Fury party target", + maxHealth: TARGET_MAX_HEALTH, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().damageMob(TARGET_ID, 1, 100); + updatePartyRuntimeTargetVisibility(mageId, TARGET_ID, true); + usePartyStore.setState({ command: "attack", activeMobId: TARGET_ID }); + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + [TARGET_ID]: { ...state.mobs[TARGET_ID]!, health, dead: false }, + }, + })); +} + +function setPartyFireballCast(mageId: string, startedAt: number, targetId: string): void { + const fireball = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Fireball")!, 80); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === mageId ? { + ...member, + activeCast: { + abilityId: fireball.id, + school: "fire", + mode: "cast", + startedAt, + completesAt: startedAt + fireball.castTimeMs, + originalDurationMs: fireball.castTimeMs, + pushbackHits: 0, + origin: [0, 0, 0], + targetId, + }, + activeCastTargetId: targetId, + status: "attacking", + } : member), + })); + useCombatStore.getState().synchronizeActors(); +} + +describe("WoW Mage Molten Fury combat integration", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + usePartyStore.getState().initializeParty( + "mage-molten-fury-player-fixture", + 80, + useCombatStore.getState().health, + "damage", + 1, + ); + }); + + it.each([ + { health: 35_000, expected: FIREBALL_BASE_DAMAGE, label: "at 35%" }, + { health: 34_999, expected: Math.round(FIREBALL_BASE_DAMAGE * 1.06), label: "just below 35%" }, + ])("applies the player's rank-one direct bonus $label", ({ health, expected }) => { + setPlayerTalentRanks({ "talent-1732": 1 }); + registerTarget(health); + const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!; + + const result = useCombatStore.getState().damageMob( + TARGET_ID, + FIREBALL_BASE_DAMAGE, + 1_000, + undefined, + "fire", + { + attackKind: "spell", + seed: `mage-molten-fury-player-direct-${health}`, + abilityId: fireball.id, + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }, + ); + + expect(result.amount).toBe(expected); + }); + + it("reevaluates a player's Ignite ticks as the target crosses below 35%", () => { + setPlayerTalentRanks({ "talent-1732": 1, "talent-34": 5 }); + registerTarget(); + const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!; + const critical = useCombatStore.getState().damageMob( + TARGET_ID, + FIREBALL_BASE_DAMAGE, + 1_000, + undefined, + "fire", + { + attackKind: "spell", + seed: "mage-molten-fury-player-ignite", + abilityId: fireball.id, + criticalChanceBonusPercent: 1_000, + rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false }, + }, + ); + expect(critical.combatResult?.critical).toBe(true); + const firstIgnite = useCombatStore.getState().effects.find((effect) => ( + effect.tag === "wow335-mage-ignite" && effect.targetId === TARGET_ID && effect.nextTickAt === 3_000 + )); + const baseTick = firstIgnite?.fixedAmount; + expect(baseTick).toBeGreaterThan(0); + + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [TARGET_ID]: { ...state.mobs[TARGET_ID]!, health: 35_000, dead: false } }, + })); + useCombatStore.getState().tick(0.1, 3_000); + const healthAfterFirstTick = useCombatStore.getState().mobs[TARGET_ID]!.health; + const firstTickDamage = 35_000 - healthAfterFirstTick; + useCombatStore.getState().tick(0.1, 5_000); + const secondTickDamage = healthAfterFirstTick - useCombatStore.getState().mobs[TARGET_ID]!.health; + + expect(firstTickDamage).toBe(baseTick); + expect(secondTickDamage).toBe(Math.round(baseTick! * 1.06)); + }); + + it.each([ + { health: 35_000, multiplier: 1, label: "at 35%" }, + { health: 34_999, multiplier: 1.06, label: "just below 35%" }, + ])("applies the party Mage direct bonus $label", ({ health, multiplier }) => { + const mage = initializePartyMage({ "talent-1732": 1 }, "mage-molten-fury-party-direct"); + const fireball = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Fireball")!, 80); + const fireballDamage = fireball.effects.find((effect) => effect.kind === "damage"); + if (!fireballDamage || fireballDamage.kind !== "damage") throw new Error("Fireball has no direct damage row."); + const expectedBaseDamage = partyAbilityAmount(mage, fireball, fireballDamage); + registerPartyTarget(mage.id, health); + const startedAt = 10_000; + setPartyFireballCast(mage.id, startedAt, TARGET_ID); + + advanceDungeonPartyCombat(startedAt); + advanceDungeonPartyCombat(startedAt + fireball.castTimeMs); + + const damage = health - useCombatStore.getState().mobs[TARGET_ID]!.health; + expect(damage).toBe(Math.round(expectedBaseDamage * multiplier)); + }); + + it("uses the party Mage's rank for periodic Fire damage below the health threshold", () => { + const mage = initializePartyMage( + { "talent-1732": 1, "talent-34": 5 }, + "mage-molten-fury-party-periodic", + ); + registerPartyTarget(mage.id, 34_999); + const fireball = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Fireball")!, 80); + const critical = useCombatStore.getState().damageMob( + TARGET_ID, + FIREBALL_BASE_DAMAGE, + 1_000, + { actorId: mage.id, role: mage.role, abilityId: fireball.id }, + "fire", + { + attackKind: "spell", + seed: "mage-molten-fury-party-ignite", + abilityId: fireball.id, + criticalChanceBonusPercent: 1_000, + rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false }, + }, + ); + expect(critical.combatResult?.critical).toBe(true); + const firstIgnite = useCombatStore.getState().effects.find((effect) => ( + effect.tag === "wow335-mage-ignite" && effect.sourceActorId === mage.id + && effect.targetId === TARGET_ID && effect.nextTickAt === 3_000 + )); + const baseTick = firstIgnite?.fixedAmount; + expect(baseTick).toBeGreaterThan(0); + + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [TARGET_ID]: { ...state.mobs[TARGET_ID]!, health: 34_999, dead: false } }, + })); + useCombatStore.getState().tick(0.1, 3_000); + + const damage = 34_999 - useCombatStore.getState().mobs[TARGET_ID]!.health; + expect(damage).toBe(Math.round(baseTick! * 1.06)); + }); +}); diff --git a/src/game/wow335MageMoltenFuryRuntime.test.ts b/src/game/wow335MageMoltenFuryRuntime.test.ts new file mode 100644 index 00000000..efe11fad --- /dev/null +++ b/src/game/wow335MageMoltenFuryRuntime.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + wow335MageMoltenFuryDamage, + wow335MageMoltenFuryMultiplier, +} from "./wow335MageMoltenFuryRuntime"; + +describe("WoW Mage Molten Fury", () => { + it("uses the generated 6% and 12% rank bonuses only below 35% target health", () => { + expect(wow335MageMoltenFuryMultiplier(1, "fire", 350, 1_000)).toBe(1); + expect(wow335MageMoltenFuryMultiplier(1, "fire", 349, 1_000)).toBe(1.06); + expect(wow335MageMoltenFuryMultiplier(2, "fire", 349, 1_000)).toBe(1.12); + expect(wow335MageMoltenFuryDamage(100, 1, "fire", 349, 1_000)).toBe(106); + expect(wow335MageMoltenFuryDamage(100, 2, "fire", 349, 1_000)).toBe(112); + }); + + it("applies to Fire, Frost, and Arcane damage, but not other schools", () => { + for (const school of ["fire", "frost", "arcane"] as const) { + expect(wow335MageMoltenFuryDamage(100, 1, school, 300, 1_000)).toBe(106); + } + for (const school of ["physical", "holy", "nature", "shadow"] as const) { + expect(wow335MageMoltenFuryDamage(100, 2, school, 300, 1_000)).toBe(100); + } + }); + + it("does nothing without an allocated rank or a valid target-health denominator", () => { + expect(wow335MageMoltenFuryDamage(100, 0, "fire", 300, 1_000)).toBe(100); + expect(wow335MageMoltenFuryDamage(100, 2, "fire", 1, 0)).toBe(100); + expect(wow335MageMoltenFuryDamage(100, 2, "fire", Number.NaN, 1_000)).toBe(100); + }); +}); diff --git a/src/game/wow335MageMoltenFuryRuntime.ts b/src/game/wow335MageMoltenFuryRuntime.ts new file mode 100644 index 00000000..571a7e6a --- /dev/null +++ b/src/game/wow335MageMoltenFuryRuntime.ts @@ -0,0 +1,33 @@ +import type { DamageSchool } from "./itemStats"; + +const MOLTEN_FURY_SCHOOLS = new Set(["fire", "frost", "arcane"]); + +export const WOW335_MAGE_MOLTEN_FURY_TALENT_ID = "talent-1732"; + +/** Returns the local generated Molten Fury multiplier for one eligible Mage hit. */ +export function wow335MageMoltenFuryMultiplier( + rank: number, + school: DamageSchool, + targetHealth: number, + targetMaxHealth: number, +): number { + const normalizedRank = Math.max(0, Math.min(2, Math.trunc(rank))); + if (normalizedRank <= 0 || !MOLTEN_FURY_SCHOOLS.has(school) + || !Number.isFinite(targetHealth) || !Number.isFinite(targetMaxHealth) + || targetMaxHealth <= 0 || targetHealth / targetMaxHealth >= 0.35) { + return 1; + } + return 1 + normalizedRank * 0.06; +} + +/** Applies Molten Fury to a Mage damage event using the target's pre-hit health. */ +export function wow335MageMoltenFuryDamage( + amount: number, + rank: number, + school: DamageSchool, + targetHealth: number, + targetMaxHealth: number, +): number { + const multiplier = wow335MageMoltenFuryMultiplier(rank, school, targetHealth, targetMaxHealth); + return multiplier === 1 ? amount : Math.round(amount * multiplier); +} diff --git a/src/game/wow335MageRuntime.test.ts b/src/game/wow335MageRuntime.test.ts index 18ba4ed3..3ae749cb 100644 --- a/src/game/wow335MageRuntime.test.ts +++ b/src/game/wow335MageRuntime.test.ts @@ -1,17 +1,27 @@ 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 { PLAYER_AURA_ENTITY_ID, resolvedAbilityCosts, useCombatStore, type TimedCombatEffect } from "./combatStore"; import { usePartyStore } from "./partyStore"; import { abilityAtLevel, abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; -import { resolveDamageValue, resolveStatValue } from "./combatAuras"; +import { applyAura, resolveDamageValue, resolveStatValue } from "./combatAuras"; import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; import { + WOW335_MAGE_ARCANE_CONCENTRATION_AURA, + WOW335_MAGE_ARCANE_CONCENTRATION_TAG, + WOW335_MAGE_BRAIN_FREEZE_AURA, + WOW335_MAGE_BRAIN_FREEZE_TAG, WOW335_MAGE_FINGERS_OF_FROST_TAG, WOW335_MAGE_FOCUS_MAGIC_TAG, WOW335_MAGE_HOT_STREAK_TAG, WOW335_MAGE_IMPROVED_SCORCH_TAG, } from "./wow335MageTalentRuntime"; +import { + WOW335_MAGE_MISSILE_BARRAGE_AURA, + WOW335_MAGE_MISSILE_BARRAGE_TAG, +} from "./wow335MageMissileBarrageRuntime"; +import { baseResourcesFor } from "./wotlkStats"; +import { talentModifiersForRanks } from "./talentModifiers"; function runtimeAbilityId(name: string): string { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); @@ -28,23 +38,145 @@ function unlockTalentAbility(name: string): string { 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"); +function setPartyMageTalentRanks(talentRanks: Readonly>): string { + const members = usePartyStore.getState().members; + const member = members.find((candidate) => candidate.resourceType === "mana") ?? members[0]!; + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === member.id ? { + ...candidate, + classId: "mage", + talentRanks, + resourceType: "mana", + resourceName: "Mana", + maxResource: 10_000, + resource: 9_000, + resourcePools: { ...candidate.resourcePools, mana: 9_000 }, + } : candidate), + })); + useCombatStore.setState((state) => { + const actors = { ...state.actors }; + delete actors[member.id]; + return { actors }; }); + return member.id; +} + +function damageMageFireballCrit(sourceActorId?: string, now = 1_000) { + const fireballId = runtimeAbilityId("Fireball"); + const member = sourceActorId + ? usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId) + : null; + return useCombatStore.getState().damageMob("cold-snap-target", 100, now, sourceActorId && member + ? { actorId: sourceActorId, role: member.role, abilityId: fireballId } + : undefined, "fire", { + attackKind: "spell", + seed: `mage-empowered-fireball-${sourceActorId ?? "player"}-${now}`, + abilityId: fireballId, + criticalChanceBonusPercent: 1_000, + rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false }, + }); +} + +function mageArcaneConcentrationRoll(seed: string): number { + let hash = 2166136261; + for (const character of `${seed}:mage-arcane-concentration`) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function mageBrainFreezeRoll(seed: string): number { + let hash = 2166136261; + for (const character of `${seed}:mage-brain-freeze`) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function mageMissileBarrageRoll(seed: string): number { + let hash = 2166136261; + for (const character of `${seed}:wow335-mage-missile-barrage`) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function applyMageClearcasting(now: number): void { + const state = useCombatStore.getState(); + const applied = applyAura(state.auras, { + definition: WOW335_MAGE_ARCANE_CONCENTRATION_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }); + useCombatStore.setState({ auras: applied.auras }); +} + +function hasMageClearcasting(): boolean { + return useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) + )); +} + +function applyMageBrainFreeze(now: number): void { + const state = useCombatStore.getState(); + const applied = applyAura(state.auras, { + definition: WOW335_MAGE_BRAIN_FREEZE_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }); + useCombatStore.setState({ auras: applied.auras }); +} + +function hasMageBrainFreeze(): boolean { + return useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG) + )); +} + +function applyMageMissileBarrage(now: number): void { + const state = useCombatStore.getState(); + const applied = applyAura(state.auras, { + definition: WOW335_MAGE_MISSILE_BARRAGE_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now, + }); + useCombatStore.setState({ auras: applied.auras }); +} + +function hasMageMissileBarrage(): boolean { + return useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG) + )); +} + +async function resetMageRuntimeFixture(): Promise { + 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"); +} + +describe("WoW 3.3.5 Mage runtime mappings", () => { + beforeEach(resetMageRuntimeFixture); 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"); @@ -183,7 +315,7 @@ describe("WoW 3.3.5 Mage runtime mappings", () => { 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", () => { + it("ticks rank-five Ignite for the DBC 40% burn total without an Empowered Fire refund", () => { useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-34": 5 }, })); @@ -217,6 +349,131 @@ describe("WoW 3.3.5 Mage runtime mappings", () => { useCombatStore.getState().tick(0.1, 5_000); expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-mage-ignite")) .toHaveLength(0); + expect(useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "resource" && event.abilityId === fireball.id + && event.sourceActorId === PLAYER_AURA_ENTITY_ID + && [3_000, 5_000].includes(event.occurredAt) + ))).toHaveLength(0); + }); + + it("restores 2% of local base mana on each Ignite tick with Empowered Fire", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-34": 5, "talent-1734": 1 }, + resource: state.maxResourcePools.mana! - 200, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! - 200 }, + })); + const fireballId = runtimeAbilityId("Fireball"); + expect(damageMageFireballCrit().combatResult?.critical).toBe(true); + const refund = Math.round(baseResourcesFor("mage", 80).mana * 0.02); + + useCombatStore.getState().tick(0.1, 3_000); + const firstRefund = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "resource" && event.abilityId === fireballId + && event.targetActorId === PLAYER_AURA_ENTITY_ID + )); + expect(firstRefund.map((event) => event.effectiveAmount)).toEqual([refund]); + + useCombatStore.getState().tick(0.1, 5_000); + const refunds = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "resource" && event.abilityId === fireballId + && event.targetActorId === PLAYER_AURA_ENTITY_ID + )); + expect(refunds.map((event) => event.effectiveAmount)).toEqual([refund, refund]); + }); + + it("caps each Empowered Fire Ignite refund at the player's mana maximum", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-34": 5, "talent-1734": 1 }, + resource: state.maxResourcePools.mana! - 10, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! - 10 }, + })); + const fireballId = runtimeAbilityId("Fireball"); + damageMageFireballCrit(); + const maximumMana = useCombatStore.getState().maxResourcePools.mana!; + + useCombatStore.getState().tick(0.1, 3_000); + expect(useCombatStore.getState().resource).toBe(maximumMana); + useCombatStore.getState().tick(0.1, 5_000); + expect(useCombatStore.getState().resource).toBe(maximumMana); + const refunds = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "resource" && event.abilityId === fireballId + && event.targetActorId === PLAYER_AURA_ENTITY_ID + )); + expect(refunds[0]?.effectiveAmount).toBeLessThanOrEqual(10); + expect(refunds.reduce((total, event) => total + event.effectiveAmount, 0)).toBeLessThanOrEqual(10); + }); + + it("does not let the player's Empowered Fire talent refund a party Mage's Ignite", () => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1734": 3 }, + })); + const partyMageId = setPartyMageTalentRanks({ "talent-34": 5 }); + const fireballId = runtimeAbilityId("Fireball"); + expect(damageMageFireballCrit(partyMageId).combatResult?.critical).toBe(true); + expect(useCombatStore.getState().effects.some((effect) => ( + effect.tag === "wow335-mage-ignite" && effect.sourceActorId === partyMageId + ))).toBe(true); + + useCombatStore.getState().tick(0.1, 3_000); + useCombatStore.getState().tick(0.1, 5_000); + expect(useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "resource" && event.abilityId === fireballId + && event.targetActorId === partyMageId + ))).toHaveLength(0); + }); + + it("uses a party Mage's own Empowered Fire rank for Ignite refunds", () => { + const partyMageId = setPartyMageTalentRanks({ "talent-34": 5, "talent-1734": 1 }); + const partyMage = usePartyStore.getState().members.find((member) => member.id === partyMageId)!; + const fireballId = runtimeAbilityId("Fireball"); + expect(damageMageFireballCrit(partyMageId).combatResult?.critical).toBe(true); + const refund = Math.round(baseResourcesFor("mage", partyMage.level).mana * 0.02); + + useCombatStore.getState().tick(0.1, 3_000); + const partyRefund = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "resource" && event.abilityId === fireballId + && event.sourceActorId === partyMageId && event.targetActorId === partyMageId + )); + expect(partyRefund.map((event) => event.effectiveAmount)).toEqual([refund]); + expect(useCombatStore.getState().resourcePools.mana).toBe(useCombatStore.getState().maxResourcePools.mana); + }); + + it.each([ + { rank: 2, canCrit: false }, + { rank: 3, canCrit: true }, + ])("allows Living Bomb periodic criticals only at Empowered Fire rank $rank", ({ rank, canCrit }) => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1734": rank }, + gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 }, + })); + const livingBomb = useCombatStore.getState().abilities.find((ability) => ability.name === "Living Bomb")!; + const effect: TimedCombatEffect = { + id: `mage-living-bomb-periodic-crit-rank-${rank}`, + kind: "dot", + sourceAbilityId: livingBomb.id, + sourceActorId: PLAYER_AURA_ENTITY_ID, + targetId: "cold-snap-target", + coefficient: 0, + fixedAmount: 100, + remainingTicks: 2, + intervalMs: 1_000, + nextTickAt: 3_000, + damageSchool: "fire", + tag: "mage-living-bomb-periodic-crit-test", + }; + useCombatStore.setState((state) => ({ effects: [...state.effects, effect] })); + + useCombatStore.getState().tick(0.1, 3_000); + const tickEvent = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === livingBomb.id && event.occurredAt === 3_000 + )); + expect(tickEvent?.critical).toBe(canCrit); + }); + + it.each([1, 2, 3])("does not treat Empowered Fire rank %s coefficient growth as total damage", (rank) => { + expect(talentModifiersForRanks({ "talent-1734": rank }, "mage").some((modifier) => ( + modifier.kind === "damage-percent" + ))).toBe(false); }); it("maps Presence of Mind to a non-expiring next cast-time Mage spell aura", () => { @@ -487,4 +744,398 @@ describe("WoW 3.3.5 Mage runtime mappings", () => { )); expect(freeze?.endsAt).toBe(procAt + 4_000); }); + + it("procs Arcane Concentration at the trained 5%, 10%, or 15% rank chance after direct spell damage", () => { + const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!; + const seed = Array.from({ length: 10_000 }, (_, index) => `mage-arcane-concentration-${index}`) + .find((candidate) => { + const roll = mageArcaneConcentrationRoll(candidate); + return roll >= 0.11 && roll < 0.15; + })!; + expect(seed).toBeTruthy(); + const proc = () => useCombatStore.getState().damageMob( + "cold-snap-target", + 100, + 2_000, + undefined, + "fire", + { + attackKind: "spell", + seed, + abilityId: fireball.id, + periodic: false, + rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false, canCrit: false }, + }, + ); + + for (const rank of [0, 1, 2]) { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-75": rank }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG)), + })); + expect(proc().amount).toBeGreaterThan(0); + expect(hasMageClearcasting()).toBe(false); + } + + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-75": 3 }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG)), + })); + expect(proc().amount).toBeGreaterThan(0); + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) + )); + expect(aura?.expiresAt).toBe(17_000); + }); + + it("makes the next resource-costing ability free, including non-Mana costs, and consumes Clearcasting on a free cast", () => { + const state = useCombatStore.getState(); + const fireball = state.abilities.find((ability) => ability.name === "Fireball")!; + const manaCost = resolvedAbilityCosts(fireball, [], { ...state, auras: [], now: 1_000 })[0]!.amount; + applyMageClearcasting(1_000); + const clearcastingState = useCombatStore.getState(); + expect(manaCost).toBeGreaterThan(0); + expect(resolvedAbilityCosts(fireball, [], { ...clearcastingState, now: 1_000 })[0]!.amount).toBe(0); + + for (const resource of ["energy", "rage"] as const) { + const alternateResourceAbility = { + ...fireball, + cost: { resource, amount: 25 }, + costs: [{ resource, amount: 25 }], + }; + expect(resolvedAbilityCosts(alternateResourceAbility, [], { ...clearcastingState, now: 1_000 })[0]!.amount) + .toBe(0); + } + + const beforeMana = useCombatStore.getState().resource; + const cast = useCombatStore.getState().castAbility(unlockTalentAbility("Arcane Power"), undefined, 2_000); + expect(cast.ok, JSON.stringify(cast)).toBe(true); + expect(useCombatStore.getState().resource).toBe(beforeMana); + expect(hasMageClearcasting()).toBe(false); + }); + + it("preserves Clearcasting when a normal cast is interrupted before completion", () => { + const fireball = runtimeAbilityId("Fireball"); + const manaBefore = useCombatStore.getState().resource; + applyMageClearcasting(1_000); + + const started = useCombatStore.getState().castAbility(fireball, undefined, 1_000); + expect(started.ok, JSON.stringify(started)).toBe(true); + expect(useCombatStore.getState().activeCast?.abilityId).toBe(fireball); + expect(hasMageClearcasting()).toBe(true); + expect(useCombatStore.getState().resource).toBe(manaBefore); + + expect(useCombatStore.getState().cancelCast()).toBe(true); + expect(hasMageClearcasting()).toBe(true); + expect(useCombatStore.getState().resource).toBe(manaBefore); + }); + + it("consumes Clearcasting when an upfront-paid channel starts, even if interrupted afterward", () => { + const arcaneMissiles = runtimeAbilityId("Arcane Missiles"); + const manaBefore = useCombatStore.getState().resource; + applyMageClearcasting(1_000); + + const started = useCombatStore.getState().castAbility(arcaneMissiles, undefined, 1_000); + expect(started.ok, JSON.stringify(started)).toBe(true); + expect(useCombatStore.getState().activeCast?.mode).toBe("channel"); + expect(hasMageClearcasting()).toBe(false); + expect(useCombatStore.getState().resource).toBe(manaBefore); + + expect(useCombatStore.getState().cancelCast()).toBe(true); + expect(hasMageClearcasting()).toBe(false); + }); + + it("procs Brain Freeze at its trained rank chance from the generated Frost damage triggers", () => { + const frostbolt = useCombatStore.getState().abilities.find((ability) => ability.name === "Frostbolt")!; + const thresholdSeed = Array.from({ length: 10_000 }, (_, index) => `mage-brain-freeze-rank-${index}`) + .find((candidate) => { + const roll = mageBrainFreezeRoll(candidate); + return roll >= 0.11 && roll < 0.15; + })!; + const guaranteedSeed = Array.from({ length: 10_000 }, (_, index) => `mage-brain-freeze-hit-${index}`) + .find((candidate) => mageBrainFreezeRoll(candidate) < 0.04)!; + const hit = (abilityId: string, seed: string, now: number, periodic = false) => ( + useCombatStore.getState().damageMob( + "cold-snap-target", + 100, + now, + undefined, + "frost", + { + attackKind: "spell", + seed, + abilityId, + periodic, + rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false, canCrit: false }, + }, + ) + ); + + for (const rank of [0, 1, 2]) { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1854": rank }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG)), + })); + expect(hit(frostbolt.id, thresholdSeed, 2_000 + rank).amount).toBeGreaterThan(0); + expect(hasMageBrainFreeze()).toBe(false); + } + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1854": 3 }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG)), + })); + expect(hit(frostbolt.id, thresholdSeed, 2_010).amount).toBeGreaterThan(0); + expect(hasMageBrainFreeze()).toBe(true); + expect(useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG) + ))?.expiresAt).toBe(17_010); + + const triggerNames = ["Frostbolt", "Frost Nova", "Blizzard", "Cone of Cold"]; + triggerNames.forEach((name, index) => { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1854": 3 }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG)), + })); + const triggerAbility = useCombatStore.getState().abilities.find((ability) => ability.name === name)!; + expect(triggerAbility, name).toBeDefined(); + expect(hit(triggerAbility.id, guaranteedSeed, 3_000 + index, name === "Blizzard").amount).toBeGreaterThan(0); + expect(hasMageBrainFreeze(), name).toBe(true); + }); + + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1854": 3 }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG)), + })); + const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!; + expect(hit(fireball.id, guaranteedSeed, 4_000).amount).toBeGreaterThan(0); + expect(hasMageBrainFreeze()).toBe(false); + }); + + it("makes Fireball and Frostfire Bolt instant and free while applying Brain Freeze's 25% damage", async () => { + for (const spellName of ["Fireball", "Frostfire Bolt"]) { + const spellId = runtimeAbilityId(spellName); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1854": 3 } })); + const startAt = Math.max(0, useCombatStore.getState().globalCooldownEndsAt) + 1; + const ordinaryStart = useCombatStore.getState().castAbility(spellId, undefined, startAt); + expect(ordinaryStart.ok, `${spellName}: ${JSON.stringify(ordinaryStart)}`).toBe(true); + const ordinaryCast = useCombatStore.getState().activeCast!; + expect(ordinaryCast).not.toBeNull(); + useCombatStore.getState().tick(0.1, ordinaryCast.completesAt); + const ordinaryRawDamage = useCombatStore.getState().combatEvents + .filter((event) => event.kind === "damage" && event.abilityId === spellId + && event.occurredAt === ordinaryCast.completesAt) + .reduce((total, event) => total + event.rawAmount, 0); + expect(ordinaryRawDamage).toBeGreaterThan(0); + + const completesAt = ordinaryCast.completesAt; + await resetMageRuntimeFixture(); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1854": 3 } })); + applyMageBrainFreeze(1_000); + const clearcastingState = useCombatStore.getState(); + const consumer = clearcastingState.abilities.find((ability) => ability.id === spellId)!; + const frostbolt = clearcastingState.abilities.find((ability) => ability.name === "Frostbolt")!; + expect(resolvedAbilityCosts(consumer, [], { ...clearcastingState, now: completesAt })[0]!.amount).toBe(0); + expect(resolvedAbilityCosts(frostbolt, [], { ...clearcastingState, now: completesAt })[0]!.amount).toBeGreaterThan(0); + + const manaBefore = clearcastingState.resource; + const empowered = useCombatStore.getState().castAbility(spellId, undefined, completesAt); + expect(empowered.ok, `${spellName}: ${JSON.stringify(empowered)}`).toBe(true); + const empoweredRawDamage = useCombatStore.getState().combatEvents + .filter((event) => event.kind === "damage" && event.abilityId === spellId + && event.occurredAt === completesAt) + .reduce((total, event) => total + event.rawAmount, 0); + expect(empoweredRawDamage).toBe(Math.round(ordinaryRawDamage * 1.25)); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(useCombatStore.getState().resource).toBe(manaBefore); + expect(hasMageBrainFreeze()).toBe(false); + } + }); + + it("does not consume an expired Brain Freeze proc or make the consumer free/instant", () => { + const fireballId = runtimeAbilityId("Fireball"); + const fireball = useCombatStore.getState().abilities.find((ability) => ability.id === fireballId)!; + const before = useCombatStore.getState(); + applyMageBrainFreeze(1_000); + const expiredAt = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WOW335_MAGE_BRAIN_FREEZE_TAG) + ))!.expiresAt!; + expect(expiredAt).toBe(16_000); + expect(resolvedAbilityCosts(fireball, [], { ...before, auras: useCombatStore.getState().auras, now: expiredAt + 1 })[0]!.amount) + .toBeGreaterThan(0); + + const result = useCombatStore.getState().castAbility(fireballId, undefined, expiredAt + 1); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().activeCast?.mode).toBe("cast"); + expect(useCombatStore.getState().activeCast?.completesAt).toBeGreaterThan(expiredAt + 1); + expect(hasMageBrainFreeze()).toBe(true); + }); + + it("uses each generated Missile Barrage rank chance group for landed damage triggers", () => { + const hit = (spellName: string, seed: string, now: number, periodic = false, miss = false) => ( + useCombatStore.getState().damageMob( + "cold-snap-target", + 100, + now, + undefined, + "arcane", + { + attackKind: "spell", + seed, + abilityId: runtimeAbilityId(spellName), + periodic, + rules: { + canMiss: miss, + baseMissChancePercent: miss ? 100 : 0, + canDodge: false, + canParry: false, + canBlock: false, + canCrit: false, + }, + }, + ) + ); + const seedAt = (prefix: string, min: number, max: number) => Array.from( + { length: 100_000 }, + (_, index) => `${prefix}-${index}`, + ).find((seed) => mageMissileBarrageRoll(seed) >= min && mageMissileBarrageRoll(seed) < max)!; + const cases = [ + { rank: 1, spell: "Arcane Blast", roll: 0.10, expected: true }, + { rank: 1, spell: "Fireball", roll: 0.10, expected: false }, + { rank: 2, spell: "Arcane Blast", roll: 0.20, expected: true }, + { rank: 2, spell: "Fireball", roll: 0.20, expected: false }, + { rank: 3, spell: "Arcane Blast", roll: 0.35, expected: true }, + { rank: 3, spell: "Fireball", roll: 0.25, expected: false }, + ] as const; + + cases.forEach(({ rank, spell, roll, expected }, index) => { + const seed = seedAt(`mage-missile-rank-${rank}-${spell}`, roll, roll + 0.001); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-2209": rank }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG)), + })); + const result = hit(spell, seed, 2_000 + index); + expect(result.combatResult?.landed, `${spell} rank ${rank}`).toBe(true); + expect(hasMageMissileBarrage(), `${spell} rank ${rank} roll ${roll}`).toBe(expected); + }); + + for (const spell of ["Arcane Blast", "Arcane Barrage", "Fireball", "Frostbolt"]) { + const seed = seedAt(`mage-missile-trigger-${spell}`, 0, 0.06); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-2209": 3 }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG)), + })); + expect(hit(spell, seed, 3_000 + spell.length).combatResult?.landed, spell).toBe(true); + expect(hasMageMissileBarrage(), spell).toBe(true); + } + + const arcaneBlastId = runtimeAbilityId("Arcane Blast"); + const missSeed = seedAt("mage-missile-miss", 0, 0.39); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-2209": 3 }, + auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG)), + })); + const miss = useCombatStore.getState().damageMob("cold-snap-target", 100, 4_000, undefined, "arcane", { + attackKind: "spell", seed: missSeed, abilityId: arcaneBlastId, + rules: { canMiss: true, baseMissChancePercent: 100, canDodge: false, canParry: false, canBlock: false, canCrit: false }, + }); + expect(miss.combatResult?.landed).toBe(false); + expect(hasMageMissileBarrage()).toBe(false); + + const periodicSeed = seedAt("mage-missile-periodic", 0, 0.39); + const periodic = hit("Arcane Blast", periodicSeed, 4_001, true); + expect(periodic.combatResult?.landed).toBe(true); + expect(hasMageMissileBarrage()).toBe(false); + + const unlistedSeed = seedAt("mage-missile-unlisted", 0, 0.39); + expect(hit("Scorch", unlistedSeed, 4_002).combatResult?.landed).toBe(true); + expect(hasMageMissileBarrage()).toBe(false); + }); + + it("grants Missile Barrage after Slow successfully applies its hostile debuff", () => { + const slow = useCombatStore.getState().abilities.find((ability) => ability.name === "Slow")!; + expect(slow).toBeDefined(); + unlockTalentAbility("Slow"); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-2209": 3 } })); + const startAt = Array.from({ length: 10_000 }, (_, index) => 2_000 + index * 3_000) + .find((candidate) => mageMissileBarrageRoll( + `${PLAYER_AURA_ENTITY_ID}:${slow.id}:cold-snap-target:${candidate + slow.castTimeMs}:slow`, + ) < 0.20)!; + const result = useCombatStore.getState().castAbility(slow.id, { + targetActorId: "cold-snap-target", + now: startAt, + }); + expect(result.ok, JSON.stringify(result)).toBe(true); + const slowCast = useCombatStore.getState().activeCast; + if (slowCast) useCombatStore.getState().tick(0.1, slowCast.completesAt); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "cold-snap-target" + && aura.definition.disposition === "debuff")).toBe(true); + expect(hasMageMissileBarrage()).toBe(true); + }); + + it("halves Arcane Missiles channel and pulse timing, removes Mana cost, and consumes the proc once", () => { + const arcaneMissilesId = runtimeAbilityId("Arcane Missiles"); + const fireballId = runtimeAbilityId("Fireball"); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-2209": 3 } })); + applyMageMissileBarrage(1_000); + const fireballStart = useCombatStore.getState().castAbility(fireballId, undefined, 1_000); + expect(fireballStart.ok, JSON.stringify(fireballStart)).toBe(true); + expect(hasMageMissileBarrage()).toBe(true); + useCombatStore.getState().cancelCast(); + + const before = useCombatStore.getState(); + const arcaneMissiles = before.abilities.find((ability) => ability.id === arcaneMissilesId)!; + const unbuffedState = { + ...before, + auras: before.auras.filter((aura) => !aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG)), + now: 3_000, + }; + const manaCost = resolvedAbilityCosts(arcaneMissiles, [], unbuffedState) + .find((cost) => cost.resource === "mana"); + expect(manaCost?.amount).toBeGreaterThan(0); + applyMageMissileBarrage(3_000); + const withProc = useCombatStore.getState(); + const procCost = resolvedAbilityCosts(arcaneMissiles, [], { ...withProc, now: 3_000 }) + .find((cost) => cost.resource === "mana"); + expect(procCost?.amount).toBe(0); + + const manaBefore = withProc.resource; + const started = useCombatStore.getState().castAbility(arcaneMissilesId, undefined, 3_000); + expect(started.ok, JSON.stringify(started)).toBe(true); + const cast = useCombatStore.getState().activeCast!; + expect(cast.mode).toBe("channel"); + expect(cast.completesAt).toBe(5_500); + expect(cast.totalTicks).toBe(5); + expect(cast.nextTickAt).toBe(3_500); + expect(useCombatStore.getState().resource).toBe(manaBefore); + expect(hasMageMissileBarrage()).toBe(false); + + useCombatStore.getState().tick(0.1, cast.completesAt); + const missileImpacts = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.abilityId === arcaneMissilesId + && event.occurredAt >= 3_500 && event.occurredAt <= 5_500 + )); + expect(missileImpacts.map((event) => event.occurredAt)).toEqual([3_500, 4_000, 4_500, 5_000, 5_500]); + expect(hasMageMissileBarrage()).toBe(false); + }); + + it("does not apply or consume an expired Missile Barrage proc", () => { + const arcaneMissilesId = runtimeAbilityId("Arcane Missiles"); + useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-2209": 3 } })); + applyMageMissileBarrage(1_000); + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === arcaneMissilesId)!; + const expiry = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WOW335_MAGE_MISSILE_BARRAGE_TAG) + ))!.expiresAt!; + expect(expiry).toBe(16_000); + const before = useCombatStore.getState(); + const expiredCost = resolvedAbilityCosts(ability, [], { ...before, now: expiry + 1 }) + .find((cost) => cost.resource === "mana"); + expect(expiredCost?.amount).toBeGreaterThan(0); + + const result = useCombatStore.getState().castAbility(arcaneMissilesId, undefined, expiry + 1); + expect(result.ok, JSON.stringify(result)).toBe(true); + const cast = useCombatStore.getState().activeCast!; + expect(cast.mode).toBe("channel"); + expect(cast.completesAt).toBe(expiry + 5_001); + expect(useCombatStore.getState().resource).toBeLessThan(before.resource); + }); }); diff --git a/src/game/wow335MageTalentRuntime.ts b/src/game/wow335MageTalentRuntime.ts index a44ab855..ab19ae33 100644 --- a/src/game/wow335MageTalentRuntime.ts +++ b/src/game/wow335MageTalentRuntime.ts @@ -1,6 +1,8 @@ import type { AuraDefinition } from "./combatAuras"; export const WOW335_MAGE_HOT_STREAK_TAG = "wow335-mage-hot-streak"; +export const WOW335_MAGE_ARCANE_CONCENTRATION_TAG = "wow335-mage-arcane-concentration"; +export const WOW335_MAGE_BRAIN_FREEZE_TAG = "wow335-mage-brain-freeze"; 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"; @@ -17,6 +19,45 @@ export const WOW335_MAGE_HOT_STREAK_AURA: AuraDefinition = { utilityTags: [WOW335_MAGE_HOT_STREAK_TAG], }; +export const WOW335_MAGE_ARCANE_CONCENTRATION_AURA: AuraDefinition = { + id: "wow335:mage:arcane-concentration-clearcasting", + name: "Clearcasting", + description: "Your next spell or ability costs no Mana, Energy, or Rage.", + disposition: "buff", + // The generated talent row omits the proc aura duration; its linked 3.3.5a + // Clearcasting child spell (12536) lasts 15 seconds. + durationMs: 15_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [WOW335_MAGE_ARCANE_CONCENTRATION_TAG], +}; + +export const WOW335_MAGE_BRAIN_FREEZE_TRIGGER_SPELLS: ReadonlySet = new Set([ + "Frostbolt", + "Frost Nova", + "Blizzard", + "Cone of Cold", + "Frozen Orb", +]); + +export const WOW335_MAGE_BRAIN_FREEZE_CONSUMER_SPELLS: ReadonlySet = new Set([ + "Fireball", + "Frostfire Bolt", +]); + +export const WOW335_MAGE_BRAIN_FREEZE_AURA: AuraDefinition = { + id: "wow335:mage:brain-freeze", + name: "Brain Freeze", + description: "Your next Fireball or Frostfire Bolt is instant, costs no Mana, and deals 25% more damage.", + disposition: "buff", + // The generated proc row links child spell 57761 but omits its duration; + // the linked 3.3.5a child lasts 15 seconds. + durationMs: 15_000, + maxStacks: 1, + stackBehavior: "refresh", + utilityTags: [WOW335_MAGE_BRAIN_FREEZE_TAG], +}; + export const WOW335_MAGE_FINGERS_OF_FROST_AURA: AuraDefinition = { id: "wow335:mage:fingers-of-frost", name: "Fingers of Frost", diff --git a/src/game/wow335MageTormentTheWeakRuntime.test.ts b/src/game/wow335MageTormentTheWeakRuntime.test.ts new file mode 100644 index 00000000..4c9ca65a --- /dev/null +++ b/src/game/wow335MageTormentTheWeakRuntime.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + applyTalentModifierValue, + talentModifiersForRanks, +} from "./talentModifiers"; +import { + wow335MageTormentTheWeakDamage, + wow335MageTormentTheWeakMultiplier, +} from "./wow335MageTormentTheWeakRuntime"; + +describe("WoW Mage Torment the Weak", () => { + it("uses the generated rank amount only for its named spells against an active slow", () => { + for (const [rank, expected] of [[1, 1.03], [2, 1.06], [3, 1.09]] as const) { + expect(wow335MageTormentTheWeakMultiplier(rank, "Fireball", [ + { kind: "slow", endsAt: 2_000 }, + ], 1_000)).toBeCloseTo(expected); + } + expect(wow335MageTormentTheWeakMultiplier(3, "Fireball", [ + { kind: "attack-speed", endsAt: 2_000 }, + ], 1_000)).toBeCloseTo(1.09); + expect(wow335MageTormentTheWeakMultiplier(3, "Fireball", [ + { kind: "slow", endsAt: 1_000 }, + ], 1_000)).toBe(1); + expect(wow335MageTormentTheWeakMultiplier(3, "Arcane Explosion", [ + { kind: "slow", endsAt: 2_000 }, + ], 1_000)).toBe(1); + expect(wow335MageTormentTheWeakDamage(101, 1, "Fireball", [ + { kind: "slow", endsAt: 2_000 }, + ], 1_000)).toBe(104); + }); + + it("does not expose the conditional bonus as an unconditional scalar modifier", () => { + const modifiers = talentModifiersForRanks({ "talent-2222": 3 }, "mage"); + expect(modifiers).toEqual([]); + expect(applyTalentModifierValue(100, modifiers, "damage-percent", { + abilityName: "Fireball", + tags: ["damage", "fire"], + })).toBe(100); + }); +}); diff --git a/src/game/wow335MageTormentTheWeakRuntime.ts b/src/game/wow335MageTormentTheWeakRuntime.ts new file mode 100644 index 00000000..392d6d5c --- /dev/null +++ b/src/game/wow335MageTormentTheWeakRuntime.ts @@ -0,0 +1,41 @@ +const TORMENT_THE_WEAK_SPELLS = new Set([ + "Frostbolt", + "Fireball", + "Frostfire Bolt", + "Pyroblast", + "Arcane Missiles", + "Arcane Blast", + "Arcane Barrage", +]); + +export const WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID = "talent-2222"; + +export interface Wow335MageTargetSlow { + readonly kind: string; + readonly endsAt: number; +} + +/** Returns the generated Torment the Weak bonus for a Mage hit on this target. */ +export function wow335MageTormentTheWeakMultiplier( + rank: number, + abilityName: string, + targetStatuses: readonly Wow335MageTargetSlow[], + now: number, +): number { + const normalizedRank = Math.max(0, Math.min(3, Math.trunc(rank))); + if (normalizedRank <= 0 || !TORMENT_THE_WEAK_SPELLS.has(abilityName)) return 1; + const targetIsSlowed = targetStatuses.some((status) => ( + (status.kind === "slow" || status.kind === "attack-speed") && status.endsAt > now + )); + return targetIsSlowed ? 1 + normalizedRank * 0.03 : 1; +} + +export function wow335MageTormentTheWeakDamage( + amount: number, + rank: number, + abilityName: string, + targetStatuses: readonly Wow335MageTargetSlow[], + now: number, +): number { + return Math.round(amount * wow335MageTormentTheWeakMultiplier(rank, abilityName, targetStatuses, now)); +} diff --git a/src/game/wow335PaladinAuraMasteryRuntime.ts b/src/game/wow335PaladinAuraMasteryRuntime.ts new file mode 100644 index 00000000..b4078f57 --- /dev/null +++ b/src/game/wow335PaladinAuraMasteryRuntime.ts @@ -0,0 +1,38 @@ +import type { ActiveAura } from "./combatAuras"; + +export const WOW335_PALADIN_AURA_MASTERY_EFFECTIVENESS_TAG = "wow335-aura-mastery-aura-effectiveness"; + +/** + * Aura Mastery doubles modifiers from the Paladin aura owned by the same + * Paladin on this recipient while the recipient has the temporary effect. + * Keep the source and target checks here so it cannot amplify another + * Paladin's aura or unrelated buffs on the same actor. + */ +export function wow335PaladinAuraMasteryAurasForEntity( + auras: readonly ActiveAura[], + entityId: string, +): ActiveAura[] { + const targetAuras = auras.filter((aura) => aura.targetId === entityId); + const auraMasterySources = new Set(targetAuras + .filter((aura) => aura.definition.utilityTags?.includes(WOW335_PALADIN_AURA_MASTERY_EFFECTIVENESS_TAG)) + .map((aura) => aura.sourceId)); + if (auraMasterySources.size === 0) return targetAuras; + + return targetAuras.map((aura) => { + if (aura.definition.exclusiveGroup !== "paladin:aura" + || !auraMasterySources.has(aura.sourceId) + || !aura.definition.modifiers?.length) { + return aura; + } + return { + ...aura, + definition: { + ...aura.definition, + modifiers: aura.definition.modifiers.map((modifier) => ({ + ...modifier, + value: modifier.value * 2, + })), + }, + }; + }); +} diff --git a/src/game/wow335PaladinBlessedLife.integration.test.ts b/src/game/wow335PaladinBlessedLife.integration.test.ts new file mode 100644 index 00000000..aac297c6 --- /dev/null +++ b/src/game/wow335PaladinBlessedLife.integration.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, +} from "./partyRuntimeRegistry"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { WOW335_PALADIN_BLESSED_LIFE_TALENT_ID } from "./wow335PaladinTalentRuntime"; + +const ATTACK_ID = "blessed-life-arcane-hit"; +const CAST_START_AT = 1_450; +const HIT_AT = 3_100; +const MISS = 0.04; +const CRIT = 0.05; +const BLESSED_LIFE_TAG = "wow335-paladin-blessed-life"; + +function stableUnit(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 mobIdForRoll(targetActorId: string, mode: "proc-hit" | "proc-miss" | "no-proc-hit"): string { + for (let index = 0; index < 100_000; index += 1) { + const mobId = `blessed-life-seed-${targetActorId}-${index}`; + const outcomeRoll = stableUnit(`${mobId}:${ATTACK_ID}:${targetActorId}:0:${HIT_AT}:outcome`); + const procRoll = stableUnit(`${targetActorId}:${mobId}:${ATTACK_ID}:0:${HIT_AT}:${BLESSED_LIFE_TAG}`); + const isHit = outcomeRoll >= MISS + CRIT && outcomeRoll < 1; + const isMiss = outcomeRoll < MISS; + if (mode === "proc-hit" && isHit && procRoll < 0.1) return mobId; + if (mode === "proc-miss" && isMiss && procRoll < 0.1) return mobId; + if (mode === "no-proc-hit" && isHit && procRoll >= 0.1) return mobId; + } + throw new Error(`Could not find a stable ${mode} seed for ${targetActorId}.`); +} + +function resetWorld(playerClassId: "paladin" | "mage", playerRank = 0): void { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: playerClassId, level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty(`blessed-life:${playerClassId}:${playerRank}`, 80, + useCombatStore.getState().health, "damage"); + useCombatStore.setState((state) => ({ + talentRanks: { + ...state.talentRanks, + [WOW335_PALADIN_BLESSED_LIFE_TALENT_ID]: playerRank, + }, + })); +} + +function registerArcaneAttacker(mobId: string): void { + useCombatStore.getState().registerMob(mobId, { + name: "Arcane attacker", + level: 80, + maxHealth: 100_000, + damageMultiplier: 4, + attacks: [{ + id: ATTACK_ID, + name: "Arcane attack", + delivery: "area", + target: "primary", + school: "arcane", + animation: "cast", + castTimeMs: 0, + range: 30, + cooldownMs: 500, + initialCooldownMs: [0, 0], + repeatCooldownMs: [500, 500], + damageMultiplier: 1, + }], + }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().damageMob(mobId, 1, 1_000); +} + +function playerDamageEvent(mobId: string) { + return useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.sourceActorId === mobId && event.targetActorId === PLAYER_AGGRO_ID + )); +} + +function runPlayerHit(rank: number, mobId: string) { + resetWorld("paladin", rank); + registerArcaneAttacker(mobId); + const healthBefore = useCombatStore.getState().health; + useCombatStore.getState().advanceMobCombat(CAST_START_AT); + useCombatStore.getState().advanceMobCombat(HIT_AT); + return { + event: playerDamageEvent(mobId), + healthBefore, + healthAfter: useCombatStore.getState().health, + }; +} + +function runPartyHit( + targetClassId: "paladin" | "mage", + targetRank: number, + playerRank: number, + mobId: string, + playerClassId: "paladin" | "mage" = "mage", +) { + resetWorld(playerClassId, playerRank); + const seed = "blessed-life-party-target"; + const target = createConfiguredPartyMember({ + name: targetClassId === "paladin" ? "Paladin target" : "Mage target", + classId: targetClassId, + specialization: targetClassId === "paladin" ? "Retribution" : "Frost", + specializationId: targetClassId === "paladin" ? "paladin:retribution" : "mage:frost", + talentTreeId: targetClassId === "paladin" ? "paladin-retribution" : "mage-frost", + talentRanks: { [WOW335_PALADIN_BLESSED_LIFE_TALENT_ID]: targetRank }, + role: "damage", + }, 80, 0, seed); + usePartyStore.setState({ members: [target] }); + registerPartyRuntimePosition(target.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + registerArcaneAttacker(mobId); + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + [mobId]: { + ...state.mobs[mobId]!, + targetActorId: target.id, + threatByActor: { [target.id]: 1_000 }, + forcedTarget: { actorId: target.id, endsAt: HIT_AT + 1_000 }, + }, + }, + })); + const healthBefore = useCombatStore.getState().actors[target.id]!.health; + useCombatStore.getState().advanceMobCombat(CAST_START_AT); + useCombatStore.getState().advanceMobCombat(HIT_AT); + const event = useCombatStore.getState().combatEvents.find((candidate) => ( + candidate.kind === "damage" && candidate.sourceActorId === mobId && candidate.targetActorId === target.id + )); + return { + target, + event, + healthBefore, + healthAfter: useCombatStore.getState().actors[target.id]?.health, + }; +} + +describe("WoW Paladin Blessed Life incoming hits", () => { + beforeEach(() => resetWorld("paladin")); + + it("halves a landed player Paladin hit on proc and leaves a same-seed non-proc unchanged", () => { + const mobId = mobIdForRoll(PLAYER_AGGRO_ID, "proc-hit"); + const baseline = runPlayerHit(0, mobId); + const proc = runPlayerHit(3, mobId); + + expect(baseline.event?.outcome).toBe("hit"); + expect(proc.event?.outcome).toBe("hit"); + expect(proc.event?.effectiveAmount).toBe(Math.round((baseline.event?.effectiveAmount ?? 0) / 2)); + expect(proc.healthBefore - proc.healthAfter).toBe(proc.event?.effectiveAmount); + + const noProcMobId = mobIdForRoll(PLAYER_AGGRO_ID, "no-proc-hit"); + const noProcBaseline = runPlayerHit(0, noProcMobId); + const noProcRankThree = runPlayerHit(3, noProcMobId); + expect(noProcRankThree.event?.outcome).toBe("hit"); + expect(noProcRankThree.event?.effectiveAmount).toBe(noProcBaseline.event?.effectiveAmount); + }); + + it("halves a landed hit on a party Paladin using that member's rank", () => { + const targetId = "party-damage-0-paladin"; + const mobId = mobIdForRoll(targetId, "proc-hit"); + const baseline = runPartyHit("paladin", 0, 0, mobId); + const proc = runPartyHit("paladin", 3, 0, mobId); + + expect(baseline.event?.outcome).toBe("hit"); + expect(proc.event?.outcome).toBe("hit"); + expect(proc.event?.effectiveAmount).toBe(Math.round((baseline.event?.effectiveAmount ?? 0) / 2)); + expect(proc.healthBefore - proc.healthAfter!).toBe(proc.event?.effectiveAmount); + }); + + it("does not proc on a miss or apply the player's talent to an unrelated party Mage", () => { + const missMobId = mobIdForRoll(PLAYER_AGGRO_ID, "proc-miss"); + const healthBefore = useCombatStore.getState().health; + const missed = runPlayerHit(3, missMobId); + expect(missed.event?.outcome).toBe("miss"); + expect(missed.event?.effectiveAmount).toBe(0); + expect(missed.healthAfter).toBe(missed.healthBefore); + expect(missed.healthBefore).toBe(healthBefore); + + const mageId = "party-damage-0-mage"; + const mobId = mobIdForRoll(mageId, "proc-hit"); + const withoutPaladinRank = runPartyHit("mage", 0, 0, mobId, "paladin"); + const withPaladinRank = runPartyHit("mage", 0, 3, mobId, "paladin"); + expect(withoutPaladinRank.event?.outcome).toBe("hit"); + expect(withPaladinRank.event?.effectiveAmount).toBe(withoutPaladinRank.event?.effectiveAmount); + }); +}); diff --git a/src/game/wow335PaladinRuntime.test.ts b/src/game/wow335PaladinRuntime.test.ts index d246995b..25f0e837 100644 --- a/src/game/wow335PaladinRuntime.test.ts +++ b/src/game/wow335PaladinRuntime.test.ts @@ -1,10 +1,11 @@ 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 { characterCombatDefender, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { usePartyStore } from "./partyStore"; import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { applyAura } from "./combatAuras"; +import { EMPTY_ITEM_STATS } from "./itemStats"; import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; function abilityId(name: string): string { @@ -97,6 +98,81 @@ describe("WoW 3.3.5 Paladin source-backed runtime mechanics", () => { expect(useCombatStore.getState().controlledUntil).toBe(14_450); }); + it("makes Aura Mastery immune to incoming interrupts while a spell is casting", () => { + const mobId = "paladin-aura-mastery-interrupt"; + useCombatStore.getState().registerMob(mobId, { + name: "Interrupt attacker", + maxHealth: 10_000, + attacks: [{ + id: "interrupt", + name: "Interrupt", + 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: "interrupt", 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 }); + const playerAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.controlImmunities?.includes("interrupt") + )); + expect(playerAura).toBeDefined(); + + const member = usePartyStore.getState().members[0]!; + updatePartyRuntimePosition(member.id, 4, 0, 0); + useCombatStore.getState().synchronizeActors(); + const holyLight = useCombatStore.getState().castAbility(abilityId("Holy Light"), { + targetActorId: member.id, + now: 2_500, + }); + expect(holyLight).toMatchObject({ ok: true }); + expect(useCombatStore.getState().activeCast).not.toBeNull(); + + useCombatStore.getState().advanceMobCombat(2_600); + expect(useCombatStore.getState().controlMechanic).toBeNull(); + expect(useCombatStore.getState().activeCast).not.toBeNull(); + }); + + it("doubles the active Paladin aura on each Aura Mastery recipient for the generated spell duration", () => { + const armor = () => characterCombatDefender(useCombatStore.getState()).armor ?? 0; + const baseArmor = armor(); + expect(useCombatStore.getState().castAbility(abilityId("Devotion Aura"), undefined, 1_000)) + .toMatchObject({ ok: true }); + expect(armor() - baseArmor).toBe(1_205); + + const mastery = useCombatStore.getState().castAbility( + unlockTalentAbility("Aura Mastery"), + undefined, + 2_500, + ); + expect(mastery).toMatchObject({ ok: true }); + + const partyMember = usePartyStore.getState().members[0]!; + const masteryAuras = useCombatStore.getState().auras.filter((aura) => ( + aura.definition.utilityTags?.includes("wow335-aura-mastery-aura-effectiveness") + )); + for (const targetId of [PLAYER_AURA_ENTITY_ID, partyMember.id]) { + expect(masteryAuras.some((aura) => aura.targetId === targetId && aura.expiresAt === 12_500)).toBe(true); + } + expect(armor() - baseArmor).toBe(2_410); + + useCombatStore.getState().tick(0.1, 12_501); + expect(armor() - baseArmor).toBe(1_205); + }); + 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) => ({ @@ -162,6 +238,173 @@ describe("WoW 3.3.5 Paladin source-backed runtime mechanics", () => { expect(result).toMatchObject({ ok: false, reason: "condition-not-met" }); }); + it("adds Redoubt's generated shield block chance only while a shield is equipped", () => { + setTalentRank("Redoubt", 2); + const withoutShield = characterCombatDefender(useCombatStore.getState()).blockPercent ?? 0; + const shieldId = "paladin-redoubt-block-test"; + const shield = { + instanceId: shieldId, + itemId: 1, + itemKey: shieldId, + name: "Redoubt Test Shield", + quality: "uncommon" as const, + inventorySlot: "off-hand", + armorType: "shield", + weaponType: null, + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...EMPTY_ITEM_STATS, blockValue: 40 }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + useCombatStore.setState((state) => ({ + inventory: [shield], + equipment: { player: { "off-hand": shieldId } }, + gearStats: { ...state.gearStats, blockValue: 40 }, + })); + const withShield = characterCombatDefender(useCombatStore.getState()).blockPercent ?? 0; + + expect(withShield - withoutShield).toBe(12); + }); + + it("procs Redoubt from a shield block with the generated rank values and duration", () => { + setTalentRank("Redoubt", 3); + const shieldId = "paladin-redoubt-proc-test"; + const shield = { + instanceId: shieldId, + itemId: 1, + itemKey: shieldId, + name: "Redoubt Proc Shield", + quality: "uncommon" as const, + inventorySlot: "off-hand", + armorType: "shield", + weaponType: null, + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...EMPTY_ITEM_STATS, blockRating: 1_000_000, blockValue: 40 }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + useCombatStore.setState((state) => ({ + inventory: [shield], + equipment: { player: { "off-hand": shieldId } }, + gearStats: { ...state.gearStats, blockRating: 1_000_000, blockValue: 40 }, + })); + const mobId = "paladin-redoubt-proc-attacker"; + useCombatStore.getState().registerMob(mobId, { + name: "Redoubt attacker", + level: 80, + maxHealth: 100_000, + attacks: [{ + id: "redoubt-proc-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); + + let redoubt = undefined as ReturnType["auras"][number] | undefined; + for (let now = 1_450; now <= 30_000; now += 250) { + useCombatStore.getState().advanceMobCombat(now); + redoubt = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes("wow335-paladin-redoubt") + )); + if (redoubt) break; + } + + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.targetActorId === PLAYER_AURA_ENTITY_ID && event.outcome === "block" + ))).toBe(true); + expect(redoubt).toBeDefined(); + expect(redoubt?.expiresAt).toBe(redoubt!.appliedAt + 10_000); + expect(redoubt?.definition.modifiers).toContainEqual({ + kind: "damage", direction: "taken", operation: "percent", value: -0.06, + }); + expect(redoubt?.definition.modifiers).toContainEqual({ + kind: "stat", stat: "strength", operation: "percent", value: 0.1, + }); + }); + + it("spends Holy Shield's eight source-defined charges on successful blocks", () => { + const shieldId = "paladin-holy-shield-charge-test"; + const shield = { + instanceId: shieldId, + itemId: 1, + itemKey: shieldId, + name: "Test Shield", + quality: "uncommon" as const, + inventorySlot: "off-hand", + armorType: "shield", + weaponType: null, + itemLevel: 1, + sourceItemLevel: 1, + stats: { ...EMPTY_ITEM_STATS, blockRating: 1_000_000, blockValue: 40 }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }; + useCombatStore.setState((state) => ({ + inventory: [shield], + equipment: { player: { "off-hand": shieldId } }, + gearStats: { ...state.gearStats, blockRating: 1_000_000, blockValue: 40 }, + })); + + const targetId = "paladin-holy-shield-charge-target"; + useCombatStore.getState().registerMob(targetId, { + name: "Melee attacker", + level: 80, + maxHealth: 100_000, + attacks: [{ + id: "holy-shield-charge-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(targetId, [2, 0, 0]); + useCombatStore.getState().damageMob(targetId, 1, 1_000); + + const holyShieldId = unlockTalentAbility("Holy Shield"); + const cast = useCombatStore.getState().castAbility(holyShieldId, undefined, 1_000); + expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); + const findHolyShieldProc = () => useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.procs?.some((proc) => proc.id.endsWith(":holy-shield")) + )); + expect(findHolyShieldProc()?.procStates[0]?.remainingCharges).toBe(8); + + for (let now = 1_450; now <= 10_000; now += 250) { + useCombatStore.getState().advanceMobCombat(now); + if (findHolyShieldProc()?.procStates[0]?.remainingCharges === 0) break; + } + + expect(findHolyShieldProc()?.procStates[0]?.remainingCharges).toBe(0); + }); + 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"; diff --git a/src/game/wow335PaladinTalentRuntime.test.ts b/src/game/wow335PaladinTalentRuntime.test.ts new file mode 100644 index 00000000..d62f77bf --- /dev/null +++ b/src/game/wow335PaladinTalentRuntime.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { resolveDamageValue, resolveStatValue } from "./combatAuras"; +import { + WOW335_PALADIN_AURA_MASTERY_EFFECTIVENESS_TAG, + wow335PaladinAuraMasteryAurasForEntity, +} from "./wow335PaladinAuraMasteryRuntime"; +import { + wow335PaladinBlessedLifeDamageMultiplierForRoll, + wow335PaladinBlessedLifeProfile, + wow335PaladinRedoubtBuff, + wow335PaladinRedoubtProfile, + wow335PaladinVindicationProfile, +} from "./wow335PaladinTalentRuntime"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; +import { talentUsesExplicitActivation } from "./talentModifiers"; + +describe("WoW 3.3.5 Paladin talent tooltip mechanics", () => { + it("resolves Blessed Life's generated 4/7/10% chances and half-damage result", () => { + const blessedLife = WOW335_TALENT_NODES.find((talent) => talent.id === "talent-1744"); + expect(blessedLife?.name).toBe("Blessed Life"); + expect([1, 2, 3].map((rank) => wow335PaladinBlessedLifeProfile(rank))).toEqual([ + { rank: 1, procChance: 0.04, incomingDamageMultiplier: 0.5 }, + { rank: 2, procChance: 0.07, incomingDamageMultiplier: 0.5 }, + { rank: 3, procChance: 0.1, incomingDamageMultiplier: 0.5 }, + ]); + expect([1, 2, 3].map((rank) => wow335PaladinBlessedLifeDamageMultiplierForRoll(rank, 0))).toEqual([ + 0.5, 0.5, 0.5, + ]); + expect([1, 2, 3].map((rank) => wow335PaladinBlessedLifeDamageMultiplierForRoll(rank, [0.04, 0.07, 0.1][rank - 1]!))) + .toEqual([1, 1, 1]); + expect(wow335PaladinBlessedLifeProfile(0)).toBeNull(); + expect(wow335PaladinBlessedLifeDamageMultiplierForRoll(3, 1)).toBe(1); + }); + + it("doubles only the same Paladin's aura modifiers on an Aura Mastery recipient", () => { + const active = [ + { + instanceId: "mastery", + definition: { + id: "mastery", + name: "Aura Mastery", + disposition: "buff" as const, + durationMs: 10_000, + utilityTags: [WOW335_PALADIN_AURA_MASTERY_EFFECTIVENESS_TAG], + }, + sourceId: "paladin-one", + targetId: "recipient", + appliedAt: 1_000, + expiresAt: 11_000, + stacks: 1, + absorbs: [], + procStates: [], + }, + { + instanceId: "own-paladin-aura", + definition: { + id: "own-paladin-aura", + name: "Devotion Aura", + disposition: "buff" as const, + durationMs: null, + exclusiveGroup: "paladin:aura", + modifiers: [{ kind: "stat" as const, stat: "armor", operation: "flat" as const, value: 55 }], + }, + sourceId: "paladin-one", + targetId: "recipient", + appliedAt: 500, + expiresAt: null, + stacks: 1, + absorbs: [], + procStates: [], + }, + { + instanceId: "other-paladin-aura", + definition: { + id: "other-paladin-aura", + name: "Devotion Aura from another Paladin", + disposition: "buff" as const, + durationMs: null, + exclusiveGroup: "paladin:aura", + modifiers: [{ kind: "stat" as const, stat: "armor", operation: "flat" as const, value: 55 }], + }, + sourceId: "paladin-two", + targetId: "recipient", + appliedAt: 500, + expiresAt: null, + stacks: 1, + absorbs: [], + procStates: [], + }, + { + instanceId: "unrelated-aura", + definition: { + id: "unrelated-aura", + name: "Mage Armor", + disposition: "buff" as const, + durationMs: null, + exclusiveGroup: "mage:armor", + modifiers: [{ kind: "stat" as const, stat: "armor", operation: "flat" as const, value: 7 }], + }, + sourceId: "paladin-one", + targetId: "recipient", + appliedAt: 500, + expiresAt: null, + stacks: 1, + absorbs: [], + procStates: [], + }, + ]; + + const mastered = wow335PaladinAuraMasteryAurasForEntity(active, "recipient"); + expect(resolveStatValue(100, mastered, "armor")).toBe(272); + expect(resolveStatValue(100, wow335PaladinAuraMasteryAurasForEntity(active, "other-recipient"), "armor")) + .toBe(100); + expect(resolveStatValue(100, active.filter((aura) => aura.instanceId !== "mastery"), "armor")) + .toBe(217); + }); + + it("reads Redoubt ranks from their generated descriptions without making its proc passive", () => { + const redoubt = WOW335_TALENT_NODES.find((talent) => talent.name === "Redoubt"); + expect(redoubt).toBeDefined(); + expect(redoubt!.rankDescriptions.map((description) => wow335PaladinRedoubtProfile(description))).toEqual([ + { blockChancePercent: 4, procChance: 0.1, strengthPercent: 4, damageTakenReductionPercent: 2, durationMs: 10_000 }, + { blockChancePercent: 7, procChance: 0.1, strengthPercent: 7, damageTakenReductionPercent: 4, durationMs: 10_000 }, + { blockChancePercent: 10, procChance: 0.1, strengthPercent: 10, damageTakenReductionPercent: 6, durationMs: 10_000 }, + ]); + expect(talentUsesExplicitActivation(redoubt!.name)).toBe(true); + }); + + it("uses local Vindication chances and level formula with only the missing 10-second duration filled from WotLKDB", () => { + expect([1, 2, 3].map((rank) => wow335PaladinVindicationProfile(rank, 80))).toEqual([ + { rank: 1, procChance: 0.17, attackPowerReduction: 357, durationMs: 10_000 }, + { rank: 2, procChance: 0.34, attackPowerReduction: 357, durationMs: 10_000 }, + { rank: 3, procChance: 0.65, attackPowerReduction: 357, durationMs: 10_000 }, + ]); + expect(wow335PaladinVindicationProfile(3, 10)?.attackPowerReduction).toBe(42); + expect(wow335PaladinVindicationProfile(0, 80)).toBeNull(); + expect(wow335PaladinVindicationProfile(3, 10_000)?.attackPowerReduction).toBe(357); + }); + + it("applies the generated Strength and damage reduction only through the proc aura", () => { + const profile = wow335PaladinRedoubtProfile( + WOW335_TALENT_NODES.find((talent) => talent.name === "Redoubt")!.rankDescriptions[2], + ); + expect(profile).not.toBeNull(); + const buff = wow335PaladinRedoubtBuff(profile!, 200); + const active = [{ + instanceId: "redoubt", + definition: buff, + sourceId: "player", + targetId: "player", + appliedAt: 1, + expiresAt: 10_001, + stacks: 1, + absorbs: [], + procStates: [], + }]; + expect(resolveStatValue(200, active, "strength")).toBeCloseTo(220); + expect(resolveStatValue(400, active, "attack-power")).toBe(440); + expect(resolveDamageValue(100, active, { direction: "taken", school: "physical", attackKind: "melee" })).toBe(94); + }); +}); diff --git a/src/game/wow335PaladinTalentRuntime.ts b/src/game/wow335PaladinTalentRuntime.ts new file mode 100644 index 00000000..8b9474e4 --- /dev/null +++ b/src/game/wow335PaladinTalentRuntime.ts @@ -0,0 +1,121 @@ +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export interface WoW335PaladinRedoubtProfile { + readonly blockChancePercent: number; + readonly procChance: number; + readonly strengthPercent: number; + readonly damageTakenReductionPercent: number; + readonly durationMs: number; +} + +export interface WoW335PaladinVindicationProfile { + readonly rank: number; + readonly procChance: number; + readonly attackPowerReduction: number; + readonly durationMs: number; +} + +export interface WoW335PaladinBlessedLifeProfile { + readonly rank: number; + readonly procChance: number; + readonly incomingDamageMultiplier: number; +} + +export const WOW335_PALADIN_BLESSED_LIFE_TALENT_ID = "talent-1744"; +const WOW335_PALADIN_VINDICATION_TALENT_ID = "talent-1633"; +// The local child aura row has no duration; WotLKDB spell 67 supplies 10 sec. +// https://wotlkdb.com/?spell=67 +const WOW335_PALADIN_VINDICATION_DURATION_MS = 10_000; + +/** Resolve only the generated rank chance and half-damage payload for Blessed Life. */ +export function wow335PaladinBlessedLifeProfile(talentRank: number): WoW335PaladinBlessedLifeProfile | null { + const rank = Math.min(3, Math.max(0, Math.trunc(Number.isFinite(talentRank) ? talentRank : 0))); + if (rank === 0) return null; + const talent = WOW335_TALENT_NODES.find((node) => node.id === WOW335_PALADIN_BLESSED_LIFE_TALENT_ID); + const rankEffect = talent?.rankEffects[rank - 1]; + const rawDescription = talent?.rawRankDescriptions[rank - 1] ?? ""; + const descriptionChance = rawDescription.match(/All attacks against you have a \$h% chance to cause half damage\./i); + const displayedChance = talent?.rankDescriptions[rank - 1]?.match( + /All attacks against you have a (\d+(?:\.\d+)?)% chance to cause half damage\./i, + ); + if (!rankEffect || !descriptionChance || !displayedChance + || Number(displayedChance[1]) !== rankEffect.procChance) return null; + return { + rank, + procChance: Math.min(1, Math.max(0, rankEffect.procChance / 100)), + incomingDamageMultiplier: 0.5, + }; +} + +/** Apply the tooltip's half-damage result to an incoming attack roll. */ +export function wow335PaladinBlessedLifeDamageMultiplierForRoll(talentRank: number, roll: number): number { + const profile = wow335PaladinBlessedLifeProfile(talentRank); + if (!profile || !Number.isFinite(roll) || roll < 0 || roll >= 1) return 1; + return roll < profile.procChance ? profile.incomingDamageMultiplier : 1; +} + +/** + * Resolve the generated Vindication rank chance and raw level-scaled tooltip. + * WotLKDB's child AP value differs from the local tooltip formula, so this + * deliberately uses only local values for proc chance and amount. + */ +export function wow335PaladinVindicationProfile( + talentRank: number, + playerLevel: number, +): WoW335PaladinVindicationProfile | null { + const rank = Math.min(3, Math.max(0, Math.trunc(Number.isFinite(talentRank) ? talentRank : 0))); + if (rank === 0) return null; + const talent = WOW335_TALENT_NODES.find((node) => node.id === WOW335_PALADIN_VINDICATION_TALENT_ID); + const rankEffect = talent?.rankEffects[rank - 1]; + const rawDescription = talent?.rawRankDescriptions[rank - 1] ?? ""; + if (!rankEffect || !/\$\{\s*4\.5\s*\*\s*\$PL\s*-\s*3\s*\}/.test(rawDescription)) return null; + const level = Math.min(80, Math.max(1, Math.trunc(Number.isFinite(playerLevel) ? playerLevel : 1))); + return { + rank, + procChance: Math.min(1, Math.max(0, rankEffect.procChance / 100)), + attackPowerReduction: Math.max(0, 4.5 * level - 3), + durationMs: WOW335_PALADIN_VINDICATION_DURATION_MS, + }; +} + +/** Read the rank values from the generated tooltip so local spell rows cannot override them. */ +export function wow335PaladinRedoubtProfile( + rankDescription: string | undefined, +): WoW335PaladinRedoubtProfile | null { + if (!rankDescription) return null; + const blockChance = rankDescription.match(/chance to block attacks with a shield by (\d+(?:\.\d+)?)%/i); + const proc = rankDescription.match( + /blocking an attack has a (\d+(?:\.\d+)?)% chance to increase your strength by (\d+(?:\.\d+)?)% and reduce your damage taken by (\d+(?:\.\d+)?)%\. lasts (\d+(?:\.\d+)?) seconds/i, + ); + if (!blockChance || !proc) return null; + return { + blockChancePercent: Number(blockChance[1]), + procChance: Number(proc[1]) / 100, + strengthPercent: Number(proc[2]), + damageTakenReductionPercent: Number(proc[3]), + durationMs: Number(proc[4]) * 1_000, + }; +} + +export function wow335PaladinRedoubtBuff( + profile: WoW335PaladinRedoubtProfile, + strength: number, +) { + const gainedStrength = Math.round(Math.max(0, strength) * profile.strengthPercent / 100); + return { + id: "wow335:paladin:redoubt", + name: "Redoubt", + description: `Increases your Strength by ${profile.strengthPercent}% and reduces your damage taken by ${profile.damageTakenReductionPercent}% for ${profile.durationMs / 1_000} seconds.`, + disposition: "buff" as const, + durationMs: profile.durationMs, + maxStacks: 1, + stackBehavior: "refresh" as const, + utilityTags: ["wow335-paladin-redoubt"], + modifiers: [ + { kind: "stat" as const, stat: "strength", operation: "percent" as const, value: profile.strengthPercent / 100 }, + // Paladins gain two attack power for each point of Strength. + { kind: "stat" as const, stat: "attack-power", operation: "flat" as const, value: gainedStrength * 2 }, + { kind: "damage" as const, direction: "taken" as const, operation: "percent" as const, value: -profile.damageTakenReductionPercent / 100 }, + ], + }; +} diff --git a/src/game/wow335PriestFocusedWill.integration.test.ts b/src/game/wow335PriestFocusedWill.integration.test.ts new file mode 100644 index 00000000..7af55748 --- /dev/null +++ b/src/game/wow335PriestFocusedWill.integration.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { applyAura as applyCombatAura } from "./combatAuras"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { + WOW335_PRIEST_FOCUSED_WILL_AURA_ID, + WOW335_PRIEST_FOCUSED_WILL_TALENT_ID, +} from "./wow335PriestFocusedWillRuntime"; + +const GUARANTEED_CRITICAL_AURA = { + id: "test:focused-will-guaranteed-incoming-critical", + name: "Guaranteed incoming critical hit", + disposition: "buff" as const, + durationMs: null, + modifiers: [ + { kind: "stat" as const, stat: "incoming-critical-strike", operation: "flat" as const, value: 100 }, + { kind: "stat" as const, stat: "incoming-melee-hit", operation: "flat" as const, value: 100 }, + ], +}; + +function preparePriestPlayer(rank: number): void { + const talentRanks: Record = rank > 0 + ? { [WOW335_PRIEST_FOCUSED_WILL_TALENT_ID]: rank } + : {}; + useCombatStore.setState({ + talentRanks, + talentModifiers: talentModifiersForRanks(talentRanks, "priest"), + }); + useCombatStore.setState((state) => ({ + auras: [...applyCombatAura(state.auras, { + definition: GUARANTEED_CRITICAL_AURA, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: 1_000, + }).auras], + })); +} + +function preparePriestCompanion(memberId: string, rank: number): void { + const talentRanks: Record = rank > 0 + ? { [WOW335_PRIEST_FOCUSED_WILL_TALENT_ID]: rank } + : {}; + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === memberId + ? { + ...member, + classId: "priest", + talentRanks, + } + : member), + })); + useCombatStore.getState().synchronizeActors(); + useCombatStore.setState((state) => ({ + auras: [...applyCombatAura(state.auras, { + definition: GUARANTEED_CRITICAL_AURA, + sourceId: memberId, + targetId: memberId, + now: 1_000, + }).auras], + })); +} + +function launchCriticalSpellAt(targetId?: string): number { + const mobId = targetId ? "focused-will-party-attacker" : "focused-will-player-attacker"; + const attack = { + id: `${mobId}-shadow-bolt`, + name: "Shadow Bolt", + delivery: "projectile" as const, + target: "primary" as const, + school: "shadow" as const, + animation: "cast" as const, + castTimeMs: 0, + range: 30, + cooldownMs: 1_900, + damageMultiplier: 1, + initialCooldownMs: [0, 0] as const, + repeatCooldownMs: [1_900, 1_900] as const, + }; + useCombatStore.getState().registerMob(mobId, { + name: "Focused Will test attacker", + level: 80, + maxHealth: 100_000, + xpReward: 0, + attacks: [attack], + }); + setMobPosition(mobId, [4, 0, 0]); + const actorId = targetId ?? PLAYER_AURA_ENTITY_ID; + const actor = targetId + ? usePartyStore.getState().members.find((member) => member.id === targetId) + : undefined; + useCombatStore.getState().damageMob(mobId, 1, 1_000, actor ? { actorId: actor.id, role: actor.role } : undefined); + const findIncoming = () => [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.sourceActorId === mobId && event.targetActorId === actorId + )); + let incoming = findIncoming(); + for (let now = 1_450; !incoming && now <= 6_000; now += 250) { + useCombatStore.getState().advanceMobCombat(now, [0, 0, 0]); + incoming = findIncoming(); + } + expect(incoming, JSON.stringify({ events: useCombatStore.getState().combatEvents, mob: useCombatStore.getState().mobs[mobId] })) + .toMatchObject({ critical: true }); + return incoming!.occurredAt; +} + +describe("WoW Priest Focused Will combat hooks", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-priest-focused-will", 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("adds a healing-taken stack to the Priest after a landed player critical hit", () => { + preparePriestPlayer(1); + const hitAt = launchCriticalSpellAt(); + + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.targetId === PLAYER_AURA_ENTITY_ID && candidate.definition.id === WOW335_PRIEST_FOCUSED_WILL_AURA_ID + )); + expect(aura).toMatchObject({ stacks: 1, appliedAt: hitAt, expiresAt: hitAt + 10_000 }); + }); + + it("adds a healing-taken stack to a Priest companion after that member is critically hit", () => { + const member = usePartyStore.getState().members[0]!; + preparePriestCompanion(member.id, 1); + const hitAt = launchCriticalSpellAt(member.id); + + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.targetId === member.id && candidate.definition.id === WOW335_PRIEST_FOCUSED_WILL_AURA_ID + )); + expect(aura).toMatchObject({ stacks: 1, appliedAt: hitAt, expiresAt: hitAt + 10_000 }); + }); + + it("does not grant the effect when the player has not learned Focused Will", () => { + preparePriestPlayer(0); + launchCriticalSpellAt(); + expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === WOW335_PRIEST_FOCUSED_WILL_AURA_ID)) + .toBe(false); + }); +}); diff --git a/src/game/wow335PriestFocusedWillRuntime.test.ts b/src/game/wow335PriestFocusedWillRuntime.test.ts new file mode 100644 index 00000000..48dcc234 --- /dev/null +++ b/src/game/wow335PriestFocusedWillRuntime.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { applyAura as applyCombatAura, type ActiveAura, resolveHealingValue } from "./combatAuras"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { + WOW335_PRIEST_FOCUSED_WILL_AURA_ID, + WOW335_PRIEST_FOCUSED_WILL_AURA_TAG, + WOW335_PRIEST_FOCUSED_WILL_DURATION_MS, + WOW335_PRIEST_FOCUSED_WILL_MAX_STACKS, + WOW335_PRIEST_FOCUSED_WILL_TALENT_ID, + wow335PriestFocusedWillAuraDefinition, + wow335PriestFocusedWillProfile, + wow335PriestFocusedWillShouldProc, +} from "./wow335PriestFocusedWillRuntime"; + +describe("WoW Priest Focused Will profile", () => { + it.each([ + { rank: 1, spellCritPercent: 1 }, + { rank: 2, spellCritPercent: 2 }, + { rank: 3, spellCritPercent: 3 }, + ])("preserves rank $rank's generated spell crit and capstone payload", ({ rank, spellCritPercent }) => { + const profile = wow335PriestFocusedWillProfile(rank); + expect(profile).toMatchObject({ + rank, + spellCriticalChanceBonusPercent: spellCritPercent, + healingTakenBonusPerStack: 0.03, + durationMs: WOW335_PRIEST_FOCUSED_WILL_DURATION_MS, + maxStacks: WOW335_PRIEST_FOCUSED_WILL_MAX_STACKS, + }); + const modifiers = talentModifiersForRanks({ [WOW335_PRIEST_FOCUSED_WILL_TALENT_ID]: rank }, "priest"); + expect(modifiers).toContainEqual(expect.objectContaining({ + kind: "critical-chance-percent", + amount: spellCritPercent, + sourceTalentId: WOW335_PRIEST_FOCUSED_WILL_TALENT_ID, + })); + expect(modifiers.filter((modifier) => modifier.kind === "healing-percent")).toEqual([]); + }); + + it("does not enable the proc without a learned rank or on a miss/noncritical hit", () => { + expect(wow335PriestFocusedWillProfile(0)).toMatchObject({ + rank: 0, + spellCriticalChanceBonusPercent: 0, + healingTakenBonusPerStack: 0, + durationMs: 0, + maxStacks: 0, + }); + expect(wow335PriestFocusedWillShouldProc(0, true, true)).toBe(false); + expect(wow335PriestFocusedWillShouldProc(1, false, true)).toBe(false); + expect(wow335PriestFocusedWillShouldProc(1, true, false)).toBe(false); + expect(wow335PriestFocusedWillShouldProc(1, true, true)).toBe(true); + expect(wow335PriestFocusedWillAuraDefinition(0)).toBeNull(); + }); + + it("adds up to three healing-taken stacks and refreshes the 10-second duration", () => { + const definition = wow335PriestFocusedWillAuraDefinition(1); + expect(definition).toMatchObject({ + id: WOW335_PRIEST_FOCUSED_WILL_AURA_ID, + durationMs: WOW335_PRIEST_FOCUSED_WILL_DURATION_MS, + maxStacks: WOW335_PRIEST_FOCUSED_WILL_MAX_STACKS, + stackBehavior: "add", + utilityTags: [WOW335_PRIEST_FOCUSED_WILL_AURA_TAG], + modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: 0.03, perStack: true }], + }); + if (!definition) throw new Error("Missing learned Focused Will aura."); + + let auras: readonly ActiveAura[] = []; + for (let critical = 1; critical <= WOW335_PRIEST_FOCUSED_WILL_MAX_STACKS + 2; critical += 1) { + auras = applyCombatAura(auras, { + definition, + sourceId: "priest-target", + targetId: "priest-target", + now: critical * 1_000, + }).auras; + } + + expect(auras).toHaveLength(1); + expect(auras[0]).toMatchObject({ + sourceId: "priest-target", + targetId: "priest-target", + stacks: WOW335_PRIEST_FOCUSED_WILL_MAX_STACKS, + appliedAt: 5_000, + expiresAt: 5_000 + WOW335_PRIEST_FOCUSED_WILL_DURATION_MS, + definition: { id: WOW335_PRIEST_FOCUSED_WILL_AURA_ID }, + }); + expect(resolveHealingValue(1_000, auras, "taken")).toBe(1_090); + }); + + it("normalizes invalid and out-of-range ranks", () => { + expect(wow335PriestFocusedWillProfile(Number.NaN).rank).toBe(0); + expect(wow335PriestFocusedWillProfile(-2).rank).toBe(0); + expect(wow335PriestFocusedWillProfile(9).spellCriticalChanceBonusPercent).toBe(3); + }); +}); diff --git a/src/game/wow335PriestFocusedWillRuntime.ts b/src/game/wow335PriestFocusedWillRuntime.ts new file mode 100644 index 00000000..e406a55e --- /dev/null +++ b/src/game/wow335PriestFocusedWillRuntime.ts @@ -0,0 +1,64 @@ +import type { AuraDefinition } from "./combatAuras"; + +export const WOW335_PRIEST_FOCUSED_WILL_TALENT_ID = "talent-1858"; +export const WOW335_PRIEST_FOCUSED_WILL_AURA_ID = "wow335:priest:focused-will"; +export const WOW335_PRIEST_FOCUSED_WILL_AURA_TAG = "wow335-priest-focused-will"; +export const WOW335_PRIEST_FOCUSED_WILL_DURATION_MS = 10_000; +export const WOW335_PRIEST_FOCUSED_WILL_MAX_STACKS = 3; + +const SPELL_CRITICAL_CHANCE_BONUS_BY_RANK = [0, 1, 2, 3] as const; +const HEALING_TAKEN_BONUS_PER_STACK = 0.03; + +export interface Wow335PriestFocusedWillProfile { + readonly rank: number; + readonly spellCriticalChanceBonusPercent: number; + readonly healingTakenBonusPerStack: number; + readonly durationMs: number; + readonly maxStacks: number; +} + +function normalizedRank(rank: number): number { + return Number.isFinite(rank) ? Math.max(0, Math.min(3, Math.trunc(rank))) : 0; +} + +/** Local generated rank tooltip: +1/2/3% spell crit, plus the 3%-per-stack capstone. */ +export function wow335PriestFocusedWillProfile(rank: number): Wow335PriestFocusedWillProfile { + const learnedRank = normalizedRank(rank); + return { + rank: learnedRank, + spellCriticalChanceBonusPercent: SPELL_CRITICAL_CHANCE_BONUS_BY_RANK[learnedRank] ?? 0, + healingTakenBonusPerStack: learnedRank > 0 ? HEALING_TAKEN_BONUS_PER_STACK : 0, + durationMs: learnedRank > 0 ? WOW335_PRIEST_FOCUSED_WILL_DURATION_MS : 0, + maxStacks: learnedRank > 0 ? WOW335_PRIEST_FOCUSED_WILL_MAX_STACKS : 0, + }; +} + +/** Focused Will procs only from a landed incoming critical hit while learned. */ +export function wow335PriestFocusedWillShouldProc( + rank: number, + landed: boolean, + critical: boolean, +): boolean { + return normalizedRank(rank) > 0 && landed && critical; +} + +export function wow335PriestFocusedWillAuraDefinition(rank: number): AuraDefinition | null { + const profile = wow335PriestFocusedWillProfile(rank); + if (profile.rank <= 0) return null; + return { + id: WOW335_PRIEST_FOCUSED_WILL_AURA_ID, + name: "Focused Will", + disposition: "buff", + durationMs: profile.durationMs, + maxStacks: profile.maxStacks, + stackBehavior: "add", + utilityTags: [WOW335_PRIEST_FOCUSED_WILL_AURA_TAG], + modifiers: [{ + kind: "healing", + direction: "taken", + operation: "percent", + value: profile.healingTakenBonusPerStack, + perStack: true, + }], + }; +} diff --git a/src/game/wow335PriestLightwellRuntime.test.ts b/src/game/wow335PriestLightwellRuntime.test.ts new file mode 100644 index 00000000..0c16e9ea --- /dev/null +++ b/src/game/wow335PriestLightwellRuntime.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog"; +import { + wow335LightwellDamageCancellationFraction, + wow335LightwellRenewProfileForCreature, +} from "./wow335PriestLightwellRuntime"; + +describe("WoW 3.3.5 Lightwell Renew source payload", () => { + it("maps each Lightwell rank to its extracted child-heal tick", () => { + const expected = [ + [31897, 7001, 267, 0.3333], + [31896, 27873, 388, 0.3333], + [31895, 27874, 533, 0.3333], + [31894, 28276, 787, 0.3333], + [31893, 48084, 1_305, 0.3333], + [31883, 48085, 1_540, undefined], + ] as const; + + for (const [creatureId, childSpellId, baseHealPerTick, coefficient] of expected) { + expect(wow335LightwellRenewProfileForCreature(creatureId)).toEqual({ + childSpellId, + baseHealPerTick, + ticks: 3, + intervalMs: 2_000, + durationMs: 6_000, + ...(coefficient === undefined ? {} : { spellPowerCoefficientPerTick: coefficient }), + }); + } + }); + + it("does not guess a heal payload for an unknown Lightwell summon", () => { + expect(wow335LightwellRenewProfileForCreature(0)).toBeNull(); + expect(wow335LightwellRenewProfileForCreature(999_999)).toBeNull(); + }); + + it("reads the 30% damage cancellation threshold from every generated Lightwell rank tooltip", () => { + const lightwell = abilitiesForClass("priest").find((ability) => ability.name === "Lightwell"); + expect(lightwell).toBeDefined(); + for (const level of [40, 50, 60, 70, 75, 80]) { + expect(wow335LightwellDamageCancellationFraction(abilityAtLevel(lightwell!, level).description)).toBe(0.3); + } + expect(wow335LightwellDamageCancellationFraction("No damage break description.")).toBeNull(); + }); +}); diff --git a/src/game/wow335PriestLightwellRuntime.ts b/src/game/wow335PriestLightwellRuntime.ts new file mode 100644 index 00000000..89b8b65c --- /dev/null +++ b/src/game/wow335PriestLightwellRuntime.ts @@ -0,0 +1,37 @@ +export interface Wow335LightwellRenewProfile { + readonly childSpellId: number; + readonly baseHealPerTick: number; + readonly ticks: number; + readonly intervalMs: number; + readonly durationMs: number; + /** AzerothCore 3.3.5 spell_bonus_data dot coefficient, applied per tick. */ + readonly spellPowerCoefficientPerTick?: number; +} + +const LIGHTWELL_RENEW_PROFILE_BY_CREATURE: Readonly> = { + // Child spell IDs and tick amounts come from the local 3.3.5 Spell.dbc rows. + // Spell.dbc lists the 6s aura and 2s tick interval for each rank. + // The 3.3.5 spell_bonus_data table gives dot_bonus=0.3333 for the first + // five child IDs; rank 6 (48085) has no matching coefficient row. + 31897: { childSpellId: 7001, baseHealPerTick: 267, ticks: 3, intervalMs: 2_000, durationMs: 6_000, spellPowerCoefficientPerTick: 0.3333 }, + 31896: { childSpellId: 27873, baseHealPerTick: 388, ticks: 3, intervalMs: 2_000, durationMs: 6_000, spellPowerCoefficientPerTick: 0.3333 }, + 31895: { childSpellId: 27874, baseHealPerTick: 533, ticks: 3, intervalMs: 2_000, durationMs: 6_000, spellPowerCoefficientPerTick: 0.3333 }, + 31894: { childSpellId: 28276, baseHealPerTick: 787, ticks: 3, intervalMs: 2_000, durationMs: 6_000, spellPowerCoefficientPerTick: 0.3333 }, + 31893: { childSpellId: 48084, baseHealPerTick: 1_305, ticks: 3, intervalMs: 2_000, durationMs: 6_000, spellPowerCoefficientPerTick: 0.3333 }, + 31883: { childSpellId: 48085, baseHealPerTick: 1_540, ticks: 3, intervalMs: 2_000, durationMs: 6_000 }, +}; + +export function wow335LightwellRenewProfileForCreature( + creatureId: number, +): Wow335LightwellRenewProfile | null { + return LIGHTWELL_RENEW_PROFILE_BY_CREATURE[creatureId] ?? null; +} + +/** Returns the cancellation fraction stated in the generated Lightwell rank tooltip. */ +export function wow335LightwellDamageCancellationFraction(description: string): number | null { + const match = /attacks done to you equal to\s+(\d+(?:\.\d+)?)%\s+of your total health will cancel the effect/i + .exec(description); + if (!match) return null; + const percent = Number(match[1]); + return Number.isFinite(percent) && percent > 0 ? percent / 100 : null; +} diff --git a/src/game/wow335PriestMechanics.test.ts b/src/game/wow335PriestMechanics.test.ts index bc21fe88..73e2c24c 100644 --- a/src/game/wow335PriestMechanics.test.ts +++ b/src/game/wow335PriestMechanics.test.ts @@ -72,6 +72,35 @@ function castHostile(name: string, targetId: string, now: number) { return result; } +function stableUnit(seed: string): number { + let hash = 2166136261; + for (const character of seed) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function painAndSufferingMindBlastStart(targetId: string, rank: number, shouldProc: boolean, earliest: number): number { + const mindBlast = priestAbility("Mind Blast"); + const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!; + const chance = rank > 0 ? (talent.rankEffects[rank - 1]?.procChance ?? 0) / 100 : 0; + for (let start = earliest; start < earliest + 12_000; start += 100) { + const completesAt = start + mindBlast.castTimeMs; + const proc = stableUnit( + `${PLAYER_AGGRO_ID}:${mindBlast.id}:${targetId}:${completesAt}:wow335-priest-pain-and-suffering`, + ) < chance; + if (proc === shouldProc) return start; + } + throw new Error(`Could not find a seeded Pain and Suffering ${shouldProc ? "proc" : "failure"}.`); +} + +function shadowWordPainEffect(targetId: string, sourceActorId = PLAYER_AGGRO_ID) { + return useCombatStore.getState().effects.find((effect) => effect.kind === "dot" + && effect.sourceAbilityId === priestAbility("Shadow Word: Pain").id + && effect.sourceActorId === sourceActorId && effect.targetId === targetId); +} + describe("WoW Priest mechanics", () => { beforeEach(() => { clearMobRuntimeRegistry(); @@ -587,6 +616,128 @@ describe("WoW Priest mechanics", () => { expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-shadow-weaving"))?.stacks).toBe(5); }); + it("uses generated Pain and Suffering proc chances to refresh Shadow Word: Pain", () => { + const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!; + expect(talent.rankEffects.map((rank) => rank.procChance)).toEqual([33, 66, 100]); + const cases = [ + { rank: 0, shouldProc: false }, + { rank: 1, shouldProc: false }, + { rank: 1, shouldProc: true }, + { rank: 2, shouldProc: false }, + { rank: 2, shouldProc: true }, + { rank: 3, shouldProc: true }, + ]; + const shadowWordPain = abilityAtLevel(priestAbility("Shadow Word: Pain"), 80); + const dotDefinition = shadowWordPain.effects.find((effect) => effect.kind === "dot")!; + expect(dotDefinition.kind).toBe("dot"); + + cases.forEach(({ rank, shouldProc }, index) => { + const targetId = `priest-pain-and-suffering-rank-${rank}-${index}`; + useCombatStore.getState().registerMob(targetId, { + name: "Pain and Suffering target", maxHealth: 1_000_000, xpReward: 0, + }); + setMobPosition(targetId, [4, 0, 0]); + setPriestTalentRanks(rank ? { [talent.id]: rank } : {}); + const appliedAt = 1_000 + index * 25_000; + expect(castHostile("Shadow Word: Pain", targetId, appliedAt).ok).toBe(true); + const before = shadowWordPainEffect(targetId)!; + expect(before).toBeDefined(); + const expirationBefore = before.nextTickAt + (before.remainingTicks - 1) * before.intervalMs; + const mindBlast = priestAbility("Mind Blast"); + const castAt = painAndSufferingMindBlastStart(targetId, rank, shouldProc, appliedAt + 2_000); + expect(castHostile("Mind Blast", targetId, castAt).ok).toBe(true); + const completedCast = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "cast" && event.abilityId === mindBlast.id && event.targetActorId === targetId + )).at(-1)!; + const after = shadowWordPainEffect(targetId)!; + const expirationAfter = after.nextTickAt + (after.remainingTicks - 1) * after.intervalMs; + if (shouldProc) { + expect(after.remainingTicks).toBe(dotDefinition.ticks); + expect(after.nextTickAt).toBe(completedCast.occurredAt + after.intervalMs); + expect(expirationAfter).toBe(completedCast.occurredAt + dotDefinition.ticks * dotDefinition.intervalMs); + } else { + expect(expirationAfter).toBe(expirationBefore); + } + }); + }); + + it("refreshes only its own Shadow Word: Pain on the eligible cast target", () => { + const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!; + setPriestTalentRanks({ [talent.id]: 3 }); + for (const targetId of ["priest-pain-scope-primary", "priest-pain-scope-other"]) { + useCombatStore.getState().registerMob(targetId, { + name: "Pain scope target", maxHealth: 1_000_000, xpReward: 0, + }); + setMobPosition(targetId, [4, 0, 0]); + } + expect(castHostile("Shadow Word: Pain", "priest-pain-scope-primary", 1_000).ok).toBe(true); + expect(castHostile("Shadow Word: Pain", "priest-pain-scope-other", 5_000).ok).toBe(true); + const ownedPrimary = shadowWordPainEffect("priest-pain-scope-primary")!; + const ownedOther = shadowWordPainEffect("priest-pain-scope-other")!; + const foreignPrimary = { ...ownedPrimary, id: "foreign-priest-shadow-word-pain", sourceActorId: "other-priest" }; + useCombatStore.setState((state) => ({ effects: [...state.effects, foreignPrimary] })); + const endOtherBefore = ownedOther.nextTickAt + (ownedOther.remainingTicks - 1) * ownedOther.intervalMs; + const endForeignBefore = foreignPrimary.nextTickAt + (foreignPrimary.remainingTicks - 1) * foreignPrimary.intervalMs; + + expect(castHostile("Mind Blast", "priest-pain-scope-primary", 10_000).ok).toBe(true); + const ownAfter = shadowWordPainEffect("priest-pain-scope-primary")!; + const otherAfter = shadowWordPainEffect("priest-pain-scope-other")!; + const foreignAfter = useCombatStore.getState().effects.find((effect) => effect.id === foreignPrimary.id)!; + expect(ownAfter.remainingTicks).toBe(6); + expect(otherAfter.nextTickAt + (otherAfter.remainingTicks - 1) * otherAfter.intervalMs).toBe(endOtherBefore); + expect(foreignAfter.nextTickAt + (foreignAfter.remainingTicks - 1) * foreignAfter.intervalMs).toBe(endForeignBefore); + }); + + it("does not refresh for unrelated casts or when the target has no Shadow Word: Pain", () => { + const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!; + setPriestTalentRanks({ [talent.id]: 3 }); + for (const targetId of ["priest-pain-unrelated", "priest-pain-absent"]) { + useCombatStore.getState().registerMob(targetId, { + name: "Pain trigger guard target", maxHealth: 1_000_000, xpReward: 0, + }); + setMobPosition(targetId, [4, 0, 0]); + } + expect(castHostile("Shadow Word: Pain", "priest-pain-unrelated", 1_000).ok).toBe(true); + const dot = shadowWordPainEffect("priest-pain-unrelated")!; + const endBefore = dot.nextTickAt + (dot.remainingTicks - 1) * dot.intervalMs; + expect(castHostile("Smite", "priest-pain-unrelated", 5_000).ok).toBe(true); + const afterSmite = shadowWordPainEffect("priest-pain-unrelated")!; + expect(afterSmite.nextTickAt + (afterSmite.remainingTicks - 1) * afterSmite.intervalMs).toBe(endBefore); + expect(castHostile("Mind Blast", "priest-pain-absent", 10_000).ok).toBe(true); + expect(shadowWordPainEffect("priest-pain-absent")).toBeUndefined(); + }); + + it.each(["Mind Flay", "Mind Sear", "Mind Blast"])("refreshes Shadow Word: Pain after a %s cast", (castName) => { + const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!; + const castAbilityNodeId = abilityTalentNodeId(priestAbility(castName)); + setPriestTalentRanks({ + [talent.id]: 3, + ...(castAbilityNodeId ? { [castAbilityNodeId]: 1 } : {}), + }); + const targetId = `priest-pain-and-suffering-${castName.toLowerCase().replaceAll(" ", "-")}`; + useCombatStore.getState().registerMob(targetId, { + name: "Pain and Suffering eligible cast target", maxHealth: 1_000_000, xpReward: 0, + }); + setMobPosition(targetId, [4, 0, 0]); + expect(castHostile("Shadow Word: Pain", targetId, 1_000).ok).toBe(true); + useCombatStore.getState().tick(9, 10_000); + const before = shadowWordPainEffect(targetId)!; + expect(before.remainingTicks).toBeLessThan(6); + const castResult = castHostile(castName, targetId, 11_000); + expect(castResult.ok, JSON.stringify(castResult)).toBe(true); + const cast = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "cast" && event.abilityId === priestAbility(castName).id && event.targetActorId === targetId + )).at(-1)!; + const after = shadowWordPainEffect(targetId)!; + expect(after.remainingTicks).toBe(6); + expect(after.nextTickAt).toBe(cast.occurredAt + after.intervalMs); + expect(after.tickAmounts).toEqual(before.tickAmounts); + expect(after.basePoints).toBe(before.basePoints); + expect(after.intervalMs).toBe(before.intervalMs); + expect(after.damageSchool).toBe(before.damageSchool); + expect(after.id).toBe(before.id); + }); + 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); @@ -649,18 +800,24 @@ describe("WoW Priest mechanics", () => { 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(clickAmount).toBe(1_540 * 3); 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, + remainingTicks: 3, + intervalMs: 2_000, + coefficient: 0, + basePoints: 1_540, + nextTickAt: completion! + 2_001, })); - useCombatStore.getState().tick(6, completion! + 6_001); + useCombatStore.getState().tick(1, completion! + 1_001); + expect(useCombatStore.getState().health).toBe(healthBefore); + expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === ability.id)?.remainingTicks).toBe(3); + useCombatStore.getState().tick(1, completion! + 2_001); expect(useCombatStore.getState().health).toBeGreaterThan(healthBefore); + expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === ability.id)?.remainingTicks).toBe(2); + useCombatStore.getState().tick(4, completion! + 6_001); expect(useCombatStore.getState().summons.find((summon) => summon.id === well!.id)?.clickCharges).toBe(9); for (let click = 1; click < 10; click += 1) { @@ -669,4 +826,233 @@ describe("WoW Priest mechanics", () => { 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); }); + + it("uses the sourced per-tick spell-power coefficient for Lightwell ranks with a bonus row", () => { + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 75 }); + useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, spellPower: 300 } })); + usePartyStore.getState().initializeParty("wow-priest-lightwell-coefficient", 75, useCombatStore.getState().health); + useCombatStore.getState().synchronizeActors(); + 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?.creatureId).toBe(31893); + + expect(useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1)).toBe(1_405 * 3); + expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ + sourceAbilityId: ability.id, + basePoints: 1_305, + coefficient: 0.3333, + remainingTicks: 3, + intervalMs: 2_000, + })); + }); + + it("cancels a Lightwell HoT after cumulative effective damage reaches 30% of recipient max health", () => { + 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).toBeDefined(); + + const maxHealth = useCombatStore.getState().maxHealth; + useCombatStore.setState({ health: maxHealth, shield: 0 }); + useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1); + const threshold = maxHealth * 0.3; + const firstDamage = useCombatStore.getState().damagePlayer(Math.floor(maxHealth * 0.2), "shadow", undefined, completion! + 2); + const afterFirstHit = useCombatStore.getState().effects.find((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id); + expect(firstDamage).toBeGreaterThan(0); + expect(firstDamage).toBeLessThan(threshold); + expect(afterFirstHit?.lightwellDamageCancellation).toEqual({ threshold, accumulatedDamage: firstDamage }); + + const secondDamage = useCombatStore.getState().damagePlayer(Math.floor(maxHealth * 0.2), "shadow", undefined, completion! + 3); + expect(firstDamage + secondDamage).toBeGreaterThanOrEqual(threshold); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id)).toBe(false); + + useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 4); + expect(useCombatStore.getState().effects.find((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id)?.lightwellDamageCancellation).toEqual({ + threshold, + accumulatedDamage: 0, + }); + }); + + it("does not count fully absorbed damage and starts a fresh threshold after the HoT expires", () => { + 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).toBeDefined(); + useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1); + + useCombatStore.setState({ shield: 2_000 }); + expect(useCombatStore.getState().damagePlayer(1_000, "shadow", undefined, completion! + 2)).toBe(0); + expect(useCombatStore.getState().effects.find((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id)?.lightwellDamageCancellation?.accumulatedDamage).toBe(0); + + useCombatStore.getState().tick(1, completion! + 6_001); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id)).toBe(false); + useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 6_002); + expect(useCombatStore.getState().effects.find((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id)?.lightwellDamageCancellation?.accumulatedDamage).toBe(0); + }); + + it("tracks Lightwell damage separately for each recipient and on resolved enemy hits", () => { + 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).toBeDefined(); + const member = usePartyStore.getState().members[0]!; + useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1); + useCombatStore.getState().clickLightwell(well!.id, member.id, completion! + 1); + + useCombatStore.getState().registerMob("priest-lightwell-hit", { + name: "Lightwell damage test", + maxHealth: 100_000, + xpReward: 0, + attacks: [{ + ...DEFAULT_ENEMY_ATTACK, + id: "priest-lightwell-shadow-cast", + name: "Shadow cast", + delivery: "projectile", + animation: "cast", + school: "shadow", + range: 30, + castTimeMs: 0, + }], + }); + setMobPosition("priest-lightwell-hit", [8, 0, 0]); + useCombatStore.getState().damageMob("priest-lightwell-hit", 1, completion! + 1); + const beforeHit = useCombatStore.getState().health; + useCombatStore.getState().advanceMobCombat(completion! + 451, [0, 0, 0]); + const appliedDamage = beforeHit - useCombatStore.getState().health; + const effects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id); + expect(appliedDamage).toBeGreaterThan(0); + expect(effects.find((effect) => effect.targetId === null)?.lightwellDamageCancellation?.accumulatedDamage) + .toBe(appliedDamage); + expect(effects.find((effect) => effect.targetId === member.id)?.lightwellDamageCancellation?.accumulatedDamage) + .toBe(0); + }); + + it("counts resolved enemy attacks against a party member toward that member's Lightwell cancellation", () => { + 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).toBeDefined(); + + const member = usePartyStore.getState().members[0]!; + setMemberHealth(member.id, 100_000, 100_000); + useCombatStore.getState().clickLightwell(well!.id, member.id, completion! + 1); + useCombatStore.getState().registerMob("priest-lightwell-party-hit", { + name: "Lightwell party damage test", + maxHealth: 100_000, + xpReward: 0, + attacks: [{ + ...DEFAULT_ENEMY_ATTACK, + id: "priest-lightwell-party-strike", + name: "Party strike", + range: 30, + cooldownMs: 1_900, + }], + }); + setMobPosition("priest-lightwell-party-hit", [4, 0, 0]); + useCombatStore.getState().damageMob("priest-lightwell-party-hit", 1, completion! + 1, { + actorId: member.id, + role: member.role, + }); + useCombatStore.getState().advanceMobCombat(completion! + 451, [0, 0, 0]); + + const damagedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!; + const effectiveDamage = 100_000 - damagedMember.health; + const memberHot = useCombatStore.getState().effects.find((effect) => effect.kind === "hot" + && effect.sourceAbilityId === ability.id && effect.targetId === member.id); + expect(effectiveDamage).toBeGreaterThan(0); + expect(memberHot?.lightwellDamageCancellation?.accumulatedDamage).toBe(effectiveDamage); + }); + + it("applies Spell Warding to incoming magic damage by rank and leaves physical damage unchanged", () => { + const incomingDamageAtRank = (rank: number, school: "physical" | "shadow"): number => { + setPriestTalentRanks(rank > 0 ? { "talent-411": rank } : {}); + useCombatStore.setState({ health: 1_000_000, maxHealth: 1_000_000, shield: 0 }); + const before = useCombatStore.getState().health; + useCombatStore.getState().damagePlayer(10_000, school, 80, 1_000 + rank); + return before - useCombatStore.getState().health; + }; + + const magicDamage = [0, 1, 2, 3].map((rank) => incomingDamageAtRank(rank, "shadow")); + expect(magicDamage[0]).toBeGreaterThan(magicDamage[1]!); + expect(magicDamage[1]).toBeGreaterThan(magicDamage[2]!); + expect(magicDamage[2]).toBeGreaterThan(magicDamage[3]!); + expect(magicDamage[3]).toBeCloseTo(magicDamage[0]! * 0.97, -1); + + const physicalDamage = [0, 1, 2, 3].map((rank) => incomingDamageAtRank(rank, "physical")); + expect(physicalDamage).toEqual([physicalDamage[0], physicalDamage[0], physicalDamage[0], physicalDamage[0]]); + }); + + it("applies Spell Warding to resolved enemy spell attacks without reducing enemy melee hits", () => { + const resolvedEnemyDamageAtRank = (rank: number, school: "physical" | "shadow"): number => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-priest-spell-warding", 80, useCombatStore.getState().health); + usePartyStore.getState().selectMember(null); + useCombatStore.getState().synchronizeActors(); + setPriestTalentRanks(rank > 0 ? { "talent-411": rank } : {}); + + const mobId = "priest-spell-warding-attacker"; + const attackId = "priest-spell-warding-attack"; + useCombatStore.getState().registerMob(mobId, { + name: "Spell Warding attacker", + maxHealth: 100_000, + xpReward: 0, + attacks: [{ + ...DEFAULT_ENEMY_ATTACK, + id: attackId, + delivery: school === "physical" ? "melee" : "projectile", + animation: school === "physical" ? "attack" : "cast", + school, + 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 baseSpellHit = resolvedEnemyDamageAtRank(0, "shadow"); + const wardedSpellHit = resolvedEnemyDamageAtRank(3, "shadow"); + expect(baseSpellHit).toBeGreaterThan(0); + expect(wardedSpellHit).toBeLessThan(baseSpellHit); + + const baseMeleeHit = resolvedEnemyDamageAtRank(0, "physical"); + const wardedMeleeHit = resolvedEnemyDamageAtRank(3, "physical"); + expect(baseMeleeHit).toBeGreaterThan(0); + expect(wardedMeleeHit).toBe(baseMeleeHit); + }); }); diff --git a/src/game/wow335RogueMechanics.test.ts b/src/game/wow335RogueMechanics.test.ts index df718896..c5274941 100644 --- a/src/game/wow335RogueMechanics.test.ts +++ b/src/game/wow335RogueMechanics.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { abilityTalentNodeId } from "./abilityCatalog"; +import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment"; +import { EMPTY_ITEM_STATS } from "./itemStats"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; @@ -31,11 +33,130 @@ describe("WoW Rogue class mechanics", () => { }; const addTarget = (id: string, position: readonly [number, number, number] = [3, 0, 0]) => { - useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); + useCombatStore.getState().registerMob(id, { name: id, level: 1, maxHealth: 100_000, xpReward: 0 }); setMobPosition(id, position); useCombatStore.getState().selectMob(id); }; + const weapon = (instanceId: string, speedMs: number, slot: "one-hand" | "off-hand" = "one-hand") => ({ + instanceId, + itemId: 1, + itemKey: instanceId, + name: instanceId, + quality: "uncommon" as const, + inventorySlot: slot, + armorType: null, + weaponType: "dagger", + itemLevel: 1, + sourceItemLevel: 1, + stats: { + ...EMPTY_ITEM_STATS, + hitRating: 10_000, + meleeWeapon: { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs }, + }, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }); + + const equipWeapons = (withOffhand: boolean, mainSpeedMs = 2_000, offhandSpeedMs = 1_000) => { + const main = weapon("rogue-main-hand", mainSpeedMs); + const offhand = weapon("rogue-off-hand", offhandSpeedMs, "off-hand"); + useCombatStore.getState().initializeCharacter({ + classId: "rogue", + level: 80, + inventory: withOffhand ? [main, offhand] : [main], + equipment: { + [PLAYER_EQUIPMENT_OWNER_ID]: { + "main-hand": main.instanceId, + ...(withOffhand ? { "off-hand": offhand.instanceId } : {}), + }, + }, + }); + }; + + const startAutoAttacks = (targetId: string, beforeSwing?: () => void) => { + const result = useCombatStore.getState().castAbility(ability("Sinister Strike").id, undefined, 1_000); + expect(result.ok).toBe(true); + useCombatStore.setState((state) => ({ + resource: 0, + resourcePools: { ...state.resourcePools, energy: 0 }, + })); + beforeSwing?.(); + const priorEventCount = useCombatStore.getState().combatEvents.length; + useCombatStore.getState().tick(0, 1_000); + const events = useCombatStore.getState().combatEvents.slice(priorEventCount).filter((event) => ( + event.kind === "damage" && event.targetActorId === targetId && event.occurredAt === 1_000 + )); + return events; + }; + + it("does not produce an off-hand swing or Combat Potency resource event without an off-hand weapon", () => { + equipWeapons(false); + addTarget("rogue-single-wield-target"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1825": 3 }, + })); + + const openingEvents = startAutoAttacks("rogue-single-wield-target"); + + expect(openingEvents).toHaveLength(1); + expect(openingEvents[0]?.abilityId).toBeUndefined(); + expect(openingEvents.some((event) => event.abilityId === "player-auto-attack-offhand")).toBe(false); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "resource" && event.abilityId === "wow335-rogue-combat-potency" + ))).toBe(false); + }); + + it("swings each weapon on its own interval and only procs Combat Potency from landed off-hand hits", () => { + equipWeapons(true, 3_000, 1_000); + addTarget("rogue-dual-wield-target"); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1825": 3 }, + })); + const bladeFlurry = ability("Blade Flurry"); + const killingSpree = ability("Killing Spree"); + const sinisterStrike = ability("Sinister Strike"); + const openingEvents = startAutoAttacks("rogue-dual-wield-target", () => { + useCombatStore.setState((state) => ({ + cooldowns: { + ...state.cooldowns, + [bladeFlurry.id]: 11_000, + [killingSpree.id]: 11_000, + [sinisterStrike.id]: 11_000, + }, + })); + }); + const mainHand = openingEvents.find((event) => !event.abilityId); + const offHand = openingEvents.find((event) => event.abilityId === "player-auto-attack-offhand"); + expect(mainHand).toBeDefined(); + expect(offHand).toMatchObject({ outcome: expect.stringMatching(/hit|crit/) }); + expect(offHand!.rawAmount).toBeLessThan(mainHand!.rawAmount); + expect(useCombatStore.getState().resource).toBe(5); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "resource" && event.abilityId === "wow335-rogue-combat-potency" + && event.effectiveAmount === 5 + ))).toBe(true); + expect(useCombatStore.getState().cooldowns).toMatchObject({ + [bladeFlurry.id]: 10_000, + [killingSpree.id]: 10_000, + [sinisterStrike.id]: 11_000, + }); + expect(useCombatStore.getState().nextAutoAttackAt).toBeGreaterThan( + useCombatStore.getState().nextOffhandAutoAttackAt, + ); + + const eventCount = useCombatStore.getState().combatEvents.length; + useCombatStore.getState().tick(0, 2_000); + const nextEvents = useCombatStore.getState().combatEvents.slice(eventCount) + .filter((event) => event.kind === "damage" && event.targetActorId === "rogue-dual-wield-target"); + expect(nextEvents.map((event) => event.abilityId)).toEqual(["player-auto-attack-offhand"]); + expect(useCombatStore.getState().resource).toBe(10); + expect(useCombatStore.getState().cooldowns[bladeFlurry.id]).toBe(9_000); + expect(useCombatStore.getState().cooldowns[killingSpree.id]).toBe(9_000); + }); + it("gates stealth openers and allows Pick Pocket through Shadow Dance", () => { addTarget("rogue-opener-target"); const backstab = ability("Backstab"); diff --git a/src/game/wow335RogueTalentRuntime.ts b/src/game/wow335RogueTalentRuntime.ts new file mode 100644 index 00000000..fc053d9f --- /dev/null +++ b/src/game/wow335RogueTalentRuntime.ts @@ -0,0 +1,42 @@ +import type { AbilityEffect } from "./abilityCatalog"; +import type { AuraDefinition } from "./combatAuras"; + +export const WOW335_ROGUE_IMPROVED_KICK_TAG = "wow335-rogue-improved-kick"; +export const WOW335_ROGUE_UNFAIR_ADVANTAGE_COOLDOWN_TAG = "wow335-rogue-unfair-advantage-cooldown"; +export const WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID = "wow335-rogue-unfair-advantage"; + +/** Generated talent 2073 returns 50%/100% main-hand weapon damage at ranks 1/2. */ +export function wow335RogueUnfairAdvantageDamageMultiplierForRank(rank: number): number { + const safeRank = Math.max(0, Math.min(2, Math.trunc(Number.isFinite(rank) ? rank : 0))); + return safeRank / 2; +} + +const IMPROVED_KICK_CAPSTONE_AURA: AuraDefinition = { + id: WOW335_ROGUE_IMPROVED_KICK_TAG, + name: "Improved Kick", + description: "A successful Kick interrupt restores 40 Energy and increases attack speed by 35% for 6 seconds.", + disposition: "buff", + durationMs: 6_000, + stackBehavior: "refresh", + utilityTags: [WOW335_ROGUE_IMPROVED_KICK_TAG], + modifiers: [ + { kind: "stat", stat: "melee-haste", operation: "flat", value: 35 }, + { kind: "stat", stat: "ranged-haste", operation: "flat", value: 35 }, + ], +}; + +/** Generated talent 206 has a two-rank capstone with this success-only rider. */ +export function wow335RogueImprovedKickAuraEffectForRank( + rank: number, +): Extract | null { + if (Math.max(0, Math.min(2, Math.trunc(Number.isFinite(rank) ? rank : 0))) < 2) return null; + return { + kind: "apply-aura", + recipient: "caster", + aura: IMPROVED_KICK_CAPSTONE_AURA, + sourceEffectType: -1, + sourceAuraType: -1, + miscValue: 0, + triggerSpellId: 0, + }; +} diff --git a/src/game/wow335RogueUnfairAdvantage.test.ts b/src/game/wow335RogueUnfairAdvantage.test.ts new file mode 100644 index 00000000..bc48949d --- /dev/null +++ b/src/game/wow335RogueUnfairAdvantage.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { usePartyStore } from "./partyStore"; +import { useCombatStore } from "./combatStore"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; +import { deriveCharacterStats } from "./wotlkStats"; +import { + WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID, + wow335RogueUnfairAdvantageDamageMultiplierForRank, +} from "./wow335RogueTalentRuntime"; + +function stableUnit(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 timeForDodge( + mobId: string, + attackId: string, + attackRevision: number, + earliest: number, + actorId = "__player__", +): number { + for (let now = earliest; now < earliest + 30_000; now += 1) { + const roll = stableUnit(`${mobId}:${attackId}:${actorId}:${attackRevision}:${now}:outcome`); + if (roll >= 0.1 && roll < 0.99) return now; + } + throw new Error(`Could not find a deterministic dodge roll for ${mobId}:${attackRevision}`); +} + +describe("WoW Rogue Unfair Advantage", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + }); + + const setUpDodgeTarget = (id: string, rank: number, repeatCooldownMs = 3_000) => { + const attackId = `${id}-swing`; + useCombatStore.setState((state) => ({ + gearStats: { + ...state.gearStats, + dodgeRating: 1_000_000, + meleeWeapon: { + damageMin: 100, + damageMax: 100, + damageSchool: "physical", + speedMs: 2_000, + }, + }, + talentRanks: { ...state.talentRanks, "talent-2073": rank }, + })); + useCombatStore.getState().registerMob(id, { + name: id, + level: 80, + maxHealth: 100_000, + xpReward: 0, + attacks: [{ + ...DEFAULT_ENEMY_ATTACK, + id: attackId, + initialCooldownMs: [0, 0], + repeatCooldownMs: [repeatCooldownMs, repeatCooldownMs], + }], + }); + setMobPosition(id, [2, 0, 0]); + useCombatStore.getState().damageMob(id, 1, 1_100); + return attackId; + }; + + it.each([[1, 0.5], [2, 1]] as const)( + "returns the generated rank %i main-hand damage fraction after a dodge", + (rank, multiplier) => { + const targetId = `unfair-advantage-rank-${rank}`; + const attackId = setUpDodgeTarget(targetId, rank); + const state = useCombatStore.getState(); + const weapon = state.gearStats.meleeWeapon!; + const stats = deriveCharacterStats( + state.classId, + state.level, + state.gearStats, + state.raceId, + {}, + state.secondaryClassId, + ); + const expectedRawDamage = Math.round(( + (weapon.damageMin + weapon.damageMax) / 2 + + stats.attackPower / 14 * weapon.speedMs / 1_000 + ) * multiplier); + + const dodgeAt = timeForDodge(targetId, attackId, 0, 1_450); + useCombatStore.getState().advanceMobCombat(dodgeAt); + + const retaliation = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" + && event.sourceActorId === PLAYER_AGGRO_ID + && event.targetActorId === targetId + && event.abilityId === WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID + )); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" + && event.sourceActorId === targetId + && event.targetActorId === PLAYER_AGGRO_ID + && event.outcome === "dodge" + ))).toBe(true); + expect(retaliation?.rawAmount).toBe(expectedRawDamage); + expect(wow335RogueUnfairAdvantageDamageMultiplierForRank(rank)).toBe(multiplier); + }, + ); + + it("shares one two-second cooldown across repeated dodges", () => { + const targetId = "unfair-advantage-cooldown-target"; + const attackId = setUpDodgeTarget(targetId, 2, 500); + const procTimes: number[] = []; + let attackAt = timeForDodge(targetId, attackId, 0, 1_450); + + for (let attackRevision = 0; attackRevision < 12; attackRevision += 1) { + useCombatStore.getState().advanceMobCombat(attackAt); + const procs = useCombatStore.getState().combatEvents.filter((event) => ( + event.kind === "damage" && event.abilityId === WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID + )); + if (procs.length > procTimes.length) procTimes.push(attackAt); + attackAt = timeForDodge(targetId, attackId, attackRevision + 1, attackAt + 500); + if (procTimes.length === 2) break; + } + + expect(procTimes).toHaveLength(2); + expect(procTimes[1]! - procTimes[0]!).toBeGreaterThanOrEqual(2_000); + }); + + it("uses a companion Rogue's rank, melee weapon, and individual cooldown on dodge", () => { + const playerHealth = useCombatStore.getState().health; + usePartyStore.getState().initializeParty("unfair-advantage-companion", 80, playerHealth, "damage", 2); + const initialMember = usePartyStore.getState().members[0]!; + const memberId = initialMember.id; + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === memberId + ? { + ...member, + classId: "rogue", + talentRanks: { ...member.talentRanks, "talent-2073": 2 }, + gearStats: { + ...member.gearStats, + dodgeRating: 1_000_000, + meleeWeapon: { + damageMin: 100, + damageMax: 100, + damageSchool: "physical" as const, + speedMs: 2_000, + }, + }, + } + : member), + })); + registerPartyRuntimePosition(memberId, [1, 0, 0]); + + const targetId = "unfair-advantage-companion-target"; + const attackId = `${targetId}-swing`; + useCombatStore.getState().registerMob(targetId, { + name: targetId, + level: 80, + maxHealth: 100_000, + xpReward: 0, + attacks: [{ + ...DEFAULT_ENEMY_ATTACK, + id: attackId, + initialCooldownMs: [0, 0], + repeatCooldownMs: [3_000, 3_000], + }], + }); + setMobPosition(targetId, [2, 0, 0]); + const member = usePartyStore.getState().members.find((candidate) => candidate.id === memberId)!; + useCombatStore.getState().damageMob(targetId, 1, 1_100, { + actorId: memberId, + role: member.role, + abilityId: "test-companion-engage", + }); + const dodgeAt = timeForDodge(targetId, attackId, 0, 1_450, memberId); + useCombatStore.getState().advanceMobCombat(dodgeAt); + + const retaliation = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" + && event.sourceActorId === memberId + && event.targetActorId === targetId + && event.abilityId === WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID + )); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" + && event.sourceActorId === targetId + && event.targetActorId === memberId + && event.outcome === "dodge" + ))).toBe(true); + expect(retaliation?.rawAmount).toBeGreaterThan(0); + expect(usePartyStore.getState().members.find((candidate) => candidate.id === memberId)?.cooldowns[ + WOW335_ROGUE_UNFAIR_ADVANTAGE_DAMAGE_ID + ]).toBe(dodgeAt + 2_000); + }); +}); diff --git a/src/game/wow335ShamanConditionalTalentsRuntime.test.ts b/src/game/wow335ShamanConditionalTalentsRuntime.test.ts index 47a895d4..7761d4d1 100644 --- a/src/game/wow335ShamanConditionalTalentsRuntime.test.ts +++ b/src/game/wow335ShamanConditionalTalentsRuntime.test.ts @@ -3,6 +3,9 @@ import { PLAYER_AURA_ENTITY_ID, useCombatStore, resolvedAbilityCosts } from "./c import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; +import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers"; +import { deriveCharacterStats } from "./wotlkStats"; function shamanAbility(name: string) { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); @@ -11,9 +14,16 @@ function shamanAbility(name: string) { } function setTalentRank(talentId: string, rank: number): void { - useCombatStore.setState((state) => ({ - talentRanks: { ...state.talentRanks, [talentId]: rank }, - })); + useCombatStore.setState((state) => { + const talentRanks = { ...state.talentRanks, [talentId]: rank }; + const passiveModifiers = state.talentModifiers.filter((modifier) => ( + modifier.sourceTalentId?.startsWith("class-passive:") + )); + return { + talentRanks, + talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")], + }; + }); } function castAndFinish(abilityId: string, targetActorId: string | null | undefined, now: number): void { @@ -26,9 +36,10 @@ function castAndFinish(abilityId: string, targetActorId: string | null | undefin if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt); } -function setupShaman(): void { +async function setupShaman(): Promise { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["shaman", "mage"]); useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); useCombatStore.getState().resetSettings(); useCombatStore.getState().setPlayerPosition([0, 0, 0]); @@ -43,9 +54,200 @@ function setupShaman(): void { useCombatStore.getState().selectMob("shaman-conditional-target"); } +function setMagePartyCaster(): string { + const member = usePartyStore.getState().members[0]!; + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === member.id ? { + ...candidate, + classId: "mage", + talentRanks: { "talent-2049": 3 }, + resourceType: "mana", + resourceName: "Mana", + } : candidate), + })); + useCombatStore.setState((state) => { + const actors = { ...state.actors }; + delete actors[member.id]; + return { actors }; + }); + return member.id; +} + +function damageWithShamanSpellCrit(sourceActorId?: string, attackKind: "spell" | "melee" | "ranged" = "spell") { + const isParty = Boolean(sourceActorId); + const sourceMember = sourceActorId + ? usePartyStore.getState().members.find((member) => member.id === sourceActorId) + : undefined; + const ability = isParty + ? abilitiesForClass("mage").find((candidate) => candidate.name === "Fireball")! + : shamanAbility("Lightning Bolt"); + return useCombatStore.getState().damageMob( + "shaman-conditional-target", + 100, + 10_000 + useCombatStore.getState().combatEvents.length, + sourceActorId && sourceMember + ? { actorId: sourceActorId, role: sourceMember.role, abilityId: ability.id } + : undefined, + attackKind === "spell" ? "nature" : "physical", + { + attackKind, + seed: `shaman-elemental-oath-${sourceActorId ?? "player"}-${attackKind}-${useCombatStore.getState().combatEvents.length}`, + abilityId: ability.id, + rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false }, + }, + ); +} + +function shamanHealRoll(abilityId: string, targetId: string, impactAt: number): number { + let hash = 2166136261; + for (const character of `${PLAYER_AURA_ENTITY_ID}:${abilityId}:${targetId}:${impactAt}:heal`) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function findShamanHealImpactAt( + abilityId: string, + targetId: string, + minimumChancePercent: number, + maximumChancePercent: number, + startAt: number, +): number { + for (let impactAt = startAt; impactAt < startAt + 100_000; impactAt += 1) { + const roll = shamanHealRoll(abilityId, targetId, impactAt); + if (roll >= minimumChancePercent / 100 && roll < maximumChancePercent / 100) return impactAt; + } + throw new Error("Could not find a seeded Shaman heal crit roll in the requested interval."); +} + describe("WoW Shaman conditional talent mechanics", () => { beforeEach(setupShaman); + it("applies each Convection rank to its complete generated spell list", () => { + const eligibleNames = [ + "Earth Shock", + "Flame Shock", + "Frost Shock", + "Lightning Bolt", + "Chain Lightning", + "Lava Burst", + "Wind Shear", + ]; + + for (let rank = 1; rank <= 5; rank += 1) { + setTalentRank("talent-564", rank); + const state = useCombatStore.getState(); + for (const name of eligibleNames) { + const ability = shamanAbility(name); + const baseCost = resolvedAbilityCosts(ability, [], { ...state, auras: [] }) + .find((cost) => cost.resource === "mana")?.amount; + const reducedCost = resolvedAbilityCosts(ability, state.talentModifiers, { ...state, auras: [] }) + .find((cost) => cost.resource === "mana")?.amount; + expect(baseCost, `${name} base mana cost`).toBeGreaterThan(0); + expect(reducedCost, `${name} at Convection rank ${rank}`) + .toBe(Math.round(baseCost! * (1 - rank * 0.03))); + } + + const healingWave = shamanAbility("Healing Wave"); + const baseHealingWaveCost = resolvedAbilityCosts(healingWave, [], { ...state, auras: [] }) + .find((cost) => cost.resource === "mana")?.amount; + const unchangedHealingWaveCost = resolvedAbilityCosts(healingWave, state.talentModifiers, { ...state, auras: [] }) + .find((cost) => cost.resource === "mana")?.amount; + expect(unchangedHealingWaveCost).toBe(baseHealingWaveCost); + } + }); + + it("adds Elemental Oath once to Shaman and party spell crit chance only", () => { + const partyCasterId = setMagePartyCaster(); + const generatedCritModifiers = (rank: number) => talentModifiersForRanks({ "talent-2049": rank }, "shaman") + .filter((modifier) => modifier.kind === "critical-chance-percent"); + const playerSpellChance: number[] = []; + const partySpellChance: number[] = []; + const playerMeleeChance: number[] = []; + const playerRangedChance: number[] = []; + let playerSpellBase = 0; + let partySpellBase = 0; + let playerMeleeBase = 0; + let playerRangedBase = 0; + + for (let rank = 0; rank <= 3; rank += 1) { + setTalentRank("talent-2049", rank); + expect(generatedCritModifiers(rank)).toEqual([]); + const playerSpell = damageWithShamanSpellCrit(); + const partySpell = damageWithShamanSpellCrit(partyCasterId); + const playerMelee = damageWithShamanSpellCrit(undefined, "melee"); + const playerRanged = damageWithShamanSpellCrit(undefined, "ranged"); + const playerSpellCrit = playerSpell.combatResult!.attackTable.criticalPercent; + const partySpellCrit = partySpell.combatResult!.attackTable.criticalPercent; + const playerMeleeCrit = playerMelee.combatResult!.attackTable.criticalPercent; + const playerRangedCrit = playerRanged.combatResult!.attackTable.criticalPercent; + if (rank === 0) { + playerSpellBase = playerSpellCrit; + partySpellBase = partySpellCrit; + playerMeleeBase = playerMeleeCrit; + playerRangedBase = playerRangedCrit; + } + playerSpellChance.push(playerSpellCrit); + partySpellChance.push(partySpellCrit); + playerMeleeChance.push(playerMeleeCrit); + playerRangedChance.push(playerRangedCrit); + } + + expect(playerSpellChance).toEqual([0, 1, 2, 3].map((rank) => playerSpellBase + rank)); + expect(partySpellChance).toEqual([0, 1, 2, 3].map((rank) => partySpellBase + rank)); + expect(playerMeleeChance).toEqual([playerMeleeBase, playerMeleeBase, playerMeleeBase, playerMeleeBase]); + expect(playerRangedChance).toEqual([playerRangedBase, playerRangedBase, playerRangedBase, playerRangedBase]); + }); + + it("applies the Elemental Oath rank once to Shaman healing spell crits", async () => { + const probeHealingWave = shamanAbility("Healing Wave"); + const probe = useCombatStore.getState().castAbility(probeHealingWave.id, { targetActorId: null, now: 1_000 }); + expect(probe.ok, JSON.stringify(probe)).toBe(true); + const castDurationMs = useCombatStore.getState().activeCast!.completesAt - 1_000; + await setupShaman(); + const healingWave = shamanAbility("Healing Wave"); + const state = useCombatStore.getState(); + useCombatStore.setState({ selectedFriendlyActorId: null }); + usePartyStore.setState({ selectedMemberId: null }); + const baseSpellCrit = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId).spellCritPercent + + applyTalentModifierValue(0, state.talentModifiers, "critical-chance-percent", { + abilityName: healingWave.name, + tags: ["cast-time", "healing", "nature"], + }); + const targetId = PLAYER_AURA_ENTITY_ID; + const firstImpactAt = findShamanHealImpactAt( + healingWave.id, + targetId, + baseSpellCrit, + baseSpellCrit + 1, + 10_000, + ); + const secondImpactAt = findShamanHealImpactAt( + healingWave.id, + targetId, + baseSpellCrit + 1, + baseSpellCrit + 2, + firstImpactAt + 20_000, + ); + setTalentRank("talent-2049", 1); + + const castForImpact = (impactAt: number) => { + useCombatStore.setState((current) => ({ health: Math.max(1, current.maxHealth - 1_000) })); + castAndFinish(healingWave.id, null, impactAt - castDurationMs); + const events = useCombatStore.getState().combatEvents; + const event = [...events].reverse().find((candidate) => ( + candidate.kind === "healing" && candidate.abilityId === healingWave.id + )); + expect(event, JSON.stringify(events)).toBeDefined(); + expect(event?.occurredAt).toBe(impactAt); + return event; + }; + + expect(castForImpact(firstImpactAt)?.critical).toBe(true); + expect(castForImpact(secondImpactAt)?.critical).toBe(false); + }); + it("grants two 30% mana-cost reductions after a critical elemental spell", () => { setTalentRank("talent-574", 2); useCombatStore.setState((state) => ({ @@ -54,14 +256,16 @@ describe("WoW Shaman conditional talent mechanics", () => { const lightningBolt = shamanAbility("Lightning Bolt"); castAndFinish(lightningBolt.id, undefined, 1_000); - expect(useCombatStore.getState().combatEvents.some((event) => ( + const criticalBolt = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.abilityId === lightningBolt.id && event.critical - ))).toBe(true); + )); + expect(criticalBolt).toBeDefined(); 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); + expect(clearcasting()?.expiresAt).toBe(criticalBolt!.occurredAt + 15_000); const healingWave = shamanAbility("Healing Wave"); const before = useCombatStore.getState(); @@ -79,9 +283,12 @@ describe("WoW Shaman conditional talent mechanics", () => { .find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost); castAndFinish(healingWave.id, null, 20_000); expect(clearcasting()).toBeUndefined(); + const expiredState = useCombatStore.getState(); + expect(resolvedAbilityCosts(healingWave, expiredState.talentModifiers, expiredState) + .find((cost) => cost.resource === "mana")?.amount).toBe(fullCost); }); - it("uses Reverberation's rank chance and 20% bonus on the next different Shock", () => { + it("uses Reverberation's rank chance and 20% bonus on the next different Shock", async () => { setTalentRank("talent-575", 3); const earthShock = shamanAbility("Earth Shock"); const frostShock = shamanAbility("Frost Shock"); @@ -105,7 +312,7 @@ describe("WoW Shaman conditional talent mechanics", () => { .toContain(`wow335-shaman-reverberation:from:${frostShock.id}`); const buffedRawDamage = frostShockHit?.rawAmount; - setupShaman(); + await setupShaman(); const baselineBolt = shamanAbility("Lightning Bolt"); const baselineFrostShock = shamanAbility("Frost Shock"); castAndFinish(baselineBolt.id, undefined, 1_000); diff --git a/src/game/wow335ShamanImprovedWaterShieldRuntime.test.ts b/src/game/wow335ShamanImprovedWaterShieldRuntime.test.ts new file mode 100644 index 00000000..90e803ba --- /dev/null +++ b/src/game/wow335ShamanImprovedWaterShieldRuntime.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { usePartyStore } from "./partyStore"; +import { + wow335ShamanImprovedWaterShieldCappedRestore, + wow335ShamanImprovedWaterShieldProcChance, + wow335ShamanImprovedWaterShieldShouldProc, + wow335ShamanWaterShieldOrbManaAtLevel, +} from "./wow335ShamanImprovedWaterShieldRuntime"; + +function shamanAbility(name: string) { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, `Shaman ${name}`).toBeDefined(); + return ability!; +} + +function prepareShaman(): void { + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); + useCombatStore.getState().resetSettings(); + usePartyStore.getState().initializeParty("wow-shaman-improved-water-shield", 80, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + health: 1, + gearStats: { ...state.gearStats, criticalStrikeRating: 100_000 }, + resource: 8_000, + maxResource: 10_000, + resourcePools: { ...state.resourcePools, mana: 8_000 }, + maxResourcePools: { ...state.maxResourcePools, mana: 10_000 }, + talentRanks: { ...state.talentRanks, "talent-583": 3 }, + })); +} + +function castAt(name: string, targetId: string | null, now: number): void { + const ability = shamanAbility(name); + const result = useCombatStore.getState().castAbility(ability.id, { targetActorId: targetId, now }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + const cast = useCombatStore.getState().activeCast; + if (cast) useCombatStore.getState().tick(0.1, cast.completesAt); +} + +describe("WoW Shaman Improved Water Shield", () => { + beforeEach(prepareShaman); + + it.each([ + [1, true, "Chain Heal", 0.1], + [1, false, "Chain Heal", 0.05], + [2, true, "Riptide", 0.2], + [2, false, "Riptide", 0.1], + [3, true, "Lesser Healing Wave", 0.3], + [3, false, "Lesser Healing Wave", 0.15], + [3, true, "Healing Wave", 0.6], + [3, false, "Healing Wave", 0.3], + ])("uses rank %i critical=%s chance for %s", (rank, critical, abilityName, chance) => { + expect(wow335ShamanImprovedWaterShieldProcChance(rank, abilityName, "nature", critical)).toBe(chance); + }); + + it("rejects unlearned/invalid ranks and non-Nature heals, and uses the local orb amount", () => { + expect(wow335ShamanImprovedWaterShieldProcChance(0, "Healing Wave", "nature", true)).toBe(0); + expect(wow335ShamanImprovedWaterShieldProcChance(Number.NaN, "Healing Wave", "nature", true)).toBe(0); + expect(wow335ShamanImprovedWaterShieldProcChance(Number.POSITIVE_INFINITY, "Healing Wave", "nature", true)).toBe(0); + expect(wow335ShamanImprovedWaterShieldProcChance(3, "Healing Wave", "fire", true)).toBe(0); + expect(wow335ShamanImprovedWaterShieldShouldProc(3, "Healing Wave", "nature", true, 0.59)).toBe(true); + expect(wow335ShamanImprovedWaterShieldShouldProc(3, "Healing Wave", "nature", true, 0.6)).toBe(false); + expect(wow335ShamanWaterShieldOrbManaAtLevel(80)).toBe(400); + expect(wow335ShamanWaterShieldOrbManaAtLevel(70)).toBe(200); + expect(wow335ShamanImprovedWaterShieldCappedRestore(100, 200, 400)).toBe(100); + }); + + it.each([ + { shieldActive: false, healAt: 2_500, completionAt: 5_500 }, + { shieldActive: true, healAt: 3_000, completionAt: 6_000 }, + ])("restores 400 mana on a critical Healing Wave with Water Shield active=$shieldActive", ({ shieldActive, healAt, completionAt }) => { + if (shieldActive) castAt("Water Shield", null, 1_000); + const shieldAurasBeforeHeal = useCombatStore.getState().auras.filter((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.name.startsWith("Water Shield") + )); + expect(shieldAurasBeforeHeal.length > 0).toBe(shieldActive); + + const heal = shamanAbility("Healing Wave"); + const result = useCombatStore.getState().castAbility(heal.id, { + targetActorId: null, + now: healAt, + }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + const cast = useCombatStore.getState().activeCast; + expect(cast?.completesAt).toBe(completionAt); + useCombatStore.getState().tick(0.1, completionAt); + + const directHeal = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "healing" && event.abilityId === heal.id + )); + expect(directHeal?.critical).toBe(true); + expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({ + kind: "resource", + abilityId: "wow335-shaman-improved-water-shield", + rawAmount: 400, + effectiveAmount: 400, + })); + }); + +}); diff --git a/src/game/wow335ShamanImprovedWaterShieldRuntime.ts b/src/game/wow335ShamanImprovedWaterShieldRuntime.ts new file mode 100644 index 00000000..17f22595 --- /dev/null +++ b/src/game/wow335ShamanImprovedWaterShieldRuntime.ts @@ -0,0 +1,57 @@ +import { abilityAtLevel, type AbilityDefinition } from "./abilityCatalog"; +import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; + +const IMPROVED_WATER_SHIELD_CRITICAL_CHANCE_BY_RANK = [0, 0.1, 0.2, 0.3] as const; +const IMPROVED_WATER_SHIELD_NORMAL_CHANCE_BY_RANK = [0, 0.05, 0.1, 0.15] as const; + +export function wow335ShamanImprovedWaterShieldProcChance( + rank: number, + abilityName: string, + school: string, + critical: boolean, +): number { + const normalizedRank = Number.isFinite(rank) + ? Math.max(0, Math.min(3, Math.trunc(rank))) + : 0; + if (normalizedRank <= 0 || school !== "nature") return 0; + const chances = critical + ? IMPROVED_WATER_SHIELD_CRITICAL_CHANCE_BY_RANK + : IMPROVED_WATER_SHIELD_NORMAL_CHANCE_BY_RANK; + const chance = chances[normalizedRank] ?? 0; + return abilityName === "Healing Wave" ? Math.min(1, chance * 2) : chance; +} + +export function wow335ShamanImprovedWaterShieldShouldProc( + rank: number, + abilityName: string, + school: string, + critical: boolean, + roll: number, +): boolean { + const chance = wow335ShamanImprovedWaterShieldProcChance(rank, abilityName, school, critical); + return chance > 0 && Number.isFinite(roll) && roll >= 0 && roll < chance; +} + +export function wow335ShamanWaterShieldOrbManaAtLevel(level: number): number { + const waterShield = WOW335_ABILITIES_BY_CLASS.shaman.find((ability) => ability.name === "Water Shield"); + if (!waterShield) return 0; + const rankedWaterShield = abilityAtLevel(waterShield, level); + const orbProc = rankedWaterShield.effects + .filter((effect): effect is Extract => ( + effect.kind === "apply-aura" + )) + .flatMap((effect) => effect.aura.procs ?? []) + .find((proc) => proc.id.endsWith(":water-shield") + && proc.action.kind === "resource" + && proc.action.resource === "mana"); + return orbProc?.action.kind === "resource" ? Math.max(0, orbProc.action.amount) : 0; +} + +export function wow335ShamanImprovedWaterShieldCappedRestore( + currentMana: number, + maximumMana: number, + requestedMana: number, +): number { + if (![currentMana, maximumMana, requestedMana].every(Number.isFinite)) return 0; + return Math.max(0, Math.min(Math.max(0, requestedMana), maximumMana - currentMana)); +} diff --git a/src/game/wow335ShamanMaelstromWeaponCombatRuntime.test.ts b/src/game/wow335ShamanMaelstromWeaponCombatRuntime.test.ts new file mode 100644 index 00000000..1cc97d0d --- /dev/null +++ b/src/game/wow335ShamanMaelstromWeaponCombatRuntime.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AURA_ENTITY_ID, resolvedAbilityCosts, useCombatStore } from "./combatStore"; +import { abilityAtLevel, abilityById } from "./abilityCatalog"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; +import { EMPTY_ITEM_STATS } from "./itemStats"; +import { usePartyStore } from "./partyStore"; +import { WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG } from "./wow335ShamanMaelstromWeaponRuntime"; + +const TARGET_ID = "maelstrom-target"; +const OFF_HAND_WEAPON_ID = "maelstrom-test-off-hand"; + +function runtimeManaCost(abilityId: string, state: ReturnType, now: number): number { + const catalogAbility = abilityById(abilityId); + expect(catalogAbility).not.toBeNull(); + const rankedAbility = abilityAtLevel(catalogAbility!, state.level); + return resolvedAbilityCosts(rankedAbility, state.talentModifiers, { ...state, now }) + .find((cost) => cost.resource === "mana")?.amount ?? 0; +} + +function shamanAbility(name: string) { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, `Shaman ${name}`).toBeDefined(); + return ability!; +} + +function setMaelstromRank(rank: number): void { + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-2057": rank }, + })); +} + +function maelstromAura() { + return useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG) + )); +} + +function stableCombatUnit(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 setupShaman(): void { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + const offHandStats = { + ...EMPTY_ITEM_STATS, + meleeWeapon: { damageMin: 20, damageMax: 30, damageSchool: "physical" as const, speedMs: 5_000 }, + }; + useCombatStore.getState().initializeCharacter({ + classId: "shaman", + level: 80, + xp: 0, + inventory: [{ + instanceId: OFF_HAND_WEAPON_ID, + itemId: 99001, + itemKey: OFF_HAND_WEAPON_ID, + name: "Slow off-hand weapon", + quality: "uncommon", + inventorySlot: "off-hand", + armorType: null, + weaponType: "mace", + itemLevel: 80, + sourceItemLevel: 80, + stats: offHandStats, + quantity: 1, + sourceBossKey: "test", + sourceBossName: "Test", + acquiredAt: 1, + }], + equipment: { player: { "off-hand": OFF_HAND_WEAPON_ID } }, + }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + // Keep a distinct one-second main-hand value so the Lava Lash assertion + // proves that the proc uses its equipped five-second off-hand weapon. + useCombatStore.setState((state) => ({ + gearStats: { + ...state.gearStats, + meleeWeapon: { damageMin: 20, damageMax: 30, damageSchool: "physical", speedMs: 1_000 }, + }, + })); + usePartyStore.getState().initializeParty("wow-shaman-maelstrom-tests", 80, useCombatStore.getState().health); + useCombatStore.getState().registerMob(TARGET_ID, { + name: "Maelstrom target", + level: 80, + maxHealth: 1_000_000, + xpReward: 0, + }); + setMobPosition(TARGET_ID, [3, 0, 0]); + useCombatStore.getState().selectMob(TARGET_ID); +} + +function cast(abilityId: string, now: number, targetActorId: string | null = TARGET_ID): void { + const result = useCombatStore.getState().castAbility(abilityId, { targetActorId, now }); + expect(result.ok, JSON.stringify(result)).toBe(true); +} + +describe("WoW Shaman Maelstrom Weapon combat runtime", () => { + beforeEach(setupShaman); + + it("procs on landed melee hits using Lava Lash's equipped off-hand speed", () => { + setMaelstromRank(3); + const lavaLash = shamanAbility("Lava Lash"); + const seedUnitAtMainHandRate = (now: number) => stableCombatUnit( + `${PLAYER_AURA_ENTITY_ID}:${lavaLash.id}:${TARGET_ID}:${now}:direct:wow335-shaman-maelstrom-weapon`, + ); + const now = Array.from({ length: 1_000 }, (_, index) => 1_000 + index) + .find((candidate) => seedUnitAtMainHandRate(candidate) >= 0.2)!; + expect(now).toBeDefined(); + + cast(lavaLash.id, now); + + expect(seedUnitAtMainHandRate(now)).toBeGreaterThanOrEqual(0.2); + expect(maelstromAura()).toMatchObject({ stacks: 1, expiresAt: now + 30_000 }); + expect(useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" && event.abilityId === lavaLash.id && event.effectiveAmount > 0 + ))).toBe(true); + }); + + it("adds one stack per proc, refreshes the 30-second duration, and caps at five", () => { + setMaelstromRank(3); + const lavaLash = shamanAbility("Lava Lash"); + const interval = Math.max(lavaLash.cooldownMs, lavaLash.gcdMs, 1_500) + 10; + let now = 1_000; + + for (let hit = 1; hit <= 6; hit += 1) { + cast(lavaLash.id, now); + expect(maelstromAura()?.stacks).toBe(Math.min(hit, 5)); + expect(maelstromAura()?.expiresAt).toBe(now + 30_000); + now += interval; + } + }); + + it("reduces Lightning Bolt cast time per stack, consumes the whole bundle at cast start, and still charges Mana", () => { + setMaelstromRank(3); + const lavaLash = shamanAbility("Lava Lash"); + const lightningBolt = shamanAbility("Lightning Bolt"); + const interval = Math.max(lavaLash.cooldownMs, lavaLash.gcdMs, 1_500) + 10; + let now = 1_000; + for (let hit = 0; hit < 5; hit += 1) { + cast(lavaLash.id, now); + now += interval; + } + expect(maelstromAura()?.stacks).toBe(5); + + // Earth Shock is not listed by the generated Maelstrom tooltip and must + // leave the active next-spell bundle alone. + const earthShock = shamanAbility("Earth Shock"); + cast(earthShock.id, now); + expect(maelstromAura()?.stacks).toBe(5); + + now += Math.max(earthShock.cooldownMs, earthShock.gcdMs, 1_500) + 10; + const before = useCombatStore.getState(); + const manaBefore = before.resource; + const expectedManaCost = runtimeManaCost(lightningBolt.id, before, now); + cast(lightningBolt.id, now); + + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(maelstromAura()).toBeUndefined(); + expect(useCombatStore.getState().resource).toBe(manaBefore - expectedManaCost); + }); + + it("applies a partial cast-time reduction, consumes the bundle on start, and does not make the spell free", () => { + const lightningBolt = shamanAbility("Lightning Bolt"); + cast(lightningBolt.id, 500); + const baselineCast = useCombatStore.getState().activeCast; + expect(baselineCast).not.toBeNull(); + const baselineDurationMs = baselineCast!.completesAt - baselineCast!.startedAt; + + setupShaman(); + setMaelstromRank(3); + const lavaLash = shamanAbility("Lava Lash"); + const rankedLightningBolt = shamanAbility("Lightning Bolt"); + cast(lavaLash.id, 1_000); + expect(maelstromAura()?.stacks).toBe(1); + + const now = 3_000; + const before = useCombatStore.getState(); + const manaBefore = before.resource; + const expectedManaCost = runtimeManaCost(rankedLightningBolt.id, before, now); + cast(rankedLightningBolt.id, now); + const activeCast = useCombatStore.getState().activeCast; + expect(activeCast!.completesAt! - activeCast!.startedAt!) + .toBe(Math.round(baselineDurationMs * 0.8)); + expect(maelstromAura()).toBeUndefined(); + expect(useCombatStore.getState().resource).toBe(manaBefore); + + useCombatStore.getState().tick(0.1, activeCast!.completesAt); + expect(useCombatStore.getState().resource).toBeLessThan(manaBefore); + expect(useCombatStore.getState().resource).toBeCloseTo(manaBefore - expectedManaCost, 0); + }); + + it("does not proc from a spell hit or a melee hit without a trained rank", () => { + const lavaLash = shamanAbility("Lava Lash"); + const lightningBolt = shamanAbility("Lightning Bolt"); + cast(lavaLash.id, 1_000); + expect(maelstromAura()).toBeUndefined(); + + setMaelstromRank(3); + cast(lightningBolt.id, 3_000); + expect(maelstromAura()).toBeUndefined(); + }); + + it("does not proc from a missed melee attack", () => { + setMaelstromRank(3); + const lavaLash = shamanAbility("Lava Lash"); + useCombatStore.getState().registerMob(TARGET_ID, { + name: "High level Maelstrom target", + level: 200, + maxHealth: 1_000_000, + xpReward: 0, + }); + const now = 1_000; + + cast(lavaLash.id, now); + + const damageEvent = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === lavaLash.id + )); + expect(damageEvent?.effectiveAmount).toBe(0); + expect(maelstromAura()).toBeUndefined(); + }); + + it("ignores an expired Maelstrom aura when calculating cast time", () => { + setMaelstromRank(3); + const lavaLash = shamanAbility("Lava Lash"); + const lightningBolt = shamanAbility("Lightning Bolt"); + cast(lavaLash.id, 1_000); + const generatedAura = maelstromAura(); + expect(generatedAura).toBeDefined(); + useCombatStore.setState((state) => ({ + auras: state.auras.map((aura) => aura.instanceId === generatedAura!.instanceId + ? { ...aura, expiresAt: 3_000 } + : aura), + })); + + cast(lightningBolt.id, 3_000); + + const activeCast = useCombatStore.getState().activeCast; + expect(activeCast!.completesAt! - activeCast!.startedAt!).toBe(2_500); + expect(maelstromAura()?.expiresAt).toBe(3_000); + }); +}); diff --git a/src/game/wow335ShamanMaelstromWeaponRuntime.test.ts b/src/game/wow335ShamanMaelstromWeaponRuntime.test.ts new file mode 100644 index 00000000..98806c83 --- /dev/null +++ b/src/game/wow335ShamanMaelstromWeaponRuntime.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS, + WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS, + WOW335_SHAMAN_MAELSTROM_WEAPON_DURATION_MS, + wow335ShamanMaelstromWeaponCastTimeReductionPercent, + wow335ShamanMaelstromWeaponProfile, +} from "./wow335ShamanMaelstromWeaponRuntime"; + +describe("WoW Shaman Maelstrom Weapon source profile", () => { + it.each([ + [1, 51528, 4, 0.16], + [2, 51529, 8, 0.32], + [3, 51530, 12, 0.48], + ])("uses generated rank %i with its missing WotLKDB PPM value", (rank, spellId, ppm, chance) => { + expect(wow335ShamanMaelstromWeaponProfile(rank)).toMatchObject({ + rank, + spellId, + procsPerMinute: ppm, + procChancePerWeaponHit: chance, + durationMs: WOW335_SHAMAN_MAELSTROM_WEAPON_DURATION_MS, + maxStacks: 5, + castTimeReductionPerStackPercent: 20, + }); + }); + + it("scales the PPM chance with weapon speed and caps it at one proc per hit", () => { + expect(wow335ShamanMaelstromWeaponProfile(1, 1_200)?.procChancePerWeaponHit).toBe(0.08); + expect(wow335ShamanMaelstromWeaponProfile(3, 6_000)?.procChancePerWeaponHit).toBe(1); + expect(wow335ShamanMaelstromWeaponProfile(1)?.procChancePerWeaponHit) + .toBe(4 * WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS / 60_000); + }); + + it("uses the generated eligible spell names without adding cross-class casts", () => { + expect([...WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS]).toEqual([ + "Lightning Bolt", + "Chain Lightning", + "Lava Burst", + "Lesser Healing Wave", + "Fireball", + "Frostbolt", + ]); + }); + + it("reduces cast time by 20% per stack up to the generated five-stack cap", () => { + const profile = wow335ShamanMaelstromWeaponProfile(3)!; + expect(wow335ShamanMaelstromWeaponCastTimeReductionPercent(profile, 0)).toBe(0); + expect(wow335ShamanMaelstromWeaponCastTimeReductionPercent(profile, 1)).toBe(20); + expect(wow335ShamanMaelstromWeaponCastTimeReductionPercent(profile, 3)).toBe(60); + expect(wow335ShamanMaelstromWeaponCastTimeReductionPercent(profile, 5)).toBe(100); + expect(wow335ShamanMaelstromWeaponCastTimeReductionPercent(profile, 9)).toBe(100); + expect(wow335ShamanMaelstromWeaponCastTimeReductionPercent(profile, -2)).toBe(0); + }); + + it("rejects an unlearned rank or invalid weapon speed", () => { + expect(wow335ShamanMaelstromWeaponProfile(0)).toBeNull(); + expect(wow335ShamanMaelstromWeaponProfile(-1)).toBeNull(); + expect(wow335ShamanMaelstromWeaponProfile(Number.NaN)).toBeNull(); + expect(wow335ShamanMaelstromWeaponProfile(1, 0)).toBeNull(); + expect(wow335ShamanMaelstromWeaponProfile(1, Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); diff --git a/src/game/wow335ShamanMaelstromWeaponRuntime.ts b/src/game/wow335ShamanMaelstromWeaponRuntime.ts new file mode 100644 index 00000000..f58281a2 --- /dev/null +++ b/src/game/wow335ShamanMaelstromWeaponRuntime.ts @@ -0,0 +1,84 @@ +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export const WOW335_SHAMAN_MAELSTROM_WEAPON_TALENT_ID = "talent-2057"; +export const WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG = "wow335-shaman-maelstrom-weapon"; +export const WOW335_SHAMAN_MAELSTROM_WEAPON_DURATION_MS = 30_000; +export const WOW335_SHAMAN_MAELSTROM_WEAPON_MAX_STACKS = 5; +export const WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS = 2_400; + +export const WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS: ReadonlySet = new Set([ + "Lightning Bolt", + "Chain Lightning", + "Lava Burst", + "Lesser Healing Wave", + "Fireball", + "Frostbolt", +]); + +const WOW335_SHAMAN_MAELSTROM_WEAPON_RANK_SPELL_IDS = [51528, 51529, 51530] as const; + +// The generated rank rows omit proc rates (procChance is 0 for each rank). +// WotLKDB spells 51528, 51529, and 51530 supply 4/8/12 procs per minute. +const WOW335_SHAMAN_MAELSTROM_WEAPON_PROCS_PER_MINUTE = [4, 8, 12] as const; + +const WOW335_SHAMAN_MAELSTROM_WEAPON_SOURCE = WOW335_TALENT_NODES.find((node) => ( + node.id === WOW335_SHAMAN_MAELSTROM_WEAPON_TALENT_ID +)); + +export interface Wow335ShamanMaelstromWeaponProfile { + readonly rank: 1 | 2 | 3; + readonly spellId: number; + readonly procsPerMinute: number; + readonly procChancePerWeaponHit: number; + readonly durationMs: number; + readonly maxStacks: number; + readonly castTimeReductionPerStackPercent: number; +} + +/** + * Resolves the generated Maelstrom Weapon rank and the missing 3.3.5a PPM + * value. WotLKDB child spell 53817 supplies the missing 30-second aura + * duration; the generated rank tooltip remains authoritative for 20% per + * stack and the five-stack cap. + */ +export function wow335ShamanMaelstromWeaponProfile( + rank: number, + weaponSpeedMs = WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS, +): Wow335ShamanMaelstromWeaponProfile | null { + const safeRank = Math.min(3, Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0))); + if (safeRank === 0 || !Number.isFinite(weaponSpeedMs) || weaponSpeedMs <= 0) return null; + + const rankDescription = WOW335_SHAMAN_MAELSTROM_WEAPON_SOURCE?.rankDescriptions[safeRank - 1]; + const rankEffect = WOW335_SHAMAN_MAELSTROM_WEAPON_SOURCE?.rankEffects[safeRank - 1]; + const expectedSpellId = WOW335_SHAMAN_MAELSTROM_WEAPON_RANK_SPELL_IDS[safeRank - 1]; + if (!rankDescription || !rankEffect || rankEffect.spellId !== expectedSpellId) return null; + + const castTimeReductionPerStackPercent = Number(rankDescription.match(/reduce the cast time .*? by (\d+)%/i)?.[1]); + const maxStacks = Number(rankDescription.match(/Stacks up to (\d+)/i)?.[1]); + if (!Number.isFinite(castTimeReductionPerStackPercent) || castTimeReductionPerStackPercent <= 0 + || !Number.isFinite(maxStacks) || maxStacks <= 0) return null; + + const procsPerMinute = WOW335_SHAMAN_MAELSTROM_WEAPON_PROCS_PER_MINUTE[safeRank - 1]; + return { + rank: safeRank as 1 | 2 | 3, + spellId: expectedSpellId, + procsPerMinute, + // The source supplies a PPM rate, so chance scales with the attacking + // weapon's speed. This simulator models at most one proc per hit. + procChancePerWeaponHit: Math.min(1, procsPerMinute * weaponSpeedMs / 60_000), + durationMs: WOW335_SHAMAN_MAELSTROM_WEAPON_DURATION_MS, + maxStacks, + castTimeReductionPerStackPercent, + }; +} + +export function wow335ShamanMaelstromWeaponCastTimeReductionPercent( + profile: Pick, + stacks: number, +): number { + const safeStacks = Math.min( + profile.maxStacks, + Math.max(0, Math.trunc(Number.isFinite(stacks) ? stacks : 0)), + ); + return Math.min(100, safeStacks * profile.castTimeReductionPerStackPercent); +} diff --git a/src/game/wow335ShamanPartyRuntime.test.ts b/src/game/wow335ShamanPartyRuntime.test.ts new file mode 100644 index 00000000..8d247672 --- /dev/null +++ b/src/game/wow335ShamanPartyRuntime.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, it } from "vitest"; +import { abilityAtLevel, abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore"; +import { EMPTY_ITEM_STATS } from "./itemStats"; +import { partyAbilityAmount } from "./partyAbilityAi"; +import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG } from "./wow335ShamanTidalWavesRuntime"; + +function stableUnit(seed: string): number { + let hash = 2_166_136_261; + for (const character of seed) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function shamanAbility(name: string) { + const source = abilitiesForClass("shaman").find((candidate) => candidate.name === name); + expect(source, `generated Shaman ${name}`).toBeDefined(); + return abilityAtLevel(source!, 80); +} + +function onlyReady(member: PartyMember, abilityName: string): PartyMember { + const readyAbility = shamanAbility(abilityName); + return { + ...member, + cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((candidate) => [ + candidate.id, + candidate.id === readyAbility.id ? 0 : 1_000_000, + ])), + activeCast: null, + activeCastTargetId: null, + nextActionAt: 0, + }; +} + +function noAbilitiesReady(member: PartyMember): PartyMember { + return { + ...member, + cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((candidate) => [candidate.id, 1_000_000])), + activeCast: null, + activeCastTargetId: null, + nextActionAt: 0, + }; +} + +async function setupShamanParty(options: { + readonly tidalRank?: number; + readonly riptideRank?: number; + readonly waterShieldRank?: number; + readonly secondShaman?: boolean; + readonly secondShamanTidalRank?: number; + readonly spellPower?: number; + readonly criticalStrikeRating?: number; +} = {}) { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + await preloadAbilityCatalogsForClasses(["shaman", "warrior"]); + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + useCombatStore.setState({ auras: [], effects: [], combatEvents: [] }); + usePartyStore.getState().initializeParty("shaman-party-runtime", 80, useCombatStore.getState().health, "damage", 3); + + const talentRanks = { + "talent-2063": options.tidalRank ?? 0, + "talent-2064": options.riptideRank ?? 0, + "talent-583": options.waterShieldRank ?? 0, + }; + const firstShamanBase = createConfiguredPartyMember({ + name: "Party Shaman", + classId: "shaman", + specialization: "Restoration", + specializationId: "shaman:restoration", + talentTreeId: "shaman-restoration", + talentRanks, + role: "healer", + }, 80, 0, "shaman-party-runtime:first"); + const spellPower = options.spellPower ?? 0; + const criticalStrikeRating = options.criticalStrikeRating ?? 0; + const firstShaman: PartyMember = { + ...onlyReady(firstShamanBase, "Healing Wave"), + gearStats: { ...EMPTY_ITEM_STATS, spellPower, criticalStrikeRating }, + resource: 10_000, + maxResource: 20_000, + resourcePools: { ...firstShamanBase.resourcePools, mana: 10_000 }, + health: firstShamanBase.maxHealth, + }; + const tankBase = createConfiguredPartyMember({ + name: "Party Tank", + classId: "warrior", + specialization: "Protection", + specializationId: "warrior:protection", + talentTreeId: "warrior-protection", + role: "tank", + }, 80, 1, "shaman-party-runtime:tank"); + const tank: PartyMember = { + ...tankBase, + health: Math.round(tankBase.maxHealth * 0.25), + activeCast: null, + activeCastTargetId: null, + nextActionAt: 0, + }; + const members: PartyMember[] = [firstShaman, tank]; + if (options.secondShaman) { + const secondBase = createConfiguredPartyMember({ + name: "Second Party Shaman", + classId: "shaman", + specialization: "Restoration", + specializationId: "shaman:restoration", + talentTreeId: "shaman-restoration", + talentRanks: { + "talent-2063": options.secondShamanTidalRank ?? 0, + "talent-2064": 1, + "talent-583": 0, + }, + role: "healer", + }, 80, 2, "shaman-party-runtime:second"); + members.push({ + ...onlyReady(secondBase, "Healing Wave"), + gearStats: { ...EMPTY_ITEM_STATS }, + resource: 10_000, + maxResource: 20_000, + resourcePools: { ...secondBase.resourcePools, mana: 10_000 }, + health: secondBase.maxHealth, + }); + } + usePartyStore.setState({ members }); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]); + return usePartyStore.getState().members; +} + +function findPartyHealTime( + memberId: string, + abilityId: string, + targetId: string, + predicate: (roll: number, now: number) => boolean, + from = 20_000, +): number { + for (let now = from; now < from + 100_000; now += 1) { + if (predicate(stableUnit(`${memberId}:${abilityId}:${targetId}:${now}:wow335-shaman-improved-water-shield`), now)) { + return now; + } + } + throw new Error("Could not find a seeded Shaman party healing proc timestamp."); +} + +describe("party WoW Shaman healing talents", () => { + it("procs rank-owned Improved Water Shield from its own critical Healing Wave and restores only its mana", async () => { + const members = await setupShamanParty({ + tidalRank: 3, + riptideRank: 1, + waterShieldRank: 3, + spellPower: 1_000, + criticalStrikeRating: 100_000, + secondShaman: true, + }); + const shaman = members[0]; + const tank = members[1]; + const secondShaman = members[2]; + const playerManaBefore = useCombatStore.getState().resourcePools.mana; + const riptide = shamanAbility("Riptide"); + const healingWave = shamanAbility("Healing Wave"); + const riptideAt = findPartyHealTime( + shaman.id, + riptide.id, + tank.id, + (roll, now) => roll >= 0.3 && stableUnit( + `${shaman.id}:${healingWave.id}:${tank.id}:${now + 3_500}:wow335-shaman-improved-water-shield`, + ) < 0.6, + ); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === shaman.id + ? onlyReady(member, "Riptide") + : member.id === secondShaman.id ? noAbilitiesReady(member) : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(riptideAt); + const tidalAura = useCombatStore.getState().auras.find((aura) => ( + aura.sourceId === shaman.id + && aura.targetId === shaman.id + && aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) + )); + expect(tidalAura).toMatchObject({ stacks: 2, expiresAt: riptideAt + 15_000 }); + expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ + kind: "hot", + sourceActorId: shaman.id, + sourceAbilityId: riptide.id, + targetId: tank.id, + })); + + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === shaman.id + ? onlyReady(member, "Healing Wave") + : member.id === secondShaman.id ? noAbilitiesReady(member) : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(riptideAt + 1_500); + const activeCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; + expect(activeCast).toMatchObject({ + abilityId: healingWave.id, + originalDurationMs: Math.round(healingWave.castTimeMs * 0.8), + tidalWavesChargesConsumed: 2, + }); + expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === tidalAura?.instanceId)?.stacks).toBe(1); + + advanceDungeonPartyCombat(activeCast!.completesAt); + + const combat = useCombatStore.getState(); + const healingEvent = [...combat.combatEvents].reverse().find((event) => ( + event.kind === "healing" && event.sourceActorId === shaman.id && event.abilityId === healingWave.id + )); + const waterShieldEvent = [...combat.combatEvents].reverse().find((event) => ( + event.kind === "resource" && event.sourceActorId === shaman.id + && event.abilityId === "wow335-shaman-improved-water-shield" + )); + const effect = healingWave.effects.find((candidate) => candidate.kind === "heal"); + expect(effect).toBeDefined(); + const ordinaryHeal = partyAbilityAmount(shaman, healingWave, effect!); + expect(healingEvent).toMatchObject({ targetActorId: tank.id, school: "nature", critical: true }); + expect(healingEvent?.rawAmount).toBe(Math.round((ordinaryHeal + 120) * 1.5)); + expect(waterShieldEvent).toMatchObject({ + targetActorId: shaman.id, + effectiveAmount: 400, + overheal: 0, + }); + expect(combat.combatEvents).not.toContainEqual(expect.objectContaining({ + kind: "resource", + sourceActorId: secondShaman.id, + abilityId: "wow335-shaman-improved-water-shield", + })); + expect(combat.actors[shaman.id]?.resources.mana.current).toBe( + usePartyStore.getState().members.find((member) => member.id === shaman.id)?.resourcePools.mana, + ); + expect(combat.resourcePools.mana).toBe(playerManaBefore); + expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ + kind: "hot", + sourceActorId: shaman.id, + sourceAbilityId: riptide.id, + targetId: tank.id, + })); + }); + + it("adds party Tidal Waves charges to Lesser Healing Wave crit chance and consumes one charge", async () => { + const members = await setupShamanParty({ tidalRank: 3, riptideRank: 1 }); + const shaman = members[0]; + const tank = members[1]; + const riptide = shamanAbility("Riptide"); + const lesserHealingWave = shamanAbility("Lesser Healing Wave"); + let riptideAt = 1_000; + for (; riptideAt < 100_000; riptideAt += 1) { + if (stableUnit(`${shaman.id}:${lesserHealingWave.id}:${tank.id}:${riptideAt + 3_000}:heal`) < 0.2) break; + } + expect(riptideAt).toBeLessThan(100_000); + + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Riptide") : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(riptideAt); + expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ + sourceId: shaman.id, + targetId: shaman.id, + stacks: 2, + definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }), + })); + + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === shaman.id + ? onlyReady(member, "Lesser Healing Wave") + : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(riptideAt + 1_500); + const activeCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; + expect(activeCast).toMatchObject({ + abilityId: lesserHealingWave.id, + originalDurationMs: lesserHealingWave.castTimeMs, + tidalWavesChargesConsumed: 2, + }); + expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ + sourceId: shaman.id, + targetId: shaman.id, + stacks: 1, + definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }), + })); + advanceDungeonPartyCombat(activeCast!.completesAt); + + const healingEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "healing" && event.sourceActorId === shaman.id && event.abilityId === lesserHealingWave.id + )); + expect(healingEvent).toMatchObject({ targetActorId: tank.id, critical: true }); + }); + + it("expires party Tidal Waves after 15 seconds and ignores another Shaman's Riptide HoT", async () => { + const members = await setupShamanParty({ tidalRank: 3, riptideRank: 1, secondShaman: true }); + const shaman = members[0]; + const tank = members[1]; + const secondShaman = members[2]; + const riptide = shamanAbility("Riptide"); + const chainHeal = shamanAbility("Chain Heal"); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === shaman.id + ? onlyReady(member, "Riptide") + : member.id === secondShaman.id + ? { ...onlyReady(member, "Healing Wave"), cooldowns: Object.fromEntries( + abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000]), + ) } + : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + const riptideAt = 1_000; + advanceDungeonPartyCombat(riptideAt); + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.sourceId === shaman.id + && candidate.targetId === shaman.id + && candidate.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) + )); + expect(aura).toMatchObject({ stacks: 2, expiresAt: 16_000 }); + + const initialHot = useCombatStore.getState().effects.find((effect) => ( + effect.kind === "hot" && effect.sourceActorId === shaman.id && effect.targetId === tank.id + )); + expect(initialHot?.sourceAbilityId).toBe(riptide.id); + const chainEffect = chainHeal.effects.find((effect) => effect.kind === "heal"); + expect(chainEffect).toBeDefined(); + const baseAmount = partyAbilityAmount(secondShaman, chainHeal, chainEffect!); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === secondShaman.id + ? onlyReady(member, "Chain Heal") + : member.id === shaman.id + ? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) } + : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(riptideAt + 1_000); + const secondCast = usePartyStore.getState().members.find((member) => member.id === secondShaman.id)?.activeCast; + expect(secondCast?.abilityId).toBe(chainHeal.id); + advanceDungeonPartyCombat(secondCast!.completesAt); + const secondHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "healing" && event.sourceActorId === secondShaman.id + && event.abilityId === chainHeal.id && event.targetActorId === tank.id + )); + expect(secondHeal?.rawAmount).toBe(baseAmount); + expect(useCombatStore.getState().auras).not.toContainEqual(expect.objectContaining({ + sourceId: secondShaman.id, + targetId: secondShaman.id, + definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }), + })); + expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ + id: initialHot?.id, + sourceActorId: shaman.id, + targetId: tank.id, + })); + + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === shaman.id + ? onlyReady(member, "Healing Wave") + : member.id === secondShaman.id + ? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) } + : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(16_000); + const expiredCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; + expect(expiredCast?.abilityId).toBe(shamanAbility("Healing Wave").id); + expect(expiredCast?.originalDurationMs).toBe(shamanAbility("Healing Wave").castTimeMs); + expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura?.instanceId)?.stacks).toBe(2); + }); + + it("consumes only the Riptide caster's matching HoT for the Chain Heal primary target", async () => { + const members = await setupShamanParty({ + riptideRank: 1, + secondShaman: true, + secondShamanTidalRank: 3, + }); + const shaman = members[0]; + const tank = members[1]; + const secondShaman = members[2]; + const riptide = shamanAbility("Riptide"); + const chainHeal = shamanAbility("Chain Heal"); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === shaman.id + ? onlyReady(member, "Riptide") + : member.id === secondShaman.id + ? { ...onlyReady(member, "Healing Wave"), cooldowns: Object.fromEntries( + abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000]), + ) } + : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(1_000); + const riptideHot = useCombatStore.getState().effects.find((effect) => ( + effect.kind === "hot" && effect.sourceActorId === shaman.id + && effect.sourceAbilityId === riptide.id && effect.targetId === tank.id + )); + expect(riptideHot).toBeDefined(); + + const baseAmount = partyAbilityAmount(secondShaman, chainHeal, chainHeal.effects.find((effect) => effect.kind === "heal")!); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === secondShaman.id + ? onlyReady(member, "Chain Heal") + : member.id === shaman.id + ? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) } + : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(2_500); + const otherCasterCast = usePartyStore.getState().members.find((member) => member.id === secondShaman.id)?.activeCast; + advanceDungeonPartyCombat(otherCasterCast!.completesAt); + const otherCasterHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "healing" && event.sourceActorId === secondShaman.id + && event.abilityId === chainHeal.id && event.targetActorId === tank.id + )); + expect(otherCasterHeal?.rawAmount).toBe(baseAmount); + expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ + sourceId: secondShaman.id, + targetId: secondShaman.id, + stacks: 3, + definition: expect.objectContaining({ + utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]), + }), + })); + expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ id: riptideHot?.id })); + + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === shaman.id + ? onlyReady(member, "Chain Heal") + : member.id === secondShaman.id + ? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) } + : member), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(otherCasterCast!.completesAt + 1_000); + const ownCasterCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; + advanceDungeonPartyCombat(ownCasterCast!.completesAt); + const ownCasterHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "healing" && event.sourceActorId === shaman.id + && event.abilityId === chainHeal.id && event.targetActorId === tank.id + )); + expect(ownCasterHeal?.rawAmount).toBe(Math.round(baseAmount * 1.25)); + expect(useCombatStore.getState().effects).not.toContainEqual(expect.objectContaining({ id: riptideHot?.id })); + }); +}); diff --git a/src/game/wow335ShamanTalentRuntime.test.ts b/src/game/wow335ShamanTalentRuntime.test.ts new file mode 100644 index 00000000..f440f73b --- /dev/null +++ b/src/game/wow335ShamanTalentRuntime.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers"; + +const CONVECTION_SPELLS = [ + "Earth Shock", + "Flame Shock", + "Frost Shock", + "Lightning Bolt", + "Chain Lightning", + "Lava Burst", + "Earthquake", + "Wind Shear", +] as const; + +describe("WoW Shaman Convection source scopes", () => { + it.each([1, 2, 3, 4, 5])("applies the generated rank %i reduction to every listed ability", (rank) => { + const modifiers = talentModifiersForRanks({ "talent-564": rank }, "shaman"); + const convection = modifiers.filter((modifier) => modifier.sourceTalentId === "talent-564"); + expect(convection).toHaveLength(CONVECTION_SPELLS.length); + + for (const abilityName of CONVECTION_SPELLS) { + const tags = abilityName === "Frost Shock" ? ["frost"] : []; + expect(applyTalentModifierValue(100, convection, "resource-cost-percent", { abilityName, tags }), abilityName) + .toBe(100 - rank * 3); + } + expect(applyTalentModifierValue(100, convection, "resource-cost-percent", { abilityName: "Healing Wave" })) + .toBe(100); + }); + + it("does not create a Convection cost modifier without a trained rank", () => { + expect(talentModifiersForRanks({ "talent-564": 0 }, "shaman") + .filter((modifier) => modifier.sourceTalentId === "talent-564")).toEqual([]); + }); +}); diff --git a/src/game/wow335ShamanTalentRuntime.ts b/src/game/wow335ShamanTalentRuntime.ts new file mode 100644 index 00000000..6d74da14 --- /dev/null +++ b/src/game/wow335ShamanTalentRuntime.ts @@ -0,0 +1,50 @@ +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export const WOW335_SHAMAN_CONVECTION_TALENT_ID = "talent-564"; + +const SHAMAN_CONVECTION_SOURCE = WOW335_TALENT_NODES.find((node) => ( + node.id === WOW335_SHAMAN_CONVECTION_TALENT_ID +)); + +const SHOCK_ABILITY_NAMES = ["Earth Shock", "Flame Shock", "Frost Shock"] as const; + +export interface Wow335ShamanConvectionModifier { + readonly kind: "resource-cost-percent"; + /** Cost reductions use a negative modifier amount. */ + readonly amount: number; + readonly unit: "percent"; + readonly scope: string; +} + +/** + * Builds Convection's exact spell scopes from the generated rank tooltip. + * The generic tooltip parser stops at the comma after "Shock", which drops + * Lightning Bolt and the remaining named spells from this six-spell list. + */ +export function wow335ShamanConvectionModifiersForRank( + rank: number, +): readonly Wow335ShamanConvectionModifier[] { + const safeRank = Math.max(0, Math.min(5, Math.trunc(Number.isFinite(rank) ? rank : 0))); + const description = SHAMAN_CONVECTION_SOURCE?.rankDescriptions[safeRank - 1]; + if (!description) return []; + + const match = description.match(/reduces the mana cost of your (.+?) spells by (\d+(?:\.\d+)?)%/i); + if (!match) return []; + const percent = Number(match[2]); + if (!Number.isFinite(percent) || percent <= 0) return []; + + const tooltipNames = match[1]! + .replace(/\s+(?:and|or)\s+/gi, ",") + .split(",") + .map((name) => name.trim()) + .filter(Boolean); + const spellNames = tooltipNames.flatMap((name) => ( + name.toLowerCase() === "shock" ? [...SHOCK_ABILITY_NAMES] : [name] + )); + return [...new Set(spellNames)].map((name) => ({ + kind: "resource-cost-percent", + amount: -percent, + unit: "percent", + scope: `mana cost of your ${name}`, + })); +} diff --git a/src/game/wow335ShamanTidalWavesRuntime.test.ts b/src/game/wow335ShamanTidalWavesRuntime.test.ts new file mode 100644 index 00000000..f99ad5f6 --- /dev/null +++ b/src/game/wow335ShamanTidalWavesRuntime.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; +import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { usePartyStore } from "./partyStore"; +import { talentModifiersForRanks } from "./talentModifiers"; +import { deriveCharacterStats } from "./wotlkStats"; +import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG } from "./wow335ShamanTidalWavesRuntime"; + +function shamanAbility(name: string) { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, `Shaman ${name}`).toBeDefined(); + return ability!; +} + +function setTidalWavesRank(rank: number): void { + useCombatStore.setState((state) => { + const talentRanks = { ...state.talentRanks, "talent-2063": rank }; + const passiveModifiers = state.talentModifiers.filter((modifier) => ( + modifier.sourceTalentId?.startsWith("class-passive:") + )); + return { + talentRanks, + talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")], + }; + }); +} + +function setRiptideRank(rank = 1): void { + useCombatStore.setState((state) => { + const talentRanks = { ...state.talentRanks, "talent-2064": rank }; + const passiveModifiers = state.talentModifiers.filter((modifier) => ( + modifier.sourceTalentId?.startsWith("class-passive:") + )); + return { + talentRanks, + talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")], + }; + }); +} + +async function setupShaman(): Promise { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["shaman"]); + useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-shaman-tidal-waves", 80, useCombatStore.getState().health); + usePartyStore.getState().members.forEach((member, index) => { + registerPartyRuntimePosition(member.id, [index + 1, 0, 0]); + }); + useCombatStore.setState((state) => { + const talentRanks = { "talent-2064": 1 }; + const passiveModifiers = state.talentModifiers.filter((modifier) => ( + modifier.sourceTalentId?.startsWith("class-passive:") + )); + return { + health: Math.max(1, state.maxHealth - 5_000), + talentRanks, + talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")], + auras: [], + effects: [], + activeCast: null, + gearStats: { ...state.gearStats, criticalStrikeRating: 0, hasteRating: 0, spellPower: 0 }, + resource: 8_000, + maxResource: 10_000, + resourcePools: { ...state.resourcePools, mana: 8_000 }, + maxResourcePools: { ...state.maxResourcePools, mana: 10_000 }, + }; + }); +} + +function cast(name: string, now: number, targetActorId: string | null = null) { + const ability = shamanAbility(name); + const result = useCombatStore.getState().castAbility(ability.id, { targetActorId, now }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + return { ability, result, activeCast: useCombatStore.getState().activeCast }; +} + +function finishCast(now: number): void { + const activeCast = useCombatStore.getState().activeCast; + if (activeCast) useCombatStore.getState().tick(0.1, now); +} + +function tidalWavesAura() { + return useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) + )); +} + +function healRoll(abilityId: string, targetId: string, impactAt: number): number { + let hash = 2_166_136_261; + for (const character of `${PLAYER_AURA_ENTITY_ID}:${abilityId}:${targetId}:${impactAt}:heal`) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0) / 0x1_0000_0000; +} + +function findHealImpactAt( + abilityId: string, + minimumChancePercent: number, + maximumChancePercent: number, + startAt: number, + endAt: number, + targetId = PLAYER_AURA_ENTITY_ID, +): number { + for (let impactAt = startAt; impactAt <= endAt; impactAt += 1) { + const roll = healRoll(abilityId, targetId, impactAt); + if (roll >= minimumChancePercent / 100 && roll < maximumChancePercent / 100) return impactAt; + } + throw new Error("Could not find a seeded Shaman healing roll in the requested interval."); +} + +function lastHealingEvent(abilityId: string) { + return [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "healing" && event.abilityId === abilityId + )); +} + +describe("WoW Shaman Tidal Waves", () => { + beforeEach(setupShaman); + + it.each([ + { rank: 1, now: 1_500, expectedCharges: 2 }, + { rank: 1, now: 1_000, expectedCharges: 0 }, + { rank: 2, now: 1_500, expectedCharges: 2 }, + { rank: 2, now: 1_000, expectedCharges: 0 }, + { rank: 3, now: 1_000, expectedCharges: 2 }, + ])("uses rank $rank's actual Riptide proc chance at $now", ({ rank, now, expectedCharges }) => { + setRiptideRank(); + setTidalWavesRank(rank); + expect(talentModifiersForRanks({ "talent-2063": rank }, "shaman")).toEqual([]); + + const { ability } = cast("Riptide", now); + expect(lastHealingEvent(ability.id)).toBeDefined(); + finishCast(now); + + expect(tidalWavesAura()?.stacks ?? 0).toBe(expectedCharges); + if (expectedCharges > 0) expect(tidalWavesAura()?.expiresAt).toBe(now + 15_000); + }); + + it("grants three Chain Heal charges and consumes one per Healing Wave cast", () => { + setTidalWavesRank(3); + const { ability: chainHeal, activeCast: chainCast } = cast("Chain Heal", 1_000); + expect(chainCast?.completesAt).toBe(3_500); + finishCast(3_500); + expect(lastHealingEvent(chainHeal.id)).toBeDefined(); + expect(tidalWavesAura()?.stacks).toBe(3); + expect(tidalWavesAura()?.expiresAt).toBe(18_500); + + const expectedDurations = [2_100, 2_400, 2_700]; + const startsAt = [4_000, 6_200, 8_700]; + for (const [index, startAt] of startsAt.entries()) { + const { activeCast } = cast("Healing Wave", startAt); + expect(activeCast?.originalDurationMs).toBe(expectedDurations[index]); + expect(tidalWavesAura()?.stacks ?? 0).toBe(2 - index); + finishCast(activeCast!.completesAt); + } + expect(tidalWavesAura()).toBeUndefined(); + }); + + it("uses Riptide charges for Lesser Healing Wave crit chance and spends one charge", () => { + setRiptideRank(); + setTidalWavesRank(3); + cast("Riptide", 1_000); + expect(tidalWavesAura()?.stacks).toBe(2); + + const lesserHealingWave = shamanAbility("Lesser Healing Wave"); + const state = useCombatStore.getState(); + const baseCritPercent = deriveCharacterStats( + state.classId, + state.level, + state.gearStats, + state.raceId, + {}, + state.secondaryClassId, + ).spellCritPercent; + const impactAt = findHealImpactAt( + lesserHealingWave.id, + baseCritPercent, + baseCritPercent + 20, + 8_000, + 15_000, + ); + const { activeCast } = cast("Lesser Healing Wave", impactAt - 1_500); + expect(activeCast?.completesAt).toBe(impactAt); + expect(activeCast?.tidalWavesChargesConsumed).toBe(2); + expect(tidalWavesAura()?.stacks).toBe(1); + finishCast(impactAt); + expect(lastHealingEvent(lesserHealingWave.id)?.critical).toBe(true); + }); + + it("consumes only the primary target's Riptide HoT for its Chain Heal bonus", async () => { + expect(talentModifiersForRanks({ "talent-2064": 1 }, "shaman")).toEqual([]); + const chainHeal = shamanAbility("Chain Heal"); + const primaryTarget = usePartyStore.getState().members[0]; + expect(primaryTarget).toBeDefined(); + const baselineState = useCombatStore.getState(); + const baselineCrit = deriveCharacterStats( + baselineState.classId, + baselineState.level, + baselineState.gearStats, + baselineState.raceId, + {}, + baselineState.secondaryClassId, + ).spellCritPercent; + const impactAt = findHealImpactAt( + chainHeal.id, + baselineCrit, + 100, + 5_000, + 10_000, + primaryTarget.id, + ); + + const { activeCast: baselineCast } = cast("Chain Heal", impactAt - 2_500, primaryTarget.id); + expect(baselineCast?.completesAt).toBe(impactAt); + finishCast(impactAt); + const baselineEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "healing" && event.abilityId === chainHeal.id && event.targetActorId === primaryTarget.id + )); + expect(baselineEvent?.critical).toBe(false); + expect(baselineEvent?.rawAmount).toBeGreaterThan(0); + + await setupShaman(); + const sameTarget = usePartyStore.getState().members[0]; + const sameTargetRiptide = cast("Riptide", 1_000, sameTarget.id); + const riptideHot = useCombatStore.getState().effects.find((effect) => ( + effect.kind === "hot" + && effect.sourceAbilityId === sameTargetRiptide.ability.id + && effect.targetId === sameTarget.id + )); + expect(riptideHot).toMatchObject({ remainingTicks: 5, intervalMs: 3_000 }); + const { activeCast: empoweredCast } = cast("Chain Heal", impactAt - 2_500, sameTarget.id); + expect(empoweredCast?.completesAt).toBe(impactAt); + finishCast(impactAt); + const empoweredEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "healing" && event.abilityId === chainHeal.id && event.targetActorId === sameTarget.id + )); + expect(empoweredEvent?.critical).toBe(false); + expect(empoweredEvent?.rawAmount).toBe(Math.round((baselineEvent?.rawAmount ?? 0) * 1.25)); + expect(useCombatStore.getState().effects).not.toContainEqual(expect.objectContaining({ + kind: "hot", + sourceAbilityId: sameTargetRiptide.ability.id, + targetId: sameTarget.id, + })); + + await setupShaman(); + const riptideTarget = usePartyStore.getState().members[0]; + const differentChainHealTarget = usePartyStore.getState().members[1]; + const mismatchedRiptide = cast("Riptide", 1_000, riptideTarget.id); + const { activeCast: mismatchedCast } = cast( + "Chain Heal", + impactAt - 2_500, + differentChainHealTarget.id, + ); + expect(mismatchedCast?.completesAt).toBe(impactAt); + finishCast(impactAt); + const mismatchedEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( + event.kind === "healing" + && event.abilityId === chainHeal.id + && event.targetActorId === differentChainHealTarget.id + )); + expect(mismatchedEvent?.rawAmount).toBe(baselineEvent?.rawAmount); + expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ + kind: "hot", + sourceAbilityId: mismatchedRiptide.ability.id, + targetId: riptideTarget.id, + })); + }); + + it("adds the rank's bonus-healing coefficient without passively granting cast or crit effects", async () => { + const spellPower = 1_000; + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, spellPower }, + })); + const lesserHealingWave = shamanAbility("Lesser Healing Wave"); + const baselineState = useCombatStore.getState(); + const baselineCrit = deriveCharacterStats( + baselineState.classId, + baselineState.level, + baselineState.gearStats, + baselineState.raceId, + {}, + baselineState.secondaryClassId, + ).spellCritPercent; + const impactAt = findHealImpactAt( + lesserHealingWave.id, + baselineCrit, + 100, + 10_000, + 15_000, + ); + const castStart = impactAt - 1_500; + const { activeCast: baselineCast } = cast("Lesser Healing Wave", castStart); + expect(baselineCast?.originalDurationMs).toBe(1_500); + finishCast(impactAt); + const baselineEvent = lastHealingEvent(lesserHealingWave.id); + expect(baselineEvent?.critical).toBe(false); + const baselineRawAmount = baselineEvent?.rawAmount ?? 0; + + await setupShaman(); + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, spellPower }, + })); + setTidalWavesRank(3); + const rankThreeHealingWave = shamanAbility("Healing Wave"); + const { activeCast: unchargedHealingWave } = cast("Healing Wave", 10_000); + expect(unchargedHealingWave?.originalDurationMs).toBe(3_000); + finishCast(13_000); + const rankThreeHeal = lastHealingEvent(rankThreeHealingWave.id); + expect(rankThreeHeal?.rawAmount).toBeGreaterThan(0); + + await setupShaman(); + useCombatStore.setState((state) => ({ + gearStats: { ...state.gearStats, spellPower }, + })); + setTidalWavesRank(3); + const rankedLesserHealingWave = shamanAbility("Lesser Healing Wave"); + const { activeCast: rankThreeCast } = cast("Lesser Healing Wave", castStart); + expect(rankThreeCast?.originalDurationMs).toBe(1_500); + finishCast(impactAt); + const rankThreeEvent = lastHealingEvent(rankedLesserHealingWave.id); + expect(rankThreeEvent?.critical).toBe(false); + expect(rankThreeEvent?.rawAmount).toBe(baselineRawAmount + 120); + }); +}); diff --git a/src/game/wow335ShamanTidalWavesRuntime.ts b/src/game/wow335ShamanTidalWavesRuntime.ts new file mode 100644 index 00000000..086337e6 --- /dev/null +++ b/src/game/wow335ShamanTidalWavesRuntime.ts @@ -0,0 +1,43 @@ +export const WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG = "wow335-shaman-tidal-waves"; +// The generated rank rows reference child 53390 but do not include its aura duration. +// WotLKDB's 3.3.5a child row supplies the missing 15-second lifetime. +export const WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS = 15_000; +export const WOW335_SHAMAN_TIDAL_WAVES_MAX_CHARGES = 3; + +const TIDAL_WAVES_PROC_CHANCE_BY_RANK = [0, 0.33, 0.66, 1] as const; +const TIDAL_WAVES_BONUS_HEALING_PERCENT_BY_RANK = [0, 0.04, 0.08, 0.12] as const; + +function normalizedTidalWavesRank(rank: number): number { + return Number.isFinite(rank) ? Math.max(0, Math.min(3, Math.trunc(rank))) : 0; +} + +function normalizedTidalWavesCharges(charges: number): number { + return Number.isFinite(charges) + ? Math.max(0, Math.min(WOW335_SHAMAN_TIDAL_WAVES_MAX_CHARGES, Math.trunc(charges))) + : 0; +} + +export function wow335ShamanTidalWavesProcChance(rank: number): number { + return TIDAL_WAVES_PROC_CHANCE_BY_RANK[normalizedTidalWavesRank(rank)] ?? 0; +} + +export function wow335ShamanTidalWavesChargesForTrigger(abilityName: string): number { + if (abilityName === "Chain Heal") return 3; + if (abilityName === "Riptide") return 2; + return 0; +} + +export function wow335ShamanTidalWavesCastTimeMultiplier(charges: number): number { + return 1 - normalizedTidalWavesCharges(charges) * 0.1; +} + +export function wow335ShamanTidalWavesCriticalChanceBonusPercent( + abilityName: string, + charges: number, +): number { + return abilityName === "Lesser Healing Wave" ? normalizedTidalWavesCharges(charges) * 10 : 0; +} + +export function wow335ShamanTidalWavesBonusHealingPercent(rank: number): number { + return TIDAL_WAVES_BONUS_HEALING_PERCENT_BY_RANK[normalizedTidalWavesRank(rank)] ?? 0; +} diff --git a/src/game/wow335WarlockMetamorphosisCombat.test.ts b/src/game/wow335WarlockMetamorphosisCombat.test.ts new file mode 100644 index 00000000..117bb724 --- /dev/null +++ b/src/game/wow335WarlockMetamorphosisCombat.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForClass, abilityById, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { useCombatStore } from "./combatStore"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, resetPartyRuntimeClock } from "./partyRuntime"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; +import { + WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG, + WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG, +} from "./wow335WarlockMetamorphosisRuntime"; + +function abilityId(name: string): string { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + return ability!.id; +} + +function learnMetamorphosis(): void { + const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === "Metamorphosis"); + expect(talent).toBeDefined(); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, [talent!.id]: 1 }, + })); +} + +function addNearbyTarget(id: string): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 }); + setMobPosition(id, [3, 0, 0]); +} + +describe("WoW Warlock Metamorphosis combat integration", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + await preloadAbilityCatalogsForClasses(["warlock"]); + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-warlock-metamorphosis", 80, + useCombatStore.getState().health, "damage", 2); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + })); + }); + + it("applies its timed form, permits Fire/Shadow, rejects unsupported spells, and gates form buttons", () => { + learnMetamorphosis(); + addNearbyTarget("form-gate-target"); + const formAbilityId = abilityId("Metamorphosis"); + const shadowCleaveId = "wow335-warlock-shadow-cleave"; + const deniedCleave = useCombatStore.getState().castAbility(shadowCleaveId, undefined, 1_000); + expect(deniedCleave).toMatchObject({ ok: false, reason: "condition-not-met" }); + + const transformed = useCombatStore.getState().castAbility(formAbilityId, undefined, 1_000); + expect(transformed.ok).toBe(true); + const formAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) + )); + expect(formAura?.expiresAt).toBe(31_000); + expect(useCombatStore.getState().cooldowns[formAbilityId]).toBe(181_000); + + const unsupportedSpell = useCombatStore.getState().castAbility(abilityId("Summon Imp"), undefined, 1_100); + expect(unsupportedSpell).toMatchObject({ ok: false, reason: "condition-not-met" }); + expect(useCombatStore.getState().castAbility(shadowCleaveId, undefined, 1_200).ok).toBe(true); + expect(useCombatStore.getState().castAbility(abilityId("Shadow Bolt"), undefined, 7_300).ok).toBe(true); + }); + + it("ticks Immolation only while in control and refreshes its source-owned mob AP debuff", () => { + learnMetamorphosis(); + addNearbyTarget("immolation-target"); + expect(useCombatStore.getState().castAbility(abilityId("Metamorphosis"), undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().castAbility("wow335-warlock-immolation-aura", undefined, 2_000).ok).toBe(true); + expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" + && effect.tag === WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG)).toBe(true); + + useCombatStore.setState({ controlledUntil: 6_000, controlMechanic: "stun" }); + useCombatStore.getState().tick(0.1, 3_000); + expect(useCombatStore.getState().mobs["immolation-target"]!.auras.some((aura) => ( + aura.id.startsWith("wow335:warlock:immolation-ap:") + ))).toBe(false); + + useCombatStore.setState({ controlledUntil: 0, controlMechanic: null }); + useCombatStore.getState().tick(0.1, 4_000); + const mobAura = useCombatStore.getState().mobs["immolation-target"]!.auras.find((aura) => ( + aura.id.startsWith("wow335:warlock:immolation-ap:") + )); + expect(mobAura).toMatchObject({ magnitude: 357, endsAt: 34_000 }); + expect(mobAura?.sourceActorId).toBe(PLAYER_AGGRO_ID); + }); + + it("requires an active form for a companion's form-only ability and enforces its school gate", () => { + const member = usePartyStore.getState().members[0]!; + const warlock = { ...member, classId: "warlock" as const, level: 80, health: member.maxHealth }; + usePartyStore.setState({ members: [warlock] }); + registerPartyRuntimePosition(warlock.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + addNearbyTarget("companion-form-target"); + const shadowCleave = abilityById("wow335-warlock-shadow-cleave")!; + const metamorphosis = abilityById("wow335-warlock-metamorphosis")!; + expect(partyAbilityTargetIsValid(warlock, shadowCleave, "companion-form-target", undefined, 2_000)).toBe(false); + + const formEffect = metamorphosis.effects.find((effect) => effect.kind === "apply-aura")!; + expect(formEffect.kind).toBe("apply-aura"); + if (formEffect.kind === "apply-aura") { + useCombatStore.getState().applyActorAura(warlock.id, warlock.id, metamorphosis, formEffect, 1_000); + } + expect(partyAbilityTargetIsValid(warlock, shadowCleave, "companion-form-target", undefined, 2_000)).toBe(true); + const physicalStrike = { + ...shadowCleave, + id: "test-warlock-physical-strike", + name: "Physical Strike", + description: "Deals physical damage.", + effects: [{ kind: "damage" as const, coefficient: 1, school: "physical" as const }], + }; + expect(partyAbilityTargetIsValid(warlock, physicalStrike, "companion-form-target", undefined, 2_000)).toBe(false); + }); + + it("uses the active Immolation AP aura to reduce modeled enemy melee damage", () => { + const swingDamage = (immolated: boolean): number => { + clearMobRuntimeRegistry(); + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 }); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.getState().registerMob("ap-attacker", { + name: "AP attacker", serverEntry: 598, level: 80, maxHealth: 10_000, xpReward: 0, + attacks: [DEFAULT_ENEMY_ATTACK], + }); + setMobPosition("ap-attacker", [2, 0, 0]); + useCombatStore.getState().selectMob("ap-attacker"); + useCombatStore.getState().damageMob("ap-attacker", 1, 1_000); + if (immolated) { + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + "ap-attacker": { + ...state.mobs["ap-attacker"]!, + auras: [{ + id: "wow335:warlock:immolation-ap:source-warlock", + sourceAbilityId: "wow335-warlock-immolation-aura", + sourceActorId: PLAYER_AGGRO_ID, + endsAt: 31_000, + magnitude: 357, + }], + }, + }, + })); + } + const healthBeforeSwing = useCombatStore.getState().health; + useCombatStore.getState().advanceMobCombat(1_450); + return healthBeforeSwing - useCombatStore.getState().health; + }; + + const unmodified = swingDamage(false); + const reduced = swingDamage(true); + expect(unmodified).toBeGreaterThan(0); + expect(reduced).toBeGreaterThan(0); + expect(reduced).toBeLessThan(unmodified); + }); + + it("taunts only nearby mobs with Challenging Howl's sourced 10-yard radius and 6-second duration", () => { + learnMetamorphosis(); + for (const [id, x] of [["howl-near", 4], ["howl-edge", 9], ["howl-outside", 11]] as const) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 }); + setMobPosition(id, [x, 0, 0]); + } + + expect(useCombatStore.getState().castAbility(abilityId("Metamorphosis"), undefined, 1_000).ok).toBe(true); + const howl = useCombatStore.getState().castAbility("wow335-warlock-challenging-howl", undefined, 2_000); + + expect(howl.ok).toBe(true); + expect(useCombatStore.getState().mobs["howl-near"]?.forcedTarget).toEqual({ + actorId: PLAYER_AGGRO_ID, + endsAt: 8_000, + }); + expect(useCombatStore.getState().mobs["howl-edge"]?.forcedTarget).toEqual({ + actorId: PLAYER_AGGRO_ID, + endsAt: 8_000, + }); + expect(useCombatStore.getState().mobs["howl-outside"]?.forcedTarget).toBeNull(); + }); + + it("moves the player into melee range and stuns a valid-range Demon Charge target for 3 seconds", () => { + learnMetamorphosis(); + addNearbyTarget("demon-charge-target"); + setMobPosition("demon-charge-target", [10, 0, 0]); + expect(useCombatStore.getState().castAbility(abilityId("Metamorphosis"), undefined, 1_000).ok).toBe(true); + + const charge = useCombatStore.getState().castAbility("wow335-warlock-demon-charge", { + targetActorId: "demon-charge-target", + now: 2_000, + }); + + expect(charge.ok).toBe(true); + expect(useCombatStore.getState().playerPosition).toEqual([8.5, 0, 0]); + expect(useCombatStore.getState().mobs["demon-charge-target"]?.statuses).toContainEqual( + expect.objectContaining({ + kind: "stun", + sourceAbilityId: "wow335-warlock-demon-charge", + sourceActorId: PLAYER_AGGRO_ID, + endsAt: 5_000, + }), + ); + }); + + it("lets a transformed companion cast Challenging Howl on a loose nearby mob", () => { + const metamorphosisTalent = WOW335_TALENT_NODES.find((candidate) => candidate.name === "Metamorphosis")!; + const now = 1_000; + const warlock = createConfiguredPartyMember({ + name: "Metamorphosis companion", + classId: "warlock", + specialization: "Demonology", + specializationId: "warlock:demonology", + talentTreeId: "warlock-demonology", + talentRanks: { [metamorphosisTalent.id]: 1 }, + role: "tank", + }, 80, 0, "warlock-metamorphosis-companion"); + const howl = abilityById("wow335-warlock-challenging-howl")!; + const metamorphosis = abilityById("wow335-warlock-metamorphosis")!; + usePartyStore.setState({ command: "attack", members: [{ + ...warlock, + cooldowns: Object.fromEntries(abilitiesForClass("warlock") + .filter((ability) => ability.id !== howl.id) + .map((ability) => [ability.id, now + 100_000])), + }] }); + registerPartyRuntimePosition(warlock.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + const formEffect = metamorphosis.effects.find((effect) => effect.kind === "apply-aura")!; + expect(formEffect.kind).toBe("apply-aura"); + if (formEffect.kind === "apply-aura") { + useCombatStore.getState().applyActorAura(warlock.id, warlock.id, metamorphosis, formEffect, now); + } + useCombatStore.getState().registerMob("companion-howl-target", { + name: "companion-howl-target", maxHealth: 100_000, + }); + setMobPosition("companion-howl-target", [3, 0, 0]); + useCombatStore.getState().damageMob("companion-howl-target", 1, now); + useCombatStore.getState().selectMob("companion-howl-target"); + updatePartyRuntimeTargetVisibility(warlock.id, "companion-howl-target", true); + + advanceDungeonPartyCombat(now); + + expect(useCombatStore.getState().mobs["companion-howl-target"]?.forcedTarget).toMatchObject({ + actorId: warlock.id, + endsAt: now + 6_000, + }); + expect(usePartyStore.getState().members[0]?.cooldowns[howl.id]).toBe(now + 180_000); + }); +}); diff --git a/src/game/wow335WarlockMetamorphosisRuntime.test.ts b/src/game/wow335WarlockMetamorphosisRuntime.test.ts new file mode 100644 index 00000000..f38337ba --- /dev/null +++ b/src/game/wow335WarlockMetamorphosisRuntime.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { applyAura, resolveDamageValue, resolveStatValue } from "./combatAuras"; +import { + WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG, + WOW335_WARLOCK_METAMORPHOSIS_FORM_ID, + WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS, + warlockMetamorphosisCanCastAbilityWhileActive, + warlockMetamorphosisAbilityEligible, + warlockMetamorphosisFormActionReady, + wow335WarlockImmolationAttackPowerAura, + wow335WarlockImmolationAttackPowerReduction, + wow335WarlockMetamorphosisAuraForRank, + wow335WarlockMetamorphosisControlDuration, + wow335WarlockMetamorphosisEffectsForRank, +} from "./wow335WarlockMetamorphosisRuntime"; + +describe("WoW Warlock Metamorphosis profile", () => { + it("uses the generated 3.3.5a rank values and leaves unlearned ranks inactive", () => { + expect(wow335WarlockMetamorphosisEffectsForRank(0)).toBeNull(); + expect(wow335WarlockMetamorphosisEffectsForRank(1)).toEqual({ + rank: 1, + durationMs: 30_000, + cooldownMs: 180_000, + armorContributionPercent: 130, + damagePercent: 15, + meleeCritChanceTakenReductionPercent: 6, + stunSnareDurationReductionPercent: 25, + allowedSchools: ["fire", "shadow"], + pvpDamageBonusPercent: null, + }); + expect(wow335WarlockMetamorphosisEffectsForRank(Number.NaN)).toBeNull(); + }); + + it("allows only Fire/Shadow or explicitly shapeshift-usable abilities while active", () => { + expect(warlockMetamorphosisAbilityEligible(false, {})).toBe(true); + expect(warlockMetamorphosisAbilityEligible(true, { school: "fire" })).toBe(true); + expect(warlockMetamorphosisAbilityEligible(true, { school: "shadow" })).toBe(true); + expect(warlockMetamorphosisAbilityEligible(true, { schools: ["holy", "shadow"] })).toBe(true); + expect(warlockMetamorphosisAbilityEligible(true, { + school: "arcane", + usableWhileShapeshifted: true, + })).toBe(true); + expect(warlockMetamorphosisAbilityEligible(true, { school: "arcane" })).toBe(false); + expect(warlockMetamorphosisAbilityEligible(true, { school: "physical" })).toBe(false); + expect(warlockMetamorphosisAbilityEligible(true, {})).toBe(false); + expect(warlockMetamorphosisCanCastAbilityWhileActive({ + id: "physical-strike", description: "Strikes for physical damage.", effects: [{ kind: "damage", coefficient: 1, school: "physical" }], + } as Parameters[0])).toBe(false); + expect(warlockMetamorphosisCanCastAbilityWhileActive({ + id: "explicit-shadow", description: "Deals Shadow damage.", effects: [{ kind: "damage", coefficient: 1 }], + } as Parameters[0])).toBe(true); + expect(warlockMetamorphosisCanCastAbilityWhileActive({ + id: "physical-melee", description: "Usable while shapeshifted.", effects: [{ kind: "damage", coefficient: 1, school: "physical" }], + } as Parameters[0])).toBe(true); + expect(warlockMetamorphosisFormActionReady(WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS[0], false)).toBe(false); + expect(warlockMetamorphosisFormActionReady(WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS[0], true)).toBe(true); + }); + + it("creates a timed source-scoped aura with only supported armor and damage modifiers", () => { + const definition = wow335WarlockMetamorphosisAuraForRank(1); + expect(definition).toMatchObject({ + id: "wow335:warlock:metamorphosis", + durationMs: 30_000, + uniqueBySource: true, + utilityTags: ["shapeshift", WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG], + }); + expect(wow335WarlockMetamorphosisAuraForRank(0)).toBeNull(); + expect(WOW335_WARLOCK_METAMORPHOSIS_FORM_ID).toBe(22); + + const active = applyAura([], { + definition: definition!, + sourceId: "warlock-a", + targetId: "warlock-a", + now: 10_000, + }).aura; + expect(active.expiresAt).toBe(40_000); + expect(resolveStatValue(100, [active], "armor")).toBeCloseTo(230); + expect(resolveDamageValue(100, [active], { + direction: "dealt", + school: "shadow", + attackKind: "spell", + })).toBeCloseTo(115); + expect(resolveDamageValue(100, [active], { + direction: "dealt", + school: "physical", + attackKind: "melee", + })).toBeCloseTo(115); + expect(definition?.modifiers).toHaveLength(3); + expect(resolveStatValue(100, [active], "incoming-melee-critical-strike")).toBe(94); + }); + + it("keeps two Warlocks' form auras source-scoped", () => { + const definition = wow335WarlockMetamorphosisAuraForRank(1)!; + const first = applyAura([], { + definition, + sourceId: "warlock-a", + targetId: "warlock-a", + now: 1_000, + }); + const second = applyAura(first.auras, { + definition, + sourceId: "warlock-b", + targetId: "warlock-b", + now: 1_500, + }); + expect(second.auras).toHaveLength(2); + expect(second.auras.map((aura) => aura.sourceId)).toEqual(["warlock-a", "warlock-b"]); + }); + + it("uses the Immolation AP formula and reduces only sourced stun, root, and slow durations", () => { + expect(wow335WarlockImmolationAttackPowerReduction(1)).toBe(2); + expect(wow335WarlockImmolationAttackPowerReduction(80)).toBe(357); + expect(wow335WarlockImmolationAttackPowerAura(80).modifiers).toEqual([ + { kind: "stat", stat: "attack-power", operation: "flat", value: -357 }, + ]); + for (const mechanic of ["stun", "root", "slow"]) { + expect(wow335WarlockMetamorphosisControlDuration(10_000, mechanic, true)).toBe(7_500); + } + expect(wow335WarlockMetamorphosisControlDuration(10_000, "fear", true)).toBe(10_000); + expect(wow335WarlockMetamorphosisControlDuration(10_000, "stun", false)).toBe(10_000); + }); +}); diff --git a/src/game/wow335WarlockMetamorphosisRuntime.ts b/src/game/wow335WarlockMetamorphosisRuntime.ts new file mode 100644 index 00000000..c395dec4 --- /dev/null +++ b/src/game/wow335WarlockMetamorphosisRuntime.ts @@ -0,0 +1,220 @@ +import type { AbilityDefinition } from "./abilityCatalog"; +import type { AuraDefinition, DamageSchool } from "./combatAuras"; + +export const WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG = "wow335-warlock-metamorphosis"; +export const WOW335_WARLOCK_METAMORPHOSIS_FORM_ID = 22; +export const WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG = "wow335-warlock-immolation-aura"; +export const WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_AP_AURA_TAG = "wow335-warlock-immolation-ap"; +export const WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS = Object.freeze([ + "wow335-warlock-shadow-cleave", + "wow335-warlock-challenging-howl", + "wow335-warlock-demon-charge", + "wow335-warlock-immolation-aura", +] as const); + +export interface WarlockMetamorphosisEffects { + readonly rank: 1; + readonly durationMs: 30_000; + readonly cooldownMs: 180_000; + readonly armorContributionPercent: 130; + readonly damagePercent: 15; + readonly meleeCritChanceTakenReductionPercent: 6; + readonly stunSnareDurationReductionPercent: 25; + readonly allowedSchools: readonly ["fire", "shadow"]; + /** PvP-only modifier is sourced but has no player-target model here. */ + readonly pvpDamageBonusPercent: null; +} + +/** + * Values come from the generated local Metamorphosis talent-1886 row and its + * source spells 47241, 54817, and 54879 in the local 3.3.5a talent DBC. + * The PvP-only +10% damage tooltip rider is omitted because this runtime has + * no PvP player-target model. + */ +export function wow335WarlockMetamorphosisEffectsForRank(rank: number): WarlockMetamorphosisEffects | null { + if (!Number.isFinite(rank) || Math.trunc(rank) < 1) return null; + return { + rank: 1, + durationMs: 30_000, + cooldownMs: 180_000, + armorContributionPercent: 130, + damagePercent: 15, + meleeCritChanceTakenReductionPercent: 6, + stunSnareDurationReductionPercent: 25, + allowedSchools: ["fire", "shadow"], + pvpDamageBonusPercent: null, + }; +} + +export interface WarlockMetamorphosisAbilityContext { + readonly school?: DamageSchool; + readonly schools?: readonly DamageSchool[]; + /** Explicit local metadata for spells that are allowed during shapeshift. */ + readonly usableWhileShapeshifted?: boolean; +} + +/** The local Metamorphosis description permits Fire/Shadow or shapeshift-safe spells. */ +export function warlockMetamorphosisAbilityEligible( + isMetamorphosisActive: boolean, + ability: WarlockMetamorphosisAbilityContext, +): boolean { + const schools = [ability.school, ...(ability.schools ?? [])]; + return !isMetamorphosisActive + || ability.usableWhileShapeshifted === true + || schools.some((school) => school === "fire" || school === "shadow"); +} + +/** Gate based only on explicit spell schools or explicit shapeshift-safe text. */ +export function warlockMetamorphosisCanCastAbilityWhileActive( + ability: Pick, +): boolean { + if ((WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS as readonly string[]).includes(ability.id)) return true; + const schools: DamageSchool[] = ability.effects.flatMap((effect) => ( + (effect.kind === "damage" || effect.kind === "dot") && effect.school ? [effect.school] : [] + )); + if (ability.castRules?.school) schools.push(ability.castRules.school); + if (!schools.length && ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot")) { + const text = ability.description.toLowerCase(); + if (/\bshadow\s+(?:damage|spell|magic)\b/.test(text)) schools.push("shadow"); + else if (/\bfire\s+(?:damage|spell|magic)\b/.test(text)) schools.push("fire"); + } + return warlockMetamorphosisAbilityEligible(true, { + schools, + usableWhileShapeshifted: /\busable while shapeshifted\b/i.test(ability.description), + }); +} + +export function warlockMetamorphosisFormActionReady(abilityId: string, isFormActive: boolean): boolean { + return !(WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS as readonly string[]).includes(abilityId) + || isFormActive; +} + +export function wow335WarlockMetamorphosisControlDuration( + durationMs: number, + mechanic: string, + isFormActive: boolean, +): number { + const safeDuration = Math.max(0, Number.isFinite(durationMs) ? durationMs : 0); + return isFormActive && ["stun", "root", "slow"].includes(mechanic) + ? Math.round(safeDuration * 0.75) + : safeDuration; +} + +/** Spell.dbc Immolated child: tooltip formula is 4.5 × player level − 3. */ +export function wow335WarlockImmolationAttackPowerReduction(level: number): number { + const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1)); + return Math.max(0, Math.round(4.5 * safeLevel - 3)); +} + +export function wow335WarlockImmolationAttackPowerAura(level: number): AuraDefinition { + const reduction = wow335WarlockImmolationAttackPowerReduction(level); + return { + id: `wow335:warlock:immolation-ap:${reduction}`, + name: "Immolated", + description: `Reduces attack power by ${reduction} for 30 seconds.`, + disposition: "debuff", + durationMs: 30_000, + maxStacks: 1, + stackBehavior: "replace", + uniqueBySource: true, + utilityTags: [WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_AP_AURA_TAG], + modifiers: [{ kind: "stat", stat: "attack-power", operation: "flat", value: -reduction }], + }; +} + +/** Numeric modifiers currently expressible by the shared aura resolver. */ +export function wow335WarlockMetamorphosisAuraForRank(rank: number): AuraDefinition | null { + const effects = wow335WarlockMetamorphosisEffectsForRank(rank); + if (!effects) return null; + return { + id: "wow335:warlock:metamorphosis", + name: "Metamorphosis", + description: "Increases armor contribution from cloth and leather by 130% and damage dealt by 15%; reduces melee critical hits against you by 6% and stun and snare durations by 25% while transformed. The PvP-only +10% damage rider is not applied because PvP player targets are unsupported.", + disposition: "buff", + durationMs: effects.durationMs, + maxStacks: 1, + stackBehavior: "replace", + uniqueBySource: true, + exclusiveGroup: "warlock:form", + utilityTags: ["shapeshift", WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG], + modifiers: [ + { kind: "stat", stat: "armor", operation: "percent", value: effects.armorContributionPercent / 100 }, + { kind: "stat", stat: "incoming-melee-critical-strike", operation: "flat", value: -effects.meleeCritChanceTakenReductionPercent }, + { kind: "damage", direction: "dealt", operation: "percent", value: effects.damagePercent / 100 }, + ], + }; +} + +const formOnly = { sourceRankText: "Demon", formOnly: true } as const; + +/** These rankless buttons are reconstructed from their local Spell.dbc rows and children. */ +export const WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES: readonly AbilityDefinition[] = [ + { + id: WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS[0], classId: "warlock", dbcSpellId: 50581, + name: "Shadow Cleave", unlockLevel: 1, sourceUnlockLevel: 1, + icon: "/assets/ui/spells/spell_shadow_shadowfury.png", sigil: "SC", + description: "Inflicts 110 Shadow damage to an enemy target and nearby allies, affecting up to 3 targets. Costs 4% base mana.", + target: "hostile", range: { min: 0, max: 5 }, castMode: "instant", castTimeMs: 0, + cooldownMs: 6_000, gcdMs: 0, + cost: { resource: "mana", amount: 4, percentage: true, percentageBasis: "base-mana" }, + effects: [{ kind: "damage", coefficient: 0, school: "shadow", basePoints: 110, dieSides: 1, bonusPowerCoefficient: 1 }], + targeting: { shape: "chain", maximumTargets: 3 }, + source: "wow335-progression", ...formOnly, + } as AbilityDefinition, + { + id: WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS[1], classId: "warlock", dbcSpellId: 59671, + name: "Challenging Howl", unlockLevel: 1, sourceUnlockLevel: 1, + icon: "/assets/ui/spells/ability_warlock_demonicempowerment.png", sigil: "CH", + description: "Forces all nearby enemies within 10 yards to focus attacks on you for 6 seconds.", + target: "self", range: { min: 0, max: 0 }, radius: 10, castMode: "instant", castTimeMs: 0, + cooldownMs: 180_000, gcdMs: 1_500, cost: { resource: "rage", amount: 0 }, + effects: [{ kind: "taunt", durationMs: 6_000 }], + targeting: { shape: "circle", radius: 10 }, source: "wow335-progression", ...formOnly, + } as AbilityDefinition, + { + id: WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS[2], classId: "warlock", dbcSpellId: 54785, + name: "Demon Charge", unlockLevel: 1, sourceUnlockLevel: 1, + icon: "/assets/ui/spells/ability_warlock_demonicempowerment.png", sigil: "DC", + description: "Charge an enemy, stunning it for 3 seconds. Range 8–25 yards; 45 second cooldown.", + target: "hostile", range: { min: 8, max: 25 }, castMode: "instant", castTimeMs: 0, + cooldownMs: 45_000, gcdMs: 1_500, cost: { resource: "mana", amount: 0 }, + effects: [ + { kind: "movement", movement: "charge", toward: "target" }, + { kind: "status", status: "stun", durationMs: 3_000 }, + ], source: "wow335-progression", ...formOnly, + } as AbilityDefinition, + { + id: WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITY_IDS[3], classId: "warlock", dbcSpellId: 50589, + name: "Immolation Aura", unlockLevel: 1, sourceUnlockLevel: 1, + icon: "/assets/ui/spells/spell_fire_selfdestruct.png", sigil: "IA", + description: "For 10 seconds, burns nearby enemies for 251 Fire damage plus 11.5 per level and 14.3% spell power every second within 8 yards, reducing their attack power by 4.5 × level − 3 for 30 seconds. Costs 64% base mana.", + target: "self", range: { min: 0, max: 0 }, radius: 8, castMode: "instant", castTimeMs: 0, + cooldownMs: 30_000, gcdMs: 1_500, + cost: { resource: "mana", amount: 64, percentage: true, percentageBasis: "base-mana" }, + effects: [ + { kind: "dot", coefficient: 0, school: "fire", basePoints: 251, dieSides: 1, pointsPerLevel: 11.5, + bonusPowerCoefficient: 0.143, sourceLevel: 1, ticks: 10, intervalMs: 1_000, tag: WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG }, + { kind: "apply-aura", recipient: "caster", aura: { + id: "wow335:warlock:immolation-aura", name: "Immolation Aura", + description: "Emits Fire pulses for 10 seconds. Loss of control suppresses pulses.", + disposition: "buff", durationMs: 10_000, maxStacks: 1, stackBehavior: "replace", + utilityTags: [WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG], + }, sourceEffectType: 6, sourceAuraType: 23, miscValue: 0, triggerSpellId: 50590 }, + ], + targeting: { shape: "circle", radius: 8 }, source: "wow335-progression", ...formOnly, + } as AbilityDefinition, +]; + +export function wow335WarlockMetamorphosisAbility(): AbilityDefinition { + return { + id: "wow335-warlock-metamorphosis", classId: "warlock", dbcSpellId: 59672, + name: "Metamorphosis", unlockLevel: 1, sourceUnlockLevel: 1, + icon: "/assets/ui/spells/spell_shadow_demonform.png", sigil: "ME", + description: "Transform into a Demon for 30 seconds. Increases armor contribution from cloth and leather items by 130% and damage dealt by 15%; reduces melee critical chance taken by 6% and stun/snare duration by 25%. Grants four Demon-only abilities. Only Fire/Shadow spells and abilities or spells explicitly usable while shapeshifted may be cast. PvP-only +10% damage is unsupported.", + target: "self", range: { min: 0, max: 0 }, castMode: "instant", castTimeMs: 0, + cooldownMs: 180_000, gcdMs: 0, cost: { resource: "mana", amount: 0 }, + effects: [{ kind: "apply-aura", recipient: "caster", aura: wow335WarlockMetamorphosisAuraForRank(1)!, + sourceEffectType: 6, sourceAuraType: 36, miscValue: WOW335_WARLOCK_METAMORPHOSIS_FORM_ID, triggerSpellId: 0 }], + talentNodeId: "talent-1886", source: "wow335-progression", + }; +} diff --git a/src/game/wow335WarlockPartyBackdraft.test.ts b/src/game/wow335WarlockPartyBackdraft.test.ts new file mode 100644 index 00000000..4abcb2ef --- /dev/null +++ b/src/game/wow335WarlockPartyBackdraft.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog"; +import { useCombatStore } from "./combatStore"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { EMPTY_ITEM_STATS } from "./itemStats"; +import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime"; +import { WARLOCK_BACKDRAFT_READY_TAG, warlockBackdraftEffectsForRank } from "./wow335WarlockTalentRuntime"; + +const PARTY_ID = "warlock-backdraft-party"; +const TARGET_ID = "warlock-backdraft-target"; + +function setUpPartyWarlocks(ranks: readonly number[]) { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + const members = ranks.map((rank, index) => ({ + ...createConfiguredPartyMember({ + name: `Destruction Warlock ${index + 1}`, + classId: "warlock", + specialization: "Destruction", + specializationId: "warlock:destruction", + talentTreeId: "warlock-destruction", + talentRanks: runtimePartyTalentRanksForLevel("warlock", "Destruction", 80), + role: "damage", + }, 80, index, PARTY_ID), + talentRanks: { ...runtimePartyTalentRanksForLevel("warlock", "Destruction", 80), "talent-1888": rank }, + gearStats: EMPTY_ITEM_STATS, + })); + usePartyStore.getState().initializeParty(PARTY_ID, 80, useCombatStore.getState().health, "damage", 2); + usePartyStore.setState({ members }); + for (const member of members) registerPartyRuntimePosition(member.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 }); + setMobPosition(TARGET_ID, [2, 0, 0]); + useCombatStore.getState().damageMob(TARGET_ID, 1, 100); + for (const member of members) updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); + usePartyStore.getState().setCommand("attack"); + return members; +} + +function completePartyCast(memberId: string, abilityName: string, startedAt: number, completesAt: number): void { + const ability = abilityAtLevel(abilitiesForClass("warlock").find((candidate) => candidate.name === abilityName)!, 80); + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === memberId ? { + ...candidate, + status: "attacking", + activeCast: { + abilityId: ability.id, + school: abilityName === "Shadowburn" || abilityName === "Shadow Bolt" ? "shadow" : "fire", + mode: "cast", + startedAt, + completesAt, + originalDurationMs: completesAt - startedAt, + pushbackHits: 0, + origin: [0, 0, 0], + targetId: TARGET_ID, + }, + activeCastTargetId: TARGET_ID, + } : candidate), + })); + registerPartyRuntimePosition(memberId, [0, 0, 0]); + updatePartyRuntimeTargetVisibility(memberId, TARGET_ID, true); + useCombatStore.getState().synchronizeActors(); + advanceDungeonPartyCombat(completesAt); +} + +describe("WoW Warlock party Backdraft", () => { + beforeEach(() => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); + usePartyStore.getState().initializeParty(PARTY_ID, 80, useCombatStore.getState().health, "damage"); + }); + + it("applies Backdraft to only its Warlock owner, reduces real cast/GCD timing, consumes a charge, and refreshes on trigger", () => { + const [warlock, unrelatedWarlock] = setUpPartyWarlocks([3, 0]); + completePartyCast(warlock.id, "Shadowburn", 900, 1_000); + + const proc = useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + )); + expect(proc).toMatchObject({ sourceId: warlock.id, targetId: warlock.id, stacks: 3, appliedAt: 1_000, expiresAt: 16_000 }); + expect(useCombatStore.getState().auras.some((aura) => aura.targetId === unrelatedWarlock.id + && aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG))).toBe(false); + + const shadowBolt = abilityAtLevel(abilitiesForClass("warlock").find((ability) => ability.name === "Shadow Bolt")!, 80); + const cooldowns = Object.fromEntries(abilitiesForClass("warlock") + .filter((ability) => ability.id !== shadowBolt.id && !ability.passive) + .map((ability) => [ability.id, 60_000])); + usePartyStore.setState((state) => ({ + members: state.members.map((member) => member.id === warlock.id + ? { ...member, activeCast: null, activeCastTargetId: null, cooldowns, globalCooldownEndsAt: 0, nextActionAt: 0 } + : { ...member, nextActionAt: 60_000 }), + })); + useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); + advanceDungeonPartyCombat(3_000); + + const afterStart = usePartyStore.getState().members.find((member) => member.id === warlock.id)!; + expect(afterStart.activeCast?.abilityId).toBe(shadowBolt.id); + expect(afterStart.activeCast!.originalDurationMs).toBe(Math.round(shadowBolt.castTimeMs * 0.75)); + expect(afterStart.activeCast!.backdraftGcdMs).toBe(Math.max(1_000, Math.round(shadowBolt.gcdMs * 0.75))); + expect(afterStart.globalCooldownEndsAt - 3_000).toBe(afterStart.activeCast!.backdraftGcdMs); + expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === proc!.instanceId)?.stacks).toBe(2); + + const castEnd = afterStart.activeCast!.completesAt; + advanceDungeonPartyCombat(castEnd); + const afterFinish = usePartyStore.getState().members.find((member) => member.id === warlock.id)!; + expect(afterFinish.nextActionAt - castEnd).toBe(afterStart.activeCast!.backdraftGcdMs); + + completePartyCast(warlock.id, "Shadowburn", castEnd + 900, castEnd + 1_000); + const refreshed = useCombatStore.getState().auras.find((aura) => ( + aura.sourceId === warlock.id && aura.targetId === warlock.id + && aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + )); + expect(refreshed).toMatchObject({ stacks: 3, appliedAt: castEnd + 1_000, expiresAt: castEnd + 16_000 }); + }); + + it("applies each Backdraft rank's passive Shadowburn damage to a party Warlock", () => { + const amounts: number[] = []; + for (const rank of [0, 1, 2, 3]) { + const [warlock] = setUpPartyWarlocks([rank]); + completePartyCast(warlock.id, "Shadowburn", 900, 1_000); + const event = useCombatStore.getState().combatEvents.find((candidate) => ( + candidate.kind === "damage" && candidate.sourceActorId === warlock.id + && candidate.abilityId === abilityAtLevel( + abilitiesForClass("warlock").find((ability) => ability.name === "Shadowburn")!, 80, + ).id && candidate.occurredAt === 1_000 + )); + expect(event?.rawAmount).toBeGreaterThan(0); + amounts.push(event!.rawAmount); + expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === warlock.id + && aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG))).toBe(rank > 0); + } + for (const rank of [1, 2, 3]) { + const percent = warlockBackdraftEffectsForRank(rank).shadowburnDamagePercent; + expect(amounts[rank]).toBe(Math.round(amounts[0]! * (1 + percent / 100))); + } + }); +}); diff --git a/src/game/wow335WarlockSoulLinkRuntime.test.ts b/src/game/wow335WarlockSoulLinkRuntime.test.ts new file mode 100644 index 00000000..496c74a9 --- /dev/null +++ b/src/game/wow335WarlockSoulLinkRuntime.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore, type CombatSummon } from "./combatStore"; +import { setMobPosition } from "./mobRuntimeRegistry"; +import { applyAura } from "./combatAuras"; +import { usePartyStore } from "./partyStore"; +import { registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { + warlockSoulLinkDamageSplit, + warlockSoulLinkDemon, + warlockSoulLinkSharePercent, + type WarlockSoulLinkSummon, +} from "./wow335WarlockSoulLinkRuntime"; + +const demon = (overrides: Partial = {}): WarlockSoulLinkSummon => ({ + sourceAbilityId: "wow335-warlock-summon-felhunter", + ownerActorId: "warlock-player", + health: 300, + expiresAt: 10_000, + controlledUntil: 0, + position: [300, 0, 0], + ...overrides, +}); + +function combatDemon(overrides: Partial = {}): CombatSummon { + return { + id: "soul-link-felhunter", + sourceAbilityId: "wow335-warlock-summon-felhunter", + creatureId: 417, + targetId: null, + expiresAt: Number.MAX_SAFE_INTEGER, + kind: "pet", + name: "Felhunter", + ownerActorId: PLAYER_AURA_ENTITY_ID, + health: 500, + maxHealth: 500, + position: [10, 0, 0], + nextActionAt: 0, + pulseIntervalMs: 0, + attackDamage: 0, + abilityIds: [], + resources: {}, + cooldowns: {}, + globalCooldownEndsAt: 0, + cast: null, + controlledUntil: 0, + schoolLockedUntil: {}, + ...overrides, + }; +} + +describe("WoW Warlock Soul Link mechanics", () => { + it("uses the populated generated talent tooltip and exposes Soul Link as an active self buff", () => { + const ability = WOW335_ABILITIES_BY_CLASS.warlock.find((candidate) => candidate.name === "Soul Link"); + expect(ability?.target).toBe("self"); + expect(ability?.description).toContain("6% of all damage taken by the caster"); + expect(ability?.description).toContain("Cannot Soul Link a Summoned Imp"); + expect(ability?.effects).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "apply-aura", + recipient: "caster", + aura: expect.objectContaining({ + utilityTags: ["wow335-warlock-soul-link"], + durationMs: null, + }), + }), + ])); + }); + + it("uses the generated Soul Link and Demonic Embrace rank descriptions", () => { + expect([0, 1, 2, 3].map((rank) => warlockSoulLinkSharePercent(1, rank))) + .toEqual([6, 8, 10, 12]); + expect(warlockSoulLinkSharePercent(0, 3)).toBe(0); + }); + + it("requires the caster's live, controlled, non-Imp demon within 30 yards", () => { + const linked = warlockSoulLinkDemon([demon()], "warlock-player", [0, 0, 0], 1_000); + expect(linked).not.toBeNull(); + expect(warlockSoulLinkDemon( + [demon({ sourceAbilityId: "wow335-warlock-summon-imp" })], + "warlock-player", + [0, 0, 0], + 1_000, + )).toBeNull(); + expect(warlockSoulLinkDemon([demon({ ownerActorId: "another-warlock" })], "warlock-player", [0, 0, 0], 1_000)) + .toBeNull(); + expect(warlockSoulLinkDemon([demon({ health: 0 })], "warlock-player", [0, 0, 0], 1_000)).toBeNull(); + expect(warlockSoulLinkDemon([demon({ expiresAt: 1_000 })], "warlock-player", [0, 0, 0], 1_000)).toBeNull(); + expect(warlockSoulLinkDemon([demon({ controlledUntil: 1_001 })], "warlock-player", [0, 0, 0], 1_000)) + .toBeNull(); + expect(warlockSoulLinkDemon([demon({ position: [301, 0, 0] })], "warlock-player", [0, 0, 0], 1_000)) + .toBeNull(); + }); + + it("splits post-mitigation damage and rounds the unpreventable demon share", () => { + expect(warlockSoulLinkDamageSplit(101, 6)).toEqual({ casterDamage: 95, demonDamage: 6 }); + expect(warlockSoulLinkDamageSplit(5, 12)).toEqual({ casterDamage: 4, demonDamage: 1 }); + expect(warlockSoulLinkDamageSplit(0, 12)).toEqual({ casterDamage: 0, demonDamage: 0 }); + }); + + it("transfers the generated Soul Link and Demonic Embrace share during incoming damage", () => { + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 }); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1282": 1, "talent-1223": 3 }, + summons: [combatDemon()], + playerPosition: [0, 0, 0], + })); + const soulLink = useCombatStore.getState().abilities.find((ability) => ability.name === "Soul Link"); + expect(soulLink).toBeDefined(); + expect(useCombatStore.getState().castAbility(soulLink!.id, undefined, 1_000).ok).toBe(true); + + const demonBefore = useCombatStore.getState().summons[0]!.health; + const playerDamage = useCombatStore.getState().damagePlayer(100, "shadow", 80, 2_000); + const state = useCombatStore.getState(); + const playerHit = [...state.combatEvents].reverse().find((event) => ( + event.kind === "damage" && event.targetActorId === PLAYER_AURA_ENTITY_ID && event.occurredAt === 2_000 + )); + const demonHit = [...state.combatEvents].reverse().find((event) => ( + event.kind === "damage" && event.targetActorId === "soul-link-felhunter" && event.occurredAt === 2_000 + )); + expect(playerHit).toBeDefined(); + expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 }); + const expectedShare = Math.round(playerHit!.rawAmount * 0.12); + expect(demonHit!.effectiveAmount).toBe(expectedShare); + expect(state.summons[0]!.health).toBe(demonBefore - expectedShare); + expect(playerDamage).toBe(playerHit!.rawAmount - expectedShare); + }); + + it("shares enemy melee damage with an active demon during combat", () => { + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 }); + useCombatStore.setState((state) => ({ + talentRanks: { ...state.talentRanks, "talent-1282": 1 }, + summons: [combatDemon()], + playerPosition: [0, 0, 0], + })); + const soulLink = useCombatStore.getState().abilities.find((ability) => ability.name === "Soul Link"); + expect(useCombatStore.getState().castAbility(soulLink!.id, undefined, 1_000).ok).toBe(true); + useCombatStore.getState().registerMob("soul-link-attacker", { name: "Attacker", maxHealth: 10_000, xpReward: 0 }); + setMobPosition("soul-link-attacker", [2, 0, 0]); + useCombatStore.getState().damageMob("soul-link-attacker", 1, 1_000); + + expect(useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0])).toBeGreaterThan(0); + const state = useCombatStore.getState(); + const demonHit = [...state.combatEvents].reverse().find((event) => ( + event.kind === "damage" && event.targetActorId === "soul-link-felhunter" && event.occurredAt === 1_450 + )); + expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 }); + expect(demonHit!.effectiveAmount).toBeGreaterThan(0); + expect(state.summons[0]!.health).toBe(500 - demonHit!.effectiveAmount); + }); + + it("shares enemy damage with a companion Warlock's linked demon", () => { + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 }); + usePartyStore.getState().initializeParty("soul-link-companion-test", 80, useCombatStore.getState().health); + const member = usePartyStore.getState().members[0]!; + usePartyStore.setState((state) => ({ + members: state.members.map((candidate) => candidate.id === member.id + ? { ...candidate, classId: "warlock", talentRanks: { ...candidate.talentRanks, "talent-1282": 1 } } + : candidate), + })); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + const linkAbility = WOW335_ABILITIES_BY_CLASS.warlock.find((ability) => ability.name === "Soul Link")!; + const linkAura = linkAbility.effects.find((effect) => effect.kind === "apply-aura")!; + if (linkAura.kind !== "apply-aura") throw new Error("Soul Link must apply its caster aura"); + const companionDemon = combatDemon({ + id: "companion-soul-link-felhunter", + ownerActorId: member.id, + position: [10, 0, 0], + }); + useCombatStore.setState((state) => ({ + playerPosition: [20, 0, 0], + summons: [companionDemon], + auras: [...applyAura(state.auras, { + definition: linkAura.aura, + sourceId: member.id, + targetId: member.id, + now: 1_000, + }).auras], + })); + useCombatStore.getState().registerMob("companion-soul-link-attacker", { + name: "Attacker", + maxHealth: 10_000, + xpReward: 0, + }); + setMobPosition("companion-soul-link-attacker", [2, 0, 0]); + useCombatStore.getState().damageMob( + "companion-soul-link-attacker", + 1, + 1_000, + { actorId: member.id, role: "tank" }, + ); + + let combatDamage = 0; + for (let now = 3_350; now < 20_000; now += 1_900) { + combatDamage += useCombatStore.getState().advanceMobCombat(now, [20, 0, 0]); + if (useCombatStore.getState().combatEvents.some((event) => ( + event.kind === "damage" + && event.targetActorId === "companion-soul-link-felhunter" + && event.occurredAt === now + ))) break; + } + expect(combatDamage).toBeGreaterThan(0); + const state = useCombatStore.getState(); + const demonHit = [...state.combatEvents].reverse().find((event) => ( + event.kind === "damage" + && event.targetActorId === "companion-soul-link-felhunter" + && event.occurredAt >= 3_350 + )); + expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 }); + expect(demonHit!.effectiveAmount).toBeGreaterThan(0); + expect(state.summons[0]!.health).toBe(500 - demonHit!.effectiveAmount); + }); +}); diff --git a/src/game/wow335WarlockSoulLinkRuntime.ts b/src/game/wow335WarlockSoulLinkRuntime.ts new file mode 100644 index 00000000..cb3ba14d --- /dev/null +++ b/src/game/wow335WarlockSoulLinkRuntime.ts @@ -0,0 +1,65 @@ +export const WOW335_WARLOCK_SOUL_LINK_TAG = "wow335-warlock-soul-link"; + +const WOW335_SOUL_LINKABLE_DEMON_SPELLS = new Set([ + "wow335-warlock-summon-voidwalker", + "wow335-warlock-summon-succubus", + "wow335-warlock-summon-felhunter", + "wow335-warlock-summon-felguard", + "wow335-warlock-inferno", +]); + +export interface WarlockSoulLinkSummon { + readonly sourceAbilityId: string; + readonly ownerActorId: string; + readonly health: number; + readonly expiresAt: number; + readonly controlledUntil: number; + readonly position: readonly [number, number, number]; +} + +/** Soul Link is 6%, plus Demonic Embrace's local rank tooltip of 2/4/6%. */ +export function warlockSoulLinkSharePercent( + soulLinkRank: number, + demonicEmbraceRank: number, +): number { + const linkRank = Math.max(0, Math.min(1, Math.trunc(Number.isFinite(soulLinkRank) ? soulLinkRank : 0))); + if (linkRank === 0) return 0; + const embraceRank = Math.max(0, Math.min(3, Math.trunc(Number.isFinite(demonicEmbraceRank) ? demonicEmbraceRank : 0))); + return 6 + 2 * embraceRank; +} + +/** + * Selects the caster's live, controlled Warlock demon within the generated + * talent tooltip's 30-yard link distance. The simulator represents yards as + * ten combat-position units. + */ +export function warlockSoulLinkDemon( + summons: readonly T[], + ownerActorId: string, + ownerPosition: readonly [number, number, number], + now: number, +): T | null { + return summons.find((summon) => ( + WOW335_SOUL_LINKABLE_DEMON_SPELLS.has(summon.sourceAbilityId) + && summon.ownerActorId === ownerActorId + && summon.health > 0 + && summon.expiresAt > now + && summon.controlledUntil <= now + && Math.hypot( + summon.position[0] - ownerPosition[0], + summon.position[1] - ownerPosition[1], + summon.position[2] - ownerPosition[2], + ) <= 300 + )) ?? null; +} + +/** Split post-mitigation incoming damage; the transferred portion bypasses prevention. */ +export function warlockSoulLinkDamageSplit(amount: number, sharePercent: number): { + readonly casterDamage: number; + readonly demonDamage: number; +} { + const safeAmount = Math.max(0, Math.trunc(Number.isFinite(amount) ? amount : 0)); + const safePercent = Math.max(0, Math.min(100, Number.isFinite(sharePercent) ? sharePercent : 0)); + const demonDamage = Math.min(safeAmount, Math.round(safeAmount * safePercent / 100)); + return { casterDamage: safeAmount - demonDamage, demonDamage }; +} diff --git a/src/game/wow335WarlockTalentProcs.test.ts b/src/game/wow335WarlockTalentProcs.test.ts index 7fd715b7..897cd345 100644 --- a/src/game/wow335WarlockTalentProcs.test.ts +++ b/src/game/wow335WarlockTalentProcs.test.ts @@ -1,11 +1,23 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { abilityAtLevel, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; +import { PLAYER_AGGRO_ID } from "./aggro"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; -import { useCombatStore } from "./combatStore"; +import { resolvedAbilityCosts, useCombatStore } from "./combatStore"; import { usePartyStore } from "./partyStore"; import { talentModifiersForRanks } from "./talentModifiers"; import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; +import { + WARLOCK_BACKDRAFT_DESTRUCTION_SPELLS, + WARLOCK_BACKDRAFT_READY_TAG, + warlockBackdraftDestructionSpellEligible, + warlockBackdraftEffectsForRank, + warlockBackdraftTriggerEligible, + warlockNightfallConsumerEligible, + warlockNightfallDamageMultiplier, + warlockNightfallEffectsForRank, + warlockNightfallTriggerEligible, +} from "./wow335WarlockTalentRuntime"; function abilityId(name: string): string { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); @@ -16,9 +28,13 @@ function abilityId(name: string): string { 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 }, - })); + useCombatStore.setState((state) => { + const talentRanks = { ...state.talentRanks, [talent!.id]: rank }; + return { + talentRanks, + talentModifiers: talentModifiersForRanks(talentRanks, state.classId), + }; + }); } function addTarget(id = "warlock-proc-target"): void { @@ -39,6 +55,23 @@ function castAndFinish(name: string, now: number): number { return now; } +function stableCombatUnitForNightfallTest(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 seedForNightfallProc(): string { + for (let index = 0; index < 1_000; index += 1) { + const seed = `nightfall-proc-test-${index}`; + if (stableCombatUnitForNightfallTest(`${seed}:wow335-warlock-nightfall`) < 0.08) return seed; + } + throw new Error("Could not find a deterministic Nightfall proc roll seed."); +} + describe("WoW 3.3.5 Warlock talent procs", () => { beforeEach(async () => { clearMobRuntimeRegistry(); @@ -103,6 +136,71 @@ describe("WoW 3.3.5 Warlock talent procs", () => { expect(followupSoulLeechEvents).toHaveLength(0); }); + it("reduces target melee damage by the tooltip's 2 attack power for ten seconds", () => { + setTalentRank("Soul Leech", 3); + const targetId = "warlock-proc-target"; + const scheduleAttack = (now: number) => { + const target = useCombatStore.getState().mobs[targetId]!; + const attack = target.attacks[0]!; + useCombatStore.setState((state) => ({ + mobs: { + ...state.mobs, + [targetId]: { + ...state.mobs[targetId]!, + attackPower: 10, + attackPowerDamageContribution: 100, + attackDamage: 100, + engaged: true, + combatPhase: "attacking", + targetActorId: PLAYER_AGGRO_ID, + threatByActor: { [PLAYER_AGGRO_ID]: 1 }, + nextAttackAt: now, + abilityReadyAt: { ...state.mobs[targetId]!.abilityReadyAt, [attack.id]: now }, + }, + }, + })); + useCombatStore.getState().advanceMobCombat(now); + return useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.sourceActorId === targetId && event.occurredAt === now + )); + }; + + const baseline = scheduleAttack(1_000); + expect(baseline?.rawAmount).toBe(100); + + const shadowBoltId = abilityId("Shadow Bolt"); + useCombatStore.getState().damageMob( + targetId, + 1_000, + 2_000, + { actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: shadowBoltId }, + "shadow", + { + attackKind: "spell", + seed: "soul-leech-ap-debuff-test", + abilityId: shadowBoltId, + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }, + ); + expect(useCombatStore.getState().mobs[targetId]!.auras).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "wow335:warlock:soul-leech-ap-debuff", + sourceActorId: PLAYER_AGGRO_ID, + sourceAbilityId: shadowBoltId, + magnitude: 2, + endsAt: 12_000, + }), + ])); + + const duringDebuff = scheduleAttack(5_000); + const afterDebuff = scheduleAttack(12_000); + expect(duringDebuff?.rawAmount).toBeLessThan(baseline!.rawAmount); + expect(afterDebuff?.rawAmount).toBe(baseline?.rawAmount); + expect(useCombatStore.getState().mobs[targetId]!.auras.some((aura) => ( + aura.id === "wow335:warlock:soul-leech-ap-debuff" + ))).toBe(false); + }); + it("applies Improved Shadow Bolt vulnerability only after a critical Shadow Bolt", () => { setTalentRank("Improved Shadow Bolt", 5); const firstCompleteAt = castAndFinish("Shadow Bolt", 1_000); @@ -146,12 +244,269 @@ describe("WoW 3.3.5 Warlock talent procs", () => { .toEqual([expect.objectContaining({ kind: "damage-percent", amount: 15 })]); }); + it("matches generated Nightfall ranks, trigger spells, and one-cast consumers", () => { + expect(warlockNightfallEffectsForRank(1)).toEqual({ + rank: 1, + procChancePercent: 4, + manaCostReductionPercent: 25, + }); + expect(warlockNightfallEffectsForRank(2)).toEqual({ + rank: 2, + procChancePercent: 8, + manaCostReductionPercent: 50, + }); + expect(["Corruption", "Drain Life", "Drain Soul", "Shadowflame"].every(warlockNightfallTriggerEligible)).toBe(true); + expect(["Shadow Bolt", "Seed of Corruption"].every(warlockNightfallConsumerEligible)).toBe(true); + expect(warlockNightfallTriggerEligible("Immolate")).toBe(false); + expect(warlockNightfallDamageMultiplier("Shadow Bolt", true)).toBe(1.2); + expect(warlockNightfallDamageMultiplier("Seed of Corruption", true)).toBe(1); + expect(warlockNightfallDamageMultiplier("Shadow Bolt", false)).toBe(1); + }); + + it("procs from a qualifying damage tick, then makes the next Shadow Bolt instant and cheaper", () => { + setTalentRank("Nightfall", 2); + const state = useCombatStore.getState(); + const corruption = state.abilities.find((ability) => ability.name === "Corruption")!; + const shadowBolt = state.abilities.find((ability) => ability.name === "Shadow Bolt")!; + const procSeed = seedForNightfallProc(); + const tick = useCombatStore.getState().damageMob( + "warlock-proc-target", + 10, + 4_000, + { actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: corruption.id }, + "shadow", + { + attackKind: "spell", + seed: procSeed, + abilityId: corruption.id, + periodic: true, + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }, + ); + expect(tick.amount).toBeGreaterThan(0); + const ready = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.utilityTags?.includes("wow335-warlock-nightfall-ready") + )); + expect(ready).toMatchObject({ expiresAt: 14_000 }); + + const activeAuras = useCombatStore.getState().auras; + useCombatStore.setState({ auras: activeAuras.filter((aura) => aura.instanceId !== ready!.instanceId) }); + const boltDamageSeed = "nightfall-shadow-bolt-damage-test"; + const boltDamageContext = { + attackKind: "spell" as const, + seed: boltDamageSeed, + abilityId: shadowBolt.id, + periodic: true, + rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, + }; + const boltSource = { + actorId: PLAYER_AGGRO_ID, + role: "damage" as const, + abilityId: shadowBolt.id, + }; + const baseBolt = useCombatStore.getState().damageMob( + "warlock-proc-target", 100, 4_050, boltSource, "shadow", boltDamageContext, + ); + useCombatStore.setState({ auras: activeAuras }); + const nightfallBolt = useCombatStore.getState().damageMob( + "warlock-proc-target", 100, 4_050, boltSource, "shadow", boltDamageContext, + ); + expect(nightfallBolt.amount).toBe(Math.round(baseBolt.amount * 1.2)); + + const current = useCombatStore.getState(); + const normalCosts = resolvedAbilityCosts( + abilityAtLevel(shadowBolt, current.level), + current.talentModifiers, + current, + ); + const manaBefore = current.resource; + const result = useCombatStore.getState().castAbility(shadowBolt.id, undefined, 4_100); + + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(result.damage).toBeGreaterThan(0); + expect(useCombatStore.getState().activeCast).toBeNull(); + expect(manaBefore - useCombatStore.getState().resource) + .toBe(Math.round((normalCosts.find((cost) => cost.resource === "mana")?.amount ?? 0) * 0.5)); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes("wow335-warlock-nightfall-ready") + ))).toBe(false); + }); + 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("matches Backdraft rank payload and keeps only its Shadowburn rider passive", () => { + const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === "Backdraft")!; + expect(talent.id).toBe("talent-1888"); + expect(talent.rankSpellIds).toEqual([47258, 47259, 47260]); + expect(talent.rankEffects.map((rank) => rank.effects.map((effect) => effect.triggerSpellId))) + .toEqual([[54274, 0], [54276, 0], [54277, 0]]); + expect(talent.rankEffects.map((rank) => rank.effects[1]?.basePoints)).toEqual([7, 14, 20]); + expect([1, 2, 3].map((rank) => warlockBackdraftEffectsForRank(rank))).toEqual([ + { rank: 1, reductionPercent: 8, shadowburnDamagePercent: 7, charges: 3, durationMs: 15_000 }, + { rank: 2, reductionPercent: 17, shadowburnDamagePercent: 14, charges: 3, durationMs: 15_000 }, + { rank: 3, reductionPercent: 25, shadowburnDamagePercent: 20, charges: 3, durationMs: 15_000 }, + ]); + expect(warlockBackdraftEffectsForRank(0).charges).toBe(0); + expect(["Conflagrate", "Mind Blast", "Shadowfury", "Shadowburn"].every(warlockBackdraftTriggerEligible)).toBe(true); + expect(warlockBackdraftDestructionSpellEligible("Shadow Bolt")).toBe(true); + expect(warlockBackdraftDestructionSpellEligible("Fear")).toBe(false); + expect([...WARLOCK_BACKDRAFT_DESTRUCTION_SPELLS].every((name) => ( + useCombatStore.getState().abilities.some((ability) => ability.name === name) + ))).toBe(true); + + // The generated trigger wording includes Mind Blast; current generated Warlock spell rows do not. + const modifiers = talentModifiersForRanks({ [talent.id]: 3 }, "warlock") + .filter((modifier) => modifier.sourceTalentName === "Backdraft"); + expect(modifiers).toEqual([expect.objectContaining({ + kind: "damage-percent", + amount: 20, + scope: "damage of your Shadowburn", + })]); + expect(useCombatStore.getState().abilities.some((ability) => ability.name === "Mind Blast")).toBe(false); + }); + + it.each([1, 2, 3])("applies Backdraft rank %i cast-time and GCD reductions", (rank) => { + setTalentRank("Shadowburn", 1); + setTalentRank("Backdraft", rank); + castAndFinish("Shadowburn", 1_000); + const aura = useCombatStore.getState().auras.find((candidate) => ( + candidate.targetId === PLAYER_AGGRO_ID + && candidate.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + )); + expect(aura).toMatchObject({ stacks: 3, expiresAt: 16_000 }); + + const shadowBoltId = abilityId("Shadow Bolt"); + const shadowBolt = useCombatStore.getState().abilities.find((ability) => ability.id === shadowBoltId)!; + const baseCastTimeMs = shadowBolt.ranks?.at(-1)?.castTimeMs ?? shadowBolt.castTimeMs; + const startedAt = 3_000; + const result = useCombatStore.getState().castAbility(shadowBoltId, undefined, startedAt); + expect(result.ok, JSON.stringify(result)).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + expect(activeCast?.abilityId).toBe(shadowBoltId); + const reduction = warlockBackdraftEffectsForRank(rank).reductionPercent / 100; + expect(activeCast!.completesAt - startedAt).toBe(Math.round(baseCastTimeMs * (1 - reduction))); + expect(useCombatStore.getState().globalCooldownEndsAt - startedAt) + .toBe(Math.max(1_000, Math.round(shadowBolt.gcdMs * (1 - reduction)))); + expect(useCombatStore.getState().auras.find((candidate) => ( + candidate.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + ))?.stacks).toBe(2); + useCombatStore.getState().tick(0.1, activeCast!.completesAt); + }); + + it("consumes only three eligible Destruction casts and expires Backdraft after 15 seconds", () => { + setTalentRank("Shadowburn", 1); + setTalentRank("Backdraft", 3); + castAndFinish("Shadowburn", 1_000); + const shadowBoltId = abilityId("Shadow Bolt"); + const baseCastTimeMs = useCombatStore.getState().abilities.find((ability) => ability.id === shadowBoltId)! + .ranks?.at(-1)?.castTimeMs ?? 3_000; + let startedAt = 3_000; + + for (let charge = 3; charge > 0; charge -= 1) { + const result = useCombatStore.getState().castAbility(shadowBoltId, undefined, startedAt); + expect(result.ok, JSON.stringify(result)).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + expect(activeCast?.completesAt! - startedAt).toBe(Math.round(baseCastTimeMs * 0.75)); + expect(useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + ))?.stacks).toBe(charge - 1 || undefined); + useCombatStore.getState().tick(0.1, activeCast!.completesAt); + startedAt = activeCast!.completesAt + 100; + } + + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + ))).toBe(false); + const fourth = useCombatStore.getState().castAbility(shadowBoltId, undefined, startedAt); + expect(fourth.ok, JSON.stringify(fourth)).toBe(true); + expect(useCombatStore.getState().activeCast?.completesAt! - startedAt).toBe(baseCastTimeMs); + useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt); + + useCombatStore.setState({ cooldowns: {} }); + castAndFinish("Shadowburn", 14_000); + const afterExpiry = useCombatStore.getState().castAbility(shadowBoltId, undefined, 29_000); + expect(afterExpiry.ok, JSON.stringify(afterExpiry)).toBe(true); + expect(useCombatStore.getState().activeCast?.completesAt! - 29_000).toBe(baseCastTimeMs); + }); + + it("does not give unrelated casts the Backdraft reduction or consume a charge", () => { + setTalentRank("Shadowburn", 1); + setTalentRank("Backdraft", 3); + castAndFinish("Shadowburn", 1_000); + const fearId = abilityId("Fear"); + const fear = useCombatStore.getState().abilities.find((ability) => ability.id === fearId)!; + const fearBaseCastTimeMs = fear.ranks?.at(-1)?.castTimeMs ?? fear.castTimeMs; + const fearResult = useCombatStore.getState().castAbility(fearId, undefined, 3_000); + expect(fearResult.ok, JSON.stringify(fearResult)).toBe(true); + expect(useCombatStore.getState().activeCast?.completesAt! - 3_000).toBe(fearBaseCastTimeMs); + expect(useCombatStore.getState().globalCooldownEndsAt - 3_000).toBe(fear.gcdMs); + useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt); + expect(useCombatStore.getState().auras.find((aura) => ( + aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + ))?.stacks).toBe(3); + + const shadowBoltId = abilityId("Shadow Bolt"); + const startedAt = useCombatStore.getState().activeCast?.completesAt ?? 4_600; + const boltResult = useCombatStore.getState().castAbility(shadowBoltId, undefined, startedAt + 100); + expect(boltResult.ok, JSON.stringify(boltResult)).toBe(true); + const shadowBolt = useCombatStore.getState().abilities.find((ability) => ability.id === shadowBoltId)!; + expect(useCombatStore.getState().activeCast?.completesAt! - (startedAt + 100)) + .toBe(Math.round((shadowBolt.ranks?.at(-1)?.castTimeMs ?? shadowBolt.castTimeMs) * 0.75)); + }); + + it("keeps Backdraft's GCD at the one-second floor with high spell haste", () => { + setTalentRank("Shadowburn", 1); + setTalentRank("Backdraft", 3); + useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, hasteRating: 10_000 } })); + castAndFinish("Shadowburn", 1_000); + + const shadowBoltId = abilityId("Shadow Bolt"); + const result = useCombatStore.getState().castAbility(shadowBoltId, undefined, 3_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().globalCooldownEndsAt - 3_000).toBe(1_000); + }); + + it("applies each Backdraft Shadowburn damage rank in combat without requiring a proc", () => { + const targetId = "warlock-proc-target"; + const shadowburnId = abilityId("Shadowburn"); + const sourceAmounts: number[] = []; + setTalentRank("Shadowburn", 1); + for (const rank of [0, 1, 2, 3]) { + setTalentRank("Backdraft", rank); + const current = useCombatStore.getState(); + const originalTarget = current.mobs[targetId]!; + useCombatStore.setState({ + auras: current.auras.filter((aura) => !aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG)), + cooldowns: {}, + globalCooldownEndsAt: 0, + resource: current.maxResource, + resourcePools: { ...current.resourcePools, mana: current.maxResourcePools.mana }, + combatEvents: [], + mobs: { ...current.mobs, [targetId]: { ...originalTarget, health: originalTarget.maxHealth, dead: false } }, + }); + + const result = useCombatStore.getState().castAbility(shadowburnId, undefined, 40_000); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(result.damage).toBeGreaterThan(0); + const damageEvent = useCombatStore.getState().combatEvents.find((event) => ( + event.kind === "damage" && event.abilityId === shadowburnId && event.occurredAt === 40_000 + )); + expect(damageEvent?.rawAmount).toBeGreaterThan(0); + sourceAmounts.push(damageEvent!.rawAmount); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) + ))).toBe(rank > 0); + } + expect(sourceAmounts[1]).toBe(Math.round(sourceAmounts[0]! * 1.07)); + expect(sourceAmounts[2]).toBe(Math.round(sourceAmounts[0]! * 1.14)); + expect(sourceAmounts[3]).toBe(Math.round(sourceAmounts[0]! * 1.2)); + }); + it("procs rank-scaled Eradication from Corruption damage and applies haste to cast time", () => { setTalentRank("Eradication", 3); let nextCastAt = 1_000; diff --git a/src/game/wow335WarlockTalentRuntime.test.ts b/src/game/wow335WarlockTalentRuntime.test.ts new file mode 100644 index 00000000..1fb3d3cb --- /dev/null +++ b/src/game/wow335WarlockTalentRuntime.test.ts @@ -0,0 +1,191 @@ +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 { loadTalentUiCatalog } from "./talentUiCatalog"; +import { + warlockDeathGraspEffectsForRank, + warlockDeathGraspHitChanceBonusForRank, + warlockDeathGraspSpellHasteModifier, +} from "./wow335WarlockTalentRuntime"; +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +const DEATH_GRASP_ID = "talent-1005"; + +function setWarlockTalentRank(name: string, rank: number): void { + const talent = WOW335_TALENT_NODES.find((node) => node.name === name); + expect(talent, name).toBeDefined(); + const state = useCombatStore.getState(); + const talentRanks = { ...state.talentRanks, [talent!.id]: rank }; + useCombatStore.setState({ + talentRanks, + talentModifiers: talentModifiersForRanks(talentRanks, state.classId), + }); +} + +function setDeathGraspRank(rank: number): void { + setWarlockTalentRank("Death's Grasp", rank); +} + +function addTarget(id = "death-grasp-target"): void { + useCombatStore.getState().registerMob(id, { + name: id, + level: 80, + maxHealth: 100_000, + xpReward: 0, + }); + setMobPosition(id, [4, 0, 0]); + useCombatStore.getState().selectMob(id); +} + +function castAndFinish(name: string, now: number): void { + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); + expect(ability, name).toBeDefined(); + const result = useCombatStore.getState().castAbility(ability!.id, undefined, now); + expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true); + const activeCast = useCombatStore.getState().activeCast; + if (activeCast?.abilityId === ability!.id) { + useCombatStore.getState().tick(0.1, activeCast.completesAt); + } +} + +describe("WoW Warlock Death's Grasp", () => { + beforeEach(async () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + await preloadAbilityCatalogsForClasses(["warlock", "mage"]); + useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow-warlock-death-grasp-tests", 1, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + resource: state.maxResource, + resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, + })); + addTarget(); + }); + + it("matches each active talent tooltip rank to its DBC spell effects", async () => { + const talent = WOW335_TALENT_NODES.find((node) => node.id === DEATH_GRASP_ID); + expect(talent?.rankSpellIds).toEqual([18174, 18175, 18176]); + expect(talent?.rankEffects.map((rank) => rank.effects.map((effect) => [effect.auraType, effect.basePoints]))) + .toEqual([ + [[333, 1], [65, 1]], + [[333, 2], [65, 2]], + [[333, 3], [65, 3]], + ]); + + const ui = await loadTalentUiCatalog("warlock"); + const activeTalent = ui.nodes.find((node) => node.id === DEATH_GRASP_ID); + expect(activeTalent?.rankDescriptions).toEqual([ + "Increases your chance to hit with spells and abilities by 1%. Increases your spell haste by 1%.", + "Increases your chance to hit with spells and abilities by 2%. Increases your spell haste by 2%.", + "Increases your chance to hit with spells and abilities by 3%. Increases your spell haste by 3%.", + ]); + }); + + it.each([1, 2, 3])("applies rank %i spell haste to cast time and the global cooldown", (rank) => { + setDeathGraspRank(rank); + const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Shadow Bolt"); + expect(ability).toBeDefined(); + const sourceCastTimeMs = ability!.ranks?.at(-1)?.castTimeMs ?? ability!.castTimeMs; + + const startedAt = 20_000; + const result = useCombatStore.getState().castAbility(ability!.id, undefined, startedAt); + expect(result.ok, JSON.stringify(result)).toBe(true); + const cast = useCombatStore.getState().activeCast; + expect(cast?.abilityId).toBe(ability!.id); + expect(cast!.completesAt - startedAt).toBe(Math.round(sourceCastTimeMs / (1 + rank / 100))); + expect(useCombatStore.getState().globalCooldownEndsAt - startedAt) + .toBe(Math.max(1_000, Math.round(ability!.gcdMs / (1 + rank / 100)))); + }); + + it.each([1, 2, 3])("reduces the Warlock's spell miss chance by rank %i", (rank) => { + setDeathGraspRank(rank); + castAndFinish("Shadow Bolt", 1_000); + + expect(useCombatStore.getState().lastCombatResult?.attackTable.missPercent).toBe(4 - rank); + }); + + it("applies the player hit bonus to direct Warlock ability attacks such as Conflagrate", () => { + setDeathGraspRank(3); + setWarlockTalentRank("Conflagrate", 1); + castAndFinish("Immolate", 1_000); + castAndFinish("Conflagrate", 10_000); + + expect(useCombatStore.getState().lastCombatResult?.attackTable.missPercent).toBe(1); + }); + + it("does not apply Death's Grasp hit to Warlock auto-attacks", () => { + setDeathGraspRank(3); + useCombatStore.setState({ + autoAttackTargetId: "death-grasp-target", + nextAutoAttackAt: 1_000, + }); + useCombatStore.getState().tick(0.1, 1_000); + + expect(useCombatStore.getState().lastCombatResult?.attackTable.missPercent).toBe(5); + }); + + it("excludes periodic damage and ability-less attacks from the hit bonus", () => { + expect(warlockDeathGraspHitChanceBonusForRank(3, { + sourceIsWarlockPlayer: true, + hasAbility: true, + periodic: false, + })).toBe(3); + expect(warlockDeathGraspHitChanceBonusForRank(3, { + sourceIsWarlockPlayer: true, + hasAbility: true, + periodic: true, + })).toBe(0); + expect(warlockDeathGraspHitChanceBonusForRank(3, { + sourceIsWarlockPlayer: true, + hasAbility: false, + periodic: false, + })).toBe(0); + }); + + it("exposes rank-scaled player spell effects and does not affect another class", () => { + expect(warlockDeathGraspEffectsForRank(0)).toEqual({ + rank: 0, + hitChancePercent: 0, + spellHastePercent: 0, + }); + for (const rank of [1, 2, 3]) { + expect(warlockDeathGraspEffectsForRank(rank)).toEqual({ + rank, + hitChancePercent: rank, + spellHastePercent: rank, + }); + } + expect(warlockDeathGraspHitChanceBonusForRank(3, { + sourceIsWarlockPlayer: false, + hasAbility: true, + periodic: false, + })).toBe(0); + expect(warlockDeathGraspSpellHasteModifier(0)).toBeNull(); + expect(warlockDeathGraspSpellHasteModifier(3)).toMatchObject({ + kind: "haste-percent", + amount: 3, + scope: "your spells", + sourceTalentName: "Death's Grasp", + rank: 3, + }); + + useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 }); + setDeathGraspRank(3); + addTarget("mage-death-grasp-target"); + const frostbolt = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Frostbolt"); + expect(frostbolt).toBeDefined(); + const frostboltCastTimeMs = frostbolt!.ranks?.at(-1)?.castTimeMs ?? frostbolt!.castTimeMs; + const startedAt = 30_000; + const result = useCombatStore.getState().castAbility(frostbolt!.id, undefined, startedAt); + expect(result.ok, JSON.stringify(result)).toBe(true); + expect(useCombatStore.getState().activeCast?.completesAt! - startedAt).toBe(frostboltCastTimeMs); + useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt); + expect(useCombatStore.getState().lastCombatResult?.attackTable.missPercent).toBe(4); + }); +}); diff --git a/src/game/wow335WarlockTalentRuntime.ts b/src/game/wow335WarlockTalentRuntime.ts new file mode 100644 index 00000000..6a17c119 --- /dev/null +++ b/src/game/wow335WarlockTalentRuntime.ts @@ -0,0 +1,134 @@ +import type { TalentModifier } from "./talentModifiers"; + +export const WARLOCK_BACKDRAFT_READY_TAG = "wow335-warlock-backdraft-ready"; +export const WARLOCK_BACKDRAFT_TRIGGER_SPELLS = new Set([ + "Conflagrate", + "Mind Blast", + "Shadowfury", + "Shadowburn", +]); + +// The generated Warlock catalog has no explicit specialization tag on spell +// rows, so keep the runtime's supported Destruction spell family explicit. +export const WARLOCK_BACKDRAFT_DESTRUCTION_SPELLS = new Set([ + "Chaos Bolt", + "Conflagrate", + "Hellfire", + "Immolate", + "Incinerate", + "Rain of Fire", + "Searing Pain", + "Shadow Bolt", + "Shadowburn", + "Shadowflame", + "Shadowfury", + "Soul Fire", +]); + +export interface WarlockBackdraftEffects { + readonly rank: number; + readonly reductionPercent: number; + readonly shadowburnDamagePercent: number; + readonly charges: number; + readonly durationMs: number; +} + +/** Rank payloads come from generated Backdraft talent rows 47258/47259/47260. */ +export function warlockBackdraftEffectsForRank(rank: number): WarlockBackdraftEffects { + const normalizedRank = Math.max(0, Math.min(3, Math.trunc(Number.isFinite(rank) ? rank : 0))); + return { + rank: normalizedRank, + reductionPercent: [0, 8, 17, 25][normalizedRank] ?? 0, + shadowburnDamagePercent: [0, 7, 14, 20][normalizedRank] ?? 0, + charges: normalizedRank > 0 ? 3 : 0, + durationMs: normalizedRank > 0 ? 15_000 : 0, + }; +} + +export function warlockBackdraftTriggerEligible(abilityName: string): boolean { + return WARLOCK_BACKDRAFT_TRIGGER_SPELLS.has(abilityName); +} + +export function warlockBackdraftDestructionSpellEligible(abilityName: string): boolean { + return WARLOCK_BACKDRAFT_DESTRUCTION_SPELLS.has(abilityName); +} + +export const WARLOCK_NIGHTFALL_READY_TAG = "wow335-warlock-nightfall-ready"; +export const WARLOCK_NIGHTFALL_COOLDOWN_TAG = "wow335-warlock-nightfall-cooldown"; + +export interface WarlockNightfallEffects { + readonly rank: number; + readonly procChancePercent: number; + readonly manaCostReductionPercent: number; +} + +/** Rank values come from generated Nightfall talent text and rank spell proc chances. */ +export function warlockNightfallEffectsForRank(rank: number): WarlockNightfallEffects { + const normalizedRank = Math.max(0, Math.min(2, Math.trunc(Number.isFinite(rank) ? rank : 0))); + return { + rank: normalizedRank, + procChancePercent: [0, 4, 8][normalizedRank] ?? 0, + manaCostReductionPercent: [0, 25, 50][normalizedRank] ?? 0, + }; +} + +export function warlockNightfallTriggerEligible(abilityName: string): boolean { + return ["Corruption", "Drain Life", "Drain Soul", "Shadowflame"].includes(abilityName); +} + +export function warlockNightfallConsumerEligible(abilityName: string): boolean { + return ["Shadow Bolt", "Seed of Corruption"].includes(abilityName); +} + +export function warlockNightfallDamageMultiplier(abilityName: string, hasReadyProc: boolean): number { + return hasReadyProc && abilityName === "Shadow Bolt" ? 1.2 : 1; +} + +export interface WarlockDeathGraspEffects { + readonly rank: number; + readonly hitChancePercent: number; + readonly spellHastePercent: number; +} + +export interface WarlockDeathGraspHitContext { + readonly sourceIsWarlockPlayer: boolean; + readonly hasAbility: boolean; + readonly periodic: boolean; +} + +/** + * Death's Grasp source ranks 18174–18176 give the Warlock 1/2/3% hit chance + * and the same amount of spell haste. + */ +export function warlockDeathGraspEffectsForRank(rank: number): WarlockDeathGraspEffects { + const normalizedRank = Math.max(0, Math.min(3, Math.trunc(Number.isFinite(rank) ? rank : 0))); + return { + rank: normalizedRank, + hitChancePercent: normalizedRank, + spellHastePercent: normalizedRank, + }; +} + +/** Only the Warlock's direct ability hit checks qualify; autos and DoT ticks do not. */ +export function warlockDeathGraspHitChanceBonusForRank( + rank: number, + context: WarlockDeathGraspHitContext, +): number { + if (!context.sourceIsWarlockPlayer || !context.hasAbility || context.periodic) return 0; + return warlockDeathGraspEffectsForRank(rank).hitChancePercent; +} + +export function warlockDeathGraspSpellHasteModifier(rank: number): TalentModifier | null { + const effects = warlockDeathGraspEffectsForRank(rank); + if (effects.spellHastePercent <= 0) return null; + return { + kind: "haste-percent", + amount: effects.spellHastePercent, + unit: "percent", + scope: "your spells", + sourceTalentId: "talent-1005", + sourceTalentName: "Death's Grasp", + rank: effects.rank, + description: `Death's Grasp increases your spell haste by ${effects.spellHastePercent}%.`, + }; +} diff --git a/src/game/wow335WarriorAngerManagementRuntime.integration.test.ts b/src/game/wow335WarriorAngerManagementRuntime.integration.test.ts new file mode 100644 index 00000000..ef3ca9eb --- /dev/null +++ b/src/game/wow335WarriorAngerManagementRuntime.integration.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; +import { + clearPartyRuntimeRegistry, + registerPartyRuntimePosition, + updatePartyRuntimeTargetVisibility, +} from "./partyRuntimeRegistry"; +import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { abilitiesForClass } from "./abilityCatalog"; +import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; +import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; +import { WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID } from "./wow335WarriorAngerManagementRuntime"; + +function prepareWarrior(level = 80, angerManagementRank = 0): void { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "warrior", level }); + useCombatStore.getState().resetSettings(); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + usePartyStore.getState().initializeParty("wow335-warrior-anger-management-tests", 1, useCombatStore.getState().health); + useCombatStore.setState((state) => ({ + talentRanks: { + ...state.talentRanks, + ...(angerManagementRank > 0 ? { [WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID]: angerManagementRank } : {}), + }, + resource: 0, + resourcePools: { ...state.resourcePools, rage: 0 }, + })); +} + +function registerChargeTarget(id: string): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); + setMobPosition(id, [12, 0, 0]); + useCombatStore.getState().selectMob(id); +} + +function keepRageFromDecaying(): void { + const id = "anger-management-clock-dummy"; + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); + const target = useCombatStore.getState().mobs[id]; + useCombatStore.setState((state) => ({ + mobs: { ...state.mobs, [id]: { ...target, engaged: true } }, + })); +} + +function prepareWarriorCompanion(level: number, angerManagementRank: number, seed: string) { + useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level }); + usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, "damage"); + const warriorAbilities = abilitiesForClass("warrior"); + const charge = warriorAbilities.find((ability) => ability.id === "warrior-charge"); + if (!charge) throw new Error("Warrior Charge was missing from the class catalog."); + const configured = createConfiguredPartyMember({ + name: "Warrior", + classId: "warrior", + specialization: "Arms", + specializationId: "warrior:arms", + talentTreeId: "warrior-arms", + talentRanks: { [WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID]: angerManagementRank }, + role: "damage", + }, level, 0, seed); + const companion = { + ...configured, + resource: 0, + resourcePools: { ...configured.resourcePools, rage: 0 }, + cooldowns: Object.fromEntries(warriorAbilities + .filter((ability) => ability.id !== charge.id) + .map((ability) => [ability.id, Number.MAX_SAFE_INTEGER])), + nextActionAt: 0, + }; + usePartyStore.setState({ members: [companion] }); + registerPartyRuntimePosition(companion.id, [0, 0, 0]); + useCombatStore.getState().synchronizeActors(); + resetPartyRuntimeClock(); + return companion; +} + +function registerEngagedCompanionTarget(id: string, position: readonly [number, number, number], memberId: string): void { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); + setMobPosition(id, position); + useCombatStore.getState().damageMob(id, 1, 100); + useCombatStore.getState().selectMob(id); + usePartyStore.getState().setCommand("attack"); + updatePartyRuntimeTargetVisibility(memberId, id, true); +} + +function companionRage(memberId: string): number { + return usePartyStore.getState().members.find((member) => member.id === memberId)?.resourcePools.rage ?? 0; +} + +function rage(): number { + return useCombatStore.getState().resourcePools.rage ?? 0; +} + +describe("WoW 3.3.5 Warrior Anger Management live mechanics", () => { + beforeEach(() => prepareWarrior()); + + it.each([ + [1, 2], + [2, 4], + [3, 6], + ] as const)("at rank %i, generates %i Rage per tick exactly every three seconds", (rank, ragePerTick) => { + prepareWarrior(80, rank); + keepRageFromDecaying(); + const tick = (deltaSeconds: number, now: number) => useCombatStore.getState().tick(deltaSeconds, now); + + tick(1, 1_000); + tick(1, 2_000); + tick(0.999, 2_999); + expect(rage()).toBe(0); + tick(0.001, 3_000); + expect(rage()).toBe(ragePerTick); + + tick(1, 4_000); + tick(1, 5_000); + tick(0.999, 5_999); + expect(rage()).toBe(ragePerTick); + tick(0.001, 6_000); + expect(rage()).toBe(ragePerTick * 2); + }); + + it("does not grant passive Rage without Anger Management and respects the Rage cap", () => { + keepRageFromDecaying(); + const tick = (deltaSeconds: number, now: number) => useCombatStore.getState().tick(deltaSeconds, now); + tick(1, 1_000); + tick(1, 2_000); + tick(1, 3_000); + expect(rage()).toBe(0); + + prepareWarrior(80, 3); + keepRageFromDecaying(); + useCombatStore.setState((state) => ({ + resource: 99, + resourcePools: { ...state.resourcePools, rage: 99 }, + })); + tick(1, 1_000); + tick(1, 2_000); + tick(1, 3_000); + expect(rage()).toBe(100); + }); + + it.each([ + [14, 1, 9, 5, 14], + [26, 2, 12, 10, 22], + [46, 3, 15, 15, 30], + ] as const)( + "uses the level %i Charge row at rank %i and adds the matching Anger Management bonus", + (level, _chargeRank, baseline, talentBonus, expectedTotal) => { + prepareWarrior(level, _chargeRank); + registerChargeTarget(`anger-management-charge-${level}`); + + const result = useCombatStore.getState().castAbility("warrior-charge", undefined, 1_000); + + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + expect(rage()).toBe(baseline + talentBonus); + expect(useCombatStore.getState().resource).toBe(expectedTotal); + }, + ); + + it("grants the rank-three four-second damage reduction after a successful Charge only", () => { + prepareWarrior(46, 3); + registerChargeTarget("anger-management-capstone-target"); + const result = useCombatStore.getState().castAbility("warrior-charge", undefined, 1_000); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + + const reductionAura = useCombatStore.getState().auras.find((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.expiresAt === 5_000 + && aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" + && modifier.direction === "taken" + && modifier.operation === "percent" + && modifier.value === -0.3) + )); + expect(reductionAura).toBeDefined(); + + const damageWithCapstone = useCombatStore.getState().damagePlayer(1_000, "fire", 46, 2_000); + expect(damageWithCapstone).toBe(700); + + prepareWarrior(46, 3); + const failed = useCombatStore.getState().castAbility("warrior-charge", undefined, 1_000); + expect(failed.ok).toBe(false); + expect(rage()).toBe(0); + expect(useCombatStore.getState().auras.some((aura) => ( + aura.targetId === PLAYER_AURA_ENTITY_ID + && aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" + && modifier.direction === "taken" + && modifier.operation === "percent" + && modifier.value === -0.3) + ))).toBe(false); + }); + + it("ticks a Warrior companion's Anger Management Rage on the exact three-second cadence", () => { + const companion = prepareWarriorCompanion(46, 3, "warrior-companion-anger-management-tick"); + registerEngagedCompanionTarget("warrior-companion-distant-target", [1_000, 0, 0], companion.id); + + advanceDungeonPartyCombat(1_000); + advanceDungeonPartyCombat(2_000); + advanceDungeonPartyCombat(3_000); + advanceDungeonPartyCombat(3_999); + expect(companionRage(companion.id)).toBe(0); + advanceDungeonPartyCombat(4_000); + expect(companionRage(companion.id)).toBe(6); + + advanceDungeonPartyCombat(5_000); + advanceDungeonPartyCombat(6_000); + advanceDungeonPartyCombat(6_999); + expect(companionRage(companion.id)).toBe(6); + advanceDungeonPartyCombat(7_000); + expect(companionRage(companion.id)).toBe(12); + }); +}); diff --git a/src/game/wow335WarriorAngerManagementRuntime.test.ts b/src/game/wow335WarriorAngerManagementRuntime.test.ts new file mode 100644 index 00000000..84dd3fa3 --- /dev/null +++ b/src/game/wow335WarriorAngerManagementRuntime.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + wow335WarriorAngerManagementProfile, + wow335WarriorChargeRageProfile, +} from "./wow335WarriorAngerManagementRuntime"; + +describe("WoW 3.3.5 Warrior Anger Management generated tooltip mechanics", () => { + it.each([ + [1, 2, 5], + [2, 4, 10], + [3, 6, 15], + ] as const)("reads rank %i Rage and Charge values from its generated tooltip", (rank, ragePerTick, chargeBonusRage) => { + expect(wow335WarriorAngerManagementProfile(rank)).toMatchObject({ + rank, + ragePerTick, + tickIntervalMs: 3_000, + chargeBonusRage, + }); + }); + + it("exposes the generated Charge damage reduction only at the final rank", () => { + expect(wow335WarriorAngerManagementProfile(1)).toMatchObject({ + chargeDamageReductionPercent: 0, + chargeDamageReductionDurationMs: 0, + chargeDamageReductionSpellId: null, + }); + expect(wow335WarriorAngerManagementProfile(3)).toMatchObject({ + chargeDamageReductionPercent: 30, + chargeDamageReductionDurationMs: 4_000, + chargeDamageReductionSpellId: 812288, + }); + }); + + it("does not produce an effect profile without an allocated rank", () => { + expect(wow335WarriorAngerManagementProfile(0)).toBeNull(); + }); +}); + +describe("WoW 3.3.5 Warrior Charge Rage source mechanics", () => { + it.each([ + [1, 100, 9, 5, 14], + [2, 6178, 12, 10, 22], + [3, 11578, 15, 15, 30], + ] as const)( + "combines Charge rank %i baseline and matching Anger Management bonus", + (rank, chargeSpellId, baseRage, angerManagementBonusRage, totalRage) => { + expect(wow335WarriorChargeRageProfile(rank, rank)).toEqual({ + chargeRank: rank, + chargeSpellId, + angerManagementRank: rank, + baseRage, + angerManagementBonusRage, + totalRage, + }); + }, + ); + + it("keeps baseline Charge Rage when Anger Management is unlearned", () => { + expect(wow335WarriorChargeRageProfile(3, 0)).toMatchObject({ + chargeRank: 3, + baseRage: 15, + angerManagementBonusRage: 0, + totalRage: 15, + }); + }); + + it("does not produce a Charge profile without a learned Charge rank", () => { + expect(wow335WarriorChargeRageProfile(0, 3)).toBeNull(); + }); +}); diff --git a/src/game/wow335WarriorAngerManagementRuntime.ts b/src/game/wow335WarriorAngerManagementRuntime.ts new file mode 100644 index 00000000..dce5ca21 --- /dev/null +++ b/src/game/wow335WarriorAngerManagementRuntime.ts @@ -0,0 +1,121 @@ +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; +import generatedData from "./wow335AbilityData.generated.json"; + +export const WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID = "talent-137"; +export const WOW335_WARRIOR_ANGER_MANAGEMENT_CHARGE_AURA_TAG = "wow335-warrior-anger-management-charge"; + +const SOURCE = WOW335_TALENT_NODES.find((node) => ( + node.id === WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID +)); +const CHARGE_SOURCE = generatedData.classes.warrior.find((ability) => ability.name === "Charge"); + +export function wow335WarriorChargeRank(spellId: number): number { + return CHARGE_SOURCE?.ranks.find((rank) => rank.spellId === spellId)?.rank ?? 0; +} + +export interface Wow335WarriorAngerManagementProfile { + readonly rank: number; + readonly ragePerTick: number; + readonly tickIntervalMs: number; + readonly chargeBonusRage: number; + readonly chargeDamageReductionPercent: number; + readonly chargeDamageReductionDurationMs: number; + readonly chargeDamageReductionSpellId: number | null; +} + +export interface Wow335WarriorChargeRageProfile { + readonly chargeRank: number; + readonly chargeSpellId: number; + readonly angerManagementRank: number; + readonly baseRage: number; + readonly angerManagementBonusRage: number; + readonly totalRage: number; +} + +/** + * Resolves the base Charge Rage and the selected Anger Management rank. + * Generated Charge tooltips hide the amount as "listed amount" while the + * local dummy effect contains 90/120/150. WotLKDB's Charge rank-1 tooltip + * confirms that dummy value 90 means 9 Rage; the same local field conversion + * is applied to ranks 2 and 3 (120/150 => 12/15 Rage). The database supplies + * only the missing unit scale; local rank rows remain the data source. + */ +export function wow335WarriorChargeRageProfile( + chargeRank: number, + angerManagementRank: number, +): Wow335WarriorChargeRageProfile | null { + const maxChargeRank = CHARGE_SOURCE?.ranks.length ?? 0; + const safeChargeRank = Math.min(maxChargeRank, Math.max(0, Math.trunc( + Number.isFinite(chargeRank) ? chargeRank : 0, + ))); + const charge = CHARGE_SOURCE?.ranks.find((rank) => rank.rank === safeChargeRank); + const rageEffect = charge?.effects.find((effect) => effect.effectType === 3 && effect.implicitTargetA === 1); + if (safeChargeRank === 0 || !charge || !rageEffect || rageEffect.basePoints <= 0 || rageEffect.basePoints % 10 !== 0) { + return null; + } + + const maxTalentRank = SOURCE?.rankSpellIds.length ?? 0; + const safeTalentRank = Math.min(maxTalentRank, Math.max(0, Math.trunc( + Number.isFinite(angerManagementRank) ? angerManagementRank : 0, + ))); + const talentProfile = wow335WarriorAngerManagementProfile(safeTalentRank); + const baseRage = rageEffect.basePoints / 10; + const angerManagementBonusRage = talentProfile?.chargeBonusRage ?? 0; + return { + chargeRank: safeChargeRank, + chargeSpellId: charge.spellId, + angerManagementRank: safeTalentRank, + baseRage, + angerManagementBonusRage, + totalRage: baseRage + angerManagementBonusRage, + }; +} + +/** + * Reads the local generated rank tooltip and rank effects. The three rank + * tooltips specify passive Rage and Charge bonuses; the maximum-rank tooltip + * additionally describes Charge's four-second damage reduction. Rank 1 and + * 2 mention that capstone in their text only as a rank-3 unlock, so it is not + * exposed until the final rank is allocated. + */ +export function wow335WarriorAngerManagementProfile( + rank: number, +): Wow335WarriorAngerManagementProfile | null { + const rankCount = SOURCE?.rankSpellIds.length ?? 0; + const safeRank = Math.min(rankCount, Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0))); + const description = SOURCE?.rankDescriptions[safeRank - 1]; + const rankEffect = SOURCE?.rankEffects[safeRank - 1]; + if (safeRank === 0 || !description || !rankEffect) return null; + + const rage = description.match(/generates (\d+) rage per (\d+) seconds/i); + const chargeBonus = description.match(/Charge ability by (\d+)/i); + if (!rage || !chargeBonus) return null; + + const ragePerTick = Number(rage[1]); + const tickIntervalMs = Number(rage[2]) * 1_000; + const chargeBonusRage = Number(chargeBonus[1]); + if (![ragePerTick, tickIntervalMs, chargeBonusRage].every(Number.isFinite) + || ragePerTick < 0 || tickIntervalMs <= 0 || chargeBonusRage < 0) return null; + + let chargeDamageReductionPercent = 0; + let chargeDamageReductionDurationMs = 0; + let chargeDamageReductionSpellId: number | null = null; + if (safeRank === rankCount) { + const reduction = description.match(/Charge also reduces your damage taken by (\d+)% for (\d+) seconds/i); + if (reduction) { + chargeDamageReductionPercent = Number(reduction[1]); + chargeDamageReductionDurationMs = Number(reduction[2]) * 1_000; + chargeDamageReductionSpellId = rankEffect.effects.find((effect) => effect.triggerSpellId > 0)?.triggerSpellId ?? null; + } + } + + return { + rank: safeRank, + ragePerTick, + tickIntervalMs, + chargeBonusRage, + chargeDamageReductionPercent, + chargeDamageReductionDurationMs, + chargeDamageReductionSpellId, + }; +} diff --git a/src/game/wow335WarriorRuntime.test.ts b/src/game/wow335WarriorRuntime.test.ts index 4470a02a..67cafb6d 100644 --- a/src/game/wow335WarriorRuntime.test.ts +++ b/src/game/wow335WarriorRuntime.test.ts @@ -1,10 +1,44 @@ import { beforeEach, describe, expect, it } from "vitest"; import { abilityTalentNodeId } from "./abilityCatalog"; -import { resolveStatValue } from "./combatAuras"; +import { applyAura as applyCombatAura, resolveStatValue } from "./combatAuras"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; +import { PLAYER_AGGRO_ID } from "./aggro"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; +import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { usePartyStore } from "./partyStore"; +import { baseResourcesFor } from "./wotlkStats"; +import { + WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY, + WOW335_WARRIOR_BLOOD_CRAZE_TALENT_ID, + WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY, + WOW335_WARRIOR_SECOND_WIND_TALENT_ID, +} from "./wow335WarriorTalentRuntime"; + +function stableUnit(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 criticalAttackTime(mobId: string, attackId: string): number { + for (let now = 2_000; now < 200_000; now += 1) { + const roll = stableUnit(`${mobId}:${attackId}:${PLAYER_AURA_ENTITY_ID}:0:${now}:outcome`); + if (roll >= 0.25 && roll < 0.75) return now; + } + throw new Error("Could not find a deterministic critical-strike attack time."); +} + +function landedHeroicStrikeTime(targetId: string, startAt: number): number { + for (let now = startAt; now < startAt + 100_000; now += 1) { + const seed = `${PLAYER_AGGRO_ID}:warrior-heroic-strike:${targetId}:${now}:direct`; + if (stableUnit(`${seed}:outcome`) >= 0.5) return now; + } + throw new Error("Could not find a deterministic landed Heroic Strike time."); +} function unlockWarriorTalent(abilityId: string): void { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === abilityId); @@ -64,6 +98,163 @@ describe("WoW 3.3.5 Warrior source-row mechanics", () => { ))).toBe(false); }); + it("procs Blood Craze from a critical strike received, triples with Enrage, and starts the 12-second ICD", () => { + const mobId = "blood-craze-crit-source"; + const attackId = DEFAULT_ENEMY_ATTACK.id; + const attackAt = criticalAttackTime(mobId, attackId); + const engageAt = attackAt - 450; + const state = useCombatStore.getState(); + const baseHealth = baseResourcesFor("warrior", 80).health; + const expectedHealing = Math.round(baseHealth * 0.09); + useCombatStore.setState((current) => ({ + talentRanks: { ...current.talentRanks, [WOW335_WARRIOR_BLOOD_CRAZE_TALENT_ID]: 3 }, + health: current.maxHealth - expectedHealing - 100, + auras: [...applyCombatAura(current.auras, { + definition: { + id: "wow335-warrior-runtime-test-enrage", + name: "Test Enrage", + disposition: "buff", + durationMs: null, + dispelCategory: "enrage", + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: engageAt, + }).auras], + })); + useCombatStore.getState().registerMob(mobId, { + name: mobId, + level: 80, + maxHealth: 10_000, + attacks: [DEFAULT_ENEMY_ATTACK], + xpReward: 0, + }); + setMobPosition(mobId, [2, 0, 0]); + useCombatStore.getState().setPlayerPosition([0, 0, 0]); + useCombatStore.setState((current) => ({ + auras: [...applyCombatAura(current.auras, { + definition: { + id: "wow335-warrior-runtime-test-guaranteed-crit", + name: "Guaranteed Incoming Crit", + disposition: "buff", + durationMs: null, + modifiers: [{ kind: "stat", stat: "incoming-critical-strike", operation: "flat", value: 100 }], + }, + sourceId: PLAYER_AURA_ENTITY_ID, + targetId: PLAYER_AURA_ENTITY_ID, + now: engageAt, + }).auras], + })); + + useCombatStore.getState().damageMob(mobId, 1, engageAt); + useCombatStore.getState().advanceMobCombat(attackAt); + + const afterCritical = useCombatStore.getState(); + const receivedAttack = [...afterCritical.combatEvents].reverse().find((event) => ( + event.sourceActorId === mobId && event.targetActorId === PLAYER_AURA_ENTITY_ID + )); + expect(receivedAttack, JSON.stringify({ mobs: afterCritical.mobs, events: afterCritical.combatEvents })).toBeDefined(); + expect(receivedAttack?.critical).toBe(true); + const effect = afterCritical.effects.find((candidate) => candidate.tag === "wow335-warrior-blood-craze"); + expect(effect).toMatchObject({ + kind: "hot", + remainingTicks: 6, + intervalMs: 1_000, + nextTickAt: attackAt + 1_000, + fixedAmount: expectedHealing / 6, + }); + expect(afterCritical.cooldowns[WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY]).toBe(attackAt + 12_000); + + useCombatStore.getState().advanceMobCombat(attackAt + 5_000); + const afterSecondCritical = useCombatStore.getState(); + const criticalHits = afterSecondCritical.combatEvents.filter((event) => ( + event.sourceActorId === mobId + && event.targetActorId === PLAYER_AURA_ENTITY_ID + && event.critical + )); + expect(criticalHits.length).toBe(2); + expect(afterSecondCritical.effects.filter((candidate) => candidate.tag === "wow335-warrior-blood-craze")) + .toHaveLength(1); + expect(afterSecondCritical.cooldowns[WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY]).toBe(attackAt + 12_000); + + useCombatStore.getState().tick(0, attackAt + 6_000); + expect(useCombatStore.getState().health).toBeGreaterThan(afterSecondCritical.health); + expect(useCombatStore.getState().effects.some((candidate) => candidate.tag === "wow335-warrior-blood-craze")) + .toBe(false); + }); + + it.each([ + [1, 8], + [2, 16], + [3, 24], + ] as const)("starts Second Wind rank %i from a landed melee ability for its generated heal total", (rank, healthPercent) => { + registerTarget(`second-wind-rank-${rank}`, [2, 0, 0], 1_000_000); + useCombatStore.getState().selectMob(`second-wind-rank-${rank}`); + const state = useCombatStore.getState(); + const startingHealth = Math.floor(state.maxHealth * 0.30); + const baseHealth = baseResourcesFor("warrior", state.level).health; + const totalHealing = Math.round(baseHealth * healthPercent / 100); + useCombatStore.setState((current) => ({ + health: startingHealth, + resource: 100, + resourcePools: { ...current.resourcePools, rage: 100 }, + talentRanks: { ...current.talentRanks, [WOW335_WARRIOR_SECOND_WIND_TALENT_ID]: rank }, + })); + + const result = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 1_000); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + + const afterHit = useCombatStore.getState(); + expect(afterHit.effects.find((effect) => effect.tag === "wow335-warrior-second-wind")).toMatchObject({ + kind: "hot", + remainingTicks: 6, + intervalMs: 2_000, + nextTickAt: 3_000, + fixedAmount: totalHealing / 6, + }); + expect(afterHit.cooldowns[WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY]).toBe(31_000); + + useCombatStore.getState().tick(0, 13_000); + expect(useCombatStore.getState().health - startingHealth).toBe(totalHealing); + expect(useCombatStore.getState().effects.some((effect) => effect.tag === "wow335-warrior-second-wind")).toBe(false); + }); + + it("requires below 35% health, respects its 30-second ICD, and removes the HoT after 12 seconds", () => { + const targetId = "second-wind-threshold-target"; + registerTarget(targetId, [2, 0, 0], 1_000_000); + useCombatStore.getState().selectMob(targetId); + const initial = useCombatStore.getState(); + useCombatStore.setState((current) => ({ + health: Math.ceil(current.maxHealth * 0.35), + resource: 100, + resourcePools: { ...current.resourcePools, rage: 100 }, + talentRanks: { ...current.talentRanks, [WOW335_WARRIOR_SECOND_WIND_TALENT_ID]: 3 }, + })); + + expect(useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 1_000).ok).toBe(true); + expect(useCombatStore.getState().effects.some((effect) => effect.tag === "wow335-warrior-second-wind")).toBe(false); + expect(useCombatStore.getState().cooldowns[WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY]).toBeUndefined(); + + const lowHealth = Math.floor(initial.maxHealth * 0.30); + useCombatStore.setState((current) => ({ health: lowHealth })); + const triggerAt = landedHeroicStrikeTime(targetId, 3_000); + expect(useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, triggerAt).ok).toBe(true); + expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-warrior-second-wind")).toHaveLength(1); + expect(useCombatStore.getState().cooldowns[WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY]).toBe(triggerAt + 30_000); + + expect(useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, triggerAt + 2_000).ok).toBe(true); + expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-warrior-second-wind")).toHaveLength(1); + + useCombatStore.getState().tick(0, triggerAt + 12_000); + expect(useCombatStore.getState().effects.some((effect) => effect.tag === "wow335-warrior-second-wind")).toBe(false); + + useCombatStore.setState((current) => ({ health: Math.floor(current.maxHealth * 0.30) })); + const reprocAt = landedHeroicStrikeTime(targetId, triggerAt + 31_000); + expect(useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, reprocAt).ok).toBe(true); + expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-warrior-second-wind")).toHaveLength(1); + expect(useCombatStore.getState().cooldowns[WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY]).toBe(reprocAt + 30_000); + }); + 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); diff --git a/src/game/wow335WarriorTalentRuntime.test.ts b/src/game/wow335WarriorTalentRuntime.test.ts new file mode 100644 index 00000000..60216c96 --- /dev/null +++ b/src/game/wow335WarriorTalentRuntime.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + WOW335_WARRIOR_BLOOD_CRAZE_DURATION_MS, + wow335WarriorBloodCrazeProfile, + wow335WarriorSecondWindProfile, +} from "./wow335WarriorTalentRuntime"; + +describe("WoW 3.3.5 Warrior Blood Craze tooltip mechanics", () => { + it.each([ + [1, 3, 2], + [2, 1.5, 4], + [3, 1, 6], + ] as const)("distributes rank %i's base-health heal across its six-second duration", (rank, intervalSeconds, ticks) => { + const profile = wow335WarriorBloodCrazeProfile(10_000, rank, false); + + expect(profile).toMatchObject({ + rank, + ticks, + intervalMs: intervalSeconds * 1_000, + totalHealing: rank * 100, + healingPerTick: rank * 100 / ticks, + }); + expect(profile!.ticks * profile!.intervalMs).toBe(WOW335_WARRIOR_BLOOD_CRAZE_DURATION_MS); + }); + + it("triples the tooltip-defined total while an Enrage effect is active", () => { + expect(wow335WarriorBloodCrazeProfile(10_000, 2, true)).toMatchObject({ + rank: 2, + totalHealing: 600, + healingPerTick: 150, + }); + }); + + it("does not create a profile without an allocated rank or valid base health", () => { + expect(wow335WarriorBloodCrazeProfile(10_000, 0, false)).toBeNull(); + expect(wow335WarriorBloodCrazeProfile(0, 3, false)).toBeNull(); + }); +}); + +describe("WoW 3.3.5 Warrior Second Wind tooltip mechanics", () => { + it.each([ + [1, 8], + [2, 16], + [3, 24], + ] as const)("builds rank %i from its generated tooltip heal", (rank, healthPercent) => { + const profile = wow335WarriorSecondWindProfile(10_000, rank); + + expect(profile).toMatchObject({ + rank, + healthThresholdPercent: 35, + procChancePercent: 100, + durationMs: 12_000, + cooldownMs: 30_000, + ticks: 6, + intervalMs: 2_000, + totalHealing: healthPercent * 100, + healingPerTick: healthPercent * 100 / 6, + }); + expect(profile!.ticks * profile!.intervalMs).toBe(profile!.durationMs); + expect(profile!.tickAmounts.reduce((total, amount) => total + amount, 0)).toBe(profile!.totalHealing); + }); + + it("does not create a profile without a learned rank or valid base health", () => { + expect(wow335WarriorSecondWindProfile(10_000, 0)).toBeNull(); + expect(wow335WarriorSecondWindProfile(0, 3)).toBeNull(); + }); +}); diff --git a/src/game/wow335WarriorTalentRuntime.ts b/src/game/wow335WarriorTalentRuntime.ts new file mode 100644 index 00000000..80f0215d --- /dev/null +++ b/src/game/wow335WarriorTalentRuntime.ts @@ -0,0 +1,105 @@ +import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; + +export const WOW335_WARRIOR_BLOOD_CRAZE_TALENT_ID = "talent-661"; +export const WOW335_WARRIOR_BLOOD_CRAZE_COOLDOWN_KEY = "wow335-warrior-blood-craze"; +export const WOW335_WARRIOR_BLOOD_CRAZE_DURATION_MS = 6_000; +export const WOW335_WARRIOR_BLOOD_CRAZE_INTERNAL_COOLDOWN_MS = 12_000; + +export const WOW335_WARRIOR_SECOND_WIND_TALENT_ID = "talent-1663"; +export const WOW335_WARRIOR_SECOND_WIND_COOLDOWN_KEY = "wow335-warrior-second-wind"; +export const WOW335_WARRIOR_SECOND_WIND_INTERVAL_MS = 2_000; + +const WOW335_WARRIOR_SECOND_WIND_SOURCE = WOW335_TALENT_NODES.find((node) => ( + node.id === WOW335_WARRIOR_SECOND_WIND_TALENT_ID +)); + +export interface Wow335WarriorSecondWindProfile { + readonly rank: 1 | 2 | 3; + readonly healthThresholdPercent: number; + readonly procChancePercent: number; + readonly durationMs: number; + readonly cooldownMs: number; + readonly ticks: number; + readonly intervalMs: number; + readonly totalHealing: number; + readonly healingPerTick: number; + readonly tickAmounts: readonly number[]; +} + +/** + * Uses the populated generated rank tooltip for threshold, duration, heal, + * cooldown, and the generated rank procChance. WotLKDB spells 29841/29842 + * both show a 2-second periodic interval; the generated custom rank-3 child + * (829839) has no WotLKDB row, so the same confirmed cadence is carried forward. + */ +export function wow335WarriorSecondWindProfile( + baseHealth: number, + rank: number, +): Wow335WarriorSecondWindProfile | null { + const safeRank = Math.min(3, Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0))); + const rankDescription = WOW335_WARRIOR_SECOND_WIND_SOURCE?.rankDescriptions[safeRank - 1]; + const rankEffect = WOW335_WARRIOR_SECOND_WIND_SOURCE?.rankEffects[safeRank - 1]; + if (safeRank === 0 || !rankDescription || !rankEffect || !Number.isFinite(baseHealth) || baseHealth <= 0) { + return null; + } + + const healthThresholdPercent = Number(rankDescription.match(/below (\d+)% health/i)?.[1]); + const healPercent = Number(rankDescription.match(/(\d+)% of your base health over (\d+) seconds/i)?.[1]); + const durationSeconds = Number(rankDescription.match(/base health over (\d+) seconds/i)?.[1]); + const cooldownSeconds = Number(rankDescription.match(/once every (\d+) seconds/i)?.[1]); + const durationMs = durationSeconds * 1_000; + if (!Number.isFinite(healthThresholdPercent) || !Number.isFinite(healPercent) + || !Number.isFinite(durationMs) || durationMs <= 0 || durationMs % WOW335_WARRIOR_SECOND_WIND_INTERVAL_MS !== 0 + || !Number.isFinite(cooldownSeconds) || cooldownSeconds <= 0) return null; + + const ticks = durationMs / WOW335_WARRIOR_SECOND_WIND_INTERVAL_MS; + const totalHealing = Math.round(baseHealth * healPercent / 100); + const baseTickHealing = Math.floor(totalHealing / ticks); + const bonusTicks = totalHealing - baseTickHealing * ticks; + const tickAmounts = Array.from({ length: ticks }, (_, index) => baseTickHealing + Number(index < bonusTicks)); + return { + rank: safeRank as 1 | 2 | 3, + healthThresholdPercent, + procChancePercent: Math.max(0, Math.min(100, rankEffect.procChance)), + durationMs, + cooldownMs: cooldownSeconds * 1_000, + ticks, + intervalMs: WOW335_WARRIOR_SECOND_WIND_INTERVAL_MS, + totalHealing, + healingPerTick: totalHealing / ticks, + tickAmounts, + }; +} + +export interface Wow335WarriorBloodCrazeProfile { + readonly rank: 1 | 2 | 3; + readonly ticks: number; + readonly intervalMs: number; + readonly totalHealing: number; + readonly healingPerTick: number; +} + +/** + * Builds the tooltip-defined total heal and uses each rank's hidden child aura + * interval only to distribute it through the six-second duration. Rank 3's + * one-second interval is also exposed by WotLKDB spell 16491. + */ +export function wow335WarriorBloodCrazeProfile( + baseHealth: number, + rank: number, + enraged: boolean, +): Wow335WarriorBloodCrazeProfile | null { + const safeRank = Math.min(3, Math.max(0, Math.trunc(Number.isFinite(rank) ? rank : 0))); + if (safeRank === 0 || !Number.isFinite(baseHealth) || baseHealth <= 0) return null; + + const intervalMs = [0, 3_000, 1_500, 1_000][safeRank]; + const ticks = WOW335_WARRIOR_BLOOD_CRAZE_DURATION_MS / intervalMs; + const totalHealing = Math.round(baseHealth * safeRank / 100 * (enraged ? 3 : 1)); + return { + rank: safeRank as 1 | 2 | 3, + ticks, + intervalMs, + totalHealing, + healingPerTick: totalHealing / ticks, + }; +}