Update Healer Man server
This commit is contained in:
1 parent
35dfdaed61
commit
4fb7a0b9c8
161 files changed
+24618
-529
No files matched your search
+67
-32
@@ -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.
|
||||
@@ -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<void> {
|
||||
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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4216
-251
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+125
-11
@@ -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<typeof romKnightWarriorWhirlwindAdjustment> {
|
||||
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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
+1257
-80
File diff suppressed because it is too large.
Load diff
+13
-1
@@ -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<Partial<Record<ResourceType, number>>>;
|
||||
readonly lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
|
||||
readonly warriorAngerManagementRageRemainderMs?: number;
|
||||
readonly comboPoints: number;
|
||||
readonly comboTargetId: string | null;
|
||||
readonly cooldowns: Readonly<Record<string, number>>;
|
||||
readonly globalCooldownEndsAt: number;
|
||||
readonly activeCast: CombatActorCastState | null;
|
||||
readonly activeCast: PartyActiveCast | null;
|
||||
readonly activeCastTargetId: string | null;
|
||||
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||
readonly runes: readonly DeathKnightRuneState[];
|
||||
|
||||
@@ -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<RomAbilitySemantic, { kind: "aura" }> => (
|
||||
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);
|
||||
|
||||
@@ -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" }),
|
||||
]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AbilityEffect, { kind: "damage" }>, factor: number): AbilityEffect {
|
||||
return {
|
||||
...effect,
|
||||
@@ -48,6 +121,83 @@ function scaleDamage(effect: Extract<AbilityEffect, { kind: "damage" }>, factor:
|
||||
};
|
||||
}
|
||||
|
||||
function casterHealFromDamage(effect: Extract<AbilityEffect, { kind: "damage" }>): 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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string>, 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;
|
||||
|
||||
@@ -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<NonNullable<typeof flatHot>, { 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<NonNullable<typeof flatHot>, { 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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
||||
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<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
||||
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<string> = [],
|
||||
): readonly AbilityEffect[] {
|
||||
const hasHolyLightsFury = Array.isArray(passiveTags)
|
||||
? passiveTags.includes(ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG)
|
||||
: (passiveTags as ReadonlySet<string>).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<string>).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,
|
||||
}];
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<typeof useCombatStore.getState>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+193
-1
@@ -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<AbilityEffect, { kind: "apply-aura" }> = {
|
||||
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<AbilityEffect, { kind: "apply-aura" }> = {
|
||||
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<ActiveAura, "targetId" | "expiresAt" | "definition">[],
|
||||
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<AbilityDefinition, "classId" | "secondaryClassId" | "source" | "dbcSpellId">,
|
||||
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<ActiveAura, "targetId" | "expiresAt" | "definition">[],
|
||||
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<AbilityDefinition, "source" | "dbcSpellId">,
|
||||
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,
|
||||
|
||||
@@ -272,7 +272,7 @@ export function romMageWarlockCriticalEffects(
|
||||
passiveTags: ReadonlySet<string>,
|
||||
): 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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AbilityEffect, { kind: "apply-aura" }> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof romEffectsForCast>): 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<AbilityDefinition, "source" | "classId" | "secondaryClassId" | "dbcSpellId">,
|
||||
): 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<AbilityDefinition, "source" | "classId" | "secondaryClassId" | "dbcSpellId">,
|
||||
activeAuraTags: readonly string[] | ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (!romPriestWarriorStanceRequired(ability)) return true;
|
||||
return Array.isArray(activeAuraTags)
|
||||
? activeAuraTags.includes(ROM_PRIEST_WARRIOR_BATTLE_MONK_STANCE_TAG)
|
||||
: (activeAuraTags as ReadonlySet<string>).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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<AbilityEffect, { kind: "apply-aura" }> = {
|
||||
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<ActiveAura, "targetId" | "expiresAt" | "definition">[],
|
||||
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<AbilityDefinition, "classId" | "secondaryClassId" | "source" | "dbcSpellId">,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
|
||||
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.",
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string> = [],
|
||||
): 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<string>).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<AbilityEffect, { kind: "apply-aura" }> => (
|
||||
effect.kind === "apply-aura"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<AbilityDefinition, "classId" | "secondaryClassId" | "id" | "source" | "romSkillGroup" | "passive">,
|
||||
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<AbilityEffect, { kind: "apply-aura" }> {
|
||||
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 },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof applyAura>["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<typeof applyAura>["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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<Record<string, string | number | boolean>>;
|
||||
} = {},
|
||||
): 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<AbilityEffect, { kind: "damage" }>;
|
||||
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<Record<string, string | number | boolean>> = {},
|
||||
): 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 {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RomCastMechanics> = {}) {
|
||||
|
||||
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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+119
-3
@@ -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<TalentModifier, "sourceTalentId" | "source
|
||||
|
||||
const EXPLICIT_ACTIVATION_TALENTS = new Set([
|
||||
"Inner Focus",
|
||||
// Arcane Potency exists only after Presence of Mind or Arcane Concentration
|
||||
// Clearcasting. The generated next-spell crit text is applied by its proc aura.
|
||||
"Arcane Potency",
|
||||
// These effects only exist after a proc or charge is earned; their tooltip
|
||||
// percentages must not be applied as passive stats while inactive.
|
||||
"Holy Concentration",
|
||||
@@ -55,10 +62,19 @@ const EXPLICIT_ACTIVATION_TALENTS = new Set([
|
||||
// Backlash has passive crit chance plus a conditional next-cast proc. Its
|
||||
// proc damage and cost clauses must not become always-on bonuses.
|
||||
"Backlash",
|
||||
// Backdraft's cast-time/GCD reduction applies only to its temporary three-cast proc.
|
||||
"Backdraft",
|
||||
// Brain Freeze mixes a conditional next Fireball/Frostfire Bolt proc with
|
||||
// separate school and target riders; the proc bonus is applied at cast time.
|
||||
"Brain Freeze",
|
||||
// Aura Mastery and Divine Favor carry scoped next-cast/aura effects. Their
|
||||
// activation tooltips must not be parsed as persistent global modifiers.
|
||||
"Aura Mastery",
|
||||
"Divine Favor",
|
||||
// Redoubt's Strength and damage-reduction clauses only exist during its
|
||||
// block-triggered ten-second proc; the shield block chance is handled by
|
||||
// the Paladin runtime with an equipped-shield check.
|
||||
"Redoubt",
|
||||
// The Beast Within activates from a pet Bestial Wrath proc; its tooltip
|
||||
// percentages must not become always-on modifiers.
|
||||
"The Beast Within",
|
||||
@@ -67,6 +83,19 @@ const EXPLICIT_ACTIVATION_TALENTS = new Set([
|
||||
"Elemental Focus",
|
||||
"Reverberation",
|
||||
"Maelstrom Weapon",
|
||||
// Tidal Waves only affects casts while its earned charge aura is active.
|
||||
"Tidal Waves",
|
||||
// Convection's comma-delimited named-spell list is represented by explicit
|
||||
// per-ability scopes so the generic parser cannot truncate it at Shock.
|
||||
"Convection",
|
||||
// Riptide's 25% rider only applies to the next Chain Heal on its HoT target.
|
||||
"Riptide",
|
||||
// Torment the Weak applies only to named damage spells and only against a
|
||||
// target that is currently slowed; its conditional part is target-aware.
|
||||
"Torment the Weak",
|
||||
// Feral Instinct has one named damage bonus plus two cast-state clauses;
|
||||
// only the named Swipe/Ravage clause is a persistent scalar modifier.
|
||||
"Feral Instinct",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -174,6 +203,42 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
|
||||
|
||||
export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): readonly TalentModifier[] {
|
||||
return activeTalentRankEffects(ranks, classId).flatMap((active) => {
|
||||
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) {
|
||||
|
||||
@@ -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<Record<number, {
|
||||
49067: { directBasePoints: 523, directDieSides: 149, periodicBasePoints: 90, periodicDieSides: 1, sourceLevel: 77, maxScalingLevel: 81 },
|
||||
};
|
||||
|
||||
const HUNTER_IMMOLATION_TRAP_CHILD_BY_RANK: Readonly<Record<number, {
|
||||
basePoints: number;
|
||||
dieSides: number;
|
||||
}>> = {
|
||||
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<Record<BaseClassId, readonly AbilityDefinition[]>> =
|
||||
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<Record<BaseClassId, readonly AbilityDefinition[]>>;
|
||||
.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<Record<BaseClassId, readonly AbilityDefinition[]>>;
|
||||
|
||||
// These ground channels store their actual damage in same-name triggered
|
||||
// child spells. Resolve the child once so each channel pulse can use its
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string>(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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}];
|
||||
}
|
||||
@@ -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 }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<Record<string, number>> = {};
|
||||
|
||||
function setupHunter(
|
||||
id: string,
|
||||
level: number,
|
||||
talentRanks: Readonly<Record<string, number>> = 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<Record<string, number>>) {
|
||||
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,
|
||||
}),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user