Update Healer Man server

This commit is contained in:
phenom committed 2026-09-28 22:46:45 -04:00
1 parent 9d7e0e2f9e
commit 35dfdaed61
94 files changed
+17034 -418

No files matched your search

+19
View File
@@ -4,6 +4,25 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-09-27 Bite-Sized editor movement regression
- The original edit-mode action blocker also suppressed continuous movement and
input snapshots, so WASD did nothing even after returning focus to the world.
Movement/jump gating is now separate from combat-action gating. Panel controls
and overlays still pause movement; Walk in scene or clicking the world returns
keyboard focus while keeping the panel open.
- A focused headless Edge check with `BITE_SIZED_EDITOR_INPUT_ONLY=1` moved the
player in all four WASD directions on the Stockades entrance stairs. Focusing
a coordinate field stopped W movement, and clicking the world restored it.
The character remained grounded after walking. An ability key started no cast
in edit mode. Right drag changed yaw, pointer lock stayed null, and release
stopped camera changes; no page errors occurred.
- Evidence: `playtest-artifacts/bite-sized-editor/editor-movement.png` and
`movement-observations.json`. All 33 focused input tests, the production build,
and release candidate check passed. The earlier authoring playtest verified
input suppression and camera drag but missed actual walking; its script now
includes this movement regression check.
## 2026-09-27 Bite-Sized spot editor
- `scripts/playtest-bite-sized-editor.cjs` used the real app in headless Edge
+140
View File
@@ -0,0 +1,140 @@
# Active class behavior and spell visual audit
Updated 2026-09-28. This is a source/runtime audit of the shipped WoW 3.3.5a and RuneWaker catalogs in Healer Man. It records confirmed gaps and the fixes made in this pass. It is not a claim that every ability has passed a live-game playtest or a screenshot-based visual review.
## Reviewed scope
- WoW: the ten playable class catalogs, including ability ranks, talent-driven modifiers, and combat-effect coverage.
- RoM: the generated catalog inventory covers all ten primary class catalogs and both sides of each paired-elite row. The generated catalog currently contains 929 unique rows (587 active rows and 342 passive rows). Counting each paired row in both its primary and secondary class view gives 1,589 class-linked row associations and 587 distinct active source spell IDs. Primary and secondary role associations have been source-reviewed; executable runtime coverage is selective, as listed below.
WoW inventory by generated catalog class:
| Class | Ability rows | Rank rows | Rank rows with manifest mapping | Rank rows with original effect graph |
|---|---:|---:|---:|---:|
| Warrior | 51 | 153 | 123 / 153 | 123 / 153 |
| Paladin | 75 | 221 | 169 / 221 | 168 / 221 |
| Hunter | 68 | 194 | 157 / 194 | 125 / 194 |
| Rogue | 42 | 136 | 114 / 136 | 108 / 136 |
| Priest | 56 | 264 | 218 / 264 | 218 / 264 |
| Death Knight | 45 | 77 | 47 / 77 | 47 / 77 |
| Shaman | 62 | 280 | 251 / 280 | 149 / 280 |
| Mage | 78 | 319 | 258 / 319 | 258 / 319 |
| Warlock | 66 | 267 | 224 / 267 | 224 / 267 |
| Druid | 74 | 318 | 283 / 318 | 280 / 318 |
- RoM rows and active-effect coverage by class (primary + secondary roles):
| Class | Rows | Active / passive | Active rank IDs with manifest entry | Active rank IDs with original effect graph |
|---|---:|---:|---:|---:|
| Warrior | 204 | 120 / 84 | 118 / 120 | 10 / 120 |
| Scout | 166 | 89 / 77 | 89 / 89 | 28 / 89 |
| Rogue | 207 | 113 / 94 | 111 / 113 | 32 / 113 |
| Mage | 205 | 121 / 84 | 122 / 124 | 28 / 124 |
| Priest | 167 | 103 / 64 | 101 / 103 | 35 / 103 |
| Knight | 123 | 68 / 55 | 68 / 68 | 23 / 68 |
| Warden | 132 | 84 / 48 | 84 / 84 | 1 / 84 |
| Druid | 130 | 80 / 50 | 81 / 81 | 5 / 81 |
| Warlock | 130 | 85 / 45 | 81 / 85 | 9 / 85 |
| Champion | 125 | 87 / 38 | 87 / 88 | 5 / 88 |
Manifest coverage means an effect mapping exists; a graph means an original effect graph can render in the browser. Neither proves the effect is the right size or brightness. Most RoM entries have only source-model metadata and use a generated fallback in the browser.
## Fixes made in this pass
- Priest Flash Heal impact is softened for both WoW catalog IDs: scale `0.38`, opacity `0.22`. Cast-start remains unchanged. The original model was inspected and showed a broad, bright impact.
- WoW Mage Arcane Explosion cast-start sphere is tuned to scale `0.65`, opacity `0.5`; the impact cue keeps default tuning after the original purple sphere appeared visually dominant.
- WoW Priest Improved Flash Heal no longer grants its crit talent globally; Inner Focus cooldown reduction now happens when the relevant cast occurs.
- WoW Shaman and Druid Nature's Swiftness now instant-casts the next successful eligible cast-time Nature spell and stays armed until used; Shaman shares its three-minute cooldown with Elemental Mastery. The effect does not grant a haste bonus.
- WoW Shaman Tidal Force now starts with three 20% healing-critical charges; Healing Wave, Lesser Healing Wave, and Chain Heal receive the bonus, and each critical heal spends one charge.
- WoW Shaman Healing Stream Totem ranks 5394–58757 now summon a five-health, nonattacking totem for five minutes. It heals injured party members in 10 yards every two seconds, with each rank's extracted child-spell amount (6–25).
- WoW Death Knight Empower Rune Weapon (47568) now activates all six runes with a single source-aligned effect. Black Ice now treats Frost and Shadow as alternative schools rather than requiring both on one spell.
- WoW Rogue Vanish now enters stealth, clears active root/slow control, and leaves the player in place; Preparation resets only Evasion, Sprint, Vanish, Cold Blood, and Shadowstep.
- WoW Warlock Soulshatter (29858) now cuts each nearby enemy's threat by the source-defined 50% in a caster-centered 50-yard radius; targets outside it remain unchanged, including when the area is empty.
- WoW Mage Blink now removes root/stun control and can be cast while stunned; the false movement-away behavior is removed. The intended 20-yard forward teleport remains unsupported until player facing is represented.
- WoW Hunter Readiness now preserves Bestial Wrath's cooldown exception. Bestial Wrath requires an active pet and places its source-row 19574 effects on that pet; The Beast Within (34471) now procs only on Bestial Wrath at its talent rank's 50%/100% chance and applies the source-row payload to the hunter. Extracted 34471 data specifies 18 seconds, +10% damage, and -20% mana cost, while the generated talent tooltip says 6 seconds, +15%, and -50%; this client/source discrepancy remains unresolved.
- WoW Priest Prayer of Mending ranks 33076/48112/48113 now heal for the extracted base values (800/905/1043), spend one of five damage-taken charges per activation, and transfer to the most injured eligible party member within 40 yards and line of sight. This models five total heals, with up to four transfers; unrelated outgoing damage does not spend a charge.
- WoW Holy Shock retains the player button and both source heal/damage payloads; Death Grip pulls and taunts; Pestilence spreads the caster's two diseases; Chains of Ice keeps its slow without a bogus root; Life Tap and Create Healthstone no longer produce false heals; Swiftmend prefers a caster-owned eligible Rejuvenation/Regrowth or otherwise the shortest remaining one, heals exactly 4/6 ticks, and consumes that HoT. Glyph of Swiftmend state is not represented, so glyph preservation is unsupported.
- WoW Warrior Bloodthirst now keeps player cast/damage fields from 23881 and uses child spell 23880 for three 1%-max-health heals on landed melee hits. Sweeping Strikes selects player self-buff 12328, arms five charges, and applies one hit to the nearest eligible secondary enemy for each landed melee hit.
- RoM Warrior Survival Instinct now checks its health threshold; Moon Cleave runs three hits and checks its two-handed weapon requirement; Shout applies the current [CoA Shout entry](https://chroniclesofarcadia.com/en/wiki/skills/list/1-8)'s 15-Rage, 40-second self-centered area stun without the stale five-target cap (the local RuneWaker snapshot has older values); The Final Battle applies its second hit only below 30% target health; Probing Attack applies a six-second Vulnerable state that Keen Attack requires.
- RoM Knight Shackles of Light resolves its all-enemy root; Knight Smash requires a recent landed player critical hit; RoM Mage Fire Ward applies to the party; RoM Priest support targets are friendly, paired Priest buffs apply to their intended party members, and Priest/Scout Curing Shot heals and applies its HoT to up to five allies.
- RoM Priest/Warlock Prayer of Tribulation (498770) now targets a friendly ally, channels four source-authored heals, and applies its local defense buff using the tooltip's 40% health branches. The local snapshot omits timing, so the matching current [CoA skill entry](https://chroniclesofarcadia.com/en/wiki/skills/list/5-9) supplied only the 1.5-second channel and 5-second cooldown; its newer area-damage and defense-buff payload was not imported.
- RoM Scout/Mage Aggro Transfer now applies its mana regeneration to the selected ally; Scout/Priest Mana Arrows shields up to six nearby allies; Scout/Knight Healing Shot fires its hostile shot and heals the party member with the lowest maximum health.
- RoM Scout, Knight, and Druid ranged skills now enforce their ranged-weapon requirement, including Thorn Arrow, Hidden Peril, Gryphon Bash, Arrow of Vengeance, Healing Arrows, and Unbinding Magic; Warrior/Scout Stun Shot and Scout Neck Strike also enforce it.
- RoM Druid/Mage Spring of Blessings heals nearby party members over time; Druid/Mage Earth Chain chains to three enemies with 70% falloff; Shield of Light applies to up to three nearby allies; Druid/Scout Group Exorcism cleanses nearby allies and consumes its required Nature Power. Knight/Priest Free Will grants its selected ally 10 seconds of Root and Slow immunity.
- RoM Scout/Warden Thorn Arrow now requires the Briar Shield buff before it can be cast.
- RoM Warden Crystalline Protection and Acceleration now fan out within the source radius; Coat of Arms applies its source armor/defense buffs to the caster and selected ally.
- RoM Druid/Scout Healing Arrows (494021) now selects its Nature-Power-enhanced HoT when the caster has a point, drains that point, and uses the baseline HoT at zero points. Player and companion casts follow the same branch.
- RoM Plasma Arrow (491168) impact tuning is scale `0.45` and opacity `0.32`; Electric Bolt (491169) is scale `0.6` and opacity `0.45`. These impact adjustments leave cast cues unchanged.
- RoM Warlock/Priest Ring of Comfort heals up to six nearby allies instead of damaging the selected hostile target.
- Self-target Champion Rune Pulse's Rage regeneration is put on the caster.
- RoM Champion Kinetic Burn, Vacuum Wave, and Light Pulse resolve their self-centered nearby areas; Shock Strike uses a forward cone; Overrule clears control and grants the stated short control immunity.
### Second-pass class follow-ups
- WoW Priest Test of Faith now applies its rank-scaled healing and Smite/Holy Fire bonus below 50% target health, plus the source-described 10% magic-damage reduction while casting an eligible Priest heal, Smite, or Holy Fire. Divine Aegis, Surge of Light, Serendipity, Shadow Weaving, and Holy Concentration also have event-backed hooks. The focused Priest runtime suite passed 29 tests across three files.
- WoW Priest Psychic Scream now applies its fear status once per cast, avoiding duplicate boss diminishing-return consumption, and Pain Suppression now remains eligible for low-health mitigation even though it also reduces threat. The Priest AI/control regression suites passed 45 tests across three files.
- WoW Paladin Eye for an Eye reflects the talent rank's 5/10/15% of incoming critical damage, capped at 50% of maximum health. Incoming critical-chance modifiers now increase the enemy attack's crit chance instead of reducing a clamped crit-avoidance value. Divine Sacrifice tracks its source-defined 150%-of-caster-max-health cumulative redirect cap on the caster aura. Party hits redirect only the remaining allowance; self-targeted hits do not enter that redirect path. Redemption also enforces its source-described out-of-combat gate. Aura Mastery grants the active 10-second silence immunity to eligible party recipients; interrupt immunity and the aura-amplification rider remain unmodeled. The focused Paladin runtime suite passed 14 tests.
- WoW Mage now wires Improved Scorch, Fingers of Frost, and Hot Streak from rank-specific source data. Its focused Mage, Blink, and effect-tuning suites passed 26 tests across three files. Ignite and several proc payloads remain source-conflicted or incomplete.
- WoW Shaman now wires Ancestral Awakening, Ancestral Healing, Shamanism, and rank-scaled Reverberation Shock cooldown reduction. The focused Shaman proc runtime file passed five tests, and the shared Shock cooldown regression passed one test.
- RoM Champion rank 1 Forge installs a five-minute trigger; landed normal melee hits add a capped 8-second, 2%-per-stack attack-power buff and 10 Rage. Its runtime test file passed five tests. Shield Form and rank-2 proc chance data remain unavailable.
- WoW Hunter's Intimidation proc is attributed to the pet and consumes on its successful melee hit. Kill Command's special-attack rider remains unsupported because the simulator emits no distinct pet-special event; The Beast Within source row and generated tooltip still conflict. The focused Hunter suite passed 10 tests.
- RoM Scout now keeps bow/crossbow accuracy out of melee hit chance, gates Poisonous Spit on Poison Powder, and implements Accurate Targeting's 15-second +50 physical-attack buff with its 30-second cooldown. The ranged-hit consumer and Scout-focused suites passed 49 tests across two files.
- RoM Priest/Mage Lightning Rod (492638) now requires both its passive state and Bone Chill on the target before Lightning applies Electric Shock (20 seconds; −50 Water and Wind resistance). Its focused test file passed three tests.
- RoM Priest/Scout Saint's Blessing (493039) now triggers on Heal when a friendly target is below 30% health and applies its extracted +30 healing and +1% healing for 10 seconds before the triggering heal. The focused Priest/Scout runtime suites passed 12 tests across three files.
- RoM Mage rank-2 Catalysis's +50% spell haste now shortens casts/GCDs only for the RoM Mage class; rank 1 and other classes are unchanged. The focused RoM Mage runtime suite passed 10 tests.
- RoM Druid now selects source-backed Nature Power branches for Fountain, Seeds, Wrath, Sandstorm, Earth Chain, Healing Wind, Poisonous Widow, Heart Piercing, and Cross of Thorns, with exact resource drain/refund behavior. Holy Fury, Extended Recovery, and Knee Ulcer also use their sourced duration/root values and passive gate. Five focused Druid/RoM suites passed 115 tests.
- RoM Warden's Will Attack passive now gates Frantic Briar's 10-second, 10% enemy damage reduction and routes it to every affected mob; all modeled incoming melee recipient paths consume the debuff. Its runtime suite passed nine tests.
- RoM Knight Divine Punishment now stores its passive as a marker and applies the extracted 1% bonus only to Punishment damage/DoT components; Holy Seal behavior is unchanged. Knight periodic/mechanics suites passed 23 tests across two files. The separate Light-vulnerability component remains unresolved because its extracted resistance direction conflicts with the tooltip.
- WoW Warlock Fel Synergy heals its active pet for 15% of effective spell damage at 50%/100% proc ranks; Soul Leech heals on eligible direct single-target Destruction hits with rank-scaled chance, a 15%-of-damage heal capped at 15% max health, and a 3-second cooldown; Eradication procs from Corruption at 15% and grants 7/14/20% cast haste for 10 seconds; Improved Shadow Bolt requires a critical Shadow Bolt and applies a 5% spell-crit vulnerability for 30 seconds. The focused Warlock suite passed 19 tests across four files.
- WoW Death Knight Butchery now grants 10/20 Runic Power for eligible player killing blows at ranks 1/2, clamps to the resource cap, and ignores ineligible targets and party/pet kills. Its focused runtime test passed 15 cases.
- WoW Mage Impact now uses the active rank-3 row's 4% landed direct-magic-spell proc to arm the next Fire Blast, which stuns for 3 seconds and consumes the charge on a landed cast; a miss preserves it. The focused Mage/Impact suites passed 13 tests across two files. The local legacy DBC differs on proc chance and stun duration.
- RoM Rogue/Priest Shadow Walker now lets Sneak Attack work in combat, reduces its Energy cost by 10, and adds 10 seconds to its cooldown. The Rogue mechanics and paired-class regression suites passed 60 tests across two files.
- RoM Warrior/Priest Defender's Roar now applies both source-backed maximum-health auras (+300 HP and +1% max HP for 120 seconds) to the caster and party; the separate timeout/proc aura remains caster-only. Its focused runtime test passed 2/2.
- RoM Champion/Warrior Determination Rune (498584) now scales its source-backed shield to 30% of caster max health; other classes and abilities are unchanged. The focused Champion runtime suite passed 7/7.
- WoW Warrior's Improved Whirlwind now reduces Whirlwind's cooldown by 1/2 seconds at ranks 1/2 without affecting other attacks. Battle Stance's active 10% armor-penetration aura now applies to physical attacks. Battle Shout and Commanding Shout reach party members within their source 30-yard radius. Improved Heroic Strike and Improved Execute reduce Rage costs by 2/4/6 per rank; Improved Execute damage and Unrelenting Assault damage/cooldown are scoped to the named abilities. Execute keeps its below-20% gate and rank-scaled damage instead of mapping to an instant-kill effect. The focused Warrior runtime, catalog, and talent suites passed 57 tests across three files.
- WoW Druid Improved Rejuvenation now parses its rank-scaled +7/14/20% bonus and applies it only to Rejuvenation healing. The focused talent and Druid runtime suites passed 12 tests across two files.
- Companion resource restores now update only the restored resource pool, preserving the WoW Druid Cat's Energy regeneration when its companion casts Enrage. The party damage audit suite passed 15 tests.
- RoM Warlock Defense Net now retains its 5-second 50% magic mitigation and grants fear, stun, root, and slow immunity to its friendly target; the runtime maps the source's Helpless category to fear because it has no distinct Helpless state. Its focused runtime suite passed seven tests.
- RoM Warlock/Priest Mind Shield now shortens Defense Net to four seconds for that class pair, as its description specifies; the normal spell remains five seconds elsewhere. The Warlock runtime suite passed eight tests.
## Confirmed remaining behavior gaps
These are specific, source-backed gaps that still need implementation or a gameplay test. The catalog contains many more conditional and proc skills than the current focused tests exercise.
### WoW
- **Hunter:** Intimidation now stores and consumes its next-melee proc on the pet; Freezing Arrow and Explosive Trap have armed-trap behavior. Kill Command's 1%-per-special-attack rider cannot be represented correctly because the pet runtime emits only generic melee hits, not pet-special events. The Beast Within's extracted spell row and generated talent tooltip still disagree on duration and amounts.
- **Priest:** Desperate Prayer, allied Dispel Magic, party buffs, Pain Suppression threat reduction, Prayer of Mending, Divine Aegis, Surge of Light, Serendipity, Shadow Weaving, Holy Concentration, and Test of Faith have focused runtime behavior. Lightwell's exact click-heal amount remains undocumented in the available child-spell payload; its visual remains unreviewed in live gameplay.
- **Death Knight:** Death Coil's friendly Undead heal, Death Strike disease-count self-heal, Anti-Magic Shell and Zone absorbs, Bone Shield charges, Blood Tap rune restriction, Deathchill, Improved Rune Tap, and Butchery's eligible-kill Runic Power have focused fixes/tests. Anti-Magic Shell's runic-power gain remains uncertain; spell visuals have not received a rendered class pass.
- **Rogue:** stealth/dagger gates, five-assault Killing Spree (both weapons per assault), Shadowstep, Pick Pocket feedback, Riposte readiness, and future-only Tricks threat routing are covered. Initiative, Ruthlessness, and rank-3 Honor Among Thieves now have proc hooks; Honor Among Thieves uses the Rogue's own landed direct-damage casts with a three-second limit. Shadow Walker also enables combat Sneak Attack and applies its sourced cost/cooldown changes only to the Rogue/Priest pair. Combat Potency remains unsupported without off-hand hit identity; Pick Pocket still has no loot/currency result model.
- **Shaman:** Nature's Swiftness, Tidal Force, Healing Stream Totem, Shamanistic Rage, Ancestral Awakening, Ancestral Healing, Shamanism, and rank-scaled Reverberation are wired. Elemental Focus now grants rank-scaled Clearcasting with two discounted casts even though its child spell is absent; Reverberation's extra-Shock child 965977 remains missing. Maelstrom Weapon's tooltip describes its eligible spells and 20%-per-stack cast-time reduction, but proc chance, stack duration, and child payloads are absent; PvP damage reduction has no PvP runtime path. Other passives still need event-model coverage; no exhaustive class runtime or rendered VFX pass is claimed.
- **Paladin:** Redemption, Reckoning's rank-based proc and three-hit charge window, Eye for an Eye reflection/cap, Divine Storm healing, Beacon transfer, Greater Blessing fanout, Hammer of Wrath health gate, Holy Shield shield gate, Divine Sacrifice redirect cap, aura parsing, Aura Mastery's source-row 10-second silence immunity, and Avenger's Shield's three-target/no-falloff chain have targeted fixes or runtime coverage. Remaining gaps include Vindication, Holy Shield block-charge consumption, Aura Master's interrupt immunity and aura amplification, and the active/UI Aura Mastery duration/scope discrepancy. Holy Shield's exact charge count is absent from available source payloads.
- **Mage:** Blink root/stun clearing, Improved Scorch, Fingers of Frost, Hot Streak, and active-row rank-3 Impact's next-Fire-Blast stun have focused coverage. Impact's active generated row (4% proc, 3-second stun) conflicts with the local legacy DBC (10% proc, 2-second child); runtime follows the active row. Ignite and Focus Magic retain source/UI conflicts; Brain Freeze, Arcane Concentration, and Missile Barrage lack complete proc-expiry data. The forward Blink teleport is unsupported while player facing is absent.
- **Warlock:** Conflagrate consumption, Shadowflame periodic damage, Seed of Corruption threshold/death explosion, Curse of Agony ramp, Soulshatter, Fel Synergy, Soul Leech healing, Eradication, crit-gated Improved Shadow Bolt, and Backlash's rank-based proc/cast rider have focused coverage. Soul Link's DBC value conflicts with the talent UI; Nightfall's next-cast/expiry payload, Soul Leech's missing child 900067 AP debuff and capstone crit-after-hit, and Metamorphosis remain unsupported or unresolved.
- **Druid:** Eclipse, Omen of Clarity (including successful overheal events), Nature's Grace, and the rank-scaled Improved Rejuvenation healing bonus have source-backed behavior. Glyph of Swiftmend state is not represented, so its HoT-preservation behavior remains unsupported.
- **Warrior:** Battle Stance armor penetration, Battle/Commanding Shout party radius, Improved Heroic Strike/Execute Rage discounts, Improved Execute's below-20% gate, rank-scaled damage and conditional crit window, Unrelenting Assault's named ability scope, Bloodthirst/Sweeping Strikes, and Improved Whirlwind's 1/2-second cooldown reduction have focused behavior tests. Unbridled Wrath, Blood Craze, and Second Wind still reference hidden child payloads. Other passives/procs remain incomplete.
### RoM
- **Warrior:** Survival thresholds, weapon requirements, three-hit Moon Cleave, several Slash/state riders, and Warrior/Priest Defender's Roar party max-health effects have focused fixes; numerical source gaps remain for Heroic Surprise Attack, Resist, Recovery of Life's additional rider, Exhaustion's drain chance, Speed Slash's reset chance, and missing source rows 499825/499826.
- **Scout:** Ranged Weapon Mastery's bow/crossbow accuracy now affects ranged hit chance only; Poisonous Spit requires Poison Powder; Accurate Targeting grants its source-backed buff and cooldown. Natural Poison Powder acquisition for Scout/Rogue remains unavailable in the loadout model. Pet crit/dodge/damage passives remain unsupported because summons expose no pet-specific stats or event path; other paired passives still need review.
- **Rogue:** Dagger Mastery, Treasure Hunter, Weakened Soul Stab, rear-position gates, Sneak Attack's out-of-combat gate, resource gates, and the Rogue/Priest Shadow Walker override have targeted behavior. Off Hand Mastery remains unsupported without an off-hand damage event; Instant Shadow's dodge trigger and Day of Rain's hit-rate value need further source/runtime validation.
- **Priest:** Knight/Priest Holy Smite now heals and consumes Holy Seals correctly; Holy Protection applies its alternate threat payload as intended; Lightning Rod requires the sourced Bone Chill target state; Priest/Scout Saint's Blessing uses its friendly-target health threshold; and Priest/Warlock Prayer of Tribulation has focused runtime coverage. Infused Light Chain's trigger chance and Rage values are absent from local source rows. Current CoA records conflict with local RuneWaker IDs/names or payloads for Priest/Champion 498610 and 498612 and Priest 490274–490276; those records were not used to remap the local catalog. Other conditional/passive mechanics still need review.
- **Knight:** Divine Punishment's 1% damage modifier is now limited to Punishment instead of applying globally. Some periodic/triggered spell references do not resolve. Multiple passives are tooltip-only or inert. Throw Shield and Shield Bash now require an equipped shield, and Arrow of Vengeance enforces its ranged-weapon requirement. Light-vulnerability remains ambiguous between the tooltip and extracted resistance component.
- **Warden:** Nature Crystal immunity, Heart of Oak/Crystalline absorbs, Awakened immunity/next-crit behavior, Nature Power scaling for Healing Ripple/Gift Pulse, Spirit of Life party HoT, Mysterious Grace party scope, Will Attack's Frantic Briar debuff, and Damage Transfer's 30% split to a live nearby pet have focused fixes. Hidden child 493416, Revival's trigger row 499544, and Weak Point Strike's extra-hit proc chance are missing; other reactive passives remain incomplete.
- **Mage:** rank-1 Catalysis mana ticks, rank-2 Catalysis cast haste, Phoenix's 12-target cap, Eruption's 10% double-damage proc, Control Flame, and Flame Follower's Fireball cooldown rider are covered. Rank 2's source payload has no mana tick; Flame Follower's crit amount is absent from source components.
- **Druid:** Spirit of Life party HoT, Unity's guaranteed spell crit/healing and 15-second movement lock, Spirit Guidance's Nature Power gain, Savage Blessing's friendly target, Healing Arrows, source-backed Nature Power selection/spending for Fountain, Seeds, Wrath, Sandstorm, Earth Chain, Healing Wind, Poisonous Widow, Heart Piercing, and Cross of Thorns, plus Brilliance of Nature's 10-to-15 dynamic cap for the player and companion, have focused fixes. Charm faction conversion and Rebirth's debt reductions have no corresponding simulator state; several source-incomplete refund/proc values remain; Seed modifier ranks and Necrotic Wound's player-only healing reduction are not distinct in current source/runtime data.
- **Warlock:** Mind Transference's Rage-to-psi conversion, Heart Fence's full Rage consumption and duration scaling, Heart Strike gate, Crime and Punishment refresh/scaling, Liquidation Suffering target scopes, named cooldown resets, Defense Net's magic mitigation/control immunity, and Mind Shield's pair-scoped duration reduction have focused behavior. Helpless currently maps to fear in the runtime. Breathing Control and several Priest-pair passives lack proc probabilities or complete event details; hidden child 499869 and End of Thought's tick interval are absent. Current [CoA Warlock/Warrior](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1) and [Warlock/Priest](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) records conflict with RuneWaker values and IDs for Mind Transference, Deep Breathing Control, Divine Assistance, and related triggers; those were preserved.
- **Champion:** Fearless Blow's health/Rage gate, Rune Draw pull, Indomitable Spirit threshold, cooldown-reset scopes, rank-1 Forge trigger, Champion/Warrior Determination Rune's 30%-max-HP shield, and Rune Overload's local 15-Rage-per-second drain and zero-Rage termination have focused fixes. Shield Form has no player-learnable source skill; Forge rank-2 and other proc chances are absent or incomplete. The current [CoA Champion/Rogue skills page](https://chroniclesofarcadia.com/en/wiki/skills/list/10-3) reuses Rune Overload's identity with newer values/behavior, so local RuneWaker values were retained.
## Visual review and evidence limits
- Spell effect manifest counts above identify mapped source assets and browser-renderable graphs. They do not certify perceived brightness, screen coverage, or combat readability. WoW phase graph coverage is strong for mapped ranks; Shaman has the largest graph gap (149/251 mapped rank rows), followed by Hunter (125/157).
- The original WoW Flash Heal impact and Arcane Explosion cast-start sphere, plus RoM Plasma Arrow and Electric Bolt impact models, were inspected in the development preview. Their selected phases are tuned; other WoW spell visuals still need a per-spell rendered review.
- Hunter graph metadata shows Arcane Shot/Aimed Shot using a five-emitter/two-ribbon ArcaneShot_Missile release (particle max scale about 0.26) and the shared Magic_Impact_Chest impact (max scale about 1.76, peak alpha 1); Volley uses a 16-emitter/four-ribbon release (max scale about 0.26) and Magic_Impact_Chest/ArcaneShot_Area impacts (ArcaneShot_Area max scale about 0.83). The Beast Within cast graphs report Magic_Cast_Hand max scale about 0.45 and BeastRageCaster about 0.96. These are extracted graph values only: the Hunter review's CUA browser inventory exposed no browser, so no rendered size/brightness verdict was recorded.
- Warrior evidence gap: Bloodthirst and Sweeping Strikes have focused catalog/runtime regression coverage, but the remaining Warrior abilities/talents have not received a complete runtime pass, and Warrior spell size/brightness has not received per-spell rendered review.
- RoM visual review remains limited: only a subset of active source effects have browser-renderable graphs. A class-by-class size/brightness verdict cannot be derived from source file names or default `1x` tuning. Most RoM spells still need per-spell visual review.
- Verification is against the local simulator/runtime and extracted catalogs, not an official WoW or RoM server. The focused tests validate selected fixes; they do not prove every class ability or talent works.
## Focused test coverage
The consolidated class-related suite matched `^(wow335|rom|deathKnight|mageBlinkRuntime|wowDruid|wowNature|wowTidal|talentModifiers|combatAuras|combatStore)` plus `src/scene/combatEffectRuntime.test.ts`: **55 files and 711 tests passed**. This covers focused behavior changes, not every ability/talent combination. The earlier full `npm test` run had 15 failing files and 21 failed tests; class regressions from that run have since passed in focused suites, while several dungeon-count and balance expectation failures remain. The full suite was not rerun after the latest class fixes. The final `npm run build` passed, including TypeScript, the KTX2 audit (zero fallback textures), the environment-performance audit (zero regressions), bundle generation, and release verification; Vite reported existing chunk-size/dynamic-import warnings. `npm run release:check` passed, including untracked files.
+9 -3
View File
@@ -5,9 +5,11 @@ GM administration also provides the same entry. A selected character and an onli
GM account are required. Leave an online activity or Dungeon Race before editing.
The scene uses the normal renderer, movement, and hold-right-drag camera. The right
panel scrolls and collapses to leave the world visible. Focusing a panel control
suspends movement and camera input. Click a world marker or collapse the panel to
return to placement.
panel scrolls and collapses to leave the world visible. Click the world or press
**Walk in scene**, then use **WASD** to move, **Space** to jump, and hold the
**right mouse button** to look. Focusing a panel control suspends movement and
camera input until you return focus to the scene. The panel can stay open while
you walk and place points.
- Select Party start, Random boss spawn, or an individual mob point. Walk to a
grounded position and press **Set here**, edit coordinates/facing, or use
@@ -88,3 +90,7 @@ using another dev port. It records WoW/RoM authoring, imported previews, both bo
settings, completion and unranked safeguards, panel input gating, right-drag release
without pointer lock, and randomized entry into distinct parent maps under
`playtest-artifacts/bite-sized-editor/`.
Set `BITE_SIZED_EDITOR_INPUT_ONLY=1` for a focused WASD, panel-focus, combat-gating
and right-drag check. It writes `editor-movement.png` and
`movement-observations.json` in the same evidence directory.
+43
View File
@@ -28,6 +28,42 @@ async function ready(page) {
await page.waitForTimeout(1200);
console.log(`Ready: ${(await state(page)).mode || 'run'}`);
}
async function verifyEditorMovement(page, panel) {
await panel.getByRole('button', { name: 'Walk in scene', exact: true }).click();
for (const key of ['w', 's', 'a', 'd']) {
const before = await state(page);
await page.keyboard.down(key); await page.waitForTimeout(600); await page.keyboard.up(key);
await page.waitForTimeout(150);
const after = await state(page);
assert.ok(Math.hypot(after.position[0] - before.position[0], after.position[2] - before.position[2]) > .15, `${key.toUpperCase()} must move the player in edit mode`);
}
await panel.getByLabel('Spawn X', { exact: true }).focus();
const before = await state(page);
assert.equal(before.focused, true);
await page.keyboard.down('w'); await page.waitForTimeout(350); await page.keyboard.up('w');
const paused = await state(page);
assert.ok(Math.hypot(paused.position[0] - before.position[0], paused.position[2] - before.position[2]) < .1, 'Panel focus must pause movement');
await page.mouse.click(550, 380);
assert.equal((await state(page)).focused, false, 'Clicking the world must return keyboard control');
await page.keyboard.down('w'); await page.waitForTimeout(500); await page.keyboard.up('w');
const walking = await state(page);
assert.ok(Math.hypot(walking.position[0] - paused.position[0], walking.position[2] - paused.position[2]) > .15, 'W must work again after clicking the world');
await page.keyboard.press('Digit1');
assert.equal(await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
return useCombatStore.getState().activeCast;
}), null, 'Editing must still block combat actions');
await page.mouse.move(550, 380); await page.mouse.down({ button: 'right' });
const yaw = (await state(page)).yaw;
await page.mouse.move(650, 395, { steps: 8 }); await page.waitForTimeout(300);
assert.notEqual((await state(page)).yaw, yaw); assert.equal((await state(page)).pointerLock, false);
await page.mouse.up({ button: 'right' }); await page.waitForTimeout(100);
const released = (await state(page)).yaw;
await page.mouse.move(700, 420, { steps: 4 }); await page.waitForTimeout(300);
assert.equal((await state(page)).yaw, released, 'Camera motion must stop after right-button release');
observations.push({ name: 'editor WASD, panel focus return, combat gating, and right drag without pointer lock', state: await state(page) });
await page.screenshot({ path: path.join(out, 'editor-movement.png') });
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-editor-playtest-'));
@@ -74,6 +110,13 @@ async function ready(page) {
assert.equal((await state(page)).mode, 'edit');
assert.equal((await state(page)).grounded, true);
await page.screenshot({ path: path.join(out, 'wow-editor.png') });
await verifyEditorMovement(page, panel);
if (process.env.BITE_SIZED_EDITOR_INPUT_ONLY === '1') {
assert.deepEqual(errors, []);
fs.writeFileSync(path.join(out, 'movement-observations.json'), `${JSON.stringify({ observations, errors }, null, 2)}\n`);
console.log(`Passed editor movement and camera checks. Screenshots: ${out}`);
return;
}
await panel.getByRole('button', { name: 'New spot', exact: true }).click();
await ready(page);
await panel.getByLabel('Spot name', { exact: true }).fill('Playtest new spot');
+4
View File
@@ -98,6 +98,10 @@ function InputBridge() {
const game = useGameStore.getState();
return game.overlay !== null || game.companionOpen || useBiteSizedLayoutsStore.getState().editorMode === "edit";
},
movementBlocked: () => {
const game = useGameStore.getState();
return game.overlay !== null || game.companionOpen;
},
setCameraLookActive: (active) => useGameStore.getState().setCameraLookActive(active),
setInputMode: (mode) => useGameStore.getState().setInputMode(mode),
setActionLayer: (layer) => useGameStore.getState().setActionLayer(layer),
+45 -12
View File
@@ -149,7 +149,9 @@ export interface AbilityHealingRecipients {
readonly center?: "caster" | "ability-target";
readonly radius?: number;
readonly maximumTargets?: number;
readonly selection?: "random";
readonly falloffPerJump?: number;
readonly selection?: "random" | "lowest-max-health";
readonly excludeCaster?: boolean;
}
export interface AbilityDamageResource {
@@ -166,12 +168,21 @@ export type AbilityEffect =
/** Optional stack-based payload used by poison-consuming finishers. */
readonly poison?: import("./roguePoison").RoguePoisonFinisherScaling;
}
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number; readonly percentOfDamage?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number }
| {
readonly kind: "summon";
readonly creatureId: number;
readonly coefficient: number;
readonly durationMs: number;
/** Source-defined pulse for stationary healing totems. */
readonly healingPulse?: { readonly amount: number; readonly intervalMs: number; readonly radius: number };
/** Source-defined party absorb area carried by a stationary Death Knight zone. */
readonly antiMagicZone?: { readonly radius: number; readonly capacityBase: number; readonly capacityPerAttackPower: number; readonly absorbFraction: number; readonly intervalMs: number };
}
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean }
| { readonly kind: "combo"; readonly amount: number }
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
@@ -180,7 +191,7 @@ export type AbilityEffect =
readonly kind: "apply-aura";
readonly aura: AuraDefinition;
/** Some hostile spells apply their beneficial rider to the caster. */
readonly recipient?: "ability-target" | "caster";
readonly recipient?: "ability-target" | "caster" | "pet";
/** Party/raid area auras are applied to the player and every active party member. */
readonly scope?: "target" | "party";
/** Some party effects benefit allies but exclude the caster. */
@@ -218,11 +229,18 @@ export type AbilityEffect =
| { readonly kind: "dispel-mechanic"; readonly mechanic: number; readonly maxCount: number }
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
| { readonly kind: "totem"; readonly action: "destroy-all" }
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
| {
readonly kind: "rune";
readonly action: "activate";
readonly count: number;
readonly runeType?: "blood" | "frost" | "unholy";
readonly convertToDeathMs?: number;
}
| {
readonly kind: "cooldown-reset";
readonly mode: "reset" | "reduce";
readonly abilityName?: string;
readonly excludeAbilityNames?: readonly string[];
readonly amountMs?: number;
/** Fraction of the target ability's remaining cooldown removed at execution time. */
readonly amountPercent?: number;
@@ -907,7 +925,7 @@ const abilityCatalog: Record<string, AbilityDefinition> = {};
let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
const coaTriggeredAbilities = new Map<number, AbilityDefinition>();
const classicChannelTriggeredAbilities = new Map<number, AbilityDefinition>();
const classicTriggeredAbilities = new Map<number, AbilityDefinition>();
let classicLoad: Promise<void> | null = null;
let romLoad: Promise<void> | null = null;
const coaLoads = new Map<CoaClassId, Promise<void>>();
@@ -950,7 +968,7 @@ async function preloadClassicAbilityCatalog(): Promise<void> {
if (classicLoad) return classicLoad;
classicLoad = import("./wow335AbilityCatalog").then(({
WOW335_ABILITIES_BY_CLASS,
WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID,
WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID,
}) => {
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(
@@ -958,8 +976,8 @@ async function preloadClassicAbilityCatalog(): Promise<void> {
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]),
);
}
for (const [spellId, ability] of Object.entries(WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
classicChannelTriggeredAbilities.set(Number(spellId), ability);
for (const [spellId, ability] of Object.entries(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
classicTriggeredAbilities.set(Number(spellId), ability);
}
}).catch((error: unknown) => {
classicLoad = null;
@@ -1026,7 +1044,7 @@ export function triggeredAbilityBySpellId(
includeCoa = true,
): AbilityDefinition | null {
return romTriggeredAbilityResolver?.(spellId)
?? classicChannelTriggeredAbilities.get(spellId)
?? classicTriggeredAbilities.get(spellId)
?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined)
?? null;
}
@@ -1048,6 +1066,21 @@ export function abilitiesForCharacter(
return [...primary, ...secondaryGeneral, ...elites];
}
/** Return the RuneWaker Nature Power cap granted by the learned Brilliance passive. */
export function naturePowerMaximumForCharacter(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
level: number,
): number {
const hasBrillianceOfNature = abilitiesForCharacter(classId, secondaryClassId).some((ability) => (
ability.source === "runewaker"
&& ability.dbcSpellId === 494931
&& ability.passive
&& level >= ability.unlockLevel
));
return hasBrillianceOfNature ? 15 : 10;
}
export function abilityRankForLevel(
ability: AbilityDefinition,
playerLevel: number,
+8
View File
@@ -0,0 +1,8 @@
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
/** Give placement controls keyboard focus without collapsing the editor panel. */
export function focusBiteSizedEditorScene(): void {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
document.querySelector<HTMLElement>(".scene-shell")?.focus({ preventScroll: true });
useBiteSizedLayoutsStore.setState({ panelFocused: false });
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
import { naturePowerMaximumForCharacter, resourceProfileForClass, type ResourceType } from "./abilityCatalog";
import {
normalizeActorResourcePool,
type CombatActorState,
@@ -10,7 +10,7 @@ import { deriveCharacterStats } from "./wotlkStats";
export function companionResourceMaximum(member: PartyMember, id: string): number {
return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId)
? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana
: id === "nature-power" ? 10
: id === "nature-power" ? naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level)
: id === "psi" ? 6
: 100;
}
+1
View File
@@ -20,6 +20,7 @@ export interface DeathKnightRuneState {
readonly baseType: Exclude<DeathKnightRuneType, "death">;
readonly type: DeathKnightRuneType;
readonly readyAt: number;
readonly deathRuneExpiresAt?: number;
}
export interface CombatActorCastState {
+68
View File
@@ -124,6 +124,43 @@ describe("combat auras", () => {
expect(result.auras[0].absorbs.map((absorb) => absorb.remaining)).toEqual([0, 0]);
});
it("supports fractional, multi-school absorbs while keeping the capacity shared", () => {
const auras = apply(buff({
id: "anti-magic-shell",
absorbs: [{
id: "shell",
amount: 120,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
fraction: 0.75,
}],
}));
const magical = absorbDamage(auras, {
targetId: "player",
amount: 100,
school: "fire",
now: 100,
});
expect(magical).toMatchObject({ absorbed: 75, remainingDamage: 25 });
expect(magical.auras[0].absorbs[0].remaining).toBe(45);
const physical = absorbDamage(magical.auras, {
targetId: "player",
amount: 40,
school: "physical",
now: 200,
});
expect(physical).toMatchObject({ absorbed: 0, remainingDamage: 40 });
expect(physical.auras[0].absorbs[0].remaining).toBe(45);
const capped = absorbDamage(magical.auras, {
targetId: "player",
amount: 100,
school: "shadow",
now: 300,
});
expect(capped).toMatchObject({ absorbed: 45, remainingDamage: 55 });
});
it("supports guaranteed and failed deterministic proc rolls", () => {
const definition = buff({
procs: [{
@@ -149,6 +186,37 @@ describe("combat auras", () => {
expect(failed.triggered).toHaveLength(0);
});
it("spends melee-only proc charges only on landed melee attack events", () => {
const auras = apply(buff({
procs: [{
id: "melee-charge",
triggers: ["hit", "crit"],
attackKinds: ["melee"],
chance: 1,
charges: 5,
action: { kind: "custom", id: "melee-only" },
}],
}));
const rangedHit = evaluateAuraProcs(auras, {
trigger: "hit",
subjectId: "player",
otherId: "mob",
attackKind: "ranged",
}, 100, () => 0);
expect(rangedHit.triggered).toHaveLength(0);
expect(rangedHit.auras[0].procStates[0].remainingCharges).toBe(5);
const meleeHit = evaluateAuraProcs(rangedHit.auras, {
trigger: "hit",
subjectId: "player",
otherId: "mob",
attackKind: "melee",
}, 200, () => 0);
expect(meleeHit.triggered).toHaveLength(1);
expect(meleeHit.auras[0].procStates[0].remainingCharges).toBe(4);
});
it("enforces proc internal cooldowns and consumes charges only on success", () => {
const roll = vi.fn(() => 0);
const definition = buff({
+40 -3
View File
@@ -69,8 +69,14 @@ export type AuraModifier =
export interface AuraAbsorbDefinition {
readonly id: string;
readonly amount: number;
/** Number of matching attacks this absorb fully negates instead of consuming a point pool. */
readonly hitCharges?: number;
/** Omit to absorb all damage schools. */
readonly school?: DamageSchool;
/** Optional school set for absorbs such as Anti-Magic Shell. */
readonly schools?: readonly DamageSchool[];
/** Fraction of each incoming packet this absorb can consume; defaults to 1. */
readonly fraction?: number;
/** Defaults to true, granting `amount` of capacity for each aura stack. */
readonly perStack?: boolean;
}
@@ -78,7 +84,10 @@ export interface AuraAbsorbDefinition {
export interface AuraAbsorbState {
readonly id: string;
readonly school?: DamageSchool;
readonly schools?: readonly DamageSchool[];
readonly fraction?: number;
readonly remaining: number;
readonly remainingHits?: number;
}
export type AuraProcAction =
@@ -114,6 +123,8 @@ export type AuraProcAction =
export interface AuraProcDefinition {
readonly id: string;
readonly triggers: ProcTrigger | readonly ProcTrigger[];
/** Limit attack-triggered procs to the listed landed attack types. */
readonly attackKinds?: readonly AttackKind[];
/** Probability from 0 through 1. */
readonly chance: number;
readonly internalCooldownMs?: number;
@@ -157,6 +168,8 @@ export interface AuraDefinition {
readonly reflectSpellChance?: number;
/** Portion of incoming target damage transferred to the aura source. */
readonly redirectDamagePercent?: number;
/** Shared source-max-health cap for Paladin Divine Sacrifice redirection. */
readonly redirectDamageCapPercent?: number;
readonly dispelCategory?: DispelCategory;
readonly dispelPriority?: number;
readonly modifiers?: readonly AuraModifier[];
@@ -174,6 +187,8 @@ export interface ActiveAura {
readonly stacks: number;
readonly absorbs: readonly AuraAbsorbState[];
readonly procStates: readonly AuraProcState[];
/** Cumulative redirected damage for an aura that owns a shared redirect cap. */
readonly redirectedDamage?: number;
/** Number of Fire critical hits landed during the current Combustion buff. */
readonly combustionCriticalHits?: number;
}
@@ -214,6 +229,7 @@ export interface AbsorbDamageRequest {
readonly amount: number;
readonly school: DamageSchool;
readonly now: number;
readonly countsAsAttack?: boolean;
}
export interface AbsorbConsumption {
@@ -288,7 +304,12 @@ function newAbsorbStates(definition: AuraDefinition, stacks: number): readonly A
return (definition.absorbs ?? []).map((absorb) => ({
id: absorb.id,
school: absorb.school,
schools: absorb.schools,
fraction: absorb.fraction,
remaining: nonNegative(absorb.amount) * (absorb.perStack === false ? 1 : stacks),
...(absorb.hitCharges === undefined
? {}
: { remainingHits: Math.max(0, Math.floor(nonNegative(absorb.hitCharges))) }),
}));
}
@@ -313,6 +334,7 @@ function makeAura(request: ApplyAuraRequest, stacks: number): ActiveAura {
stacks,
absorbs: newAbsorbStates(definition, stacks),
procStates: newProcStates(definition),
...(definition.redirectDamageCapPercent === undefined ? {} : { redirectedDamage: 0 }),
};
}
@@ -498,15 +520,28 @@ export function absorbDamage(
if (index < 0) continue;
const aura = next[index];
const absorbs = aura.absorbs.map((absorb) => {
if (remainingDamage <= 0 || (absorb.school !== undefined && absorb.school !== request.school)) {
if (remainingDamage <= 0
|| (absorb.school !== undefined && absorb.school !== request.school)
|| (absorb.schools !== undefined && !absorb.schools.includes(request.school))) {
return absorb;
}
const amount = Math.min(absorb.remaining, remainingDamage);
const packetFraction = Math.max(0, Math.min(1, absorb.fraction ?? 1));
const isChargedAttackAbsorb = absorb.remainingHits !== undefined
&& request.countsAsAttack === true
&& absorb.remainingHits > 0;
if (absorb.remainingHits !== undefined && !isChargedAttackAbsorb) return absorb;
const amount = isChargedAttackAbsorb
? remainingDamage * packetFraction
: Math.min(absorb.remaining, remainingDamage * packetFraction);
remainingDamage -= amount;
if (amount > 0) {
consumptions.push({ auraInstanceId: aura.instanceId, absorbId: absorb.id, amount });
}
return { ...absorb, remaining: absorb.remaining - amount };
return {
...absorb,
remaining: absorb.remaining - amount,
...(isChargedAttackAbsorb ? { remainingHits: absorb.remainingHits! - 1 } : {}),
};
});
next[index] = { ...aura, absorbs };
}
@@ -545,6 +580,8 @@ export function evaluateAuraProcs(
if (
proc === undefined ||
!procHasTrigger(proc, event.trigger) ||
(proc.attackKinds !== undefined
&& (event.attackKind === undefined || !proc.attackKinds.includes(event.attackKind))) ||
state.readyAt > now ||
state.remainingCharges === 0
) {
+44 -2
View File
@@ -47,7 +47,47 @@ export function availableDeathKnightRunes(
runes: readonly DeathKnightRuneState[],
now: number,
): readonly DeathKnightRuneState[] {
return runes.filter((rune) => rune.readyAt <= now);
return normalizeDeathKnightRunes(runes, now).filter((rune) => rune.readyAt <= now);
}
export function normalizeDeathKnightRunes(
runes: readonly DeathKnightRuneState[],
now: number,
): readonly DeathKnightRuneState[] {
return Object.freeze(runes.map((rune) => {
if (rune.deathRuneExpiresAt === undefined || rune.deathRuneExpiresAt > now) return rune;
const { deathRuneExpiresAt: _expiredAt, ...baseRune } = rune;
void _expiredAt;
return Object.freeze({ ...baseRune, type: rune.baseType });
}));
}
export function canActivateDeathKnightBloodTapRune(
runes: readonly DeathKnightRuneState[],
now: number,
): boolean {
return normalizeDeathKnightRunes(runes, now).some((rune) => rune.baseType === "blood" && rune.type === "blood");
}
export function activateDeathKnightBloodTapRune(
runes: readonly DeathKnightRuneState[],
now: number,
durationMs: number,
): readonly DeathKnightRuneState[] {
const current = [...normalizeDeathKnightRunes(runes, now)];
const candidate = current
.filter((rune) => rune.baseType === "blood" && rune.type === "blood")
.sort((left, right) => Number(right.readyAt > now) - Number(left.readyAt > now)
|| right.readyAt - left.readyAt || left.slot - right.slot)[0];
if (!candidate) return Object.freeze(current);
return Object.freeze(current.map((rune) => rune.slot === candidate.slot
? Object.freeze({
...rune,
type: "death" as const,
readyAt: Math.min(rune.readyAt, now),
deathRuneExpiresAt: now + Math.max(1, durationMs),
})
: rune));
}
function runeMatches(rune: DeathKnightRuneState, type: Exclude<DeathKnightRuneType, "death">): boolean {
@@ -60,7 +100,9 @@ export function spendDeathKnightRunes(
now: number,
rechargeMs = DEATH_KNIGHT_RUNE_RECHARGE_MS,
): readonly DeathKnightRuneState[] | null {
const current = runes?.length === 6 ? [...runes] : [...createDeathKnightRunes(now)];
const current = [...normalizeDeathKnightRunes(
runes?.length === 6 ? runes : createDeathKnightRunes(now), now,
)];
const spent = new Set<number>();
for (const type of ["blood", "frost", "unholy"] as const) {
const count = Math.max(0, Math.trunc(cost[type] ?? 0));
+4 -1
View File
@@ -476,7 +476,10 @@ function scoreAbility(
targetId: context.selfId ?? null,
} : null;
}
if (categories.has("threat") && !damaging && !maintenanceBuff) {
// Some defensive spells also reduce the target's threat (for example
// Pain Suppression). Keep them eligible for mitigation scoring even when
// the healer does not currently hold enemy attention.
if (categories.has("threat") && !categories.has("mitigation") && !damaging && !maintenanceBuff) {
if (THREAT_REDIRECT_NAMES.has(ability.name) && context.role !== "tank" && durableTarget) {
return {
ability,
+181 -4
View File
@@ -522,7 +522,7 @@ describe("combat store", () => {
expect(useCombatStore.getState().shield).toBe(0);
});
it("keeps Inner Focus as an on-use, one-heal effect", () => {
it("keeps Inner Focus as an on-use, next-spell effect", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
const ranks = { [talent.id]: 1 };
@@ -543,7 +543,7 @@ describe("combat store", () => {
))).toBe(true);
expect(useCombatStore.getState().globalCooldownEndsAt).toBe(0);
const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 1_000);
const heal = useCombatStore.getState().castAbility("wow335-priest-greater-heal", undefined, 1_000);
expect(heal.ok).toBe(true);
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0);
const completion = useCombatStore.getState().activeCast!.completesAt;
@@ -576,6 +576,40 @@ describe("combat store", () => {
.toMatchObject({ ok: false, reason: "global-cooldown" });
});
it("reduces Inner Focus's active cooldown when Improved Flash Heal is used", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const talent = TALENT_NODES.find((node) => node.name === "Improved Flash Heal")!;
const ranks = { [talent.id]: talent.maxRank };
const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!;
useCombatStore.setState((state) => ({
talentRanks: ranks,
talentModifiers: talentModifiersForRanks(ranks, "priest"),
cooldowns: { ...state.cooldowns, [innerFocus.id]: 10_000 },
}));
const cast = useCombatStore.getState().castAbility("priest-flash-heal", {
targetActorId: null,
now: 1_000,
});
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
const completion = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().tick(0.1, completion);
expect(useCombatStore.getState().cooldowns[innerFocus.id]).toBe(8_000);
});
it("converts health into mana with Life Tap", () => {
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 });
useCombatStore.setState((state) => ({ health: 5_000, resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 } }));
const lifeTap = useCombatStore.getState().abilities.find((ability) => ability.name === "Life Tap")!;
const result = useCombatStore.getState().castAbility(lifeTap.id, undefined, 1_000);
expect(result).toMatchObject({ ok: true, healing: 0 });
expect(useCombatStore.getState()).toMatchObject({ health: 3_510, resource: 1_490 });
});
it("ticks damage over time and awards kill experience exactly once", () => {
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 });
registerTarget("ooze", [10, 0, 0], 5, 100);
@@ -839,6 +873,34 @@ describe("combat store", () => {
});
});
it("spreads only active Death Knight diseases with Pestilence and taunts with Death Grip", () => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
registerTarget("disease-primary", [2, 0, 0], 5_000);
registerTarget("disease-neighbor", [5, 0, 0], 5_000);
useCombatStore.getState().selectMob("disease-primary");
const abilities = useCombatStore.getState().abilities;
const spellId = (name: string) => {
const ability = abilities.find((entry) => entry.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
};
expect(useCombatStore.getState().castAbility(spellId("Icy Touch"), undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility(spellId("Plague Strike"), undefined, 3_000).ok).toBe(true);
const neighborHealth = useCombatStore.getState().mobs["disease-neighbor"].health;
expect(useCombatStore.getState().castAbility(spellId("Pestilence"), undefined, 5_000).ok).toBe(true);
expect(useCombatStore.getState().mobs["disease-neighbor"].health).toBe(neighborHealth);
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === "disease-neighbor")
.map((effect) => effect.tag).sort()).toEqual(["blood-plague", "frost-fever"]);
useCombatStore.getState().selectMob("disease-neighbor");
expect(useCombatStore.getState().castAbility(spellId("Death Grip"), undefined, 7_000).ok).toBe(true);
expect(useCombatStore.getState().mobs["disease-neighbor"]).toMatchObject({
forcedTarget: { actorId: PLAYER_AGGRO_ID, endsAt: 10_000 },
});
});
it("swings at the resolved companion without redirecting player damage", () => {
const target = usePartyStore.getState().members.find((member) => member.role === "damage")!;
usePartyStore.setState((state) => ({
@@ -1425,6 +1487,97 @@ describe("combat store", () => {
expect(seals[0].definition.name).toContain("Seal of Wisdom");
});
it("applies Free Will Root and Slow immunity to the selected Knight/Priest ally", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-priest",
level: 80,
xp: 0,
});
usePartyStore.getState().initializeParty("free-will-test", 80, useCombatStore.getState().health);
const memberId = preparePartyMember(0, [2, 0, 0], Number.MAX_SAFE_INTEGER, true);
const freeWill = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491351);
expect(freeWill).toBeDefined();
const result = useCombatStore.getState().castAbility(freeWill!.id, {
targetActorId: memberId,
now: 1_000,
});
expect(result).toMatchObject({ ok: true, abilityId: freeWill!.id });
const aura = useCombatStore.getState().auras.find((entry) => (
entry.targetId === memberId && entry.definition.id === "rom-free-will-immunity"
));
expect(aura).toMatchObject({
targetId: memberId,
definition: {
durationMs: 10_000,
controlImmunities: ["root", "slow"],
},
});
expect(useCombatStore.getState().auras.some((entry) => (
entry.targetId === PLAYER_AURA_ENTITY_ID && entry.definition.id === "rom-free-will-immunity"
))).toBe(false);
});
it("fans out Crystalline Protection and Acceleration only to party members in range", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", level: 80, xp: 0 });
usePartyStore.getState().initializeParty("warden-crystalline-test", 80, useCombatStore.getState().health);
const inRangeId = preparePartyMember(0, [6, 0, 0], Number.MAX_SAFE_INTEGER);
const outOfRangeId = preparePartyMember(1, [8, 0, 0], Number.MAX_SAFE_INTEGER);
const abilities = useCombatStore.getState().abilities;
const protection = abilities.find((ability) => ability.dbcSpellId === 498201)!;
const acceleration = abilities.find((ability) => ability.dbcSpellId === 498202)!;
expect(useCombatStore.getState().castAbility(protection.id, { now: 1_000 }).ok).toBe(true);
let auras = useCombatStore.getState().auras;
expect(auras.some((entry) => entry.targetId === inRangeId && entry.definition.name === "Crystalline Protection"
&& (entry.definition.absorbs?.length ?? 0) > 0)).toBe(true);
expect(auras.some((entry) => entry.targetId === outOfRangeId && entry.definition.name === "Crystalline Protection"))
.toBe(false);
expect(useCombatStore.getState().castAbility(acceleration.id, { now: 3_000 }).ok).toBe(true);
auras = useCombatStore.getState().auras;
const accelerationAuraFor = (targetId: string) => auras.filter((entry) => (
entry.targetId === targetId && entry.definition.name.startsWith("Crystalline Acceleration")
));
expect(accelerationAuraFor(inRangeId).flatMap((entry) => entry.definition.modifiers ?? []))
.toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "attack-speed" }),
expect.objectContaining({ kind: "stat", stat: "spell-haste" }),
]));
expect(accelerationAuraFor(outOfRangeId)).toHaveLength(0);
});
it("applies Coat of Arms to its caster and the selected ally only", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warden",
secondaryClassId: "rom-warrior",
level: 80,
xp: 0,
});
usePartyStore.getState().initializeParty("warden-coat-of-arms-test", 80, useCombatStore.getState().health);
const selectedId = preparePartyMember(0, [2, 0, 0], Number.MAX_SAFE_INTEGER, true);
const otherId = preparePartyMember(1, [3, 0, 0], Number.MAX_SAFE_INTEGER);
const coatOfArms = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 494611)!;
const result = useCombatStore.getState().castAbility(coatOfArms.id, {
targetActorId: selectedId,
now: 1_000,
});
expect(result).toMatchObject({ ok: true, abilityId: coatOfArms.id });
const auras = useCombatStore.getState().auras.filter((entry) => entry.definition.name === "Coat of Arms");
const hasBothDefenses = (targetId: string) => auras.some((entry) => (
entry.targetId === targetId
&& entry.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "armor")
&& entry.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "magical-defense")
));
expect(hasBothDefenses(PLAYER_AURA_ENTITY_ID)).toBe(true);
expect(hasBothDefenses(selectedId)).toBe(true);
expect(hasBothDefenses(otherId)).toBe(false);
});
it("redirects Hand of Sacrifice damage to the paladin", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
usePartyStore.getState().initializeParty("hand-of-sacrifice-test", 80, useCombatStore.getState().health);
@@ -1619,15 +1772,39 @@ describe("combat store", () => {
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === secondId)).toHaveLength(1);
});
it("requires and consumes a Rejuvenation or Regrowth HoT for Swiftmend", () => {
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
useCombatStore.setState({ health: 1_000 });
const swiftmend = useCombatStore.getState().abilities.find((ability) => ability.name === "Swiftmend")!;
unlockReadySpell(swiftmend.id);
expect(useCombatStore.getState().castAbility(swiftmend.id, { targetActorId: null, now: 1_000 }))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("druid-rejuvenation", {
targetActorId: null,
now: 1_000,
}).ok).toBe(true);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === "druid-rejuvenation")).toBe(true);
const result = useCombatStore.getState().castAbility(swiftmend.id, { targetActorId: null, now: 3_000 });
expect(result.ok).toBe(true);
expect(result.healing).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBeGreaterThan(1_000);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === "druid-rejuvenation")).toBe(false);
});
it("keeps healing components of hostile abilities on the player", () => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 2 });
useCombatStore.setState({ resource: 100 });
registerTarget("death-strike-target", [2, 0, 0], 100, 0);
registerTarget("death-strike-target", [2, 0, 0], 1_000, 0);
const memberId = preparePartyMember(0, [2, 0, 0], 1, true);
useCombatStore.getState().damagePlayer(40);
const playerHealthBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("death-knight-icy-touch", [0, 0, 0], 1_000).ok).toBe(true);
const result = useCombatStore.getState().castAbility("death-knight-death-strike", [0, 0, 0], 1_000);
const result = useCombatStore.getState().castAbility("death-knight-death-strike", [0, 0, 0], 3_000);
expect(result.ok).toBe(true);
expect(result.damage).toBeGreaterThan(0);
expect(result.healing).toBeGreaterThan(0);
+4018 -209
View File
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -9,10 +9,13 @@ export function cooldownResetCandidates(
abilities: readonly AbilityDefinition[],
sourceAbilityId: string,
abilityName?: string,
excludeAbilityNames: readonly string[] = [],
): readonly AbilityDefinition[] {
const excludedNames = new Set(excludeAbilityNames.map((name) => name.toLowerCase()));
const eligible = abilities.filter((candidate) => !excludedNames.has(candidate.name.toLowerCase()));
if (abilityName) {
const normalizedName = abilityName.toLowerCase();
return abilities.filter((candidate) => candidate.name.toLowerCase() === normalizedName);
return eligible.filter((candidate) => candidate.name.toLowerCase() === normalizedName);
}
return abilities.filter((candidate) => candidate.id !== sourceAbilityId);
return eligible.filter((candidate) => candidate.id !== sourceAbilityId);
}
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCombatStore } from "./combatStore";
describe("Death Knight Empower Rune Weapon runtime", () => {
beforeEach(() => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
});
it("restores all six spent runes when cast", () => {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Empower Rune Weapon");
expect(ability).toBeDefined();
useCombatStore.setState((state) => ({
deathKnightRunes: state.deathKnightRunes.map((rune) => ({ ...rune, readyAt: 11_000 })),
}));
const result = useCombatStore.getState().castAbility(ability!.id, { origin: [0, 0, 0], now: 1_000 });
expect(result.ok).toBe(true);
expect(useCombatStore.getState().deathKnightRunes).toHaveLength(6);
expect(useCombatStore.getState().deathKnightRunes.every((rune) => rune.readyAt === 0)).toBe(true);
});
it("generates the source-listed 25 Runic Power when cast", () => {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Empower Rune Weapon");
expect(ability).toBeDefined();
useCombatStore.setState({ resource: 0, resourcePools: { "runic-power": 0 } });
const result = useCombatStore.getState().castAbility(ability!.id, { origin: [0, 0, 0], now: 1_000 });
expect(result.ok).toBe(true);
expect(useCombatStore.getState().resource).toBe(25);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(25);
});
});
+379
View File
@@ -0,0 +1,379 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { installCombatClock } from "./combatClock";
import { createDeathKnightRunes } from "./combatActors";
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { talentNodesForTree } from "./talentCatalog";
let now = 1_000;
let restoreClock: (() => void) | undefined;
function ability(name: string) {
const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
if (!found) throw new Error("Missing Death Knight ability: " + name);
return found;
}
function learnActiveAbility(name: string) {
const found = ability(name);
if (found.talentNodeId) {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [found.talentNodeId!]: 1 },
}));
}
return found;
}
function registerTarget(id = "dk-target", position: readonly [number, number, number] = [2, 0, 0]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
}
function selectPartyMember(index = 0, raceId: "undead" | "human" = "human") {
const initial = usePartyStore.getState().members[index];
if (!initial) throw new Error("Expected a party member for the Death Knight test.");
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === initial.id
? { ...member, raceId, health: 1 }
: member),
}));
registerPartyRuntimePosition(initial.id, [2, 0, 0]);
usePartyStore.getState().selectMember(initial.id);
return initial.id;
}
function setButchery(rank: number, runicPower = 0) {
const node = talentNodesForTree("death-knight-blood").find((candidate) => candidate.name === "Butchery");
if (!node) throw new Error("Missing Death Knight Butchery talent.");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [node.id]: rank },
resource: runicPower,
resourcePools: { ...state.resourcePools, "runic-power": runicPower },
}));
}
function registerXpTarget(id: string, xpReward: number) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1, xpReward });
setMobPosition(id, [2, 0, 0]);
}
describe("Death Knight source-backed runtime mechanics", () => {
beforeEach(() => {
now = 1_000;
restoreClock = installCombatClock(() => now);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
usePartyStore.getState().initializeParty("death-knight-source-runtime", 80,
useCombatStore.getState().health, "tank", 5);
});
afterEach(() => {
restoreClock?.();
restoreClock = undefined;
});
it("heals a friendly Undead with Death Coil and rejects a living ally", () => {
const allyId = selectPartyMember(0, "human");
const deathCoil = ability("Death Coil");
const rejected = useCombatStore.getState().castAbility(deathCoil.id, {
targetActorId: allyId,
now,
});
expect(rejected).toMatchObject({ ok: false, reason: "condition-not-met" });
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === allyId
? { ...member, raceId: "undead" }
: member),
}));
now = 3_000;
const healed = useCombatStore.getState().castAbility(deathCoil.id, {
targetActorId: allyId,
now,
});
expect(healed.ok).toBe(true);
expect(healed.healing).toBeGreaterThan(0);
expect(usePartyStore.getState().members.find((member) => member.id === allyId)!.health).toBeGreaterThan(1);
});
it("uses Death Coil's damaging branch against a hostile living target", () => {
registerTarget();
const deathCoil = ability("Death Coil");
const attempts = [1_000, 3_000, 5_000].map((castAt) => {
now = castAt;
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
return useCombatStore.getState().castAbility(deathCoil.id, { now });
});
expect(attempts.every((result) => result.ok)).toBe(true);
expect(attempts.some((result) => result.damage > 0)).toBe(true);
expect(attempts.every((result) => result.healing === 0)).toBe(true);
});
it("absorbs three quarters of magical damage up to half of maximum health", () => {
const shell = ability("Anti-Magic Shell");
expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID
&& candidate.definition.name === "Anti-Magic Shell");
expect(aura?.absorbs[0]).toMatchObject({
remaining: Math.round(useCombatStore.getState().maxHealth * 0.5),
fraction: 0.75,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
});
now += 1;
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25);
expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura!.instanceId)
?.absorbs[0].remaining).toBe(Math.round(useCombatStore.getState().maxHealth * 0.5) - 75);
});
it("blocks harmful magic controls during Anti-Magic Shell while allowing physical controls", () => {
const shell = ability("Anti-Magic Shell");
expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true);
const addControlAttacker = (id: string, school: "shadow" | "physical") => {
useCombatStore.getState().registerMob(id, {
name: id,
maxHealth: 1_000,
xpReward: 0,
attacks: [{
id: `${id}-stun`,
name: "Stunning attack",
delivery: "projectile",
target: "primary",
school,
animation: "attack",
range: 30,
cooldownMs: 1_000,
damageMultiplier: 0,
effect: { kind: "control", mechanic: "stun", durationMs: 5_000 },
}],
});
setMobPosition(id, [2, 0, 0]);
useCombatStore.getState().damageMob(id, 1, now);
return useCombatStore.getState().mobs[id].nextAttackAt;
};
const magicReadyAt = addControlAttacker("magic-stun", "shadow");
useCombatStore.getState().advanceMobCombat(magicReadyAt, [0, 0, 0]);
expect(useCombatStore.getState().mobs["magic-stun"].attackRevision).toBe(1);
expect(useCombatStore.getState().controlledUntil).toBe(0);
const physicalReadyAt = addControlAttacker("physical-stun", "physical");
useCombatStore.getState().advanceMobCombat(physicalReadyAt, [0, 0, 0]);
expect(useCombatStore.getState().mobs["physical-stun"].attackRevision).toBe(1);
expect(useCombatStore.getState().controlledUntil).toBeGreaterThan(physicalReadyAt);
});
it("places Anti-Magic Zone at the selected point and protects party members within 10 yards", () => {
const inRangeAlly = selectPartyMember(0, "human");
const outOfRangeAlly = usePartyStore.getState().members[1].id;
usePartyStore.setState((state) => ({
members: state.members.map((member) => ({ ...member, health: 1 })),
}));
registerPartyRuntimePosition(inRangeAlly, [5, 0, 0]);
registerPartyRuntimePosition(outOfRangeAlly, [11, 0, 0]);
const antiMagicZone = learnActiveAbility("Anti-Magic Zone");
const cast = useCombatStore.getState().castAbility(antiMagicZone.id, {
now,
groundPosition: [0, 0, 0],
});
expect(cast.ok).toBe(true);
expect(useCombatStore.getState().summons).toContainEqual(expect.objectContaining({
creatureId: 28306,
position: [0, 0, 0],
expiresAt: now + 10_000,
antiMagicZone: expect.objectContaining({ radius: 10, absorbFraction: 0.75 }),
}));
const partyAuras = useCombatStore.getState().auras.filter((aura) => (
aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone")
));
expect(partyAuras.map((aura) => aura.targetId)).toEqual(expect.arrayContaining([
PLAYER_AURA_ENTITY_ID,
inRangeAlly,
]));
expect(partyAuras.map((aura) => aura.targetId)).not.toContain(outOfRangeAlly);
expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)?.absorbs[0]).toMatchObject({
remaining: expect.any(Number),
fraction: 0.75,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
});
expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)!.absorbs[0].remaining).toBeGreaterThan(10_000);
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25);
expect(useCombatStore.getState().damagePlayer(100, "physical")).toBe(100);
registerPartyRuntimePosition(inRangeAlly, [11, 0, 0]);
now += 250;
useCombatStore.getState().tick(0.25, now);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === inRangeAlly
&& aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone"))).toBe(false);
});
it("spends Bone Shield's four charges on landed damage while keeping its reduction non-stacking", () => {
const boneShield = learnActiveAbility("Bone Shield");
const boneCast = useCombatStore.getState().castAbility(boneShield.id, { now });
expect(boneCast.reason).toBeUndefined();
expect(boneCast.ok).toBe(true);
const auraId = useCombatStore.getState().auras.find((aura) => aura.definition.name === "Bone Shield")!.instanceId;
for (const remaining of [3, 2, 1]) {
now += 1;
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8);
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === auraId)?.stacks).toBe(remaining);
}
now += 1;
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8);
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false);
now += 1;
expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(10);
});
it("converts exactly one spent Blood Rune for Blood Tap without activating a Frost Rune", () => {
const initialRunes = createDeathKnightRunes().map((rune) => (
rune.slot === 0 || rune.slot === 2 ? { ...rune, readyAt: now + 10_000 } : rune
));
useCombatStore.setState({ deathKnightRunes: initialRunes });
const bloodTap = ability("Blood Tap");
expect(useCombatStore.getState().castAbility(bloodTap.id, { now }).ok).toBe(true);
const runes = useCombatStore.getState().deathKnightRunes;
expect(runes.filter((rune) => rune.type === "death")).toEqual([
expect.objectContaining({ slot: 0, baseType: "blood", readyAt: now, deathRuneExpiresAt: now + 20_000 }),
]);
expect(runes.find((rune) => rune.slot === 2)).toMatchObject({ type: "frost", readyAt: now + 10_000 });
expect(runes.filter((rune) => rune.readyAt <= now)).toHaveLength(5);
});
it("consumes Deathchill only on Icy Touch, Howling Blast, Frost Strike, or Obliterate", () => {
registerTarget();
const deathchill = learnActiveAbility("Deathchill");
expect(useCombatStore.getState().castAbility(deathchill.id, { now }).ok).toBe(true);
const auraId = useCombatStore.getState().auras.find((aura) => (
aura.definition.utilityTags?.includes("wow335-deathchill-next-critical")
))!.instanceId;
now = 3_000;
expect(useCombatStore.getState().castAbility(ability("Chains of Ice").id, { now }).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(true);
now = 5_000;
const icyTouch = ability("Icy Touch");
expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.abilityId === icyTouch.id && event.kind === "damage" && event.critical
))).toBe(true);
});
it("heals Death Strike for five percent of maximum health per active disease", () => {
registerTarget();
const icyTouch = ability("Icy Touch");
const plagueStrike = ability("Plague Strike");
const deathStrike = ability("Death Strike");
expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true);
now = 3_000;
useCombatStore.setState((state) => ({ ...state, health: 1 }));
const oneDisease = useCombatStore.getState().castAbility(deathStrike.id, { now });
expect(oneDisease.ok).toBe(true);
expect(oneDisease.healing).toBeGreaterThan(0);
now = 5_000;
expect(useCombatStore.getState().castAbility(plagueStrike.id, { now }).ok).toBe(true);
useCombatStore.setState((state) => ({
...state,
health: 1,
deathKnightRunes: createDeathKnightRunes(now),
}));
now = 7_000;
const twoDiseases = useCombatStore.getState().castAbility(deathStrike.id, { now });
expect(twoDiseases.ok).toBe(true);
expect(twoDiseases.healing).toBeGreaterThan(oneDisease.healing);
expect(twoDiseases.healing).toBeCloseTo(oneDisease.healing * 2, -1);
});
it.each([
{ rank: 1, expectedRunicPower: 10 },
{ rank: 2, expectedRunicPower: 20 },
])("grants Butchery rank $rank's runic power on an eligible player kill", ({ rank, expectedRunicPower }) => {
const targetId = `butchery-rank-${rank}`;
registerXpTarget(targetId, 1_000);
setButchery(rank);
const result = useCombatStore.getState().damageMob(targetId, 10, now);
expect(result.killed).toBe(true);
expect(useCombatStore.getState().resource).toBe(expectedRunicPower);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(expectedRunicPower);
});
it("caps Butchery at the runic power maximum", () => {
const targetId = "butchery-cap-target";
registerXpTarget(targetId, 1_000);
setButchery(2, 95);
useCombatStore.getState().damageMob(targetId, 10, now);
expect(useCombatStore.getState().resource).toBe(100);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(100);
});
it("does not grant Butchery without the talent or for a target with no XP reward", () => {
const noTalentTargetId = "butchery-no-talent-target";
registerXpTarget(noTalentTargetId, 1_000);
setButchery(0);
useCombatStore.getState().damageMob(noTalentTargetId, 10, now);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
const noXpTargetId = "butchery-no-xp-target";
registerXpTarget(noXpTargetId, 0);
setButchery(2);
useCombatStore.getState().damageMob(noXpTargetId, 10, now + 1);
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
});
it("does not grant Butchery for a party or pet killing blow", () => {
setButchery(2);
const partyActorId = usePartyStore.getState().members[0].id;
for (const [targetId, actorId] of [
["butchery-party-target", partyActorId],
["butchery-pet-target", "butchery-test-pet"],
] as const) {
registerXpTarget(targetId, 1_000);
useCombatStore.getState().damageMob(targetId, 10, now, {
actorId,
role: "damage",
abilityId: "test-non-player-kill",
});
}
expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0);
});
it("applies Improved Rune Tap's rank-specific healing and cooldown reductions", () => {
useCombatStore.setState((state) => ({
...state,
health: 1,
talentRanks: { ...state.talentRanks, "talent-1941": 1 },
}));
const runeTap = ability("Rune Tap");
const baseline = useCombatStore.getState().castAbility(runeTap.id, { now });
expect(baseline.ok).toBe(true);
now = 3_000;
useCombatStore.setState((state) => ({
...state,
health: 1,
cooldowns: {},
deathKnightRunes: createDeathKnightRunes(now),
talentRanks: { ...state.talentRanks, "talent-1942": 3 },
}));
const improved = useCombatStore.getState().castAbility(runeTap.id, { now });
expect(improved.ok).toBe(true);
expect(improved.healing).toBeCloseTo(baseline.healing * 2, -1);
expect(useCombatStore.getState().cooldowns[runeTap.id]).toBe(now + 30_000);
});
});
+4 -1
View File
@@ -22,12 +22,15 @@ export function healingRecipientIds(
if (!center) return [];
const candidates = Object.values(actors)
.filter(actor => actor.kind !== "enemy" && actor.alive && actor.health > 0)
.filter(actor => !effect.excludeCaster || actor.id !== sourceId)
.filter(actor => {
const position = positionFor(actor.id);
return position && position.reduce((sum, value, index) => sum + (value - center[index]) ** 2, 0) <= (effect.radius ?? 30) ** 2
&& hasLineOfSight(center, position);
})
.sort((a, b) => a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id));
.sort((a, b) => effect.selection === "lowest-max-health"
? a.maxHealth - b.maxHealth || a.id.localeCompare(b.id)
: a.health / a.maxHealth - b.health / b.maxHealth || a.id.localeCompare(b.id));
if (effect.selection === "random" && candidates.length > 0) {
let hash = 2166136261;
for (const value of `${seed}:${sourceId}:${targetId}`) hash = Math.imul(hash ^ value.charCodeAt(0), 16777619);
+40
View File
@@ -81,6 +81,46 @@ function keyboardEvent(type: "keydown" | "keyup", code: string, modifiers: Parti
}
describe("remappable gameplay controls", () => {
it("allows editor movement and jumping while combat actions remain disabled", () => {
stubInputEventTargets();
const next = inputActionSpies(true);
next.movementBlocked = () => false;
const dispose = installInput(next);
for (const [code, expected] of [
["KeyW", { moveX: 0, moveForward: 1 }], ["KeyS", { moveX: 0, moveForward: -1 }],
["KeyA", { moveX: -1, moveForward: 0 }], ["KeyD", { moveX: 1, moveForward: 0 }],
] as const) {
keyboardEvent("keydown", code); expect(readInputSnapshot()).toMatchObject(expected);
keyboardEvent("keyup", code); expect(readInputSnapshot()).toMatchObject({ moveX: 0, moveForward: 0 });
}
keyboardEvent("keydown", "Space"); expect(consumeJumpRequest()).not.toBeNull();
keyboardEvent("keyup", "Space");
for (const code of ["Digit1", "KeyE", "Tab", "F1"]) keyboardEvent("keydown", code);
expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.lootActiveBoss).not.toHaveBeenCalled();
expect(next.cycleTarget).not.toHaveBeenCalled(); expect(next.setPartyCommand).not.toHaveBeenCalled();
setExternalControllerMovement({ moveX: .7, moveY: -1, lookX: 0, lookY: 0 });
expect(readInputSnapshot().moveForward).toBeGreaterThan(0);
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.lootActiveBoss).not.toHaveBeenCalled();
dispose();
});
it("still clears held editor movement on panel focus and blocking overlays", () => {
stubInputEventTargets();
const next = inputActionSpies(true);
next.movementBlocked = vi.fn(() => false); next.inputSuspended = vi.fn(() => false);
const dispose = installInput(next);
keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(1);
(next.inputSuspended as ReturnType<typeof vi.fn>).mockReturnValue(true);
expect(readInputSnapshot().moveForward).toBe(0);
(next.inputSuspended as ReturnType<typeof vi.fn>).mockReturnValue(false);
expect(readInputSnapshot().moveForward).toBe(0);
keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(1);
(next.movementBlocked as ReturnType<typeof vi.fn>).mockReturnValue(true);
expect(readInputSnapshot().moveForward).toBe(0);
(next.movementBlocked as ReturnType<typeof vi.fn>).mockReturnValue(false);
expect(readInputSnapshot().moveForward).toBe(0);
dispose();
});
it("keeps moving forward when a legacy WoW skill also owns W", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
+7 -3
View File
@@ -46,6 +46,8 @@ export interface InputActions {
confirmGroundTarget: () => void;
cancelGroundTarget: () => void;
gameplayActionsBlocked: () => boolean;
/** Editors may disable combat actions while retaining movement and camera controls. */
movementBlocked?: () => boolean;
setCameraLookActive: (active: boolean) => void;
setInputMode: (mode: "keyboard" | "gamepad") => void;
setActionLayer: (layer: ActionBindingLayer) => void;
@@ -243,6 +245,7 @@ export function installInput(nextActions: InputActions): () => void {
actions = nextActions;
const keyboardModifiers = new Set<string>();
const suspended = () => isCapturingInput() || nextActions.inputSuspended?.();
const movementBlocked = () => nextActions.movementBlocked?.() ?? nextActions.gameplayActionsBlocked();
const publishActionLayer = () => {
const held = getControllerHeldTokens();
const layerHeld = (layer: string) => Object.entries(bindingsFor("controller")).some(([id, key]) => {
@@ -265,6 +268,7 @@ export function installInput(nextActions: InputActions): () => void {
};
const candidates = (device: InputDevice) => INPUT_ACTIONS.filter((entry) => {
if (entry.context === "global" || entry.context === "menu" || entry.context === "ability-editor") return false;
if (entry.continuous || entry.id === "jump") return !movementBlocked();
if (entry.context === "ground") return nextActions.groundTargetActive() && !nextActions.gameplayActionsBlocked();
if (entry.id.startsWith("bar:") && (!nextActions.desktopActionBarsActive?.() || Number(entry.id.split(":")[1]) >= 5)) return false;
if (device === "pc" && entry.id.startsWith("ability:") && nextActions.desktopActionBarsActive?.()) return false;
@@ -303,11 +307,11 @@ export function installInput(nextActions: InputActions): () => void {
const key = inputKeyForEvent(event);
if (!key) return;
const bindings = bindingsFor("pc");
if (nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return;
if (movementBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return;
const matches = candidates("pc").filter((entry) => bindings[entry.id] === key)
.sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground"));
// Preserve ordinary movement while a modifier is held (e.g. Shift + W).
const continuous = !nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key));
const continuous = !movementBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key));
if (continuous) { keys.add(event.code); keys.add(key); event.preventDefault(); nextActions.setInputMode("keyboard"); }
if (!matches.length) return;
const entry = matches[0];
@@ -389,7 +393,7 @@ export function consumeMouseLook(): readonly [number, number] {
}
export function readInputSnapshot(): InputSnapshot {
if (isCapturingInput() || actions?.inputSuspended?.() || actions?.gameplayActionsBlocked()) {
if (isCapturingInput() || actions?.inputSuspended?.() || (actions?.movementBlocked?.() ?? actions?.gameplayActionsBlocked())) {
keys.clear();
return { moveX: 0, moveForward: 0, lookX: 0, lookY: 0, gamepadActive: false };
}
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
describe("Mage Blink runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
});
it.each(["root", "stun"] as const)("clears active %s control on a successful cast", (mechanic) => {
useCombatStore.setState({ controlledUntil: 10_000, controlMechanic: mechanic });
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
origin: [0, 0, 0],
now: 1_000,
});
expect(result).toMatchObject({ ok: true, abilityId: "wow335-mage-blink" });
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
expect(useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID))
.toEqual(expect.arrayContaining([
expect.objectContaining({
expiresAt: 2_000,
definition: expect.objectContaining({ controlImmunities: ["root"] }),
}),
expect.objectContaining({
expiresAt: 2_000,
definition: expect.objectContaining({ controlImmunities: ["stun"] }),
}),
]));
});
it("does not move away from a target when forward facing is unavailable", () => {
useCombatStore.getState().registerMob("blink-target", {
name: "Blink target",
maxHealth: 100,
xpReward: 0,
});
setMobPosition("blink-target", [5, 0, 0]);
useCombatStore.getState().selectMob("blink-target");
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
origin: [0, 0, 0],
now: 1_000,
});
expect(result.ok).toBe(true);
expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 0]);
});
});
+21 -7
View File
@@ -84,7 +84,7 @@ import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics";
import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics";
import { romCreatureFamilyTargetTags } from "./romCreatureFamilies";
import { romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset } from "./romMageWarlockOverrides";
@@ -797,7 +797,7 @@ function partyAbilityTargetIsValid(
&& aura.definition.utilityTags?.includes("rom-charged"))) return false;
if (ability.dbcSpellId === 491172 && !combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("rom-static-field-charge"))) return false;
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) {
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability) || romAbilityNeedsRangedWeapon(ability)) {
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
secondaryClassId: member.secondaryClassId,
@@ -805,6 +805,7 @@ function partyAbilityTargetIsValid(
});
if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") return false;
if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") return false;
if (romAbilityNeedsRangedWeapon(ability) && !loadout.ranged) return false;
}
}
const targetPosition = hostile ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
@@ -1239,6 +1240,10 @@ function executePartyAbility(
specializationId: member.specializationId, role: member.role, level: member.level,
})["main-hand"]?.weaponType === "dagger",
casterMaxHealth: member.maxHealth,
targetHealthPercent: combat.mobs[targetId]
? combat.mobs[targetId]!.health / Math.max(1, combat.mobs[targetId]!.maxHealth)
: 1,
naturePower: member.resourcePools["nature-power"] ?? 0,
passiveTags: combat.auras.filter((aura) => aura.targetId === member.id)
.flatMap((aura) => aura.definition.utilityTags ?? []).concat(
abilitiesForCharacter(member.classId, member.secondaryClassId)
@@ -1455,9 +1460,10 @@ function executePartyAbility(
}
} else if (effect.kind === "resource") {
for (const friendlyTargetId of friendlyTargets.length ? friendlyTargets : [member.id]) {
const resource = effect.resource ?? member.resourceType;
effectiveAmount += useCombatStore.getState().restoreActorResource(
friendlyTargetId,
effect.resource ?? member.resourceType,
resource,
effect.amount,
effect.percentage,
member.id,
@@ -1473,12 +1479,20 @@ function executePartyAbility(
runtimeMember,
useCombatStore.getState().actors[member.id],
);
const restoredPool = resourceMirror.resourcePools[resource];
if (restoredPool === undefined) continue;
runtimeMember = {
...runtimeMember,
resource: resourceMirror.resource,
maxResource: resourceMirror.maxResource,
resourcePools: resourceMirror.resourcePools,
lastResourceSpentAt: resourceMirror.lastResourceSpentAt,
...(resource === runtimeMember.resourceType
? { resource: restoredPool, maxResource: resourceMirror.maxResource }
: {}),
resourcePools: { ...runtimeMember.resourcePools, [resource]: restoredPool },
...(resourceMirror.lastResourceSpentAt[resource] === undefined
? {}
: { lastResourceSpentAt: {
...runtimeMember.lastResourceSpentAt,
[resource]: resourceMirror.lastResourceSpentAt[resource],
} }),
};
}
}
+6 -3
View File
@@ -44,6 +44,7 @@ import type { PartyRole, PartySize } from "./partyRoles";
import {
abilitiesForCharacter,
learnedAbilityForBuild,
naturePowerMaximumForCharacter,
resourceProfileForClass,
type ResourceType,
} from "./abilityCatalog";
@@ -251,8 +252,10 @@ function memberMaxResource(
: profile.maximum;
}
function secondaryResourceMaximum(type: ResourceType): number {
if (type === "nature-power") return 10;
function secondaryResourceMaximum(type: ResourceType, member: PartyMember): number {
if (type === "nature-power") {
return naturePowerMaximumForCharacter(member.classId, member.secondaryClassId, member.level);
}
if (type === "psi") return 6;
return 100;
}
@@ -279,7 +282,7 @@ function withMemberAbilityResourcePools(member: PartyMember): PartyMember {
resourcePools[type] = member.resource;
continue;
}
const maximum = secondaryResourceMaximum(type);
const maximum = secondaryResourceMaximum(type, member);
resourcePools[type] = Math.max(0, Math.min(
maximum,
member.resourcePools[type] ?? initialSecondaryResource(type, maximum),
+404 -31
View File
@@ -15,8 +15,12 @@ import {
type RomSourceComponent,
} from "./romAbilitySemantics";
import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides";
import { overrideRomKnightEffects } from "./romKnightOverrides";
import { overrideRomWarriorEffects } from "./romWarriorOverrides";
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
import { overrideRomWarlockEffects } from "./romWarlockOverrides";
import { overrideRomDruidEffects } from "./romDruidOverrides";
import { romPriestDescription } from "./romPriestTooltips";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -89,7 +93,38 @@ function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
}));
}
type GeneratedSemanticRow = Pick<GeneratedSkill, "sourceSkillId" | "name" | "description" | "target" | "passive" | "source">;
function costsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "costs">): readonly AbilityCost[] {
let resolved = costs(row.costs);
if (row.sourceSkillId === 490136) {
resolved = resolved.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost);
}
if (![493536, 494217].includes(row.sourceSkillId) || resolved.some((entry) => entry.resource === "nature-power")) return resolved;
return [...resolved, { resource: "nature-power", amount: 1 }];
}
function cooldownMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "cooldownMs">): number {
// The local snapshot omitted Prayer of Tribulation's timing. CoA's current
// skill list identifies this same Priest/Warlock spell (498770) as a 1.5s
// channel with a 5s cooldown; its healing and buff payload still come from
// the local RuneWaker tooltip/components below.
if (row.sourceSkillId === 498770) return 5_000;
return row.sourceSkillId === 490136 ? 40_000 : row.cooldownMs;
}
function castTimeMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "castTimeMs">): number {
if (row.sourceSkillId === 498770) return 1_500;
if (row.sourceSkillId === 491164) return 4_000;
return row.castTimeMs;
}
function castModeFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "castTimeMs">): AbilityDefinition["castMode"] {
return row.sourceSkillId === 491164 || row.sourceSkillId === 498770
? "channel"
: row.castTimeMs > 0 ? "cast" : "instant";
}
type GeneratedSemanticRow = Pick<GeneratedSkill, "sourceSkillId" | "name" | "description" | "target" | "passive" | "source">
& Partial<Pick<GeneratedSkill, "classId" | "secondaryClassId">>;
function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "effect";
@@ -128,6 +163,16 @@ function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySe
return "ability-target";
}
function abilityTarget(row: Pick<GeneratedSemanticRow, "sourceSkillId" | "target">): GeneratedSemanticRow["target"] {
if ([490290, 490491, 492636, 493531, 494021, 494027, 498641].includes(row.sourceSkillId)) return "friendly";
// RuneWaker's tooltip calls this a four-pulse heal on a friendly target;
// sourceTarget 7 is stale/conflicting and was incorrectly making it hostile.
if (row.sourceSkillId === 498770) return "friendly";
if (row.sourceSkillId === 490239) return "hostile";
if (row.sourceSkillId === 494217) return "self";
return row.target;
}
const MAGIC_SCHOOLS: readonly DamageSchool[] = ["arcane", "fire", "frost", "holy", "nature", "shadow"];
function percent(value: number): number {
@@ -340,7 +385,10 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
if (semantic.kind === "aura") {
const recipient = auraRecipient(row, semantic);
const modifiers = auraModifiers(semantic);
const partyScope = /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description);
const partyScope = [490248, 491335, 491644, 494027, 498201, 498202, 498219, 498649, 498719]
.includes(row.sourceSkillId)
|| (row.sourceSkillId === 498722 && semantic.sourceComponentIds.includes(621463))
|| /\b(?:(?:all|every) party members?|all teammates)\b/i.test(row.description);
const aura: AuraDefinition = {
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
@@ -354,7 +402,12 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
? { utilityTags: ["stealth"] }
: {}),
};
return { kind: "apply-aura", aura, recipient, ...(partyScope ? { scope: "party" as const } : {}), sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0 };
return {
kind: "apply-aura", aura, recipient,
...(partyScope ? { scope: "party" as const } : {}),
...(row.sourceSkillId === 498719 ? { excludeCaster: true } : {}),
sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0,
};
}
if (semantic.kind === "absorb") {
if (semantic.amount <= 0 && semantic.perSkillLevel <= 0) {
@@ -369,7 +422,11 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
dispelCategory: "magic",
absorbs: [{ id: `rom-absorb-pool-${effectId}`, amount: Math.max(0, semantic.amount + semantic.perSkillLevel), school: combatSchool(semantic.school) }],
};
return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0 };
return {
kind: "apply-aura", aura, recipient,
...([491513, 494027, 498201].includes(row.sourceSkillId) ? { scope: "party" as const } : {}),
sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0,
};
}
if (semantic.kind === "proc") {
const recipient = auraRecipient(row, semantic);
@@ -390,7 +447,10 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: semantic.triggerSkillId };
}
if (semantic.kind === "resource") {
const resourceType = knownResource(semantic.resource);
const manaPercentRegen = semantic.resource === "mana-percent"
&& semantic.intervalSeconds !== null
&& semantic.intervalSeconds > 0;
const resourceType = manaPercentRegen ? "mana" : knownResource(semantic.resource);
if (semantic.intervalSeconds && semantic.intervalSeconds > 0 && resourceType) {
const amountPerSecond = semantic.amount / semantic.intervalSeconds;
const aura: AuraDefinition = {
@@ -398,15 +458,31 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
name: row.name,
disposition: semantic.operation === "drain" ? "debuff" : "buff",
durationMs: semantic.durationSeconds && semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
modifiers: [{
kind: "resource",
resource: resourceType,
property: "regeneration",
operation: "flat",
value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond,
}],
modifiers: manaPercentRegen
? [{
kind: "stat",
stat: "mana-regeneration-max-percent-per-second",
operation: "flat",
value: (semantic.operation === "drain" ? -amountPerSecond : amountPerSecond) / 100,
}]
: [{
kind: "resource",
resource: resourceType,
property: "regeneration",
operation: "flat",
value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond,
}],
};
return {
kind: "apply-aura",
aura,
recipient: row.sourceSkillId === 498529 || row.target === "self" ? "caster" : "ability-target",
...(row.sourceSkillId === 491644 ? { scope: "party" as const } : {}),
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: semantic.amount,
triggerSpellId: 0,
};
return { kind: "apply-aura", aura, recipient: row.target === "hostile" ? "ability-target" : "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: semantic.amount, triggerSpellId: 0 };
}
if (semantic.operation === "drain") return {
kind: "resource-drain",
@@ -462,7 +538,137 @@ function stateAuraEffect(
};
}
function targetStateAuraEffect(row: GeneratedSemanticRow, stateId: string, name: string, duration: number): AbilityEffect {
return {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: `rom-state-${stateId}`,
name,
disposition: "debuff",
durationMs: duration,
utilityTags: [`rom-${stateId}`],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: row.sourceSkillId,
};
}
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
if (row.classId === "rom-warden" && row.sourceSkillId === 494610) {
// This passive is a trigger marker; its source damage reduction is applied
// to Frantic Briar's target, not permanently to the Warden.
return [stateAuraEffect(row, "will-attack", row.name, null)];
}
if (row.classId === "rom-warden" && row.sourceSkillId === 493344) {
return translateRomAbilitySemantics(row).map((semantic, index) => {
const effect = semanticEffect(row, semantic, index);
return effect.kind === "summon"
? { ...effect, durationMs: Number.MAX_SAFE_INTEGER }
: effect;
});
}
if (row.classId === "rom-warden" && row.sourceSkillId === 491668) {
let keptAbsorb = false;
return translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index))
.filter((effect) => {
if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return true;
if (keptAbsorb) return false;
keptAbsorb = true;
return true;
})
.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
? {
...effect,
recipient: "caster" as const,
aura: {
...effect.aura,
absorbs: effect.aura.absorbs.map((absorb) => ({
...absorb,
amount: 0,
hitCharges: 2,
})),
},
}
: effect);
}
if (row.classId === "rom-champion" && row.sourceSkillId === 498564) {
return [
...translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index)),
{ kind: "pull", toward: "caster" },
];
}
if (row.sourceSkillId === 494611) {
const modifiers = [...new Map(translateRomAbilitySemantics(row)
.filter((semantic): semantic is Extract<RomAbilitySemantic, { kind: "aura" }> => semantic.kind === "aura")
.flatMap(auraModifiers)
.map((modifier) => [JSON.stringify(modifier), modifier] as const)).values()];
if (!modifiers.length) return null;
const aura: AuraDefinition = {
id: "rom-coat-of-arms",
name: row.name,
disposition: "buff",
durationMs: durationMs(Math.max(0, ...(row.source?.components ?? []).map((component) => Number(component.effectTime ?? 0)))),
dispelCategory: "magic",
modifiers,
};
const apply = (recipient: "caster" | "ability-target"): AbilityEffect => ({
kind: "apply-aura",
aura,
recipient,
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
});
return [apply("caster"), apply("ability-target")];
}
if (row.sourceSkillId === 491173) {
return [stateAuraEffect(row, "cripple-joint", row.name, null, {
procs: [{
id: "rom-cripple-joint-root",
triggers: ["hit", "crit"],
chance: 0.55,
action: { kind: "custom", id: "rom-cripple-joint" },
}],
})];
}
if (row.sourceSkillId === 491351) {
return [{
kind: "apply-aura",
aura: {
id: "rom-free-will-immunity",
name: row.name,
disposition: "buff",
durationMs: durationMs(row.source?.components?.[0]?.effectTime ?? 0),
controlImmunities: ["root", "slow"],
},
recipient: "ability-target",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
}];
}
if (row.sourceSkillId === 498570) {
return [{
kind: "apply-aura",
aura: {
id: "rom-overrule-immunity",
name: row.name,
disposition: "buff",
durationMs: durationMs(row.source?.components?.[0]?.effectTime ?? 3),
controlImmunities: ["fear", "confuse", "sleep", "stun", "root", "slow"],
},
recipient: "caster",
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
}];
}
if (row.sourceSkillId === 498744) {
return [{ kind: "cooldown-reset", mode: "reset", abilityName: "Electrostatic Charge" }];
}
@@ -524,10 +730,88 @@ function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[]
function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
const specialized = specializedEffects(row);
if (specialized) return specialized;
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
const pairOverride = overrideRomPriestKnightEffects(row, translated)
const semantics = translateRomAbilitySemantics(row);
const translated = semantics.map((semantic, index) => semanticEffect(row, semantic, index))
.map((effect) => row.sourceSkillId === 493642 && effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), "rom-briar-shield"],
},
} : effect);
const druidOverride = row.classId === "rom-druid" ? overrideRomDruidEffects(row, translated) : null;
if (druidOverride) return druidOverride;
if (row.sourceSkillId === 498825) return [
...translated,
{ kind: "cooldown-reset", mode: "reset", abilityName: "Lightning" },
{ kind: "cooldown-reset", mode: "reset", abilityName: "Discharge" },
];
if (row.sourceSkillId === 491606 || row.sourceSkillId === 490078) {
const damageComponentId = row.sourceSkillId === 491606 ? 502050 : 502102;
const triggerComponentId = row.sourceSkillId === 491606 ? 502049 : 502103;
const triggerComponent = row.source?.components?.find((component) => component.id === triggerComponentId);
const intervalSeconds = triggerComponent?.triggers?.onTimeSeconds ?? (row.sourceSkillId === 491606 ? 2 : 3);
const durationSeconds = triggerComponent?.effectTime ?? (row.sourceSkillId === 491606 ? 6 : 18);
const ticks = Math.max(1, Math.round(durationSeconds / intervalSeconds));
const damageSemanticIndex = semantics.findIndex((semantic) => semantic.kind === "damage"
&& semantic.sourceComponentIds.includes(damageComponentId));
const damageSemantic = semantics[damageSemanticIndex];
const damageEffect = damageSemantic?.kind === "damage"
? semanticEffect(row, damageSemantic, damageSemanticIndex)
: null;
const periodicDamage: AbilityEffect | null = damageEffect?.kind === "damage" && row.sourceSkillId === 491606
? {
kind: "dot",
coefficient: damageEffect.coefficient,
...(damageEffect.school ? { school: damageEffect.school } : {}),
...(damageEffect.basePoints === undefined ? {} : { basePoints: damageEffect.basePoints }),
...(damageEffect.dieSides === undefined ? {} : { dieSides: damageEffect.dieSides }),
...(damageEffect.pointsPerLevel === undefined ? {} : { pointsPerLevel: damageEffect.pointsPerLevel }),
...(damageEffect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: damageEffect.coefficientPerLevel }),
...(damageEffect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: damageEffect.bonusPowerCoefficient }),
...(damageEffect.sourceLevel === undefined ? {} : { sourceLevel: damageEffect.sourceLevel }),
ticks,
intervalMs: durationMs(intervalSeconds),
tag: "rom-knight-holy-light-domain",
}
: row.sourceSkillId === 490078
? {
kind: "dot",
coefficient: 0,
school: "holy",
basePoints: 0,
pointsPerLevel: 0,
sourceLevel: 1,
ticks,
intervalMs: durationMs(intervalSeconds),
tag: "rom-knight-war-prayer-seal",
}
: null;
if (!periodicDamage) return translated;
const output: AbilityEffect[] = [periodicDamage];
if (row.sourceSkillId === 490078 && damageEffect?.kind === "damage") output.unshift(damageEffect);
if (row.sourceSkillId === 491606) {
semantics.forEach((semantic, index) => {
if (!semantic.sourceComponentIds.includes(502051)) return;
output.push(semanticEffect(row, semantic, index));
});
}
return output;
}
if (row.sourceSkillId === 491133) {
const vulnerableDuration = row.source?.components?.find((component) => component.id === 501502)?.effectTime ?? 6;
return [...translated, targetStateAuraEffect(row, "vulnerable", "Vulnerable", durationMs(vulnerableDuration))];
}
if (row.sourceSkillId === 491136) {
const weakenedDuration = row.source?.components?.find((component) => component.id === 501503)?.effectTime ?? 6;
return [...translated, targetStateAuraEffect(row, "weakened", "Weakened", durationMs(weakenedDuration))];
}
const pairOverride = overrideRomKnightEffects(row, translated)
?? overrideRomWarriorEffects(row, translated)
?? overrideRomPriestKnightEffects(row, translated)
?? overrideRomScoutRogueEffects(row, translated)
?? overrideRomMageWarlockEffects(row, translated);
?? overrideRomMageWarlockEffects(row, translated)
?? overrideRomWarlockEffects(row, translated);
if (pairOverride) return pairOverride;
const outputStrength = (effect: AbilityEffect): number => (
"coefficient" in effect ? effect.coefficient : 0
@@ -541,6 +825,42 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
const holySealDot = (strongest: boolean): readonly AbilityEffect[] => oneOutput("dot", strongest).map((effect) => (
effect.kind === "dot" ? { ...effect, tag: "rom-holy-seal" } : effect
));
if (row.sourceSkillId === 494025) {
return oneOutput("damage", true).map((effect) => effect.kind === "damage"
? { ...effect, falloffPerJump: 0.7 }
: effect);
}
if (row.sourceSkillId === 499558) {
const shot = semantics.find((semantic): semantic is Extract<RomAbilitySemantic, { kind: "damage" }> => (
semantic.kind === "damage" && semantic.sourceComponentIds.includes(620194)
));
const healSource = semantics.find((semantic) => semantic.sourceComponentIds.includes(501888));
const output: AbilityEffect[] = [];
if (shot) {
const shotEffect = semanticEffect(row, shot, semantics.indexOf(shot));
if (shotEffect.kind === "damage") output.push(shotEffect);
}
if (healSource && (healSource.kind === "damage" || healSource.kind === "heal")) {
const healSemantic: RomAbilitySemantic = { ...healSource, kind: "heal" };
const healEffect = semanticEffect({ ...row, target: "friendly" }, healSemantic, semantics.indexOf(healSource));
if (healEffect.kind === "heal") output.push({
...healEffect,
recipient: "ability-target",
scope: "party",
maximumTargets: 1,
selection: "lowest-max-health",
});
}
return output;
}
if (row.classId === "rom-druid" && row.secondaryClassId === "rom-warden" && row.sourceSkillId === 494323) {
return translated.filter((effect) => effect.kind === "heal").slice(0, 2).map((effect) => effect.kind === "heal"
? { ...effect, falloffPerJump: 0.5 }
: effect);
}
// Source order is the Nature's Power enhanced HoT (504529), then the
// unconditional baseline HoT (620446). Runtime selects exactly one.
if (row.sourceSkillId === 494021) return translated.filter((effect) => effect.kind === "hot").slice(0, 2);
// These source rows carry alternate/duplicate payloads, while their player
// tooltips specify a single projectile hit. The first attack component is
// the primary shot; executing every component also applies unrelated Shot
@@ -576,6 +896,26 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] },
} : effect);
}
if (row.sourceSkillId === 498770) {
const heal = translated.find((effect) => effect.kind === "heal");
const buffs = translated.filter((effect) => effect.kind === "apply-aura");
if (!heal) return translated;
const buff = buffs[0];
const defenseModifiers = buffs.flatMap((effect) => effect.kind === "apply-aura"
? effect.aura.modifiers ?? []
: []);
// Local text says four heals; preserve that count as four separate channel
// payloads so the runtime emits one equal portion on each of four pulses.
return [
...Array.from({ length: 4 }, () => heal.kind === "heal"
? { ...heal, recipient: "ability-target" as const }
: heal),
...(buff?.kind === "apply-aura" ? [{
...buff,
aura: { ...buff.aura, modifiers: defenseModifiers },
}] : []),
];
}
if (row.sourceSkillId === 490506 || row.sourceSkillId === 490218) {
return translated.filter((effect) => effect.kind === "apply-aura");
}
@@ -615,7 +955,10 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 490438) {
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
// Neck Strike has a six-second NPC stun (component 500877); the separate
// 503863 component supplies the reduced three-second player duration.
const durationMsForTarget = durationMs(row.source?.components?.find((component) => component.id === 500877)?.effectTime ?? 6);
return [{ kind: "status", status: "stun", durationMs: durationMsForTarget, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 491172) {
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
@@ -673,12 +1016,30 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
if (row.description.includes("up to 3 targets") && row.description.includes("Inflicts")) return 6;
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) within (?:a )?range)\b/i.test(row.description)) return undefined;
const sourceRange = row.description.match(/\b(?:range|radius) of\s+(\d+(?:\.\d+)?)/i)?.[1];
if (!/\b(?:multiple targets|multiple enemies|all targets|all enemies|nearby targets|nearby party members|surrounding (?:enemies|targets)|party members within|all teammates within|friendly targets within|(?:enemies|targets) (?:within (?:a )?range|in a range|in range))\b/i.test(row.description)) return undefined;
const sourceRange = row.description.match(/\b(?:range|radius)\s+(?:of\s+)?(\d+(?:\.\d+)?)/i)?.[1];
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range > 1 ? row.range : 10));
}
function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"] | undefined {
if (sourceSkillId === 490311 || sourceSkillId === 490337) return { shape: "unit", requiresBehindTarget: true };
if (sourceSkillId === 490239) return { shape: "line", radius: 12 };
if (sourceSkillId === 498201 || sourceSkillId === 498202) return { shape: "circle", radius: 7.5 };
if (sourceSkillId === 490136) return { shape: "circle", radius: 10 };
if (sourceSkillId === 491513) return { shape: "circle", radius: 5, maximumTargets: 6 };
if (sourceSkillId === 492636) return { shape: "circle", radius: 20, maximumTargets: 5 };
if (sourceSkillId === 494021) return { shape: "circle", radius: 20, maximumTargets: 5 };
if (sourceSkillId === 494026) return { shape: "circle", radius: 12 };
if (sourceSkillId === 494027) return { shape: "circle", radius: 6, maximumTargets: 3 };
if (sourceSkillId === 494217) return { shape: "circle", radius: 5 };
if (sourceSkillId === 494025) return { shape: "chain", maximumTargets: 3, jumpRange: 6 };
if (sourceSkillId === 494323) return { shape: "circle", radius: 6, maximumTargets: 3 };
if (sourceSkillId === 499936) return { shape: "circle", radius: 10 };
if (sourceSkillId === 498641) return { shape: "circle", radius: 20, maximumTargets: 6 };
if (sourceSkillId === 493540) return { shape: "line" };
if (sourceSkillId === 498658) return { shape: "line", maximumTargets: 8 };
if (sourceSkillId === 498721) return { shape: "circle", radius: 10 };
if (sourceSkillId === 498534) return { shape: "cone", radius: 10, attackArcRadians: Math.PI / 2 };
if (sourceSkillId === 490400) return { shape: "line", maximumTargets: 8 };
if (sourceSkillId === 490457) return { shape: "chain", maximumTargets: 3, jumpRange: 6 };
if (sourceSkillId === 492920) return { shape: "circle", radius: 5, maximumTargets: 8 };
@@ -686,22 +1047,32 @@ function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"]
return undefined;
}
function sourceRange(sourceSkillId: number, fallback: number): number {
// RoM tooltip ranges are expressed in decimeters; Phoenix's source row is
// self-targeted, so use its described 120-unit path length for line casting.
return sourceSkillId === 490239 ? 12 : fallback;
}
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
const rankCosts = costs(row.costs);
const rankCosts = costsFor(row);
const target = abilityTarget({ ...row, target: row.target ?? parent.target });
return {
rank: row.rank,
spellId: row.sourceSkillId,
level: row.level,
description: parent.classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
cooldownMs: row.cooldownMs,
castMode: castModeFor(row),
castTimeMs: castTimeMsFor(row),
cooldownMs: cooldownMsFor(row),
gcdMs: 1_500,
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
costs: rankCosts,
effects: effects({
...row,
target: row.target ?? parent.target,
classId: parent.classId,
secondaryClassId: parent.secondaryClassId,
target,
source: row.source?.components?.length ? row.source : parent.source,
}),
};
@@ -713,7 +1084,8 @@ function ability(row: GeneratedSkill): AbilityDefinition {
const secondaryClassId = row.secondaryClassId && ROM_CLASS_IDS.includes(row.secondaryClassId as RomClassId)
? row.secondaryClassId as RomClassId
: undefined;
const abilityCosts = costs(row.costs);
const abilityCosts = costsFor(row);
const target = abilityTarget(row);
return {
id: row.id,
classId,
@@ -724,19 +1096,20 @@ function ability(row: GeneratedSkill): AbilityDefinition {
icon: row.icon,
sigil: row.name.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW",
description: classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
target: row.target,
range: { min: 0, max: row.range },
target,
range: { min: 0, max: sourceRange(row.sourceSkillId, row.range) },
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
? { radius: sourceTargeting(row.sourceSkillId)!.radius }
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
cooldownMs: row.cooldownMs,
castMode: castModeFor(row),
castTimeMs: castTimeMsFor(row),
cooldownMs: cooldownMsFor(row),
gcdMs: 1_500,
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
cost: abilityCosts[0] ?? { resource: "mana", amount: 0 },
costs: abilityCosts,
effects: effects(row),
effects: effects({ ...row, target, classId }),
passive: row.passive,
source: "runewaker",
sourceUnlockLevel: row.unlockLevel,
+279
View File
@@ -9,6 +9,12 @@ function named(name: string) {
return ability;
}
function sourceSkill(sourceSkillId: number) {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === sourceSkillId);
if (!ability) throw new Error(`Missing RuneWaker source skill: ${sourceSkillId}`);
return ability;
}
function isMeaningfulEffect(effect: AbilityEffect): boolean {
if (effect.kind === "scripted" || effect.kind === "unsupported") return false;
if (["damage", "heal", "dot", "hot", "shield"].includes(effect.kind)) {
@@ -53,11 +59,272 @@ describe("executable RuneWaker ability mappings", () => {
expect(named("Shout").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", control: { kind: "stun" } }),
]));
expect(named("Shout")).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "circle", radius: 10 },
cooldownMs: 40_000,
cost: { resource: "rage", amount: 15 },
});
expect(named("Cleanse").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", mode: "cleanse", relationship: "friendly" }),
]));
});
it("keeps Priest and Priest/Scout support casts on friendly targets", () => {
for (const name of ["Grace of Life", "Amplified Attack"]) {
const ability = named(name);
expect(ability.target, name).toBe("friendly");
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", recipient: "ability-target" }),
]));
}
const curingShot = named("Curing Shot");
expect(curingShot.target).toBe("friendly");
expect(curingShot.effects.map((effect) => effect.kind)).toEqual(expect.arrayContaining(["heal", "hot"]));
expect(curingShot.effects.some((effect) => effect.kind === "dot")).toBe(false);
});
it("fans Priest-secondary class buffs out to the described party recipients", () => {
for (const sourceId of [491335, 498649]) {
const auras = sourceSkill(sourceId).effects.filter((effect) => effect.kind === "apply-aura");
expect(auras.length, `source ${sourceId}`).toBeGreaterThan(0);
expect(auras.every((effect) => effect.scope === "party"), `source ${sourceId}`).toBe(true);
}
const salvation = sourceSkill(498719).effects.filter((effect) => effect.kind === "apply-aura");
expect(salvation.length).toBeGreaterThan(0);
expect(salvation.every((effect) => effect.scope === "party" && effect.excludeCaster)).toBe(true);
const advance = sourceSkill(498722).effects.filter((effect) => effect.kind === "apply-aura");
const partyBuffs = advance.filter((effect) => effect.scope === "party");
const casterPenalties = advance.filter((effect) => effect.scope !== "party");
expect(partyBuffs.some((effect) => effect.aura.modifiers?.some((modifier) => (
modifier.kind === "damage" && modifier.direction === "dealt" && modifier.value > 0
)))).toBe(true);
expect(casterPenalties.some((effect) => effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && ["armor", "magical-defense"].includes(modifier.stat) && modifier.value < 0
)))).toBe(true);
expect(casterPenalties.every((effect) => effect.recipient === "caster")).toBe(true);
});
it("keeps Scout/Knight Healing Shot damage and lowest-max-health party heal separate", () => {
const healingShot = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-scout-rom-knight-elite-20")!;
expect(healingShot).toMatchObject({ target: "hostile" });
expect(healingShot.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage" }),
expect.objectContaining({
kind: "heal", scope: "party", maximumTargets: 1, selection: "lowest-max-health",
}),
]));
});
it("requires Nature's Power for Druid/Scout Group Exorcism", () => {
const exorcism = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-druid-rom-scout-elite-30")!;
expect(exorcism).toMatchObject({ target: "self", targeting: { shape: "circle", radius: 5 } });
expect(exorcism.costs).toEqual(expect.arrayContaining([
expect.objectContaining({ resource: "nature-power", amount: 1 }),
]));
expect(exorcism.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", mode: "cleanse", relationship: "friendly" }),
]));
});
it("roots nearby enemies with Knight Shackles of Light", () => {
const shackles = named("Shackles of Light");
expect(shackles).toMatchObject({ target: "self", radius: 10 });
expect(shackles.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", control: { kind: "root" } }),
]));
});
it("maps Scout Cripple Joint to its 55% Joint Blow root proc", () => {
const crippleJoint = sourceSkill(491173);
expect(crippleJoint.passive).toBe(true);
expect(crippleJoint.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
utilityTags: ["rom-cripple-joint"],
procs: [{
id: "rom-cripple-joint-root",
triggers: ["hit", "crit"],
chance: 0.55,
action: { kind: "custom", id: "rom-cripple-joint" },
}],
}),
}),
]);
expect(sourceSkill(490420)).toMatchObject({ name: "Joint Blow", dbcSpellId: 490420 });
});
it("uses Neck Strike's sourced six-second NPC stun instead of an unsupported ten seconds", () => {
expect(sourceSkill(490438).effects).toEqual([{
kind: "status",
status: "stun",
durationMs: 6_000,
maxTargets: 1,
breakOnDamage: true,
}]);
});
it("gives Champion nearby and fan spells their source-shaped target areas", () => {
const bySpellId = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
expect(bySpellId(498481)).toMatchObject({ target: "self", radius: 10 });
expect(bySpellId(498537)).toMatchObject({ target: "self", radius: 7.5 });
expect(bySpellId(498721)).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "circle", radius: 10 },
});
expect(bySpellId(498534)).toMatchObject({
target: "self",
radius: 10,
targeting: { shape: "cone", radius: 10, attackArcRadians: Math.PI / 2 },
});
});
it("restores Rune Pulse Rage on the Champion after its hostile hit", () => {
const runePulse = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498529)!;
expect(runePulse.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({ modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "resource", resource: "rage", property: "regeneration" }),
]) }),
}),
]));
});
it("puts Scout/Mage Aggro Transfer mana regeneration on the selected ally", () => {
const aggroTransfer = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493255)!;
expect(aggroTransfer.target).toBe("friendly");
expect(aggroTransfer.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "ability-target",
aura: expect.objectContaining({ modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "resource", resource: "mana", property: "regeneration" }),
]) }),
}),
]));
});
it("preserves Druid elite healing and chain targeting semantics", () => {
const bySpellId = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
const arrows = bySpellId(494021);
expect(arrows).toMatchObject({ target: "friendly", targeting: { shape: "circle", radius: 20, maximumTargets: 5 } });
expect(arrows.effects.map((effect) => effect.kind)).toEqual(["hot", "hot"]);
const arrowHots = arrows.effects.filter((effect): effect is Extract<typeof effect, { kind: "hot" }> => effect.kind === "hot");
expect(arrowHots).toHaveLength(2);
expect(arrowHots[0]!.basePoints).toBeCloseTo(23.6);
expect(arrowHots[1]!.basePoints).toBeCloseTo(18.88);
const earthChain = bySpellId(494025);
expect(earthChain).toMatchObject({ targeting: { shape: "chain", maximumTargets: 3, jumpRange: 6 } });
expect(earthChain.effects.filter((effect) => effect.kind === "damage")).toEqual([
expect.objectContaining({ kind: "damage", falloffPerJump: 0.7 }),
expect.objectContaining({ kind: "damage", falloffPerJump: 0.7 }),
]);
const ripple = bySpellId(494323);
expect(ripple).toMatchObject({ targeting: { shape: "circle", radius: 6, maximumTargets: 3 } });
expect(ripple.effects.filter((effect) => effect.kind === "heal")).toEqual([
expect.objectContaining({ falloffPerJump: 0.5 }),
expect.objectContaining({ falloffPerJump: 0.5 }),
]);
expect(bySpellId(493540).targeting?.shape).toBe("line");
expect(bySpellId(494217)).toMatchObject({ target: "self", targeting: { shape: "circle", radius: 5 } });
expect(bySpellId(494217).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dispel", relationship: "friendly", mode: "cleanse" }),
]));
expect(bySpellId(494027)).toMatchObject({ target: "friendly", targeting: { shape: "circle", radius: 6, maximumTargets: 3 } });
expect(bySpellId(494026).targeting).toMatchObject({ shape: "circle", radius: 12 });
});
it("models Champion Overrule as control immunity instead of applying control", () => {
const overrule = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498570)!;
expect(overrule.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: 3_000,
controlImmunities: expect.arrayContaining(["stun", "root", "slow"]),
}),
}),
]));
expect(overrule.effects.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false);
});
it("models Free Will as a 10-second Root and Slow immunity for its friendly target", () => {
const freeWill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491351)!;
expect(freeWill).toMatchObject({ target: "friendly" });
expect(freeWill.effects).toEqual([expect.objectContaining({
kind: "apply-aura",
recipient: "ability-target",
aura: expect.objectContaining({
disposition: "buff",
durationMs: 10_000,
controlImmunities: ["root", "slow"],
}),
})]);
expect(freeWill.effects.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false);
});
it("applies Fire Ward to the Priest's party", () => {
expect(named("Fire Ward").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", scope: "party" }),
]));
});
it("fans out Warden Crystalline Protection and Acceleration within their source radius", () => {
const protection = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498201)!;
const acceleration = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498202)!;
for (const ability of [protection, acceleration]) {
expect(ability).toMatchObject({
target: "self",
radius: 7.5,
targeting: { shape: "circle", radius: 7.5 },
});
expect(ability.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", scope: "party" }),
]));
}
expect(protection.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
scope: "party",
aura: expect.objectContaining({ absorbs: expect.arrayContaining([expect.any(Object)]) }),
}),
]));
expect(acceleration.effects.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []))
.toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "attack-speed" }),
expect.objectContaining({ kind: "stat", stat: "spell-haste" }),
]));
});
it("applies Coat of Arms defenses to both the caster and selected ally", () => {
const coatOfArms = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494611)!;
expect(coatOfArms.target).toBe("friendly");
expect(coatOfArms.effects).toHaveLength(2);
expect(coatOfArms.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "armor", operation: "percent", value: 0.3 }),
expect.objectContaining({ kind: "stat", stat: "magical-defense", operation: "percent", value: 0.3 }),
]),
}),
}),
expect.objectContaining({ kind: "apply-aura", recipient: "ability-target" }),
]);
});
it("retains every executable part of multi-effect abilities", () => {
const kinds = named("Sneak Attack").effects.map((effect) => effect.kind);
expect(kinds).toEqual(["damage", "apply-aura", "dot"]);
@@ -170,6 +437,18 @@ describe("executable RuneWaker ability mappings", () => {
]));
});
it("marks Briar Shield as the source prerequisite for Thorn Arrow", () => {
expect(sourceSkill(493642).effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({ utilityTags: expect.arrayContaining(["rom-briar-shield"]) }),
}),
]));
expect(sourceSkill(494037).effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage" }),
]));
});
it("models the Knight's Holy Seal build-and-spend loop", () => {
expect(named("Holy Strike").effects).toEqual(expect.arrayContaining([
{ kind: "combo", amount: 1 },
+1
View File
@@ -94,6 +94,7 @@ export interface RomSourceComponent {
}
export interface RomGeneratedAbilityLike {
readonly classId?: string;
readonly sourceSkillId?: number;
readonly name: string;
readonly description: string;
+117
View File
@@ -0,0 +1,117 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition } from "./combatAuras";
export const ROM_CHAMPION_FORGE_STACK_AURA: AuraDefinition = {
id: "rom-champion-forge-stack",
name: "Forge",
description: "Each normal attack hit grants 2% physical attack for 8 seconds, stacking up to 5 times.",
disposition: "buff",
durationMs: 8_000,
maxStacks: 5,
stackBehavior: "add",
modifiers: [{
kind: "stat",
stat: "attack-power",
operation: "percent",
value: 0.02,
perStack: true,
}],
};
export const ROM_CHAMPION_RUNE_OVERLOAD_TAG = "rom-champion-rune-overload";
export const ROM_CHAMPION_RUNE_OVERLOAD_RAGE_PER_SECOND = 15;
const ROM_CHAMPION_FORGE_ACTIVE_AURA: AuraDefinition = {
id: "rom-champion-forge-active",
name: "Forge",
description: "For 5 minutes, normal attack hits build Forge stacks and restore Rage.",
disposition: "buff",
durationMs: 300_000,
utilityTags: ["rom-champion-forge-active"],
procs: [{
id: "rom-champion-forge-normal-hit",
triggers: ["hit", "crit"],
attackKinds: ["melee"],
chance: 1,
action: { kind: "custom", id: "rom-champion-forge-hit" },
}],
};
function scaleDamage(effect: Extract<AbilityEffect, { kind: "damage" }>, factor: number): AbilityEffect {
return {
...effect,
coefficient: effect.coefficient * factor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
};
}
/** Apply source-backed Champion cast branches that need runtime state. */
export function romChampionCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
targetHealthPercent: number,
casterMaxHealth: number,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker" || ability.classId !== "rom-champion") return effects;
switch (ability.dbcSpellId) {
case 498584: // Champion/Warrior Determination Rune is a 30%-of-max-HP caster shield.
if (ability.secondaryClassId !== "rom-warrior") return effects;
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
? {
...effect,
aura: {
...effect.aura,
absorbs: effect.aura.absorbs.map((absorb) => ({
...absorb,
amount: Math.max(1, Math.round(Math.max(0, casterMaxHealth) * absorb.amount / 100)),
})),
},
}
: effect);
case 498523: // Rank 1 Forge's guaranteed on-normal-attack trigger is child skill 498524.
return [{
kind: "apply-aura",
aura: ROM_CHAMPION_FORGE_ACTIVE_AURA,
recipient: "caster",
sourceEffectType: 0,
sourceAuraType: 0,
miscValue: 0,
triggerSpellId: 498524,
}];
case 498572: // Rune Overload ends when its Rage drain reaches zero.
return effects.map((effect) => effect.kind === "apply-aura"
? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_CHAMPION_RUNE_OVERLOAD_TAG],
},
}
: effect);
case 498531: // Fearless Blow's +30% hit applies only below 30% target health.
return effects.flatMap((effect) => {
if (effect.kind === "apply-aura" && effect.aura.id.includes("-621179-")) return [];
if (effect.kind === "apply-aura" && effect.aura.id.includes("-621183-")) {
return [{ ...effect, recipient: "caster" as const }];
}
if (effect.kind === "damage" && targetHealthPercent < 0.3) return [scaleDamage(effect, 1.3)];
return [effect];
});
case 498535: // Chain Drive's proc chance and damage bonus are absent from the source payload.
return effects.filter((effect) => effect.kind !== "cooldown-reset");
case 498599: // Dark Energy Strike's cooldown proc chance is absent from the source payload.
if (ability.secondaryClassId !== "rom-warlock") return effects;
return effects.filter((effect) => effect.kind !== "cooldown-reset");
case 498651: // Heart Collection Rune explicitly resets Rune Siphon.
if (ability.secondaryClassId !== "rom-warlock") return effects;
return effects.map((effect) => effect.kind === "cooldown-reset"
? { ...effect, abilityName: "Rune Siphon" }
: effect);
default:
return effects;
}
}
+194
View File
@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
import { romChampionCastEffects } from "./romChampionOverrides";
const empty: RomCastMechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 2_000,
targetHealthPercent: 1,
};
function sourceAbility(spellId: number) {
const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId);
if (!ability) throw new Error(`Missing RoM source ability ${spellId}`);
return ability;
}
function castEffects(spellId: number, context: Partial<RomCastMechanics> = {}) {
return romEffectsForCast(sourceAbility(spellId), { ...empty, ...context });
}
describe("RoM Champion source-specific behavior", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("ends Rune Overload when its Rage drain reaches zero", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 40 });
useCombatStore.setState((state) => ({
resource: 30,
resourcePools: { ...state.resourcePools, rage: 30 },
}));
expect(useCombatStore.getState().castAbility("rom-champion-primary-17", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("rom-champion-rune-overload")
))).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(15);
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("rom-champion-rune-overload")
))).toBe(false);
});
it("applies Fearless Blow's bonus only below 30% and gives its Rage rider to the caster", () => {
const atThreshold = castEffects(498531, { targetHealthPercent: 0.3 });
const belowThreshold = castEffects(498531, { targetHealthPercent: 0.299 });
const baseDamage = atThreshold.find((effect) => effect.kind === "damage");
const boostedDamage = belowThreshold.find((effect) => effect.kind === "damage");
expect(baseDamage?.kind).toBe("damage");
expect(boostedDamage?.kind).toBe("damage");
if (baseDamage?.kind === "damage" && boostedDamage?.kind === "damage") {
expect(boostedDamage.coefficient).toBeCloseTo(baseDamage.coefficient * 1.3);
expect(boostedDamage.basePoints ?? 0).toBeCloseTo((baseDamage.basePoints ?? 0) * 1.3);
}
expect(atThreshold.some((effect) => effect.kind === "apply-aura" && effect.aura.id.includes("621179"))).toBe(false);
const rage = belowThreshold.find((effect) => effect.kind === "apply-aura" && effect.aura.id.includes("621183"));
expect(rage?.kind).toBe("apply-aura");
if (rage?.kind === "apply-aura") expect(rage.recipient).toBe("caster");
});
it("adds Rune Draw's source-described pull and moves the selected target toward the Champion", () => {
const runeDraw = sourceAbility(498564);
expect(runeDraw.effects.filter((effect) => effect.kind === "pull")).toHaveLength(1);
expect(runeDraw.effects.some((effect) => effect.kind === "interrupt")).toBe(true);
expect(runeDraw.effects.some((effect) => effect.kind === "taunt")).toBe(true);
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 20 });
useCombatStore.getState().registerMob("champion-rune-draw-target", {
name: "Rune Draw Target",
maxHealth: 100_000,
});
setMobPosition("champion-rune-draw-target", [8, 0, 0]);
useCombatStore.getState().selectMob("champion-rune-draw-target");
expect(useCombatStore.getState().castAbility("rom-champion-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
expect(getMobPosition("champion-rune-draw-target")?.[0]).toBeCloseTo(1.5);
});
it("rejects Indomitable Spirit at 65% health and permits it below the threshold", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion",
secondaryClassId: "rom-warlock",
raceId: "rom-dwarf",
level: 70,
});
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.setState((state) => ({
health: Math.ceil(maxHealth * 0.65),
resource: 20,
resourcePools: { ...state.resourcePools, rage: 20 },
}));
expect(useCombatStore.getState().castAbility("rom-champion-rom-warlock-elite-35", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState((state) => ({
health: Math.floor(maxHealth * 0.65) - 1,
resource: 20,
resourcePools: { ...state.resourcePools, rage: 20 },
}));
expect(useCombatStore.getState().castAbility("rom-champion-rom-warlock-elite-35", undefined, 1_000))
.toMatchObject({ ok: true });
});
it("activates Forge for five minutes and grants one stack and Rage on a normal hit", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 20 });
useCombatStore.getState().registerMob("champion-forge-target", { name: "Forge Target", maxHealth: 100_000 });
setMobPosition("champion-forge-target", [3, 0, 0]);
useCombatStore.getState().selectMob("champion-forge-target");
const forge = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498523)!;
expect(useCombatStore.getState().castAbility(forge.id, undefined, 1_000)).toMatchObject({ ok: true });
const activeForge = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-champion-forge-active");
expect(activeForge?.expiresAt).toBe(301_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-champion-forge-stack")).toBe(false);
useCombatStore.setState({ autoAttackTargetId: "champion-forge-target", nextAutoAttackAt: 1_000 });
useCombatStore.getState().tick(0.1, 1_000);
const forgeStack = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-champion-forge-stack");
expect(forgeStack?.stacks).toBe(1);
expect(forgeStack?.expiresAt).toBe(9_000);
expect(useCombatStore.getState().resourcePools.rage).toBeGreaterThanOrEqual(10);
});
it("scales Determination Rune's source max-health shield and leaves unrelated casts unchanged", () => {
const determinationRune = sourceAbility(498584);
const sourceShield = determinationRune.effects.find((effect) => effect.kind === "apply-aura"
&& effect.aura.absorbs?.length);
expect(sourceShield?.kind).toBe("apply-aura");
if (sourceShield?.kind !== "apply-aura") throw new Error("Missing Determination Rune shield effect");
expect(sourceShield.aura.absorbs?.[0]?.amount).toBe(30);
expect(determinationRune.ranks?.[0]?.effects.find((effect) => effect.kind === "apply-aura"
&& effect.aura.absorbs?.length)).toMatchObject({
kind: "apply-aura",
aura: { absorbs: [expect.objectContaining({ amount: 30 })] },
});
const twoThousandHp = castEffects(498584, { casterMaxHealth: 2_000 });
const fiveThousandHp = castEffects(498584, { casterMaxHealth: 5_000 });
expect(twoThousandHp.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length))
.toMatchObject({ kind: "apply-aura", aura: { absorbs: [expect.objectContaining({ amount: 600 })] } });
expect(fiveThousandHp.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length))
.toMatchObject({ kind: "apply-aura", aura: { absorbs: [expect.objectContaining({ amount: 1_500 })] } });
const otherClass = { ...determinationRune, classId: "rom-mage" as const };
expect(romChampionCastEffects(otherClass, determinationRune.effects, 1, 2_000))
.toBe(determinationRune.effects);
const otherPair = { ...determinationRune, secondaryClassId: "rom-mage" as const };
expect(romChampionCastEffects(otherPair, determinationRune.effects, 1, 2_000))
.toBe(determinationRune.effects);
const otherChampionAbility = sourceAbility(498568);
expect(romChampionCastEffects(otherChampionAbility, otherChampionAbility.effects, 1, 2_000))
.toBe(otherChampionAbility.effects);
});
it("casts Determination Rune with an absorb equal to 30% of the current max HP", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion",
secondaryClassId: "rom-warrior",
raceId: "rom-dwarf",
level: 30,
});
const maxHealth = useCombatStore.getState().maxHealth;
expect(useCombatStore.getState().castAbility("rom-champion-rom-warrior-elite-30", undefined, 1_000))
.toMatchObject({ ok: true });
const shield = useCombatStore.getState().auras.find((aura) => aura.definition.absorbs?.length);
expect(shield?.definition.absorbs?.[0]?.amount).toBe(Math.round(maxHealth * 0.3));
});
it("keeps Champion cooldown resets source-scoped and removes undefined proc chances", () => {
const heartRuneResets = castEffects(498651)
.filter((effect) => effect.kind === "cooldown-reset");
expect(heartRuneResets.length).toBeGreaterThan(0);
expect(heartRuneResets.every((effect) => effect.kind === "cooldown-reset"
&& effect.abilityName === "Rune Siphon")).toBe(true);
expect(castEffects(498599).some((effect) => effect.kind === "damage")).toBe(true);
expect(castEffects(498599).some((effect) => effect.kind === "cooldown-reset")).toBe(false);
expect(castEffects(498535).some((effect) => effect.kind === "cooldown-reset")).toBe(false);
});
});
+115 -1
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import { abilityById, type AbilityEffect } from "./abilityCatalog";
import { romEffectsForCast, romTargetBleedCount, type RomCastMechanics } from "./romCombatMechanics";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorThreatMultiplier, type RomCastMechanics } from "./romCombatMechanics";
const empty: RomCastMechanics = {
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 2_000,
targetHealthPercent: 1,
};
function cast(id: string, overrides: Partial<RomCastMechanics> = {}): readonly AbilityEffect[] {
@@ -17,6 +19,28 @@ function kinds(effects: readonly AbilityEffect[]): readonly string[] {
}
describe("RoM primary class conditional combat effects", () => {
it("recognizes source-required shields and ranged weapons across primary and elite rows", () => {
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
for (const spellId of [491333, 491484]) expect(romAbilityNeedsShield(sourceAbility(spellId))).toBe(true);
for (const spellId of [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629]) {
expect(romAbilityNeedsRangedWeapon(sourceAbility(spellId))).toBe(true);
}
});
it("selects the Nature's Power Healing Arrows HoT and consumes one point only when available", () => {
const baseline = cast("rom-druid-rom-scout-elite-25", { naturePower: 0 });
const empowered = cast("rom-druid-rom-scout-elite-25", { naturePower: 1 });
expect(baseline).toHaveLength(1);
expect(baseline[0]?.kind).toBe("hot");
if (baseline[0]?.kind === "hot") expect(baseline[0].basePoints).toBeCloseTo(18.88);
expect(empowered).toHaveLength(2);
expect(empowered[0]?.kind).toBe("hot");
if (empowered[0]?.kind === "hot") expect(empowered[0].basePoints).toBeCloseTo(23.6);
expect(empowered[1]).toEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
expect(cast("rom-druid-rom-scout-elite-25", { naturePower: 0.5 }).filter((effect) => effect.kind === "resource-drain"))
.toEqual([]);
});
it("counts only active Rogue bleeds applied by the caster to that target", () => {
const timed = [
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "rogue", targetId: "mob", remainingTicks: 3 },
@@ -73,6 +97,83 @@ describe("RoM primary class conditional combat effects", () => {
expect(kinds(cast("rom-rogue-primary-9", { mainHandDagger: true }))).toEqual(["damage", "apply-aura", "dot"]);
});
it("requires an active Vulnerable target for Keen Attack", () => {
const keenAttackId = "rom-warrior-rom-rogue-elite-35";
expect(cast(keenAttackId)).toEqual([]);
expect(kinds(cast(keenAttackId, { targetTags: ["rom-vulnerable"] }))).toContain("damage");
});
it("gates Open Flank on Vulnerable and Opportunity on the resulting Weakened state", () => {
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
expect(romAbilityRequiredTargetStateTag(sourceAbility(491136))).toBe("rom-vulnerable");
expect(romAbilityRequiredTargetStateTag(sourceAbility(492617))).toBe("rom-weakened");
expect(cast("rom-warrior-primary-2")).toEqual([]);
const openFlank = cast("rom-warrior-primary-2", { targetTags: ["rom-vulnerable"] });
expect(openFlank.some((effect) => effect.kind === "apply-aura"
&& effect.aura.utilityTags?.includes("rom-weakened"))).toBe(true);
expect(cast("rom-warrior-rom-priest-elite-35")).toEqual([]);
expect(kinds(cast("rom-warrior-rom-priest-elite-35", { targetTags: ["rom-weakened"] })))
.toContain("damage");
});
it("only applies Tactical Attack's Slash bleed when a Slash bleed is active", () => {
expect(kinds(cast("rom-warrior-primary-3"))).toEqual(["damage"]);
expect(kinds(cast("rom-warrior-primary-3", { slashBleed: true }))).toEqual(["damage", "dot"]);
const effects = [
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "target", remainingTicks: 1 },
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "other", remainingTicks: 3 },
{ kind: "dot", sourceAbilityId: "rom-warrior-general-0", sourceActorId: "warrior", targetId: "target", remainingTicks: 0 },
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "warrior", targetId: "target", remainingTicks: 3 },
];
expect(romTargetHasSlashBleed(effects, "target")).toBe(true);
expect(romTargetHasSlashBleed(effects.slice(1), "target")).toBe(false);
});
it("applies explicit Warrior passive Rage and cooldown reductions to their source skills", () => {
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
expect(romAbilityNeedsTwoHand(sourceAbility(491142))).toBe(true);
expect(romWarriorAbilityAdjustment(sourceAbility(490121), [491458]))
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 20_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [493248]))
.toEqual({ rageCostReduction: 20, cooldownReductionMs: 10_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [499491]))
.toEqual({ rageCostReduction: 25, cooldownReductionMs: 15_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [494377]))
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 10_000 });
expect(romWarriorAbilityAdjustment(sourceAbility(490121), [])).toEqual({
rageCostReduction: 0, cooldownReductionMs: 0,
});
expect(romWarriorAbilityAdjustment(sourceAbility(490053), [499827]))
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 0 });
});
it("applies Warrior/Warlock's physical-damage passive and Heroic Surprise Attack's on-hit payload", () => {
const sourceAbility = (spellId: number) => ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === spellId)!;
const rageBlow = sourceAbility(498706);
const baseDamage = romEffectsForCast(rageBlow, empty).find((effect) => effect.kind === "damage");
const trainedDamage = romEffectsForCast(rageBlow, {
...empty, learnedPassiveSpellIds: [499806],
}).find((effect) => effect.kind === "damage");
expect(baseDamage?.kind).toBe("damage");
expect(trainedDamage?.kind).toBe("damage");
if (baseDamage?.kind === "damage" && trainedDamage?.kind === "damage") {
expect(baseDamage.school).toBe("physical");
expect(trainedDamage.coefficient).toBeCloseTo(baseDamage.coefficient * 1.2);
}
const surpriseAttack = sourceAbility(490129);
const heroicEffects = romEffectsForCast(surpriseAttack, {
...empty, learnedPassiveSpellIds: [492661],
});
const heroicHit = heroicEffects.find((effect) => effect.kind === "damage");
expect(heroicHit?.kind).toBe("damage");
if (heroicHit?.kind === "damage") {
expect(heroicHit.onDamageResource).toEqual({ resource: "rage", amount: 25 });
}
expect(romWarriorThreatMultiplier(surpriseAttack, [492661])).toBe(5);
expect(romWarriorThreatMultiplier(surpriseAttack, [])).toBe(1);
});
it("keeps auxiliary source rows from adding extra hits, heals, or false control", () => {
expect(kinds(cast("rom-priest-general-2"))).toEqual(["hot"]);
expect(kinds(cast("rom-mage-primary-1"))).toEqual(["damage"]);
@@ -88,6 +189,19 @@ describe("RoM primary class conditional combat effects", () => {
]);
});
it("executes Moon Cleave's three source-authored attacks", () => {
const effects = cast("rom-warrior-primary-8");
expect(effects.filter((effect) => effect.kind === "damage")).toHaveLength(3);
expect(effects.filter((effect) => effect.kind === "scripted")).toHaveLength(1);
});
it("only adds The Final Battle's second hit below 30% target health", () => {
const atThreshold = cast("rom-warrior-rom-scout-elite-50", { targetHealthPercent: 0.3 });
const belowThreshold = cast("rom-warrior-rom-scout-elite-50", { targetHealthPercent: 0.29 });
expect(atThreshold.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(belowThreshold.filter((effect) => effect.kind === "damage")).toHaveLength(2);
});
it("holds Shield of Discipline for 30 seconds and reflects only on being hit", () => {
const effects = cast("rom-knight-general-7");
expect(kinds(effects)).toEqual(["apply-aura", "apply-aura", "apply-aura"]);
+193 -17
View File
@@ -1,7 +1,13 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
import { romPriestWarlockCastEffects } from "./romPriestWarlockOverrides";
import { romMageWarlockCastEffects } from "./romMageWarlockOverrides";
import { romScoutRogueCastEffects } from "./romScoutRogueOverrides";
import { romDruidCastEffects } from "./romDruidOverrides";
import { romChampionCastEffects } from "./romChampionOverrides";
import { romWardenCastEffects } from "./romWardenOverrides";
import { romWarlockCastEffects } from "./romWarlockOverrides";
import { romKnightCastEffects } from "./romKnightOverrides";
const ROGUE_BLEED_ABILITIES = new Set([
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
@@ -16,8 +22,19 @@ export interface RomCastMechanics {
readonly charged: boolean;
readonly mainHandDagger: boolean;
readonly casterMaxHealth: number;
readonly targetHealthPercent: number;
readonly friendlyTargetHealthPercent?: number;
readonly naturePower?: number;
readonly passiveTags?: readonly string[];
readonly learnedPassiveSpellIds?: readonly number[];
readonly eruptionProcRoll?: number;
readonly targetTags?: readonly string[];
readonly slashBleed?: boolean;
}
export interface RomWarriorAbilityAdjustment {
readonly rageCostReduction: number;
readonly cooldownReductionMs: number;
}
/** Source periodic values for self-sacrifice toggle skills are percentages of HP. */
@@ -34,7 +51,74 @@ export function romSingleTargetControl(ability: AbilityDefinition | null): boole
export function romAbilityNeedsShield(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& [490175, 490186, 490192, 490468].includes(ability.dbcSpellId);
&& [490175, 490186, 490192, 490468, 491333, 491484].includes(ability.dbcSpellId);
}
export function romAbilityNeedsTwoHand(ability: AbilityDefinition): boolean {
return ability.source === "runewaker" && [490121, 491142].includes(ability.dbcSpellId);
}
export function romAbilityNeedsRangedWeapon(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& [490438, 491452, 491616, 494021, 494037, 494039, 494040, 494042, 494629].includes(ability.dbcSpellId);
}
export function romAbilityRequiredTargetStateTag(ability: AbilityDefinition): string | null {
if (ability.source !== "runewaker") return null;
if (ability.dbcSpellId === 492617) return "rom-weakened";
if (ability.dbcSpellId === 491136 || ability.dbcSpellId === 492619) return "rom-vulnerable";
return null;
}
/** Source-described Warrior elite passives that reduce a skill's Rage cost and cooldown. */
export function romWarriorAbilityAdjustment(
ability: AbilityDefinition,
learnedPassiveSpellIds: readonly number[],
): RomWarriorAbilityAdjustment {
if (ability.source !== "runewaker" || ability.classId !== "rom-warrior") {
return { rageCostReduction: 0, cooldownReductionMs: 0 };
}
const passiveIds = new Set(learnedPassiveSpellIds);
const rules: Readonly<Record<number, {
readonly abilitySpellId: number;
readonly rageCostReduction: number;
readonly cooldownReductionMs: number;
}>> = {
491458: { abilitySpellId: 490121, rageCostReduction: 10, cooldownReductionMs: 20_000 }, // Moon Cleave Perfection
493248: { abilitySpellId: 490133, rageCostReduction: 20, cooldownReductionMs: 10_000 }, // Exhaustion
499491: { abilitySpellId: 490133, rageCostReduction: 25, cooldownReductionMs: 15_000 }, // Whirlwind Battle Technique
494377: { abilitySpellId: 490133, rageCostReduction: 10, cooldownReductionMs: 10_000 }, // Recovery of Life
499827: { abilitySpellId: 490053, rageCostReduction: 10, cooldownReductionMs: 0 }, // Speed Slash
};
const applicable = Object.entries(rules)
.filter(([passiveSpellId, rule]) => passiveIds.has(Number(passiveSpellId))
&& ability.dbcSpellId === rule.abilitySpellId)
.map(([, rule]) => rule);
return {
rageCostReduction: applicable.reduce((sum, rule) => sum + rule.rageCostReduction, 0),
cooldownReductionMs: applicable.reduce((sum, rule) => sum + rule.cooldownReductionMs, 0),
};
}
/** Heroic Surprise Attack makes Surprise Attack generate five times its usual threat. */
export function romWarriorThreatMultiplier(
ability: AbilityDefinition,
learnedPassiveSpellIds: readonly number[],
): number {
return ability.source === "runewaker"
&& ability.classId === "rom-warrior"
&& ability.dbcSpellId === 490129
&& learnedPassiveSpellIds.includes(492661)
? 5
: 1;
}
/** Some Warrior source components mark physical weapon attacks as an untyped spell. */
export function romWarriorPhysicalWeaponTooltip(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& ability.classId === "rom-warrior"
&& /\b(?:physical DPS|main hand weapon DPS)\b/i.test(ability.description)
&& !/\b(?:Dark|Wind|Fire|Water|Light|Earth) Damage\b/i.test(ability.description);
}
export function romTargetBleedCount(
@@ -51,6 +135,21 @@ export function romTargetBleedCount(
.map((effect) => effect.sourceAbilityId)).size;
}
export function romTargetHasSlashBleed(
effects: readonly {
readonly kind: string;
readonly sourceAbilityId: string;
readonly targetId: string | null;
readonly remainingTicks: number;
}[],
targetId: string | null,
): boolean {
return Boolean(targetId && effects.some((effect) => effect.kind === "dot"
&& effect.remainingTicks > 0
&& effect.targetId === targetId
&& effect.sourceAbilityId === "rom-warrior-general-0"));
}
function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
return {
@@ -67,18 +166,95 @@ function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return ability.effects;
const priestKnightEffects = romPriestKnightCastEffects(
ability, ability.effects, context.passiveTags ?? [], context.casterMaxHealth, context.targetTags ?? [],
ability,
ability.effects,
context.passiveTags ?? [],
context.casterMaxHealth,
context.targetTags ?? [],
context.friendlyTargetHealthPercent ?? 1,
context.learnedPassiveSpellIds ?? [],
);
const mageWarlockEffects = romMageWarlockCastEffects(ability, priestKnightEffects, new Set(context.passiveTags ?? []));
const effects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []);
const damage = effects.filter((effect) => effect.kind === "damage");
const priestWarlockEffects = romPriestWarlockCastEffects(
ability,
priestKnightEffects,
context.friendlyTargetHealthPercent ?? 1,
);
const knightEffects = romKnightCastEffects(ability, priestWarlockEffects, context.passiveTags ?? []);
const mageWarlockEffects = romMageWarlockCastEffects(
ability,
knightEffects,
new Set(context.passiveTags ?? []),
context.eruptionProcRoll,
);
const scoutRogueEffects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []);
const druidEffects = romDruidCastEffects(ability, scoutRogueEffects, context.naturePower ?? 0, context.passiveTags ?? []);
const championEffects = romChampionCastEffects(
ability, druidEffects, context.targetHealthPercent, context.casterMaxHealth,
);
const wardenEffects = romWardenCastEffects(
ability, championEffects, context.naturePower ?? 0, context.passiveTags ?? [],
);
const effects = romWarlockCastEffects(
ability,
wardenEffects,
context.targetTags ?? [],
context.learnedPassiveSpellIds ?? [],
);
const warriorPassives = new Set(context.learnedPassiveSpellIds ?? []);
const sourceSchoolAdjustedEffects = romWarriorPhysicalWeaponTooltip(ability)
? effects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.school === "arcane"
? { ...effect, school: "physical" as const }
: effect)
: effects;
const warriorAdjustedEffects = ability.classId === "rom-warrior" && warriorPassives.has(499806)
? sourceSchoolAdjustedEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot")
&& (effect.school === undefined || effect.school === "physical")
? scaleOutput(effect, 1.2)
: effect)
: sourceSchoolAdjustedEffects;
const requiredTargetStateTag = romAbilityRequiredTargetStateTag(ability);
if (requiredTargetStateTag && !context.targetTags?.includes(requiredTargetStateTag)) return [];
const damage = warriorAdjustedEffects.filter((effect) => effect.kind === "damage");
const seals = Math.max(0, Math.min(4, context.sealStacks));
switch (ability.dbcSpellId) {
case 491142: // Tactical Attack's copied Slash bleed only applies when Slash is already bleeding the target.
return [
...damage,
...(context.slashBleed ? warriorAdjustedEffects.filter((effect) => effect.kind === "dot") : []),
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage" && effect.kind !== "dot"
&& !(effect.kind === "scripted" && effect.label.startsWith("RoM conditional-use:"))),
];
case 494021: { // Healing Arrows chooses the Nature's Power HoT when a point is available.
const hots = warriorAdjustedEffects.filter((effect) => effect.kind === "hot");
const empowered = (context.naturePower ?? 0) >= 1;
const selectedHot = empowered ? hots[0] : hots[1] ?? hots[0];
return [
...(selectedHot ? [selectedHot] : []),
...(empowered ? [{ kind: "resource-drain" as const, amount: 1, burn: false, resource: "nature-power" as const }] : []),
];
}
case 493247: // The Final Battle only adds its second hit below 30% target HP.
return [
...damage.slice(0, context.targetHealthPercent < 0.3 ? 2 : 1),
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage"),
];
case 490121: // Moon Cleave has three main-hand attack pulses.
return [...damage, ...damage, ...damage,
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage")];
case 490129: { // Heroic Surprise Attack restores Rage only when its hit lands.
if (!warriorPassives.has(492661)) return warriorAdjustedEffects;
let attachedRage = false;
return warriorAdjustedEffects.map((effect) => {
if (effect.kind !== "damage" || attachedRage) return effect;
attachedRage = true;
return { ...effect, onDamageResource: { resource: "rage", amount: 25 } };
});
}
case 492625: // Infection's poison rider needs Poisonous active.
return context.passiveTags?.includes("rom-poisonous")
? effects : effects.filter((effect) => effect.kind !== "dot");
? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
case 491156: // Electrostatic Charge's source shield is a percent of max HP.
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
return warriorAdjustedEffects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
? {
...effect,
aura: {
@@ -93,25 +269,25 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
case 490150: { // Charge adds two stacks.
const gained = ability.dbcSpellId === 490148 ? 1 : 2;
const nextStacks = Math.min(4, seals + gained);
return effects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect);
return warriorAdjustedEffects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect);
}
case 490313: // Wound Attack: one hit plus one rider per self-applied bleed.
return damage.slice(0, 1 + Math.min(2, context.bleedCount));
case 490323: // Low Blow only creates Grievous Wound on a bleeding target.
return context.bleedCount > 0 ? effects : effects.filter((effect) => effect.kind !== "dot");
return context.bleedCount > 0 ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
case 491170: // Electric Explosion has mutually exclusive normal/Charged hits.
return context.charged ? damage.slice(0, 1) : damage.slice(-1);
case 490151: // Punishment spends Holy Seals and scales its one payoff.
return seals > 0 ? effects.map((effect) => scaleOutput(effect, seals / 4)) : [];
return seals > 0 ? warriorAdjustedEffects.map((effect) => scaleOutput(effect, seals / 4)) : [];
case 490153: { // Threaten: target debuffs and extra hit require Holy Seals.
const base = damage[0];
const threat = effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
const debuffs = seals > 0 ? effects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : [];
const threat = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
const debuffs = seals > 0 ? warriorAdjustedEffects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : [];
const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null;
return [...(base ? [base] : []), ...(threat ? [threat] : []), ...debuffs, ...(bonus ? [bonus] : [])];
}
case 490154: { // Shock: one hit, one stun, plus a seal-dependent rider.
const stun = effects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun");
const stun = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun");
const scaledStun = stun && stun.kind === "apply-aura"
? { ...stun, aura: { ...stun.aura, durationMs: 1_000 * (1 + seals) } }
: null;
@@ -119,7 +295,7 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
return [...(damage[0] ? [damage[0]] : []), ...(scaledStun ? [scaledStun] : []), ...(bonus ? [bonus] : [])];
}
case 490186: { // Shield Bash shields its caster only when consuming seals.
const absorb = effects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length);
const absorb = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length);
const shield = seals > 0 && absorb?.kind === "apply-aura" ? {
...absorb,
recipient: "caster" as const,
@@ -133,10 +309,10 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
return [...(damage[0] ? [damage[0]] : []), ...(shield ? [shield, { kind: "combo" as const, amount: -5 }] : [])];
}
case 490306: // Shadowstab's bleed needs a main-hand dagger.
return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot");
return context.mainHandDagger ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
case 490337: // Sneak Attack always stuns, but its bleed needs a dagger.
return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot");
return context.mainHandDagger ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
default:
return effects;
return warriorAdjustedEffects;
}
}
+803
View File
@@ -11,6 +11,7 @@ import { useCombatStore } from "./combatStore";
import { companionActorFromMember, companionActorWithMemberRuntime } from "./combatActorAuthority";
import { targetingRulesForAbility } from "./abilityRules";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { applyAura } from "./combatAuras";
describe("RuneWaker combat runtime", () => {
beforeEach(() => {
@@ -178,6 +179,43 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().mobs["lasso-b"]!.statuses.some((status) => status.kind === "root")).toBe(false);
});
it("roots only Joint Blow targets when Cripple Joint procs", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-human", level: 80 });
const passive = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-state-cripple-joint");
expect(passive?.definition.procs?.[0]?.chance).toBe(0.55);
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.instanceId !== passive?.instanceId
? aura
: {
...aura,
definition: {
...aura.definition,
procs: aura.definition.procs?.map((proc) => ({ ...proc, chance: 1 })),
},
}),
}));
for (const id of ["cripple-other-attack", "cripple-joint-target"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [3, 0, 0]);
}
useCombatStore.getState().selectMob("cripple-other-attack");
expect(useCombatStore.getState().castAbility("rom-scout-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["cripple-other-attack"]!.statuses.some((status) => status.kind === "root"))
.toBe(false);
useCombatStore.getState().selectMob("cripple-joint-target");
expect(useCombatStore.getState().castAbility("rom-scout-general-3", undefined, 3_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["cripple-joint-target"]!.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "root",
sourceAbilityId: "rom-scout-general-3",
endsAt: 8_000,
breakOnDamage: true,
}),
]));
});
it("moves Rogue Shadow Step behind its target", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 50 });
useCombatStore.getState().registerMob("step-dummy", { name: "Step Dummy", maxHealth: 100_000 });
@@ -233,6 +271,387 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().mobs["coefficient-dummy"]!.health).toBeLessThan(1_000);
});
it("requires Probing Attack's Vulnerable state before Keen Attack", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("keen-vulnerable-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("keen-vulnerable-target", [3, 0, 0]);
useCombatStore.getState().selectMob("keen-vulnerable-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-rom-rogue-elite-35", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "keen-vulnerable-target"
&& aura.definition.utilityTags?.includes("rom-vulnerable"))).toBe(true);
const result = useCombatStore.getState().castAbility("rom-warrior-rom-rogue-elite-35", undefined, 2_600);
expect(result).toMatchObject({ ok: true, affectedMobIds: ["keen-vulnerable-target"] });
expect(result.damage).toBeGreaterThan(0);
});
it("requires Vulnerable for Open Flank and then permits Opportunity against its Weakened target", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-priest", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("opportunity-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("opportunity-target", [3, 0, 0]);
useCombatStore.getState().selectMob("opportunity-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100, mana: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-35", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warrior-primary-2", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility("rom-warrior-primary-2", undefined, 2_600).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === "opportunity-target"
&& aura.definition.utilityTags?.includes("rom-weakened"))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-35", undefined, 4_200))
.toMatchObject({ ok: true, affectedMobIds: ["opportunity-target"] });
});
it("applies Tactical Attack's conditional Slash bleed only after Slash lands", () => {
const weapon = {
instanceId: "warrior-two-hand-slash-test",
itemId: 1,
itemKey: "warrior-two-hand-slash-test",
name: "Test Two-Hander",
quality: "uncommon" as const,
inventorySlot: "two-hand",
armorType: null,
weaponType: "sword",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", raceId: "rom-human", level: 20,
inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } },
});
useCombatStore.getState().registerMob("tactical-bleed-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("tactical-bleed-target", [3, 0, 0]);
useCombatStore.getState().selectMob("tactical-bleed-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility("rom-warrior-general-0", undefined, 2_600).ok).toBe(true);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.sourceAbilityId === "rom-warrior-general-0"
&& effect.targetId === "tactical-bleed-target")).toBe(true);
expect(useCombatStore.getState().castAbility("rom-warrior-primary-3", undefined, 4_200).ok).toBe(true);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.sourceAbilityId === "rom-warrior-primary-3"
&& effect.targetId === "tactical-bleed-target")).toBe(true);
});
it("applies Warrior passive Rage and cooldown reductions to Moon Cleave and Whirlwind", () => {
const weapon = {
instanceId: "warrior-two-hand-passive-test",
itemId: 1,
itemKey: "warrior-two-hand-passive-test",
name: "Test Two-Hander",
quality: "uncommon" as const,
inventorySlot: "two-hand",
armorType: null,
weaponType: "sword",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", level: 30,
inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } },
});
useCombatStore.getState().registerMob("passive-moon-cleave-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("passive-moon-cleave-target", [3, 0, 0]);
useCombatStore.getState().selectMob("passive-moon-cleave-target");
useCombatStore.setState((state) => ({
resource: 40,
resourcePools: { ...state.resourcePools, rage: 40 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-8", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
expect(useCombatStore.getState().cooldowns["rom-warrior-primary-8"]).toBe(11_000);
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("passive-whirlwind-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("passive-whirlwind-target", [3, 0, 0]);
useCombatStore.getState().selectMob("passive-whirlwind-target");
useCombatStore.setState((state) => ({
resource: 30,
resourcePools: { ...state.resourcePools, rage: 30, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
expect(useCombatStore.getState().cooldowns["rom-warrior-general-2"]).toBe(21_000);
for (const [secondaryClassId, level, startingRage, expectedRage, expectedCooldown] of [
["rom-knight", 60, 100, 75, 16_000], // Whirlwind Battle Technique: -25 Rage, -15 seconds.
["rom-druid", 40, 100, 60, 21_000], // Recovery of Life: -10 Rage, -10 seconds.
] as const) {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId, raceId: "rom-human", level,
});
const targetId = `passive-whirlwind-${secondaryClassId}`;
useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 100_000 });
setMobPosition(targetId, [3, 0, 0]);
useCombatStore.getState().selectMob(targetId);
useCombatStore.setState((state) => ({
resource: startingRage,
resourcePools: { ...state.resourcePools, rage: startingRage },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-general-2", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(expectedRage);
expect(useCombatStore.getState().cooldowns["rom-warrior-general-2"]).toBe(expectedCooldown);
}
});
it("applies Warrior/Warlock Speed Slash cost and Warrior/Knight Heroic Surprise Attack hit effects", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-warlock", raceId: "rom-human", level: 70,
});
useCombatStore.getState().registerMob("speed-slash-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("speed-slash-target", [3, 0, 0]);
useCombatStore.getState().selectMob("speed-slash-target");
useCombatStore.setState((state) => ({
resource: 15,
resourcePools: { ...state.resourcePools, rage: 15 },
}));
const slash = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490053)!;
expect(useCombatStore.getState().castAbility(slash.id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-knight", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("heroic-surprise-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("heroic-surprise-target", [3, 0, 0]);
useCombatStore.getState().selectMob("heroic-surprise-target");
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, rage: 0 },
}));
const surpriseAttack = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490129)!;
const result = useCombatStore.getState().castAbility(surpriseAttack.id, undefined, 2_000);
expect(result.ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(25);
expect(useCombatStore.getState().mobs["heroic-surprise-target"]?.threatByActor[PLAYER_AGGRO_ID])
.toBeCloseTo(1 + result.damage * 5);
});
it("adds Blood Retention's 3 Rage per 2 seconds after Bloodlust", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-priest", raceId: "rom-human", level: 40,
});
useCombatStore.getState().registerMob("blood-retention-dummy", { name: "Target", maxHealth: 100_000 });
setMobPosition("blood-retention-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("blood-retention-dummy");
useCombatStore.setState((state) => ({
resource: 25,
resourcePools: { ...state.resourcePools, rage: 25 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-general-0", undefined, 1_000).ok).toBe(true);
useCombatStore.setState({ autoAttackTargetId: null });
expect(useCombatStore.getState().castAbility("rom-warrior-rom-priest-elite-30", undefined, 2_600).ok).toBe(true);
const afterBloodlust = useCombatStore.getState().resourcePools.rage!;
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-warrior:blood-retention"))
.toBe(true);
useCombatStore.getState().tick(1, 3_600);
useCombatStore.getState().tick(1, 4_600);
expect(useCombatStore.getState().resourcePools.rage).toBeCloseTo(afterBloodlust + 3);
});
it("only deals The Final Battle's second hit below 30% target health", () => {
const damageAtTargetHealth = (targetId: string, healthPercent: number) => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-scout", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 1_000_000 });
setMobPosition(targetId, [3, 0, 0]);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
mobs: {
...state.mobs,
[targetId]: {
...state.mobs[targetId]!,
health: Math.round(state.mobs[targetId]!.maxHealth * healthPercent),
},
},
}));
useCombatStore.getState().selectMob(targetId);
const result = useCombatStore.getState().castAbility("rom-warrior-rom-scout-elite-50", undefined, 1_000);
expect(result).toMatchObject({ ok: true, affectedMobIds: [targetId] });
return result.damage;
};
const aboveThresholdDamage = damageAtTargetHealth("final-battle-above", 0.4);
const belowThresholdDamage = damageAtTargetHealth("final-battle-below", 0.29);
expect(aboveThresholdDamage).toBeGreaterThan(0);
expect(belowThresholdDamage).toBeGreaterThan(aboveThresholdDamage);
});
it("gates Warrior Survival Instinct and Moon Cleave source requirements", () => {
const oneHandWeapon = {
instanceId: "warrior-one-hand-test",
itemId: 1,
itemKey: "warrior-one-hand-test",
name: "Test Sword",
quality: "uncommon" as const,
inventorySlot: "one-hand",
armorType: null,
weaponType: null,
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", raceId: "rom-human", level: 50,
inventory: [oneHandWeapon], equipment: { player: { "main-hand": oneHandWeapon.instanceId } },
});
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-14", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warrior-primary-8", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState((state) => ({ health: Math.floor(state.maxHealth * 0.49) }));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-14", undefined, 1_000).ok).toBe(true);
});
it.each(["rom-warrior-rom-knight-elite-15", "rom-warrior-rom-knight-elite-30"])(
"requires an equipped shield for %s",
(abilityId) => {
const shield = {
instanceId: `knight-shield-gate-${abilityId}`,
itemId: 1,
itemKey: `knight-shield-gate-${abilityId}`,
name: "Test Shield",
quality: "uncommon" as const,
inventorySlot: "off-hand",
armorType: "shield",
weaponType: null,
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
const prepareCombat = (withShield: boolean) => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80,
...(withShield ? { inventory: [shield], equipment: { player: { "off-hand": shield.instanceId } } } : {}),
});
useCombatStore.getState().registerMob("shield-gated-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("shield-gated-target", [3, 0, 0]);
useCombatStore.getState().selectMob("shield-gated-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100, mana: 100 },
}));
};
prepareCombat(false);
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
prepareCombat(true);
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
},
);
it("requires Briar Shield before Scout/Warden Thorn Arrow", () => {
const rangedWeapon = {
instanceId: "thorn-arrow-ranged-test",
itemId: 5243,
itemKey: "thorn-arrow-ranged-test",
name: "Test Ranged Weapon",
quality: "uncommon" as const,
inventorySlot: "ranged",
armorType: null,
weaponType: "bow",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-warden", raceId: "rom-elf", level: 80,
inventory: [rangedWeapon], equipment: { player: { ranged: rangedWeapon.instanceId } },
});
useCombatStore.getState().registerMob("thorn-arrow-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("thorn-arrow-target", [3, 0, 0]);
useCombatStore.getState().selectMob("thorn-arrow-target");
expect(useCombatStore.getState().castAbility("rom-scout-rom-warden-elite-15", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-warden-general-1", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes("rom-briar-shield"))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-scout-rom-warden-elite-15", undefined, 2_600))
.toMatchObject({ ok: true });
});
it("stuns all enemies within range 100 around a Warrior Shout", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 30 });
for (let index = 0; index < 6; index += 1) {
const id = "shout-target-" + index;
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000 });
setMobPosition(id, [index + 1, 0, 0]);
}
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
const result = useCombatStore.getState().castAbility("rom-warrior-primary-9", undefined, 1_000);
expect(result.ok).toBe(true);
expect(result.affectedMobIds).toHaveLength(6);
expect(useCombatStore.getState().resourcePools.rage).toBe(85);
expect(useCombatStore.getState().cooldowns["rom-warrior-primary-9"]).toBe(41_000);
for (let index = 0; index < 6; index += 1) {
expect(useCombatStore.getState().mobs[`shout-target-${index}`]!.statuses)
.toEqual(expect.arrayContaining([expect.objectContaining({ kind: "stun" })]));
}
});
it("finishes a RuneWaker direct heal on the selected party member", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 20 });
usePartyStore.getState().initializeParty("rom-urgent-heal", 20, useCombatStore.getState().health, "healer", 2, "rom");
@@ -249,6 +668,390 @@ describe("RuneWaker combat runtime", () => {
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
});
it("casts Priest/Scout Curing Shot as a five-target friendly heal and HoT", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-curing-shot", 80, useCombatStore.getState().health, "healer", 2, "rom");
const members = usePartyStore.getState().members.slice(0, 4);
const injuredHealth = new Map(members.map((member, index) => {
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3 + index, 0, 0]);
return [member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health] as const;
}));
const member = members[0]!;
usePartyStore.getState().selectMember(member.id);
const curingShot = abilityById("rom-priest-rom-scout-elite-35")!;
expect(curingShot.target).toBe("friendly");
expect(curingShot.targeting).toMatchObject({ shape: "circle", radius: 20, maximumTargets: 5 });
expect(useCombatStore.getState().castAbility(curingShot.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 3_100);
for (const [id, health] of injuredHealth) {
expect(usePartyStore.getState().members.find((candidate) => candidate.id === id)!.health).toBeGreaterThan(health);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" && effect.targetId === id)).toBe(true);
}
});
it.each([[0, 18.88], [1, 23.6]] as const)(
"casts Druid/Scout Healing Arrows with %i Nature's Power point(s) using the matching HoT",
(naturePower, expectedHoTAmount) => {
const rangedWeapon = {
instanceId: "healing-arrows-ranged-test",
itemId: 5243,
itemKey: "healing-arrows-ranged-test",
name: "Test Ranged Weapon",
quality: "uncommon" as const,
inventorySlot: "ranged",
armorType: null,
weaponType: "bow",
itemLevel: 1,
sourceItemLevel: 1,
stats: { ...useCombatStore.getState().gearStats },
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 80,
inventory: [rangedWeapon], equipment: { player: { ranged: rangedWeapon.instanceId } },
});
usePartyStore.getState().initializeParty(
`rom-healing-arrows-${naturePower}`, 80, useCombatStore.getState().health, "healer", 5, "rom",
);
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, focus: 100, mana: 100, "nature-power": naturePower },
}));
const ability = abilityById("rom-druid-rom-scout-elite-25")!;
expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 3_100);
const state = useCombatStore.getState();
expect(state.resourcePools["nature-power"]).toBe(0);
const memberHot = state.effects.find((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id && effect.targetId === member.id);
expect(memberHot?.kind).toBe("hot");
if (memberHot?.kind === "hot") expect(memberHot.basePoints).toBeCloseTo(expectedHoTAmount);
},
);
it("casts Warlock/Priest Ring of Comfort as a six-target friendly heal", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warlock", secondaryClassId: "rom-priest", raceId: "rom-dwarf", level: 80,
});
usePartyStore.getState().initializeParty("rom-ring-of-comfort", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members;
const extraMembers = ["one", "two"].map((suffix) => ({
...members[0]!, id: `ring-of-comfort-extra-ally-${suffix}`, name: `Extra Ally ${suffix}`,
}));
usePartyStore.setState((state) => ({ members: [...state.members, ...extraMembers] }));
useCombatStore.getState().damagePlayer(500);
const before = new Map<string, number>();
for (const [index, member] of [...members, ...extraMembers].entries()) {
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [2 + index, 0, 0]);
before.set(member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health);
}
usePartyStore.getState().selectMember(members[0]!.id);
useCombatStore.getState().registerMob("ring-of-comfort-hostile", { name: "Hostile", maxHealth: 10_000 });
setMobPosition("ring-of-comfort-hostile", [3, 0, 0]);
const ability = abilityById("rom-warlock-rom-priest-elite-25")!;
expect(ability).toMatchObject({
target: "friendly",
targeting: { shape: "circle", radius: 20, maximumTargets: 6 },
});
expect(ability.effects).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "heal" })]));
expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true });
const healingEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "healing"
&& event.abilityId === ability.id);
expect(healingEvents).toHaveLength(6);
const healedActorIds = new Set(healingEvents.map((event) => event.targetActorId));
expect(healedActorIds.size).toBe(6);
expect(healedActorIds).toContain(members[0]!.id);
expect(healedActorIds).not.toContain(extraMembers[1]!.id);
expect(usePartyStore.getState().members.find((member) => member.id === members[0]!.id)!.health)
.toBeGreaterThan(before.get(members[0]!.id)!);
expect(useCombatStore.getState().mobs["ring-of-comfort-hostile"]!.health).toBe(10_000);
});
it.each([
["Spiritual Leader", "rom-scout", "rom-priest", "rom-human", 30, "rom-scout-rom-priest-elite-30"],
["Salvation Engraving", "rom-champion", "rom-priest", "rom-dwarf", 30, "rom-champion-rom-priest-elite-30"],
["Suicide Advance", "rom-champion", "rom-priest", "rom-dwarf", 45, "rom-champion-rom-priest-elite-45"],
["Soul Enlightenment", "rom-warlock", "rom-priest", "rom-dwarf", 40, "rom-warlock-rom-priest-elite-40"],
] as const)("applies %s auras to its party recipients", (name, classId, secondaryClassId, raceId, level, abilityId) => {
useCombatStore.getState().initializeCharacter({ classId, secondaryClassId, raceId, level });
usePartyStore.getState().initializeParty(`rom-${abilityId}`, level, useCombatStore.getState().health, "healer", 3, "rom");
const memberIds = usePartyStore.getState().members.map((member) => member.id);
memberIds.forEach((id, index) => registerPartyRuntimePosition(id, [2 + index, 0, 0]));
const ability = abilityById(abilityId)!;
const auraEffects = ability.effects.filter((effect) => effect.kind === "apply-aura");
expect(auraEffects.length, name).toBeGreaterThan(0);
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
const allRecipients = [PLAYER_AGGRO_ID, ...memberIds];
const activeAuras = useCombatStore.getState().auras;
for (const effect of auraEffects) {
const expectedRecipients = effect.scope === "party"
? allRecipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AGGRO_ID)
: effect.recipient === "caster" ? [PLAYER_AGGRO_ID] : [];
for (const recipientId of allRecipients) {
expect(activeAuras.some((aura) => aura.targetId === recipientId
&& aura.definition.id === effect.aura.id), `${name} -> ${recipientId} (${effect.aura.id})`)
.toBe(expectedRecipients.includes(recipientId));
}
}
if (name === "Salvation Engraving") {
expect(auraEffects.every((effect) => effect.scope === "party" && effect.excludeCaster)).toBe(true);
expect(activeAuras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id.startsWith("rom-aura-498719-"))).toBe(false);
}
if (name === "Suicide Advance") {
expect(auraEffects.some((effect) => effect.scope === "party" && effect.aura.modifiers?.some((modifier) => (
modifier.kind === "damage" && modifier.direction === "dealt" && modifier.value > 0
)))).toBe(true);
expect(auraEffects.some((effect) => effect.scope !== "party" && effect.recipient === "caster"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.value < 0))).toBe(true);
}
});
it("casts Scout/Priest Mana Arrows as a six-target party absorb around the caster", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-mana-arrows", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = [
...usePartyStore.getState().members,
{ ...usePartyStore.getState().members[0]!, id: "mana-arrows-extra-ally", name: "Nearby Ally" },
];
usePartyStore.setState({ members });
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [1 + index, 0, 0]));
const manaArrows = abilityById("rom-scout-rom-priest-elite-20")!;
expect(manaArrows.target).toBe("self");
expect(manaArrows.targeting).toMatchObject({ shape: "circle", radius: 5, maximumTargets: 6 });
expect(useCombatStore.getState().castAbility(manaArrows.id, undefined, 1_000)).toMatchObject({ ok: true });
const shields = useCombatStore.getState().combatEvents.filter((event) => event.kind === "absorb"
&& event.abilityId === manaArrows.id);
expect(shields).toHaveLength(6);
expect(shields.map((event) => event.targetActorId)).toEqual(expect.arrayContaining([
PLAYER_AGGRO_ID,
...members.map((member) => member.id),
]));
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.name === "Mana Arrows"))
.toHaveLength(6);
});
it("casts Druid/Mage Spring of Blessings as a nearby party HoT", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80,
});
usePartyStore.getState().initializeParty("rom-spring-of-blessings", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members.slice(0, 4);
const injuredHealth = new Map(members.map((member, index) => {
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [2 + index, 0, 0]);
return [member.id, usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health] as const;
}));
const spring = abilityById("rom-druid-rom-mage-elite-20")!;
expect(spring.target).toBe("self");
expect(spring.targeting).toMatchObject({ shape: "circle", radius: 12 });
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, mana: 100 },
}));
expect(useCombatStore.getState().castAbility(spring.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 3_100);
for (const [id, health] of injuredHealth) {
expect(usePartyStore.getState().members.find((candidate) => candidate.id === id)!.health).toBeGreaterThan(health);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot" && effect.targetId === id)).toBe(true);
}
});
it("heals only the lowest-max-health ally with Scout/Knight Healing Shot", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-healing-shot", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members.slice(0, 4);
for (const [index, member] of members.entries()) {
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [2 + index, 0, 0]);
}
useCombatStore.getState().synchronizeActors();
const expectedRecipient = usePartyStore.getState().members.slice(0, 4)
.reduce((lowest, member) => member.maxHealth < lowest.maxHealth ? member : lowest);
const healthBefore = expectedRecipient.health;
useCombatStore.getState().registerMob("healing-shot-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("healing-shot-target", [3, 0, 0]);
useCombatStore.getState().selectMob("healing-shot-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, focus: 100, mana: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-scout-rom-knight-elite-20", undefined, 1_000)).toMatchObject({ ok: true });
const healingEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "healing"
&& event.abilityId === "rom-scout-rom-knight-elite-20");
expect(healingEvents).toHaveLength(1);
expect(healingEvents[0]!.targetActorId).toBe(expectedRecipient.id);
expect(usePartyStore.getState().members.find((member) => member.id === expectedRecipient.id)!.health)
.toBeGreaterThan(healthBefore);
});
it("requires Nature's Power and cleanses nearby allies with Group Exorcism", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 80,
});
usePartyStore.getState().initializeParty("rom-group-exorcism", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members.slice(0, 2);
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0]));
const auraDefinition = { id: "test:group-exorcism-debuff", name: "Test debuff", disposition: "debuff" as const,
dispelCategory: "magic" as const, durationMs: 20_000 };
let auras = useCombatStore.getState().auras;
for (const member of members) auras = [...applyAura(auras, {
definition: auraDefinition, sourceId: "test:source", targetId: member.id, now: 1_000,
}).auras];
useCombatStore.setState({ auras, resource: 100, resourcePools: { mana: 100, "nature-power": 0 } });
const exorcismId = "rom-druid-rom-scout-elite-30";
expect(useCombatStore.getState().castAbility(exorcismId, undefined, 1_000))
.toMatchObject({ ok: false, reason: "not-enough-resource" });
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, "nature-power": 1 },
}));
expect(useCombatStore.getState().castAbility(exorcismId, undefined, 2_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2.1, 4_100);
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(0);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === auraDefinition.id)).toBe(false);
});
it("resolves Champion Vacuum Wave against nearby enemies around the caster", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion", raceId: "rom-human", level: 80,
});
useCombatStore.getState().registerMob("vacuum-wave-nearby", { name: "Nearby", maxHealth: 10_000 });
useCombatStore.getState().registerMob("vacuum-wave-far", { name: "Far", maxHealth: 10_000 });
setMobPosition("vacuum-wave-nearby", [5, 0, 0]);
setMobPosition("vacuum-wave-far", [9, 0, 0]);
useCombatStore.setState({ resource: 100, resourcePools: { rage: 100 } });
const result = useCombatStore.getState().castAbility("rom-champion-primary-10", undefined, 1_000);
expect(result).toMatchObject({ ok: true, affectedMobIds: ["vacuum-wave-nearby"] });
});
it("resolves Champion Light Pulse against nearby enemies and injured party members", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-light-pulse", 80, useCombatStore.getState().health, "healer", 5, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3, 0, 0]);
const memberBefore = usePartyStore.getState().members[0]!.health;
useCombatStore.getState().registerMob("light-pulse-nearby", { name: "Nearby", maxHealth: 10_000 });
setMobPosition("light-pulse-nearby", [5, 0, 0]);
useCombatStore.setState({ resource: 100, resourcePools: { rage: 100, mana: 100 } });
const result = useCombatStore.getState().castAbility("rom-champion-rom-priest-elite-40", undefined, 1_000);
expect(result).toMatchObject({ ok: true, affectedMobIds: ["light-pulse-nearby"] });
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(memberBefore);
});
it("lets Champion Overrule clear and protect against control", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion", raceId: "rom-human", level: 80,
});
useCombatStore.setState({
controlledUntil: 2_000,
controlMechanic: "stun",
resource: 100,
resourcePools: { rage: 100 },
});
expect(useCombatStore.getState().castAbility("rom-champion-primary-15", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.controlImmunities?.includes("stun"))).toBe(true);
});
it("applies Scout/Mage Aggro Transfer mana regeneration to its selected ally", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
});
usePartyStore.getState().initializeParty("rom-aggro-transfer", 80, useCombatStore.getState().health, "healer", 5, "rom");
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
expect(useCombatStore.getState().castAbility("rom-scout-rom-mage-elite-50", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "resource"
&& modifier.resource === "mana" && modifier.property === "regeneration"))).toBe(true);
});
it("chains Druid/Mage Earth Chain across three targets with 70% falloff", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80,
});
const targetIds = ["earth-chain-a", "earth-chain-b", "earth-chain-c", "earth-chain-outside"];
const positions = [[3, 0, 0], [8, 0, 0], [13, 0, 0], [21, 0, 0]] as const;
for (const [index, id] of targetIds.entries()) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, positions[index]!);
}
useCombatStore.getState().selectMob(targetIds[0]!);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, mana: 100, "nature-power": 100 },
}));
expect(useCombatStore.getState().castAbility("rom-druid-rom-mage-elite-15", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2, 3_000);
const hits = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
&& event.abilityId === "rom-druid-rom-mage-elite-15");
expect(hits.map((event) => event.targetActorId)).toEqual(targetIds.slice(0, 3));
expect(hits[0]!.effectiveAmount).toBeGreaterThan(hits[1]!.effectiveAmount);
expect(hits[1]!.effectiveAmount).toBeGreaterThan(hits[2]!.effectiveAmount);
expect(useCombatStore.getState().mobs[targetIds[3]!]!.health).toBe(100_000);
});
it("applies Druid/Mage Shield of Light to up to three nearby allies", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-mage", raceId: "rom-elf", level: 80,
});
usePartyStore.getState().initializeParty("rom-shield-of-light", 80, useCombatStore.getState().health, "healer", 5, "rom");
const members = usePartyStore.getState().members.slice(0, 4);
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0]));
usePartyStore.getState().selectMember(members[2]!.id);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, mana: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-druid-rom-mage-elite-25", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(2, 3_000);
const shieldEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "absorb"
&& event.abilityId === "rom-druid-rom-mage-elite-25");
expect(shieldEvents).toHaveLength(3);
expect(shieldEvents.some((event) => event.targetActorId === members[2]!.id)).toBe(true);
});
it("uses native RoM Rage gains from auto attacks and health damage", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior",
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import type { AbilityEffect } from "./abilityCatalog";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
const empty: RomCastMechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 10_000,
targetHealthPercent: 1,
};
function cast(spellId: number, naturePower: number, passiveTags: readonly string[] = []): readonly AbilityEffect[] {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === spellId);
expect(ability, `RoM Druid spell ${spellId}`).toBeDefined();
return romEffectsForCast(ability!, { ...empty, naturePower, passiveTags });
}
function passiveTag(spellId: number): string {
const passive = ROM_ELITE_ABILITIES.find((entry) => entry.dbcSpellId === spellId);
expect(passive, `RoM Druid passive ${spellId}`).toBeDefined();
const effect = passive!.effects.find((entry) => entry.kind === "apply-aura");
expect(effect?.kind).toBe("apply-aura");
return effect?.kind === "apply-aura" ? effect.aura.utilityTags?.[0] ?? "" : "";
}
describe("RoM Druid Nature Power branches", () => {
it("selects the matching Mother Earth's Fountain heal and spends one point for DMG0", () => {
const baseline = cast(493529, 0).filter((effect) => effect.kind === "heal");
const empowered = cast(493529, 1);
const poweredHeal = empowered.find((effect) => effect.kind === "heal");
expect(baseline).toHaveLength(1);
expect(baseline[0]).toMatchObject({ kind: "heal", basePoints: 84 });
expect(poweredHeal).toMatchObject({
kind: "heal", basePoints: 67.2, scope: "party", center: "ability-target", radius: 10,
});
expect(empowered.filter((effect) => effect.kind === "resource-drain")).toEqual([
{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" },
]);
});
it("orders powered Seed, Wrath, and Widow consumption before their source refund", () => {
for (const spellId of [493546, 493545, 493549, 494022]) {
const effects = cast(spellId, 1);
const drain = effects.findIndex((effect) => effect.kind === "resource-drain"
&& effect.resource === "nature-power");
const refund = effects.findIndex((effect) => effect.kind === "resource"
&& effect.resource === "nature-power");
expect(drain, `spell ${spellId} drain`).toBeGreaterThanOrEqual(0);
expect(refund, `spell ${spellId} refund`).toBeGreaterThan(drain);
}
expect(cast(493549, 0).filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(cast(493549, 1).filter((effect) => effect.kind === "damage")).toHaveLength(1);
});
it("selects the powered Sandstorm and Earth Chain payloads without simultaneous alternatives", () => {
const sandstormBase = cast(493542, 0);
const sandstormPowered = cast(493542, 1);
expect(sandstormBase.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(sandstormPowered.filter((effect) => effect.kind === "damage")).toHaveLength(2);
expect(sandstormPowered.some((effect) => effect.kind === "summon")).toBe(false);
expect(sandstormPowered.some((effect) => effect.kind === "resource-drain"
&& effect.resource === "nature-power")).toBe(true);
const chain = cast(494025, 1);
expect(chain.filter((effect) => effect.kind === "damage")).toEqual([
expect.objectContaining({ basePoints: 49.6, falloffPerJump: 0.7 }),
]);
expect(chain.some((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power")).toBe(true);
});
it("selects Healing Wind's empowered heal and applies it to nearby party members", () => {
const effects = cast(494365, 1);
expect(effects.filter((effect) => effect.kind === "heal")).toEqual([
expect.objectContaining({ kind: "heal", basePoints: 60, scope: "party", center: "caster", radius: 10 }),
]);
expect(effects.some((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power")).toBe(true);
});
it("selects Heart Piercing's powered strike without dropping its Rage payload", () => {
const baseline = cast(494216, 0);
const empowered = cast(494216, 1);
expect(baseline.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(empowered.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(empowered).toContainEqual({ kind: "resource", resource: "rage", amount: 15 });
expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
});
it("selects Cross of Thorns' two-hit branch and consumes one point without an unknown refund chance", () => {
const baseline = cast(494018, 0);
const empowered = cast(494018, 1);
const baselineHits = baseline.filter((effect) => effect.kind === "damage");
const empoweredHits = empowered.filter((effect) => effect.kind === "damage");
expect(baselineHits).toHaveLength(2);
expect(empoweredHits).toHaveLength(2);
expect(empoweredHits[0]).not.toEqual(baselineHits[0]);
expect(empowered.filter((effect) => effect.kind === "resource-drain" && effect.resource === "nature-power"))
.toEqual([{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" }]);
expect(empowered.some((effect) => effect.kind === "resource" && effect.resource === "nature-power"))
.toBe(false);
});
});
describe("RoM Druid passive state triggers", () => {
it("applies Holy Fury's source-backed six-second Wrath duration only without Nature Power", () => {
const holyFury = passiveTag(494935);
const baseline = cast(493549, 0);
const withPassive = cast(493549, 0, [holyFury]);
const empowered = cast(493549, 1, [holyFury]);
expect(baseline.find((effect) => effect.kind === "dot" && effect.school === "nature"))
.toMatchObject({ ticks: 6, intervalMs: 2_000 });
expect(withPassive.find((effect) => effect.kind === "dot" && effect.school === "nature"))
.toMatchObject({ ticks: 9, intervalMs: 2_000 });
expect(empowered.find((effect) => effect.kind === "dot" && effect.school === "nature"))
.toMatchObject({ ticks: 6, intervalMs: 2_000 });
});
it("extends Recover by six seconds with Extended Recovery and gates Knee Ulcer's five-second root", () => {
const recoverBase = cast(493528, 0);
const recoverExtended = cast(493528, 0, [passiveTag(494200)]);
expect(recoverBase.find((effect) => effect.kind === "hot")).toMatchObject({ ticks: 7, intervalMs: 2_000 });
expect(recoverExtended.find((effect) => effect.kind === "hot")).toMatchObject({ ticks: 10, intervalMs: 2_000 });
expect(cast(494321, 0).filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"))
.toHaveLength(0);
expect(cast(494321, 0, [passiveTag(499944)]).filter((effect) => effect.kind === "apply-aura"
&& effect.control?.kind === "root")).toEqual([
expect.objectContaining({ control: { kind: "root" }, aura: expect.objectContaining({ durationMs: 5_000 }) }),
]);
});
});
+273
View File
@@ -0,0 +1,273 @@
import type { AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
function sourceDurationMs(row: RomGeneratedAbilityLike, fallbackSeconds: number): number {
const duration = row.source?.components?.find((component) => (component.effectTime ?? 0) > 0)?.effectTime;
return Math.max(1_000, Math.round((duration ?? fallbackSeconds) * 1_000));
}
function spiritOfLifeHot(effects: readonly AbilityEffect[]): AbilityEffect {
const heals = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
const sum = (key: "coefficient" | "basePoints" | "dieSides" | "pointsPerLevel" | "coefficientPerLevel"
| "bonusPowerCoefficient" | "attackPowerCoefficient" | "spiritCoefficient" | "percentMaxHealth") => (
heals.reduce((total, effect) => total + (effect[key] ?? 0), 0)
);
const hot: Extract<AbilityEffect, { kind: "hot" }> = {
kind: "hot",
coefficient: sum("coefficient"),
ticks: 10,
intervalMs: 4_000,
recipient: "caster",
scope: "party",
center: "caster",
radius: 7,
...(sum("percentMaxHealth") ? { percentMaxHealth: sum("percentMaxHealth") } : {}),
...(sum("basePoints") ? { basePoints: sum("basePoints") } : {}),
...(sum("dieSides") ? { dieSides: sum("dieSides") } : {}),
...(sum("pointsPerLevel") ? { pointsPerLevel: sum("pointsPerLevel") } : {}),
...(sum("coefficientPerLevel") ? { coefficientPerLevel: sum("coefficientPerLevel") } : {}),
...(sum("bonusPowerCoefficient") ? { bonusPowerCoefficient: sum("bonusPowerCoefficient") } : {}),
...(sum("attackPowerCoefficient") ? { attackPowerCoefficient: sum("attackPowerCoefficient") } : {}),
...(sum("spiritCoefficient") ? { spiritCoefficient: sum("spiritCoefficient") } : {}),
...(heals.find((effect) => effect.sourceLevel !== undefined)?.sourceLevel === undefined
? {} : { sourceLevel: heals.find((effect) => effect.sourceLevel !== undefined)!.sourceLevel }),
};
return hot;
}
function stateAura(
id: string,
name: string,
durationMs: number,
modifiers: NonNullable<Extract<AbilityEffect, { kind: "apply-aura" }>['aura']['modifiers']>,
): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id,
name,
disposition: "buff",
durationMs,
modifiers,
utilityTags: ["rom-druid-unity-with-mother-earth", "rom-druid-movement-locked"],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
};
}
function passiveState(sourceId: number, name: string, tag: string): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-druid-passive-${sourceId}`,
name,
disposition: "buff",
durationMs: null,
utilityTags: [tag],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
};
}
/** Source-specific player effects for Druid rows the generic translator cannot express. */
export function overrideRomDruidEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
switch (row.sourceSkillId) {
case 493531: // Savage Blessing buffs a selected friendly target.
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
recipient: "ability-target",
aura: { ...effect.aura, disposition: "buff" },
} : effect);
case 493536: { // Spirit Guidance charms a non-player enemy for the source effect duration.
const effectTimeSeconds = row.source?.components?.find((component) => component.id === 503805)?.effectTime ?? 3;
return [{
kind: "status",
status: "charm",
durationMs: Math.max(1_000, Math.round(effectTimeSeconds * 1_000)),
maxTargets: 1,
breakOnDamage: false,
}];
}
case 493548: // Unity guarantees critical spell results and adds 10% healing for 15 seconds.
return [stateAura(
"rom-druid-unity-with-mother-earth",
row.name,
sourceDurationMs(row, 15),
[
{ kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 100 },
{ kind: "healing", direction: "done", operation: "percent", value: 0.1 },
],
)];
case 494200: // Extended Recovery: passive state extends Recover by six seconds.
return [passiveState(494200, row.name, "rom-druid-extended-recovery")];
case 494935: // Holy Fury: enables the zero-Nature-Power Wrath duration branch.
return [passiveState(494935, row.name, "rom-druid-holy-fury")];
case 499944: // Knee Ulcer gates the five-second root child on Necrotic Wound.
return [passiveState(499944, row.name, "rom-druid-knee-ulcer")];
case 494025: // Preserve both Earth Chain payload branches for cast-time selection.
return translated.filter((effect) => effect.kind === "damage").map((effect) => ({
...effect,
falloffPerJump: 0.7,
}));
case 494321: // Its root is supplied by the optional Knee Ulcer passive.
return translated.filter((effect) => effect.kind !== "apply-aura" || effect.control?.kind !== "root");
case 494938: // 505938 supplies the 40-second lifetime; the tooltip states 4-second party pulses.
return [spiritOfLifeHot(translated)];
default:
return null;
}
}
function hasTag(tags: readonly string[] | ReadonlySet<string>, tag: string): boolean {
return Array.isArray(tags) ? tags.includes(tag) : (tags as ReadonlySet<string>).has(tag);
}
function naturePowerDrain(): AbilityEffect {
return { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" };
}
function spendBeforeNaturePowerRefund(effects: readonly AbilityEffect[]): readonly AbilityEffect[] {
const refundIndex = effects.findIndex((effect) => effect.kind === "resource"
&& effect.resource === "nature-power" && effect.amount > 0);
if (refundIndex < 0) return [...effects, naturePowerDrain()];
return [...effects.slice(0, refundIndex), naturePowerDrain(), ...effects.slice(refundIndex)];
}
/** Resolve Druid source branches that depend on current Nature Power or learned passive state. */
export function romDruidCastEffects(
ability: import("./abilityCatalog").AbilityDefinition,
effects: readonly AbilityEffect[],
naturePower: number,
passiveTags: readonly string[] | ReadonlySet<string> = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return effects;
const empowered = naturePower >= 1;
const damage = effects.filter((effect) => effect.kind === "damage");
const heals = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
const selectDamage = (index: number, count = 1): readonly AbilityEffect[] => {
const selected = damage[index];
return selected ? Array.from({ length: count }, () => selected) : [];
};
switch (ability.dbcSpellId) {
case 493529: { // DMG1 is baseline; DMG0 is the one-point Nature Power branch.
const selected = heals[empowered ? 1 : 0] ?? heals[0];
if (!selected) return effects;
return [
...effects.filter((effect) => effect.kind !== "heal"),
{
...selected,
scope: "party",
center: "ability-target",
radius: 10,
},
...(empowered ? [naturePowerDrain()] : []),
];
}
case 493546: // Weakening Seed consumes and refunds one point when powered.
case 493545: // Withering Seed has the same source resource branch.
return empowered ? spendBeforeNaturePowerRefund(effects) : effects;
case 493549: { // Select DMG1/DMG0, keep the single source DoT, and consume one point if powered.
const selectedDamage = damage[empowered ? 1 : 0];
if (!selectedDamage) return effects;
const selected = effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage)
.map((effect) => (
effect.kind === "dot" && !empowered && hasTag(passiveTags, "rom-druid-holy-fury")
? { ...effect, ticks: effect.ticks + 3 }
: effect
));
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
}
case 493542: { // The powered DMG0 component hits all targets twice; no creature summon row exists.
const hits = selectDamage(empowered ? 1 : 0, empowered ? 2 : 1);
return [
...hits,
...effects.filter((effect) => effect.kind !== "damage" && effect.kind !== "summon"),
...(empowered ? [naturePowerDrain()] : []),
];
}
case 494025: { // Earth Chain keeps its 70% per-jump falloff in either branch.
const selectedDamage = damage[empowered ? 1 : 0];
if (!selectedDamage) return effects;
return [selectedDamage, ...(empowered ? [naturePowerDrain()] : [])];
}
case 494365: { // Healing Wind selects DMG1 or DMG0 and heals the caster's nearby party.
const selected = heals[empowered ? 1 : 0] ?? heals[0];
if (!selected) return effects;
return [{
...selected,
scope: "party",
center: "caster",
radius: 10,
}, ...(empowered ? [naturePowerDrain()] : [])];
}
case 494216: { // Heart Piercing selects its DMG1/DMG0 strike and spends one point when powered.
const selectedDamage = damage[empowered ? 1 : 0];
if (!selectedDamage) return effects;
return [
...effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage),
...(empowered ? [naturePowerDrain()] : []),
];
}
case 494018: { // Cross of Thorns uses two DMG1 hits or two DMG0 hits after spending one point.
const selectedHits = empowered ? selectDamage(1, 2) : [damage[0], damage[2]].filter((effect) => effect !== undefined);
if (!selectedHits.length) return effects;
const selected = effects.filter((effect) => effect.kind !== "damage"
&& !(empowered && effect.kind === "resource" && effect.resource === "nature-power"));
return [...selectedHits, ...selected, ...(empowered ? [naturePowerDrain()] : [])];
}
case 494022: { // Poisonous Widow Embrace selects one damage component and refunds a spent point.
const selectedDamage = damage[empowered ? 1 : 0];
if (!selectedDamage) return effects;
const selected = effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage);
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
}
case 493528: { // Extended Recovery's source-backed duration increase; proc chance is absent.
if (!hasTag(passiveTags, "rom-druid-extended-recovery")) return effects;
return effects.map((effect) => effect.kind === "hot"
? { ...effect, ticks: effect.ticks + 3 }
: effect);
}
case 494321: { // The passive's root is only emitted when Knee Ulcer is learned.
if (!hasTag(passiveTags, "rom-druid-knee-ulcer")) return effects;
return [...effects, {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-druid-knee-ulcer-root",
name: "Knee Ulcer",
disposition: "debuff",
durationMs: 5_000,
dispelCategory: "magic",
},
control: { kind: "root" },
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 499944,
}];
}
default:
return effects;
}
}
/** Add Spirit of Life's source target-max-health component to a periodic heal tick. */
export function romDruidPeriodicHealAmount(
baseAmount: number,
targetMaxHealth: number,
percentMaxHealth: number,
): number {
return Math.max(0, baseAmount) + Math.max(0, targetMaxHealth) * Math.max(0, percentMaxHealth);
}
+188
View File
@@ -0,0 +1,188 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { installCombatClock } from "./combatClock";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { companionResourceMaximum } from "./combatActorAuthority";
import { romDruidPeriodicHealAmount } from "./romDruidOverrides";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
const TARGET_ID = "rom-druid-runtime-target";
function ability(spellId: number) {
const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId);
expect(result, `RoM Druid spell ${spellId}`).toBeDefined();
return result!;
}
function finishCast(now: number) {
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt);
}
function prepareDruid(secondaryClassId?: "rom-warden" | "rom-scout") {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid",
...(secondaryClassId ? { secondaryClassId } : {}),
raceId: "rom-elf",
level: 80,
});
useCombatStore.setState((state) => ({
resource: 1_000,
maxResource: 1_000,
resourcePools: { ...state.resourcePools, mana: 1_000, "nature-power": 0 },
maxResourcePools: { ...state.maxResourcePools, mana: 1_000 },
}));
}
describe("RuneWaker Druid runtime", () => {
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["rom-druid"]);
});
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("casts Spirit Guidance for one Nature Power and applies the source three-second Charm", () => {
prepareDruid();
useCombatStore.getState().registerMob(TARGET_ID, { name: "Guidance Target", maxHealth: 10_000 });
setMobPosition(TARGET_ID, [5, 0, 0]);
const spiritGuidance = ability(493536);
expect(spiritGuidance.costs).toContainEqual({ resource: "nature-power", amount: 1 });
expect(useCombatStore.getState().castAbility(spiritGuidance.id, { targetActorId: TARGET_ID, now: 1_000 }))
.toMatchObject({ ok: false, reason: "not-enough-resource" });
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, "nature-power": 1 } }));
expect(useCombatStore.getState().castAbility(spiritGuidance.id, { targetActorId: TARGET_ID, now: 3_000 }).ok).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
expect(activeCast?.abilityId).toBe(spiritGuidance.id);
finishCast(3_000);
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(0);
expect(useCombatStore.getState().mobs[TARGET_ID]!.statuses).toContainEqual(expect.objectContaining({
kind: "charm",
endsAt: activeCast!.completesAt + 3_000,
}));
// The combat model supports the Charm control effect; it has no faction/allegiance conversion state.
});
it("applies Unity with Mother Earth as a 15-second healing and guaranteed spell-crit aura", () => {
prepareDruid();
const unity = ability(493548);
expect(unity.target).toBe("self");
expect(useCombatStore.getState().castAbility(unity.id, { now: 1_000 }).ok).toBe(true);
const aura = useCombatStore.getState().auras.find((entry) => (
entry.targetId === PLAYER_AURA_ENTITY_ID
&& entry.definition.utilityTags?.includes("rom-druid-unity-with-mother-earth")
));
expect(aura?.expiresAt).toBe(16_000);
expect(aura?.definition.modifiers).toContainEqual({
kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 100,
});
expect(aura?.definition.modifiers).toContainEqual({
kind: "healing", direction: "done", operation: "percent", value: 0.1,
});
useCombatStore.getState().registerMob(TARGET_ID, { name: "Unity Target", maxHealth: 10_000 });
setMobPosition(TARGET_ID, [5, 0, 0]);
const arrow = ability(493538);
expect(useCombatStore.getState().castAbility(arrow.id, { targetActorId: TARGET_ID, now: 3_000 }).ok).toBe(true);
finishCast(3_000);
expect(useCombatStore.getState().lastCombatResult?.critical).toBe(true);
});
it("prevents movement during Unity and restores movement when its 15-second aura expires", () => {
let now = 1_000;
const restoreClock = installCombatClock(() => now);
try {
prepareDruid();
const unity = ability(493548);
expect(useCombatStore.getState().castAbility(unity.id, { now }).ok).toBe(true);
useCombatStore.getState().setPlayerPosition([6, 0, 0]);
expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 0]);
now = 16_001;
useCombatStore.getState().setPlayerPosition([6, 0, 0]);
expect(useCombatStore.getState().playerPosition).toEqual([6, 0, 0]);
} finally {
restoreClock();
}
});
it("raises Nature Power's maximum to 15 only after learning Brilliance of Nature", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 44,
});
expect(useCombatStore.getState().maxResourcePools["nature-power"]).toBe(10);
useCombatStore.getState().initializeCharacter({
classId: "rom-druid", secondaryClassId: "rom-scout", raceId: "rom-elf", level: 45,
});
expect(useCombatStore.getState().abilities.some((entry) => entry.dbcSpellId === 494931 && entry.passive)).toBe(true);
expect(useCombatStore.getState().maxResourcePools["nature-power"]).toBe(15);
const companion = createConfiguredPartyMember({
name: "Druid Companion",
classId: "rom-druid",
secondaryClassId: "rom-scout",
raceId: "rom-elf",
specialization: "Primary",
role: "healer",
}, 45, 0, "rom-druid-brilliance-cap");
expect(companionResourceMaximum(companion, "nature-power")).toBe(15);
});
it("adds exactly one percent of each healing target's max health to the Spirit of Life tick", () => {
expect(romDruidPeriodicHealAmount(500, 10_000, 0.01)).toBe(600);
expect(romDruidPeriodicHealAmount(500, 5_000, 0.01)).toBe(550);
});
it("gives Spirit of Life a 40-second, four-second party heal within its source radius", () => {
prepareDruid("rom-warden");
usePartyStore.getState().initializeParty("rom-druid-spirit-of-life", 80, useCombatStore.getState().health, "healer", 5, "rom");
const [near, far] = usePartyStore.getState().members;
expect(near).toBeDefined();
expect(far).toBeDefined();
useCombatStore.setState({ health: 1 });
usePartyStore.setState((state) => ({ members: state.members.map((member) => ({ ...member, health: 1 })) }));
registerPartyRuntimePosition(near!.id, [5, 0, 0]);
registerPartyRuntimePosition(far!.id, [12, 0, 0]);
useCombatStore.getState().synchronizeActors();
const spirit = ability(494938);
const hot = spirit.effects.find((effect) => effect.kind === "hot");
expect(hot).toMatchObject({
ticks: 10, intervalMs: 4_000, scope: "party", radius: 7, center: "caster", percentMaxHealth: 0.01,
});
expect(spirit.effects.some((effect) => effect.kind === "summon" || effect.kind === "heal")).toBe(false);
const beforePlayer = useCombatStore.getState().health;
const beforeNear = usePartyStore.getState().members.find((member) => member.id === near!.id)!.health;
const beforeFar = usePartyStore.getState().members.find((member) => member.id === far!.id)!.health;
expect(useCombatStore.getState().castAbility(spirit.id, { now: 1_000 }).ok).toBe(true);
expect(useCombatStore.getState().summons.some((summon) => summon.sourceAbilityId === spirit.id)).toBe(false);
useCombatStore.getState().tick(4.1, 5_100);
expect(useCombatStore.getState().health).toBeGreaterThan(beforePlayer);
expect(usePartyStore.getState().members.find((member) => member.id === near!.id)!.health).toBeGreaterThan(beforeNear);
expect(usePartyStore.getState().members.find((member) => member.id === far!.id)!.health).toBe(beforeFar);
});
it("casts Savage Blessing on the selected friendly target with beneficial hit buffs", () => {
prepareDruid();
usePartyStore.getState().initializeParty("rom-druid-savage-blessing", 80, useCombatStore.getState().health, "healer", 5, "rom");
const ally = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(ally.id, [4, 0, 0]);
useCombatStore.getState().synchronizeActors();
const blessing = ability(493531);
expect(blessing.target).toBe("friendly");
expect(useCombatStore.getState().castAbility(blessing.id, { targetActorId: ally.id, now: 1_000 }).ok).toBe(true);
finishCast(1_000);
const buffs = useCombatStore.getState().auras.filter((aura) => aura.definition.name.startsWith("Savage Blessing"));
expect(buffs.length).toBeGreaterThanOrEqual(2);
expect(buffs.every((aura) => aura.targetId === ally.id && aura.definition.disposition === "buff")).toBe(true);
});
});
+81
View File
@@ -0,0 +1,81 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
export const ROM_KNIGHT_DIVINE_PUNISHMENT_TAG = "rom-divine-punishment";
export const ROM_KNIGHT_GODS_VENGEANCE_TAG = "rom-gods-vengeance";
export const ROM_KNIGHT_SHIELD_OF_VALOR_TAG = "rom-shield-of-valor";
function divinePunishmentPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-knight-passive-491608",
name: row.name,
disposition: "buff",
durationMs: null,
utilityTags: [ROM_KNIGHT_DIVINE_PUNISHMENT_TAG],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 491608,
}];
}
/** Source-backed Knight passive and cast adjustments. */
export function overrideRomKnightEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.sourceSkillId === 491608 && row.passive) return divinePunishmentPassive(row);
if (row.sourceSkillId === 499934 && row.passive) return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-knight-passive-499934",
name: row.name,
disposition: "buff",
durationMs: null,
utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 499934,
}];
if (row.sourceSkillId === 490468) return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_KNIGHT_SHIELD_OF_VALOR_TAG],
},
} : effect);
return null;
}
/** Apply Divine Punishment's extracted +1% only to its named source skill. */
export function romKnightCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: readonly string[] | ReadonlySet<string> = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker"
|| ability.classId !== "rom-knight"
|| ability.dbcSpellId !== 490151
|| !(Array.isArray(passiveTags)
? passiveTags.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG)
: (passiveTags as ReadonlySet<string>).has(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG))) return effects;
return effects.map((effect) => {
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
return {
...effect,
coefficient: effect.coefficient * 1.01,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 1.01 }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * 1.01 }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * 1.01 }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * 1.01 }),
};
});
}
+323
View File
@@ -0,0 +1,323 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import {
ROM_KNIGHT_DIVINE_PUNISHMENT_TAG,
ROM_KNIGHT_GODS_VENGEANCE_TAG,
ROM_KNIGHT_SHIELD_OF_VALOR_TAG,
} from "./romKnightOverrides";
import { romEffectsForCast } from "./romCombatMechanics";
function stableCombatUnit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function godsVengeanceCastTime(wantProc: boolean): number {
for (let now = 11_500; now < 100_000; now += 100) {
const roll = stableCombatUnit(
`${PLAYER_AURA_ENTITY_ID}:rom-knight-general-0:gods-vengeance-target:${now}:rom-gods-vengeance`,
);
if ((roll < 0.5) === wantProc) return now;
}
throw new Error("Could not find a deterministic God's Vengeance cast-time seed.");
}
describe("RuneWaker Knight periodic skills", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("keeps Holy Light Domain as a three-pulse Holy area effect", () => {
const domain = abilityById("rom-knight-rom-mage-elite-15")!;
expect(domain).toMatchObject({ dbcSpellId: 491606, target: "self", radius: 10 });
expect(domain.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "dot",
school: "holy",
ticks: 3,
intervalMs: 2_000,
tag: "rom-knight-holy-light-domain",
}),
]));
expect(domain.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.procs?.some((proc) => proc.action.kind === "custom"
&& proc.action.id === "rom-trigger-skill"))).toBe(false);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
});
for (const [id, x] of [["domain-near", 5], ["domain-edge", 9], ["domain-far", 12]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [x, 0, 0]);
}
const initialHealth = Object.fromEntries(Object.entries(useCombatStore.getState().mobs)
.map(([id, mob]) => [id, mob.health]));
expect(useCombatStore.getState().castAbility(domain.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.filter((effect) => effect.kind === "dot")
.map((effect) => effect.targetId).sort()).toEqual(["domain-edge", "domain-near"]);
expect(useCombatStore.getState().mobs["domain-near"]!.health).toBe(initialHealth["domain-near"]);
useCombatStore.getState().tick(0.1, 3_000);
const afterFirstPulse = useCombatStore.getState();
expect(afterFirstPulse.mobs["domain-near"]!.health).toBeLessThan(initialHealth["domain-near"]!);
expect(afterFirstPulse.mobs["domain-edge"]!.health).toBeLessThan(initialHealth["domain-edge"]!);
expect(afterFirstPulse.mobs["domain-far"]!.health).toBe(initialHealth["domain-far"]);
});
it("adds War Prayer Holy Seal stacks on its six three-second pulses", () => {
const prayer = abilityById("rom-knight-rom-mage-elite-50")!;
expect(prayer).toMatchObject({ dbcSpellId: 490078, target: "hostile" });
expect(prayer.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage", school: "holy" }),
expect.objectContaining({
kind: "dot",
ticks: 6,
intervalMs: 3_000,
tag: "rom-knight-war-prayer-seal",
}),
]));
expect(prayer.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.procs?.some((proc) => proc.action.kind === "custom"
&& proc.action.id === "rom-trigger-skill"))).toBe(false);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-mage", raceId: "rom-human", level: 80,
});
useCombatStore.getState().registerMob("war-prayer-target", { name: "War Prayer Target", maxHealth: 100_000 });
setMobPosition("war-prayer-target", [3, 0, 0]);
useCombatStore.getState().selectMob("war-prayer-target");
expect(useCombatStore.getState().castAbility(prayer.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(0);
expect(useCombatStore.getState().effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "dot", targetId: "war-prayer-target", remainingTicks: 6 }),
]));
for (let tick = 1; tick <= 6; tick += 1) {
useCombatStore.getState().tick(0.1, 1_000 + tick * 3_000);
expect(useCombatStore.getState().comboTargetId).toBe("war-prayer-target");
expect(useCombatStore.getState().comboPoints).toBe(Math.min(tick, 4));
}
});
it("limits Divine Punishment's sourced 1% bonus to Punishment", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80,
});
const state = useCombatStore.getState();
const passive = state.abilities.find((ability) => ability.dbcSpellId === 491608);
expect(passive?.passive).toBe(true);
const passiveAura = state.auras.find((aura) => aura.definition.utilityTags?.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG));
expect(passiveAura).toBeDefined();
expect(passive?.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
utilityTags: [ROM_KNIGHT_DIVINE_PUNISHMENT_TAG],
}),
}),
]);
const passiveEffect = passive?.effects[0];
expect(passiveEffect?.kind).toBe("apply-aura");
if (passiveEffect?.kind === "apply-aura") expect(passiveEffect.aura.modifiers).toBeUndefined();
const punishment = abilityById("rom-knight-general-0")!;
const context = {
sealStacks: 4,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: state.maxHealth,
targetHealthPercent: 1,
} as const;
const baseline = romEffectsForCast(punishment, context);
const boosted = romEffectsForCast(punishment, {
...context,
passiveTags: passiveAura?.definition.utilityTags ?? [],
});
const damageValues = (effects: typeof baseline) => effects
.filter((effect) => effect.kind === "damage" || effect.kind === "dot")
.map((effect) => ({
coefficient: effect.coefficient,
basePoints: "basePoints" in effect ? effect.basePoints ?? 0 : 0,
pointsPerLevel: "pointsPerLevel" in effect ? effect.pointsPerLevel ?? 0 : 0,
}));
expect(damageValues(baseline)).toHaveLength(2);
expect(damageValues(boosted)).toHaveLength(2);
for (const [index, base] of damageValues(baseline).entries()) {
expect(damageValues(boosted)[index]!.coefficient).toBeCloseTo(base.coefficient * 1.01);
expect(damageValues(boosted)[index]!.basePoints).toBeCloseTo(base.basePoints * 1.01);
expect(damageValues(boosted)[index]!.pointsPerLevel).toBeCloseTo(base.pointsPerLevel * 1.01);
}
expect(romEffectsForCast(abilityById("rom-knight-primary-1")!, {
...context,
passiveTags: passiveAura?.definition.utilityTags ?? [],
})).toEqual(romEffectsForCast(abilityById("rom-knight-primary-1")!, context));
});
it.each([true, false])("lets God's Vengeance preserve Holy Seal when the 50% roll is %s", (preservesSeal) => {
const godVengeance = abilityById("rom-knight-rom-rogue-elite-50")!;
expect(godVengeance).toMatchObject({ dbcSpellId: 499934, passive: true });
expect(godVengeance.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG] }),
}),
]);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-rogue", raceId: "rom-human", level: 80,
});
useCombatStore.getState().registerMob("gods-vengeance-target", {
name: "God's Vengeance Target", maxHealth: 1_000_000,
});
setMobPosition("gods-vengeance-target", [3, 0, 0]);
useCombatStore.getState().selectMob("gods-vengeance-target");
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
expect.objectContaining({
targetId: PLAYER_AURA_ENTITY_ID,
definition: expect.objectContaining({ utilityTags: [ROM_KNIGHT_GODS_VENGEANCE_TAG] }),
}),
]));
expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(4);
const castTime = godsVengeanceCastTime(preservesSeal);
expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, castTime))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(preservesSeal ? 4 : 0);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.targetId === "gods-vengeance-target" && effect.tag === "rom-holy-seal"))
.toBe(preservesSeal);
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
&& event.abilityId === "rom-knight-general-0" && event.targetActorId === "gods-vengeance-target"
&& event.effectiveAmount > 0)).toBe(true);
});
it("reduces Holy Seal cooldown by one second when Shield of Valor parries", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", raceId: "rom-human", level: 80, role: "tank",
});
const holySeal = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 490149)!;
const shieldOfValor = abilityById("rom-knight-primary-10")!;
expect(shieldOfValor).toMatchObject({ dbcSpellId: 490468 });
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, parryRating: 100_000 },
}));
expect(useCombatStore.getState().castAbility(shieldOfValor.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
expect.objectContaining({
targetId: PLAYER_AURA_ENTITY_ID,
definition: expect.objectContaining({ utilityTags: expect.arrayContaining([ROM_KNIGHT_SHIELD_OF_VALOR_TAG]) }),
}),
]));
useCombatStore.setState((state) => ({
cooldowns: { ...state.cooldowns, [holySeal.id]: 25_000 },
}));
useCombatStore.getState().registerMob("shield-of-valor-attacker", {
name: "Shield of Valor Attacker",
maxHealth: 100_000,
attacks: [DEFAULT_ENEMY_ATTACK],
});
setMobPosition("shield-of-valor-attacker", [2, 0, 0]);
useCombatStore.getState().damageMob("shield-of-valor-attacker", 1, 1_100);
let parryAt: number | null = null;
for (let now = 1_450; now < 16_000; now += 100) {
useCombatStore.getState().advanceMobCombat(now);
if (useCombatStore.getState().lastCombatResult?.parried) {
parryAt = now;
break;
}
}
expect(parryAt).not.toBeNull();
expect(useCombatStore.getState().cooldowns[holySeal.id]).toBe(24_000);
});
it("activates the source-backed Evil Shackles passive state", () => {
const passive = abilityById("rom-knight-rom-scout-elite-60")!;
expect(passive).toMatchObject({ dbcSpellId: 499926, passive: true });
expect(passive.effects).toEqual([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: null,
utilityTags: ["rom-evil-shackles"],
}),
}),
]);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
expect.objectContaining({
definition: expect.objectContaining({ utilityTags: ["rom-evil-shackles"] }),
}),
]));
});
it("lets Evil Shackles root non-Undead/Demon targets for half duration", () => {
const holyChains = abilityById("rom-knight-rom-scout-elite-25")!;
const rootDuration = (targetTags: readonly string[]) => {
const root = romEffectsForCast(holyChains, {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 2_000,
targetHealthPercent: 1,
passiveTags: ["rom-evil-shackles"],
targetTags,
}).find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root");
return root?.kind === "apply-aura" ? root.aura.durationMs : undefined;
};
expect(rootDuration(["rom-target-undead-demon"])).toBe(10_000);
expect(rootDuration(["rom-target-living"])).toBe(5_000);
expect(rootDuration([])).toBe(5_000);
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 50,
});
useCombatStore.getState().registerMob("shackles-target", {
name: "Living target", maxHealth: 100_000, creatureFamily: "living",
});
setMobPosition("shackles-target", [3, 0, 0]);
useCombatStore.getState().selectMob("shackles-target");
expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 1_000)).toMatchObject({
ok: false, reason: "condition-not-met",
});
useCombatStore.getState().registerMob("shackles-target", {
name: "Unknown target", maxHealth: 100_000, creatureFamily: "unknown",
});
expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 2_000)).toMatchObject({
ok: false, reason: "condition-not-met",
});
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
});
useCombatStore.getState().registerMob("shackles-target", {
name: "Living target", maxHealth: 100_000, creatureFamily: "living",
});
setMobPosition("shackles-target", [3, 0, 0]);
useCombatStore.getState().selectMob("shackles-target");
expect(useCombatStore.getState().castAbility(holyChains.id, undefined, 3_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["shackles-target"]!.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "root", endsAt: 8_000 }),
]));
});
});
+365
View File
@@ -0,0 +1,365 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById, resourceProfileForClass } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura, resolveResourceValue } from "./combatAuras";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
romMageWarlockCastEffects,
} from "./romMageWarlockOverrides";
import { applyTalentModifierValue } from "./talentModifiers";
function initializeMage(secondaryClassId?: "rom-druid" | "rom-warden" | "rom-champion", level = 80): void {
useCombatStore.getState().initializeCharacter({
classId: "rom-mage",
secondaryClassId,
raceId: "rom-human",
level,
});
}
function addTarget(id: string, x: number, z = 0): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
setMobPosition(id, [x, 0, z]);
}
describe("RoM Mage mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("maps Elemental Catalysis mana percentage regeneration against maximum mana", () => {
const catalysis = abilityById("rom-mage-primary-0")!;
expect(catalysis.effects).toContainEqual(expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: expect.arrayContaining([
expect.objectContaining({
kind: "stat",
stat: "mana-regeneration-max-percent-per-second",
operation: "flat",
value: 0.01,
}),
]),
}),
}));
initializeMage(undefined, 60);
addTarget("catalysis-combat", 3);
useCombatStore.setState((state) => ({
...state,
resource: state.maxResource / 2,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 },
lastResourceSpentAt: { mana: 1_000 },
mobs: { ...state.mobs, "catalysis-combat": { ...state.mobs["catalysis-combat"]!, engaged: true } },
}));
const before = useCombatStore.getState().resourcePools.mana!;
expect(useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "mana-regeneration-max-percent-per-second" && modifier.value === 0.01))).toBe(true);
useCombatStore.getState().tick(1, 2_000);
useCombatStore.getState().tick(1, 3_000);
const restored = useCombatStore.getState().resourcePools.mana! - before;
expect(restored).toBeGreaterThanOrEqual(useCombatStore.getState().maxResourcePools.mana! * 0.02);
});
it("uses rank-two Catalysis's explicit 20-second damage and spell-speed payload without adding a mana tick", () => {
initializeMage(undefined, 65);
const catalysis = abilityById("rom-mage-primary-0")!;
const rankTwo = catalysis.ranks?.find((rank) => rank.spellId === 497971);
expect(rankTwo).toBeDefined();
expect(rankTwo?.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", attackKind: "spell", value: 0.2 })],
}),
}),
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
durationMs: 20_000,
modifiers: [expect.objectContaining({ kind: "stat", stat: "spell-haste", value: 50 })],
}),
}),
]));
expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "mana-regeneration-max-percent-per-second"))).toBe(false);
addTarget("catalysis-rank-two-combat", 3);
useCombatStore.setState((state) => ({
...state,
lastResourceSpentAt: { mana: 1_000 },
mobs: { ...state.mobs, "catalysis-rank-two-combat": { ...state.mobs["catalysis-rank-two-combat"]!, engaged: true } },
}));
const result = useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id.includes("497971")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.attackKind === "spell" && modifier.value === 0.2))).toBe(true);
});
it("applies rank-two Catalysis spell haste to Mage cast time while rank one stays unchanged", () => {
const flame = abilityById("rom-mage-primary-1")!;
expect(flame).toMatchObject({ name: "Flame", castTimeMs: 3_000 });
initializeMage(undefined, 65);
addTarget("catalysis-haste-rank-two-baseline", 3);
useCombatStore.getState().selectMob("catalysis-haste-rank-two-baseline");
expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true });
const rankTwoBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
initializeMage(undefined, 65);
clearMobRuntimeRegistry();
addTarget("catalysis-haste-rank-two-target", 3);
useCombatStore.getState().selectMob("catalysis-haste-rank-two-target");
expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 3_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.id.includes("497971")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "spell-haste" && modifier.value === 50))).toBe(true);
expect(useCombatStore.getState().castAbility(flame.id, undefined, 5_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast).toMatchObject({
abilityId: flame.id,
startedAt: 5_000,
originalDurationMs: Math.round(rankTwoBaselineMs / 1.5),
completesAt: 5_000 + Math.round(rankTwoBaselineMs / 1.5),
});
initializeMage(undefined, 60);
clearMobRuntimeRegistry();
addTarget("catalysis-haste-rank-one-baseline", 3);
useCombatStore.getState().selectMob("catalysis-haste-rank-one-baseline");
expect(useCombatStore.getState().castAbility(flame.id, undefined, 7_000)).toMatchObject({ ok: true });
const rankOneBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
initializeMage(undefined, 60);
clearMobRuntimeRegistry();
addTarget("catalysis-haste-rank-one-target", 3);
useCombatStore.getState().selectMob("catalysis-haste-rank-one-target");
expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 9_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "spell-haste"))).toBe(false);
expect(useCombatStore.getState().castAbility(flame.id, undefined, 11_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast).toMatchObject({
abilityId: flame.id,
startedAt: 11_000,
originalDurationMs: rankOneBaselineMs,
completesAt: 11_000 + rankOneBaselineMs,
});
});
it("does not apply a RoM spell-haste aura to another class's cast time", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
addTarget("non-mage-haste-baseline", 3);
useCombatStore.getState().selectMob("non-mage-haste-baseline");
expect(useCombatStore.getState().castAbility("priest-smite", undefined, 1_000)).toMatchObject({ ok: true });
const priestBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
clearMobRuntimeRegistry();
addTarget("non-mage-haste-target", 3);
useCombatStore.getState().selectMob("non-mage-haste-target");
useCombatStore.setState((state) => ({
auras: applyAura(state.auras, {
definition: {
id: "rom-catalysis-non-mage-test",
name: "Test Catalysis haste",
disposition: "buff",
durationMs: 20_000,
modifiers: [{ kind: "stat", stat: "spell-haste", operation: "flat", value: 50 }],
},
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
now: 3_000,
}).auras,
}));
expect(useCombatStore.getState().castAbility("priest-smite", undefined, 3_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast).toMatchObject({
abilityId: "priest-smite",
originalDurationMs: priestBaselineMs,
completesAt: 3_000 + priestBaselineMs,
});
});
it("leaves ordinary mana regeneration unchanged without the Catalysis aura", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
addTarget("ordinary-mana-combat", 3);
useCombatStore.setState((state) => ({
...state,
resource: state.maxResource / 2,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 },
lastResourceSpentAt: { mana: 1_000 },
mobs: { ...state.mobs, "ordinary-mana-combat": { ...state.mobs["ordinary-mana-combat"]!, engaged: true } },
}));
const beforeState = useCombatStore.getState();
const before = beforeState.resourcePools.mana!;
const playerAuras = beforeState.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID);
const baseRegeneration = applyTalentModifierValue(
resourceProfileForClass("priest").regenerationPerSecond
+ beforeState.gearStats.mp5 / 5
+ beforeState.gearStats.spirit * 0.02,
beforeState.talentModifiers,
"resource-regeneration-percent",
);
const expectedPerSecond = resolveResourceValue(
resolveResourceValue(baseRegeneration, playerAuras, "mana", "regeneration"),
playerAuras,
"power",
"regeneration",
);
expect(beforeState.auras.some((aura) => aura.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second"
)))).toBe(false);
useCombatStore.getState().tick(1, 2_000);
useCombatStore.getState().tick(1, 3_000);
expect(useCombatStore.getState().resourcePools.mana! - before).toBeCloseTo(expectedPerSecond * 2);
});
it("casts Phoenix down a capped line and leaves other line spells' extension intact", () => {
initializeMage();
for (const [id, x, z] of [
["phoenix-target", 4, 0],
["phoenix-path", 8, 0],
["phoenix-off-line", 7, 2],
["phoenix-beyond", 12.1, 0],
] as const) addTarget(id, x, z);
useCombatStore.getState().selectMob("phoenix-target");
expect(abilityById("rom-mage-primary-6")).toMatchObject({
target: "hostile",
range: { max: 12 },
targeting: { shape: "line", radius: 12 },
});
expect(useCombatStore.getState().castAbility("rom-mage-primary-6", undefined, 1_000))
.toMatchObject({ ok: true, affectedMobIds: ["phoenix-target", "phoenix-path"] });
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-elf", level: 60 });
clearMobRuntimeRegistry();
for (const [id, x] of [["arrow-target", 3], ["arrow-path", 7], ["arrow-beyond", 7.1]] as const) {
addTarget(id, x);
}
useCombatStore.getState().selectMob("arrow-target");
expect(useCombatStore.getState().castAbility("rom-scout-primary-5", undefined, 4_000))
.toMatchObject({ ok: true, affectedMobIds: ["arrow-target", "arrow-path"] });
});
it("doubles only Fireball and Flame on the 10% Eruption proc and runs Control Flame once", () => {
const fireball = abilityById("rom-mage-general-0")!;
const tags = new Set([
ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption,
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame,
]);
const procced = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.099);
const ordinary = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.1);
expect(procced.filter((effect) => effect.kind === "damage")).toEqual(
fireball.effects.filter((effect) => effect.kind === "damage").map((effect) => effect.kind === "damage"
? expect.objectContaining({
coefficient: effect.coefficient * 2,
basePoints: (effect.basePoints ?? 0) * 2,
pointsPerLevel: (effect.pointsPerLevel ?? 0) * 2,
})
: effect),
);
expect(ordinary.filter((effect) => effect.kind === "damage")).toEqual(
fireball.effects.filter((effect) => effect.kind === "damage"),
);
expect(procced).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
id: "rom-mage-druid-control-flame-buff",
durationMs: 12_000,
modifiers: [expect.objectContaining({ stat: "magical-attack", value: 0.08 })],
}),
}),
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
id: "rom-mage-druid-control-flame-cooldown",
durationMs: 20_000,
}),
}),
]));
expect(romMageWarlockCastEffects(fireball, fireball.effects, new Set([
...tags,
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown,
]), 0.099).some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-mage-druid-control-flame-buff")).toBe(false);
});
it("applies Eruption and Control Flame when a Fireball proc succeeds", () => {
initializeMage("rom-druid");
addTarget("eruption-target", 3);
useCombatStore.getState().selectMob("eruption-target");
const before = useCombatStore.getState().mobs["eruption-target"]!.health;
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_002)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["eruption-target"]!.health).toBeLessThan(before);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff"))
.toBe(true);
const flameBuff = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff");
expect(flameBuff?.definition.modifiers).toEqual([
expect.objectContaining({ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }),
]);
});
it("reduces Fireball cooldown by one second with Flame Follower", () => {
initializeMage("rom-warden");
addTarget("follower-target", 3);
useCombatStore.getState().selectMob("follower-target");
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(6_000);
});
it("resets only Lightning and Discharge with Brain Shock", () => {
initializeMage("rom-champion");
useCombatStore.setState((state) => ({
cooldowns: {
...state.cooldowns,
"rom-mage-general-1": 8_000,
"rom-mage-primary-4": 10_000,
"rom-mage-general-0": 9_000,
},
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-mage-rom-champion-elite-20", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns).toMatchObject({
"rom-mage-general-1": 0,
"rom-mage-primary-4": 0,
"rom-mage-general-0": 9_000,
});
});
it("reduces Electrostatic Charge cooldown by 30 seconds with Mage/Champion expertise", () => {
initializeMage(undefined, 80);
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000);
initializeMage("rom-champion", 40);
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000);
initializeMage("rom-champion", 80);
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(91_000);
});
it("keeps Mage Fire Ward party-scoped", () => {
const fireWard = abilityById("rom-mage-general-5")!;
expect(fireWard.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"))
.toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { AbilityDefinition } from "./abilityCatalog";
const ELECTROSTATIC_CHARGE_EXPERTISE_SPELL_ID = 498_871;
const ELECTROSTATIC_CHARGE_SPELL_ID = 491_156;
/** Mage/Champion's level-45 passive shortens Electrostatic Charge by 30 seconds. */
export function romMageCooldownReductionMs(
classId: string | null,
secondaryClassId: string | null | undefined,
ability: Pick<AbilityDefinition, "dbcSpellId">,
level: number,
abilities: readonly Pick<AbilityDefinition, "dbcSpellId" | "unlockLevel" | "passive">[],
): number {
if (classId !== "rom-mage"
|| secondaryClassId !== "rom-champion"
|| ability.dbcSpellId !== ELECTROSTATIC_CHARGE_SPELL_ID
|| level < 45) return 0;
return abilities.some((candidate) => candidate.passive
&& candidate.dbcSpellId === ELECTROSTATIC_CHARGE_EXPERTISE_SPELL_ID
&& candidate.unlockLevel <= level)
? 30_000
: 0;
}
+68
View File
@@ -5,6 +5,10 @@ import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
export const ROM_ELECTROSTATIC_PERCENT_ABSORB_TAG = "rom-electrostatic-percent-max-health";
export const ROM_MAGE_WARLOCK_PASSIVE_TAGS = Object.freeze({
eruption: "rom-mage-eruption",
controlFlame: "rom-mage-druid-control-flame",
controlFlameCooldown: "rom-mage-druid-control-flame-cooldown",
flameFollower: "rom-mage-warden-flame-follower",
industryFire: "rom-mage-warlock-industry-fire",
lightningDisplay: "rom-mage-warlock-lightning-display",
elementsOfPride: "rom-mage-warlock-elements-of-pride",
@@ -23,6 +27,55 @@ const INDUSTRY_FIRE_HIT_BASE = 140;
const INDUSTRY_FIRE_HIT_PER_LEVEL = 40;
const INDUSTRY_FIRE_DOT_BASE = 23.4;
const INDUSTRY_FIRE_DOT_PER_LEVEL = 10.4;
const MAGE_ERUPTION_PROC_CHANCE = 0.1;
function scaledDamage(effect: AbilityEffect, factor: number): AbilityEffect {
if (effect.kind !== "damage") return effect;
return {
...effect,
coefficient: effect.coefficient * factor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * factor }),
};
}
function controlFlameTriggeredAuras(): readonly AbilityEffect[] {
return [
{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-mage-druid-control-flame-buff",
name: "Control Flame",
disposition: "buff",
durationMs: 12_000,
modifiers: [{ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }],
},
sourceEffectType: -1,
sourceAuraType: 171,
miscValue: 8,
triggerSpellId: 494054,
},
{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-mage-druid-control-flame-cooldown",
name: "Control Flame cooldown",
disposition: "buff",
durationMs: 20_000,
utilityTags: [ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 494078,
},
];
}
function stateAura(id: string, tag: string, durationMs: number | null): AbilityEffect {
return {
@@ -53,6 +106,10 @@ export function overrideRomMageWarlockEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.sourceSkillId === 490221) return [stateAura("eruption", ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption, null)];
if (row.sourceSkillId === 494054) return [stateAura("control-flame", ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame, null)];
if (row.sourceSkillId === 494051) return [stateAura("flame-follower", ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower, null)];
if (row.sourceSkillId === 491156) {
return translated.map((effect) => {
if (effect.kind !== "apply-aura" || !effect.aura.absorbs?.length) return effect;
@@ -113,9 +170,20 @@ export function romMageWarlockCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: ReadonlySet<string>,
eruptionProcRoll = 1,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return effects;
if ([490204, 491150].includes(ability.dbcSpellId)
&& passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption)
&& eruptionProcRoll < MAGE_ERUPTION_PROC_CHANCE) {
const doubledDamage = effects.map((effect) => scaledDamage(effect, 2));
return passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame)
&& !passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown)
? [...doubledDamage, ...controlFlameTriggeredAuras()]
: doubledDamage;
}
if (ability.dbcSpellId === 497964 && passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire)) {
return [
{
+30 -4
View File
@@ -47,6 +47,11 @@ describe("generated RuneWaker player catalog", () => {
it("resolves every ordered pair to all ten elite milestones and the source-backed Devotion Halo", () => {
expect(ROM_ELITE_ABILITIES).toHaveLength(661);
const knownEmptyEffectElites = new Set([
"rom-warlock-rom-warrior-elite-30",
"rom-warlock-rom-priest-elite-35",
"rom-warlock-rom-priest-elite-50",
]);
const pairGroups = new Map<string, typeof ROM_ELITE_ABILITIES[number][]>();
for (const ability of ROM_ELITE_ABILITIES) {
const key = `${ability.classId}:${ability.secondaryClassId}`;
@@ -56,7 +61,15 @@ describe("generated RuneWaker player catalog", () => {
for (const [pair, elites] of pairGroups) {
expect(elites.map((ability) => ability.unlockLevel)).toEqual(pair === "rom-priest:rom-knight"
? [...ELITE_LEVELS, 70] : ELITE_LEVELS);
expect(elites.every((ability) => ability.source === "runewaker" && ability.effects.length > 0)).toBe(true);
for (const ability of elites) {
expect(ability.source, `${pair}:${ability.id} source`).toBe("runewaker");
if (knownEmptyEffectElites.has(ability.id)) {
expect(ability.passive).toBe(true);
expect(ability.effects, `${pair}:${ability.id} source payload is incomplete`).toEqual([]);
} else {
expect(ability.effects, `${pair}:${ability.id} effects`).not.toEqual([]);
}
}
}
expect(ROM_ALL_ABILITIES).toHaveLength(930);
expect(ROM_ALL_ABILITIES.filter((ability) => ability.id !== "rom-priest-rom-knight-elite-70-devotion-halo").every((ability) => (
@@ -93,13 +106,24 @@ describe("generated RuneWaker player catalog", () => {
expect(abilityAtLevel(ranked, 80).dbcSpellId).toBe(eligible.spellId);
});
it("preserves every source rank and its full flat or percentage cost", () => {
it("preserves every source rank and cost except documented runtime overrides", () => {
const normalizedCosts = (rows: readonly { resource: string; amount: number; percentage?: boolean }[]) => rows.map((cost) => ({
resource: cost.resource,
amount: cost.amount,
...(cost.percentage ? { percentage: true } : {}),
}));
const generatedRanks = generated.skills.reduce((sum, ability) => sum + ability.ranks.length, 0);
const sourceCosts = (sourceSkillId: number, rows: typeof generated.skills[number]["costs"]) => {
let normalized = normalizedCosts(rows);
// The current CoA Shout entry (same ID/name) uses 15 Rage while the
// older local RuneWaker snapshot records 30; the runtime follows CoA.
if (sourceSkillId === 490136) {
normalized = normalized.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost);
}
if (![493536, 494217].includes(sourceSkillId)
|| normalized.some((entry) => entry.resource === "nature-power")) return normalized;
return [...normalized, { resource: "nature-power", amount: 1 }];
};
expect(generatedRanks).toBe(934);
expect(Math.max(...generated.skills.flatMap((ability) => (
ability.costs.filter((cost) => !cost.percentage).map((cost) => cost.amount)
@@ -107,11 +131,13 @@ describe("generated RuneWaker player catalog", () => {
for (const source of generated.skills) {
const runtime = ROM_ALL_ABILITIES.find((ability) => ability.id === source.id)!;
expect(runtime.costs, `${source.name} base costs`).toEqual(normalizedCosts(source.costs));
expect(runtime.costs, `${source.name} base costs`).toEqual(sourceCosts(source.sourceSkillId, source.costs));
expect(runtime.ranks).toHaveLength(source.ranks.length);
for (const [index, sourceRank] of source.ranks.entries()) {
expect(runtime.ranks?.[index]?.spellId, `${source.name} rank ${sourceRank.rank}`).toBe(sourceRank.sourceSkillId);
expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual(normalizedCosts(sourceRank.costs));
expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual(
sourceCosts(sourceRank.sourceSkillId, sourceRank.costs),
);
}
}
});
+13
View File
@@ -0,0 +1,13 @@
import type { AbilityDefinition } from "./abilityCatalog";
/** Infused Light Chain removes Chain of Light's base cooldown for Priest/Champion. */
export function romPriestChampionCooldownOverride(
ability: AbilityDefinition,
learnedPassiveSourceIds: readonly number[],
): number | null {
if (ability.source !== "runewaker"
|| ability.classId !== "rom-priest"
|| ability.dbcSpellId !== 491164
|| !learnedPassiveSourceIds.includes(498610)) return null;
return 0;
}
+111
View File
@@ -33,6 +33,8 @@ export function overrideRomPriestKnightEffects(
return passiveState(499968, row.name, "rom-splendor-ripple");
case 499957: // Light Guidance: activate healing bonus only with source state 499966.
return passiveState(499957, row.name, "rom-light-guidance");
case 492638: // Lightning Rod: Lightning shocks targets already afflicted by Bone Chill.
return passiveState(492638, row.name, "rom-priest-mage-lightning-rod");
case 491610: // Enhanced Holy Strike: improves Holy Strike weapon damage.
return passiveState(491610, row.name, "rom-enhanced-holy-strike");
case 490066: // Weaken Weapon Strength: Heavenly Arrow weakens target physical damage.
@@ -151,8 +153,36 @@ export function romPriestKnightCastEffects(
passiveTags: readonly string[] | ReadonlySet<string>,
casterMaxHealth: number,
targetTags: readonly string[] | ReadonlySet<string> = [],
friendlyTargetHealthPercent = 1,
learnedPassiveSpellIds: readonly number[] = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker" || casterMaxHealth <= 0) return effects;
if (ability.dbcSpellId === 490261
&& learnedPassiveSpellIds.includes(493039)
&& friendlyTargetHealthPercent < 0.3) {
// Saint's Blessing's active source components grant +30 healing points and
// +1% healing power for 10 seconds. Put the aura before Heal so the
// triggering cast itself receives the sourced bonus as well.
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "rom-priest-saints-blessing-healing",
name: "Saint's Blessing",
disposition: "buff",
durationMs: 10_000,
modifiers: [
{ kind: "healing", direction: "done", operation: "flat", value: 30 },
{ kind: "healing", direction: "done", operation: "percent", value: 0.01 },
],
dispelCategory: "magic",
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 493039,
}, ...effects];
}
switch (ability.dbcSpellId) {
case 490290: // Enhanced Grace of Life mirrors source Grace's +50 and +1% HP as MP.
if (!hasTag(passiveTags, "rom-enhanced-grace-of-life")) return effects;
@@ -236,6 +266,28 @@ export function romPriestKnightCastEffects(
miscValue: 5,
triggerSpellId: 623015,
}];
case 490212: // Lightning Rod adds source child 502907 only while Bone Chill is on the target.
if (!hasTag(passiveTags, "rom-priest-mage-lightning-rod")
|| !hasTag(targetTags, "rom-491165-501548-0")) return effects;
return [...effects, {
kind: "apply-aura",
recipient: "ability-target",
aura: {
id: "rom-priest-mage-lightning-rod-shock",
name: "Electric Shock",
disposition: "debuff",
durationMs: 20_000,
modifiers: [
{ kind: "stat", stat: "resistance:frost", operation: "flat", value: -50 },
{ kind: "stat", stat: "resistance:nature", operation: "flat", value: -50 },
],
utilityTags: ["rom-priest-mage-lightning-rod-shock"],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 502907,
}];
case 490434: // Source 499924 adds component 623167 to the base Blood Arrow.
if (!hasTag(passiveTags, "rom-blood-punishment")) return effects;
return [...effects, {
@@ -260,6 +312,65 @@ export function romPriestKnightCastEffects(
}
}
/** Select the pair-specific payload branches that depend on target state. */
export function romPriestKnightConditionalCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
sealStacks: number,
targetIsCaster: boolean,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker"
|| ability.classId !== "rom-knight"
|| ability.secondaryClassId !== "rom-priest") return effects;
if (ability.dbcSpellId === 490194) {
const baseHit = effects.find((effect) => effect.kind === "damage");
if (!baseHit) return effects;
// The generated row carries a placeholder heal with no source-backed
// amount; only the Holy Seal components grant healing.
const nonDamage = effects.filter((effect) => effect.kind !== "damage" && effect.kind !== "heal");
const seals = Math.min(4, Math.max(0, Math.trunc(sealStacks)));
if (seals === 0) return [baseHit, ...nonDamage];
const healPercent = Math.min(8, seals * 2) / 100;
return [
baseHit,
...nonDamage,
{ kind: "heal", coefficient: 0, percentMaxHealth: healPercent, recipient: "caster" },
{
kind: "heal",
coefficient: 0,
percentMaxHealth: healPercent,
scope: "party",
center: "caster",
maximumTargets: 1,
excludeCaster: true,
},
{ kind: "combo", amount: -seals },
];
}
if (ability.dbcSpellId === 491640) {
const selectedThreatPolarity = targetIsCaster ? 1 : -1;
const hasThreatPayload = effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat"));
if (!hasThreatPayload) return effects;
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) =>
modifier.kind === "stat" && modifier.stat === "threat")
? {
...effect,
aura: {
...effect.aura,
modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "stat" && modifier.stat === "threat"
? { ...modifier, value: Math.abs(modifier.value) * selectedThreatPolarity }
: modifier),
},
}
: effect);
}
return effects;
}
/** Source skill 499966 is granted by the level-70 Priest/Knight elite pair. */
export function romPriestKnightAdditionalAbilities(
catalog: readonly AbilityDefinition[],
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { healingRecipientIds } from "./healingAbilityRules";
import type { CombatActorState } from "./combatActors";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides";
import { romPriestKnightConditionalCastEffects } from "./romPriestKnightOverrides";
const holySmite = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 490194)!;
const holyProtection = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 491640)!;
describe("RoM Knight/Priest conditional combat payloads", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
});
it("replaces Holy Smite's conditional damage with stack-scaled caster and ally heals", () => {
const noSeals = romPriestKnightConditionalCastEffects(holySmite, holySmite.effects, 0, false);
expect(noSeals.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(noSeals.filter((effect) => effect.kind === "heal")).toHaveLength(0);
const fourSeals = romPriestKnightConditionalCastEffects(holySmite, holySmite.effects, 4, false);
expect(fourSeals.filter((effect) => effect.kind === "damage")).toHaveLength(1);
expect(fourSeals.filter((effect) => effect.kind === "heal")).toEqual([
expect.objectContaining({ kind: "heal", percentMaxHealth: 0.08, recipient: "caster" }),
expect.objectContaining({ kind: "heal", percentMaxHealth: 0.08, scope: "party", excludeCaster: true }),
]);
expect(fourSeals).toContainEqual({ kind: "combo", amount: -4 });
});
it("chooses only the matching Holy Protection threat payload for self versus ally", () => {
const threatAmount = (targetIsCaster: boolean) => {
const selected = romPriestKnightConditionalCastEffects(
holyProtection, holyProtection.effects, 0, targetIsCaster,
);
const aura = selected.find((effect) => effect.kind === "apply-aura");
if (aura?.kind !== "apply-aura") return null;
return aura.aura.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat")?.value;
};
expect(threatAmount(false)).toBe(-0.02);
expect(threatAmount(true)).toBe(0.02);
for (const targetIsCaster of [false, true]) {
const selected = romPriestKnightConditionalCastEffects(
holyProtection, holyProtection.effects, 0, targetIsCaster,
);
expect(selected.filter((effect) => effect.kind === "apply-aura")).toHaveLength(3);
expect(selected.some((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& (modifier.stat === "armor" || modifier.stat === "magical-defense")))).toBe(true);
}
});
it("excludes only the caster from Holy Smite's second heal recipient", () => {
const actors = {
[PLAYER_AGGRO_ID]: { id: PLAYER_AGGRO_ID, kind: "player", alive: true, health: 50, maxHealth: 100 },
ally: { id: "ally", kind: "companion", alive: true, health: 25, maxHealth: 100 },
} as unknown as Readonly<Record<string, CombatActorState>>;
const positionFor = (id: string) => id === PLAYER_AGGRO_ID
? [0, 0, 0] as const
: id === "ally" ? [3, 0, 0] as const : null;
const normalPartyHeal = healingRecipientIds(
{ scope: "party", center: "caster" }, PLAYER_AGGRO_ID, null, actors, positionFor, [], () => true,
);
const holySmitePartyHeal = healingRecipientIds(
{ scope: "party", center: "caster", maximumTargets: 1, excludeCaster: true },
PLAYER_AGGRO_ID, null, actors, positionFor, [], () => true,
);
expect(normalPartyHeal).toContain(PLAYER_AGGRO_ID);
expect(holySmitePartyHeal).toEqual(["ally"]);
});
it("removes Chain of Light's cooldown only with Priest/Champion Infused Light Chain", () => {
const chain = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491164)!;
expect(romPriestChampionCooldownOverride(chain, [])).toBeNull();
expect(romPriestChampionCooldownOverride(chain, [498610])).toBe(0);
expect(romPriestChampionCooldownOverride({ ...chain, classId: "rom-knight" }, [498610])).toBeNull();
});
it("applies Infused Light Chain to the active Priest/Champion channel", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", secondaryClassId: "rom-champion", raceId: "rom-dwarf", level: 30,
});
useCombatStore.getState().registerMob("priest-champion-chain", { name: "Channel target", maxHealth: 100_000 });
setMobPosition("priest-champion-chain", [3, 0, 0]);
useCombatStore.getState().selectMob("priest-champion-chain");
expect(useCombatStore.getState().castAbility("rom-priest-primary-9", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().activeCast?.mode).toBe("channel");
expect(useCombatStore.getState().cooldowns["rom-priest-primary-9"]).toBeUndefined();
});
it("consumes Holy Seals only when Holy Smite uses them and heals the caster plus one ally", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 30, role: "tank",
});
usePartyStore.getState().initializeParty("knight-priest-holy-smite", 30, useCombatStore.getState().health, "tank", 2, "rom");
const ally = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(ally.id, Math.floor(ally.maxHealth * 0.5));
registerPartyRuntimePosition(ally.id, [3, 0, 0]);
useCombatStore.getState().damagePlayer(Math.floor(useCombatStore.getState().maxHealth * 0.5));
useCombatStore.getState().registerMob("knight-priest-holy-smite-target", {
name: "Holy Seal target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("knight-priest-holy-smite-target", [2, 0, 0]);
useCombatStore.getState().selectMob("knight-priest-holy-smite-target");
expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(4);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot"
&& effect.tag === "rom-holy-seal" && effect.sourceActorId === PLAYER_AGGRO_ID)).toBe(true);
const playerHealth = useCombatStore.getState().health;
const allyHealth = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-20", undefined, 2_600))
.toMatchObject({ ok: true });
const afterCast = useCombatStore.getState();
expect(afterCast.comboPoints).toBe(0);
expect(afterCast.effects.some((effect) => effect.kind === "dot"
&& effect.tag === "rom-holy-seal" && effect.sourceActorId === PLAYER_AGGRO_ID)).toBe(false);
expect(afterCast.health).toBeGreaterThan(playerHealth);
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(allyHealth);
expect(afterCast.combatEvents.filter((event) => event.kind === "damage"
&& event.abilityId === "rom-knight-rom-priest-elite-20")).toHaveLength(1);
});
it("casts only the self or ally Holy Protection threat payload", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 30, role: "tank",
});
usePartyStore.getState().initializeParty("knight-priest-holy-protection", 30, useCombatStore.getState().health, "tank", 2, "rom");
const ally = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(ally.id, [3, 0, 0]);
expect(useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-25", undefined, 1_000))
.toMatchObject({ ok: true });
const selfProtection = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat"));
expect(selfProtection?.definition.modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "threat", value: 0.02 }),
]));
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat" && modifier.value < 0))).toBe(false);
usePartyStore.getState().selectMember(ally.id);
const allyCast = useCombatStore.getState().castAbility("rom-knight-rom-priest-elite-25", undefined, 2_600);
expect(allyCast).toMatchObject({ ok: true });
const allyProtection = useCombatStore.getState().auras.find((aura) => aura.targetId === ally.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat"));
expect(allyProtection?.definition.modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "threat", value: -0.02 }),
]));
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === ally.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat" && modifier.value > 0))).toBe(false);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romEffectsForCast } from "./romCombatMechanics";
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
const lightningRodPassive = ROM_ELITE_ABILITIES.find((ability) => ability.dbcSpellId === 492638)!;
const lightning = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490212)!;
const boneChill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491165)!;
const lightningRodTag = "rom-priest-mage-lightning-rod";
const boneChillTag = "rom-491165-501548-0";
describe("RoM Priest/Mage Lightning Rod", () => {
it("exposes the passive and Bone Chill child tags used by the cast hook", () => {
expect(lightningRodPassive.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({ utilityTags: [lightningRodTag] }),
}),
]));
expect(boneChill.effects.some((effect) => effect.kind === "dot" && effect.tag === boneChillTag)).toBe(true);
});
it("applies the 20-second Water/Wind resistance shock only with the passive and Bone Chill", () => {
const withoutPassive = romPriestKnightCastEffects(
lightning, lightning.effects, [], 1_000, [boneChillTag],
);
const withoutBoneChill = romPriestKnightCastEffects(
lightning, lightning.effects, [lightningRodTag], 1_000, [],
);
expect(withoutPassive).toEqual(lightning.effects);
expect(withoutBoneChill).toEqual(lightning.effects);
const withBothConditions = romPriestKnightCastEffects(
lightning, lightning.effects, [lightningRodTag], 1_000, [boneChillTag],
);
expect(withBothConditions).toContainEqual(expect.objectContaining({
kind: "apply-aura",
recipient: "ability-target",
triggerSpellId: 502907,
aura: expect.objectContaining({
id: "rom-priest-mage-lightning-rod-shock",
name: "Electric Shock",
disposition: "debuff",
durationMs: 20_000,
modifiers: [
{ kind: "stat", stat: "resistance:frost", operation: "flat", value: -50 },
{ kind: "stat", stat: "resistance:nature", operation: "flat", value: -50 },
],
}),
}));
});
it("keeps the proc on the shared RoM cast-effects path", () => {
const mechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 1_000,
targetHealthPercent: 1,
};
const withoutBoneChill = romEffectsForCast(lightning, {
...mechanics,
passiveTags: [lightningRodTag],
});
const withBoneChill = romEffectsForCast(lightning, {
...mechanics,
passiveTags: [lightningRodTag],
targetTags: [boneChillTag],
});
expect(withoutBoneChill.some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-priest-mage-lightning-rod-shock")).toBe(false);
expect(withBoneChill.some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === "rom-priest-mage-lightning-rod-shock")).toBe(true);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { romEffectsForCast } from "./romCombatMechanics";
const heal = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490261)!;
describe("RoM Priest/Scout Saint's Blessing", () => {
beforeEach(() => {
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-scout",
raceId: "rom-human",
level: 50,
});
usePartyStore.getState().initializeParty(
"rom-priest-saints-blessing",
50,
useCombatStore.getState().health,
"healer",
2,
"rom",
);
});
it("arms the extracted 10-second +30 healing and +1% healing aura only below 30% HP", () => {
const base = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 1_000,
targetHealthPercent: 1,
learnedPassiveSpellIds: [493039],
};
const lowTarget = romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.299 });
expect(lowTarget[0]).toMatchObject({
kind: "apply-aura",
recipient: "caster",
triggerSpellId: 493039,
aura: {
id: "rom-priest-saints-blessing-healing",
durationMs: 10_000,
modifiers: [
{ kind: "healing", direction: "done", operation: "flat", value: 30 },
{ kind: "healing", direction: "done", operation: "percent", value: 0.01 },
],
},
});
expect(lowTarget[1]).toEqual(heal.effects[0]);
expect(romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.3 })).toEqual(heal.effects);
expect(romEffectsForCast(heal, { ...base, friendlyTargetHealthPercent: 0.1, learnedPassiveSpellIds: [] }))
.toEqual(heal.effects);
});
it("uses the selected ally's current HP and applies the aura before the triggering Heal", () => {
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: Math.floor(candidate.maxHealth * 0.2) }
: candidate),
}));
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const injuredHealth = usePartyStore.getState().members[0]!.health;
expect(usePartyStore.getState().members[0]!.health / member.maxHealth).toBeLessThan(0.3);
expect(useCombatStore.getState().castAbility("rom-priest-primary-2", undefined, 1_000))
.toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 3_000);
const state = useCombatStore.getState();
const aura = state.auras.find((entry) => entry.targetId === PLAYER_AURA_ENTITY_ID
&& entry.definition.id === "rom-priest-saints-blessing-healing");
expect(aura).toMatchObject({ expiresAt: 13_000 });
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
expect(state.combatEvents.some((event) => event.kind === "healing"
&& event.abilityId === "rom-priest-primary-2"
&& event.targetActorId === member.id
&& event.rawAmount > 30)).toBe(true);
});
});
+5
View File
@@ -20,6 +20,11 @@ const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
491176: "Revives a dead friendly target outside combat with reduced death debt.",
491177: "Revives a dead friendly target outside combat with greatly reduced death debt.",
491147: "Quickly restores health to a friendly target.",
// The 1.5-second channel and 5-second cooldown are supplied by the current
// CoA Wiki entry for the matching skill ID 498770; healing and threshold
// buffs remain based on this repository's RuneWaker snapshot. CoA's added
// area-damage rider is intentionally not imported into this older payload.
498770: "Channels for 1.5 seconds, healing a friendly target four times. If the target finishes above 40% health, it gains 2% Physical and Magical Defense for 10 seconds. Below 40% health, the defense effect can gain two stacks per cast, up to eight stacks. Cooldown: 5 seconds.",
490256: "Sends a torrent at the target, dealing Water damage.",
490269: "Restores a friendly target's health every 2 seconds for 20 seconds.",
490300: "Makes you immune to damage for 5 seconds.",
+33
View File
@@ -0,0 +1,33 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
/** Select local Prayer of Tribulation buff stacks from its tooltip HP gates. */
export function romPriestWarlockCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
friendlyTargetHealthPercent: number,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker"
|| ability.classId !== "rom-priest"
|| ability.secondaryClassId !== "rom-warlock"
|| ability.dbcSpellId !== 498770) return effects;
const buff = effects.find((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura"
));
if (!buff || friendlyTargetHealthPercent === 0.4) return effects.filter((effect) => effect !== buff);
const stacks = friendlyTargetHealthPercent < 0.4 ? 2 : 1;
const stackedBuff: Extract<AbilityEffect, { kind: "apply-aura" }> = {
...buff,
aura: {
...buff.aura,
maxStacks: 8,
stackBehavior: "add",
modifiers: buff.aura.modifiers?.map((modifier) => ({ ...modifier, perStack: true })),
},
};
return [
...effects.filter((effect) => effect !== buff),
...Array.from({ length: stacks }, () => stackedBuff),
];
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { romEffectsForCast } from "./romCombatMechanics";
const prayer = abilityById("rom-priest-rom-warlock-elite-25")!;
function prayerEffects(targetHealthPercent: number) {
return romEffectsForCast(prayer, {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 1_000,
targetHealthPercent: 1,
friendlyTargetHealthPercent: targetHealthPercent,
});
}
describe("RoM Priest/Warlock Prayer of Tribulation", () => {
it("channels four source-authored friendly heals and fills missing timing from CoA", () => {
expect(prayer).toMatchObject({
dbcSpellId: 498770,
target: "friendly",
castMode: "channel",
castTimeMs: 1_500,
cooldownMs: 5_000,
channelPulses: 4,
});
const heals = prayer.effects.filter((effect) => effect.kind === "heal");
expect(heals).toHaveLength(4);
expect(prayer.effects.some((effect) => effect.kind === "damage")).toBe(false);
expect(channelPulseCount(prayer)).toBe(4);
expect(channelAmountEffects(prayer, 4).filter(({ effect }) => effect.kind === "heal"))
.toEqual(heals.map((effect) => ({ effect, multiplier: 0.25 })));
});
it("applies one +2% defense stack above 40%, two below, and none at 40%", () => {
const buffStacks = (effects: ReturnType<typeof prayerEffects>) => effects.filter((effect) => (
effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "armor"
))
));
expect(buffStacks(prayerEffects(0.41))).toHaveLength(1);
expect(buffStacks(prayerEffects(0.4))).toHaveLength(0);
expect(buffStacks(prayerEffects(0.39))).toHaveLength(2);
for (const effect of buffStacks(prayerEffects(0.39))) {
expect(effect).toMatchObject({
kind: "apply-aura",
aura: { durationMs: 10_000, maxStacks: 8, stackBehavior: "add" },
});
if (effect.kind === "apply-aura") {
expect(effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "armor" && modifier.value === 0.02 && modifier.perStack
))).toBe(true);
expect(effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "magical-defense" && modifier.value === 0.02 && modifier.perStack
))).toBe(true);
}
}
});
it("heals the selected ally over four pulses and applies two stacks below 40%", () => {
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-warlock",
raceId: "rom-dwarf",
level: 30,
role: "healer",
});
usePartyStore.getState().initializeParty("rom-priest-warlock-prayer", 30, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, member.maxHealth - 1);
// Keep the target below 40% through the four small heals so this cast
// exercises the tooltip's low-health double-stack branch at resolve time.
usePartyStore.setState((state) => ({
members: state.members.map((partyMember) => partyMember.id === member.id
? { ...partyMember, maxHealth: 10_000 }
: partyMember),
}));
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const healthAtCast = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility(prayer.id, undefined, 1_000)).toMatchObject({ ok: true });
const healthByPulse: number[] = [];
let previousAt = 1_000;
for (const now of [1_375, 1_750, 2_125, 2_500]) {
useCombatStore.getState().tick((now - previousAt) / 1_000, now);
healthByPulse.push(usePartyStore.getState().members[0]!.health);
previousAt = now;
}
expect(healthByPulse[0]).toBeGreaterThan(healthAtCast);
expect(healthByPulse[1]).toBeGreaterThan(healthByPulse[0]!);
expect(healthByPulse[2]).toBeGreaterThan(healthByPulse[1]!);
expect(healthByPulse[3]).toBeGreaterThan(healthByPulse[2]!);
const defenseAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === member.id && aura.definition.name.includes("Prayer of Tribulation")
));
expect(defenseAura?.stacks).toBe(2);
expect(defenseAura?.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "armor" && modifier.perStack
))).toBe(true);
});
});
+307
View File
@@ -0,0 +1,307 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { abilityById } from "./abilityCatalog";
import { clearMobRuntimeRegistry, getMobRuntimeTransform, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { LOOT_CATALOG } from "./lootCatalog";
import { abilityRequiresOutOfCombat } from "./abilityRules";
import { romEffectsForCast } from "./romCombatMechanics";
import { PLAYER_AGGRO_ID } from "./aggro";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import {
ROM_ROGUE_DAGGER_MASTERY_TAG,
ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID,
ROM_ROGUE_TREASURE_HUNTER_TAG,
romRogueDaggerDamageMultiplier,
romRogueShadowWalkerAdjustment,
romRogueTreasureHunterLootChance,
} from "./romRogueMechanics";
function effectAuras(abilityId: string) {
return abilityById(abilityId)!.effects.filter((effect) => effect.kind === "apply-aura");
}
function testWeapon(weaponType: string, instanceId: string, stats = { ...useCombatStore.getState().gearStats }) {
return {
instanceId,
itemId: 1,
itemKey: instanceId,
name: `Test ${weaponType}`,
quality: "uncommon" as const,
inventorySlot: weaponType === "crossbow" ? "two-hand" : "one-hand",
armorType: null,
weaponType,
itemLevel: 1,
sourceItemLevel: 1,
stats,
quantity: 1,
sourceBossKey: "test",
sourceBossName: "Test",
acquiredAt: 1,
};
}
describe("RoM Rogue source mechanics", () => {
afterEach(() => resetCombatSpatialProvider());
beforeEach(() => {
resetCombatSpatialProvider();
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
useCombatStore.setState({ playerPosition: [0, 0, 0] });
registerCombatSpatialProvider({
actor: (id) => {
if (id === PLAYER_AGGRO_ID) return { id, position: useCombatStore.getState().playerPosition, yaw: 0 };
const transform = getMobRuntimeTransform(id);
return transform ? { id, ...transform } : null;
},
hasLineOfSight: () => true,
projectGroundPoint: (point) => point,
});
});
it("keeps Dagger Mastery, Treasure Hunter, and Off Hand Mastery as scoped passive tags", () => {
const daggerMastery = effectAuras("rom-rogue-primary-3")[0];
const treasureHunter = effectAuras("rom-rogue-primary-10")[0];
expect(daggerMastery?.kind === "apply-aura" && daggerMastery.aura.utilityTags)
.toContain(ROM_ROGUE_DAGGER_MASTERY_TAG);
expect(treasureHunter?.kind === "apply-aura" && treasureHunter.aura.utilityTags)
.toContain(ROM_ROGUE_TREASURE_HUNTER_TAG);
expect(abilityById("rom-rogue-primary-12")!.effects).toEqual([]);
});
it("applies Dagger Mastery only to the Rogue player's physical melee hits with a main-hand dagger", () => {
const context = {
classId: "rom-rogue",
sourceActorId: "player",
playerActorId: "player",
attackKind: "melee",
school: "physical",
mainHandDagger: true,
passiveTags: [ROM_ROGUE_DAGGER_MASTERY_TAG],
};
expect(romRogueDaggerDamageMultiplier(context)).toBe(1.1);
expect(romRogueDaggerDamageMultiplier({ ...context, mainHandDagger: false })).toBe(1);
expect(romRogueDaggerDamageMultiplier({ ...context, classId: "rom-scout" })).toBe(1);
expect(romRogueDaggerDamageMultiplier({ ...context, attackKind: "spell" })).toBe(1);
expect(romRogueDaggerDamageMultiplier({ ...context, passiveTags: [] })).toBe(1);
});
it("adds Treasure Hunter only to the supported bonus loot chance and clamps at one", () => {
expect(romRogueTreasureHunterLootChance(0.2, false)).toBe(0.2);
expect(romRogueTreasureHunterLootChance(0.2, true)).toBeCloseTo(0.25);
expect(romRogueTreasureHunterLootChance(undefined, true)).toBeCloseTo(0.05);
expect(romRogueTreasureHunterLootChance(0.98, true)).toBe(1);
});
it("limits Shadow Walker's in-combat, cost, and cooldown rider to Rogue/Priest Sneak Attack", () => {
const sneakAttack = abilityById("rom-rogue-primary-9")!;
expect(romRogueShadowWalkerAdjustment(
"rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId,
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
)).toEqual({ allowsCombatUse: true, energyCostReduction: 10, cooldownIncreaseMs: 10_000 });
expect(romRogueShadowWalkerAdjustment(
"rom-rogue", "rom-scout", sneakAttack.source, sneakAttack.dbcSpellId,
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
expect(romRogueShadowWalkerAdjustment(
"rom-rogue", "rom-priest", sneakAttack.source, sneakAttack.dbcSpellId, [],
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
expect(romRogueShadowWalkerAdjustment(
"rom-rogue", "rom-priest", sneakAttack.source, 490306,
[ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID],
)).toEqual({ allowsCombatUse: false, energyCostReduction: 0, cooldownIncreaseMs: 0 });
});
it("adds one bonus boss loot roll only while Treasure Hunter is active", () => {
const bossKey = LOOT_CATALOG.bosses[0]!.bossKey;
const lootCount = (passiveActive: boolean) => {
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 80 });
if (!passiveActive) {
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_TREASURE_HUNTER_TAG)),
}));
}
useCombatStore.getState().registerMob("rogue-treasure-target", {
name: "Treasure Target", boss: true, maxHealth: 10, lootSourceId: bossKey,
});
const random = vi.spyOn(Math, "random").mockReturnValue(0);
try {
useCombatStore.getState().damageMob("rogue-treasure-target", 10, 1_000);
} finally {
random.mockRestore();
}
return useCombatStore.getState().mobs["rogue-treasure-target"]!.pendingLoot.length;
};
const ordinaryDrops = lootCount(false);
const treasureHunterDrops = lootCount(true);
expect(treasureHunterDrops).toBe(ordinaryDrops + 1);
});
it("keeps Soul Stab's unconditional first hit and gates only the second hit on Weakened", () => {
const ability = abilityById("rom-rogue-rom-warlock-elite-25")!;
const context = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 2_000,
targetHealthPercent: 1,
};
const ordinary = romEffectsForCast(ability, context).filter((effect) => effect.kind === "damage");
const weakened = romEffectsForCast(ability, { ...context, targetTags: ["rom-weakened"] })
.filter((effect) => effect.kind === "damage");
expect(ordinary).toHaveLength(1);
expect(weakened).toHaveLength(2);
});
it("marks Blind Spot and Sneak Attack as rear attacks and preserves Sneak Attack's combat gate", () => {
const blindSpot = abilityById("rom-rogue-primary-4")!;
const sneakAttack = abilityById("rom-rogue-primary-9")!;
expect(blindSpot.targeting?.requiresBehindTarget).toBe(true);
expect(sneakAttack.targeting?.requiresBehindTarget).toBe(true);
expect(abilityRequiresOutOfCombat(sneakAttack)).toBe(true);
});
it("rejects Blind Spot from the front and allows Sneak Attack from behind out of combat", () => {
const dagger = testWeapon("dagger", "rogue-position-dagger");
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", raceId: "rom-human", level: 80,
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
});
useCombatStore.getState().registerMob("rogue-rear-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("rogue-rear-target", [0, 0, 0]);
useCombatStore.getState().selectMob("rogue-rear-target");
useCombatStore.setState((state) => ({
playerPosition: [0, 0, 2],
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-4", undefined, 1_000))
.toMatchObject({ ok: false, reason: "must-be-behind" });
useCombatStore.setState((state) => ({ playerPosition: [0, 0, -2], resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 } }));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 2_000).ok).toBe(true);
});
it("rejects Sneak Attack while an enemy is already engaged", () => {
const dagger = testWeapon("dagger", "rogue-combat-dagger");
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", raceId: "rom-human", level: 80,
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
});
useCombatStore.getState().registerMob("rogue-engaged-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("rogue-engaged-target", [0, 0, 0]);
useCombatStore.getState().selectMob("rogue-engaged-target");
useCombatStore.setState((state) => ({
playerPosition: [0, 0, -2],
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
mobs: { ...state.mobs, "rogue-engaged-target": { ...state.mobs["rogue-engaged-target"]!, engaged: true } },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
});
it("applies Shadow Walker to combat Sneak Attack with the exact Energy and cooldown rider", () => {
const dagger = testWeapon("dagger", "rogue-shadow-walker-dagger");
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } },
});
expect(useCombatStore.getState().abilities.some((candidate) => (
candidate.dbcSpellId === ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID && candidate.passive
))).toBe(true);
useCombatStore.getState().registerMob("rogue-shadow-walker-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("rogue-shadow-walker-target", [0, 0, 0]);
useCombatStore.getState().selectMob("rogue-shadow-walker-target");
useCombatStore.setState((state) => ({
playerPosition: [0, 0, -2],
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
mobs: { ...state.mobs, "rogue-shadow-walker-target": { ...state.mobs["rogue-shadow-walker-target"]!, engaged: true } },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-9", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.energy).toBe(80);
expect(useCombatStore.getState().cooldowns["rom-rogue-primary-9"]).toBe(11_000);
});
it("enforces Energy Conservation's strict below-75% health threshold", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-warlock", raceId: "rom-human", level: 80,
});
const maximum = useCombatStore.getState().maxHealth;
const thresholdHealth = Math.ceil(maximum * 0.75);
useCombatStore.setState((state) => ({
health: thresholdHealth,
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState((state) => ({
health: thresholdHealth - 1,
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warlock-elite-20", undefined, 1_000).ok).toBe(true);
});
it("requires the Rogue/Knight Lion's Protection aura for Power of the Lion", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", secondaryClassId: "rom-knight", raceId: "rom-human", level: 80,
});
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100, mana: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-25", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-491552")))
.toBe(true);
expect(useCombatStore.getState().castAbility("rom-rogue-rom-knight-elite-40", undefined, 2_600).ok).toBe(true);
});
it("increases Shadowstab damage only with Dagger Mastery and a main-hand dagger", () => {
const baselineStats = { ...useCombatStore.getState().gearStats };
const hitWith = (weaponType: string, passiveActive: boolean, instanceId: string) => {
const weapon = testWeapon(weaponType, instanceId, baselineStats);
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue", raceId: "rom-human", level: 80,
inventory: [weapon], equipment: { player: { "main-hand": weapon.instanceId } },
});
const equipped = resolvedEquipmentLoadout(useCombatStore.getState().equipment, useCombatStore.getState().inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: "rom-rogue",
specializationId: null, role: "damage", level: 80,
});
expect(equipped["main-hand"]?.weaponType).toBe(weaponType);
if (!passiveActive) {
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)),
}));
}
useCombatStore.getState().registerMob("rogue-dagger-mastery-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("rogue-dagger-mastery-target", [0, 0, 3]);
useCombatStore.getState().selectMob("rogue-dagger-mastery-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
const before = useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health;
const cast = useCombatStore.getState().castAbility("rom-rogue-general-0", undefined, 1_000);
if (!cast.ok) throw new Error(JSON.stringify(cast));
return before - useCombatStore.getState().mobs["rogue-dagger-mastery-target"]!.health;
};
const baseDaggerDamage = hitWith("dagger", false, "rogue-mastery-dagger-without-passive");
const daggerDamage = hitWith("dagger", true, "rogue-mastery-dagger-with-passive");
const baseNonDaggerDamage = hitWith("crossbow", false, "rogue-mastery-crossbow-without-passive");
const nonDaggerDamage = hitWith("crossbow", true, "rogue-mastery-crossbow-with-passive");
expect(daggerDamage).toBeGreaterThan(baseDaggerDamage);
expect(daggerDamage / baseDaggerDamage).toBeCloseTo(1.1, 1);
expect(nonDaggerDamage).toBe(baseNonDaggerDamage);
});
});
+65
View File
@@ -0,0 +1,65 @@
export const ROM_ROGUE_DAGGER_MASTERY_TAG = "rom-dagger-mastery";
export const ROM_ROGUE_TREASURE_HUNTER_TAG = "rom-treasure-hunter";
export const ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID = 491537;
export interface RomRogueAbilityAdjustment {
readonly allowsCombatUse: boolean;
readonly energyCostReduction: number;
readonly cooldownIncreaseMs: number;
}
const NO_SHADOW_WALKER_ADJUSTMENT: RomRogueAbilityAdjustment = Object.freeze({
allowsCombatUse: false,
energyCostReduction: 0,
cooldownIncreaseMs: 0,
});
/** Shadow Walker's exact Rogue/Priest payload applies only to Sneak Attack. */
export function romRogueShadowWalkerAdjustment(
classId: string,
secondaryClassId: string | null | undefined,
abilitySource: string | undefined,
abilitySpellId: number,
passiveSpellIds: readonly number[],
): RomRogueAbilityAdjustment {
if (classId !== "rom-rogue"
|| secondaryClassId !== "rom-priest"
|| abilitySource !== "runewaker"
|| abilitySpellId !== 490337
|| !passiveSpellIds.includes(ROM_ROGUE_SHADOW_WALKER_PASSIVE_SPELL_ID)) {
return NO_SHADOW_WALKER_ADJUSTMENT;
}
return {
allowsCombatUse: true,
energyCostReduction: 10,
cooldownIncreaseMs: 10_000,
};
}
export interface RomRogueDaggerDamageContext {
readonly classId: string;
readonly sourceActorId: string;
readonly playerActorId: string;
readonly attackKind: string;
readonly school: string;
readonly mainHandDagger: boolean;
readonly passiveTags: readonly string[];
}
/** The RoM passive modifies only the player's physical melee hits while a dagger is equipped in the main hand. */
export function romRogueDaggerDamageMultiplier(context: RomRogueDaggerDamageContext): number {
return context.classId === "rom-rogue"
&& context.sourceActorId === context.playerActorId
&& context.attackKind === "melee"
&& context.school === "physical"
&& context.mainHandDagger
&& context.passiveTags.includes(ROM_ROGUE_DAGGER_MASTERY_TAG)
? 1.1
: 1;
}
/** Treasure Hunter adds one five percentage point bonus roll to the supported boss-loot path. */
export function romRogueTreasureHunterLootChance(baseChance: number | undefined, passiveActive: boolean): number {
const base = Number.isFinite(baseChance) ? Math.max(0, Math.min(1, baseChance!)) : 0;
return passiveActive ? Math.min(1, base + 0.05) : base;
}
+107
View File
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityById } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { resolveDamageValue, resolveStatValue } from "./combatAuras";
import { useCombatStore } from "./combatStore";
describe("RoM Scout ranged weapon masteries", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
});
it.each(["bow", "crossbow"] as const)(
"applies Ranged Weapon Mastery and Speed Shooting Mastery once with an equipped %s",
(weaponType) => {
const rangedWeapon = {
instanceId: `scout-mastery-${weaponType}`,
itemId: 1,
itemKey: `scout-mastery-${weaponType}`,
name: `Test ${weaponType}`,
quality: "uncommon" as const,
inventorySlot: "ranged",
armorType: null,
weaponType,
itemLevel: 1,
sourceItemLevel: 1,
stats: EMPTY_ITEM_STATS,
quantity: 1,
sourceBossKey: "test",
sourceBossName: "test",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
raceId: "rom-human",
level: 50,
inventory: [rangedWeapon],
equipment: { player: { ranged: rangedWeapon.instanceId } },
});
const state = useCombatStore.getState();
expect(state.equipment.player?.ranged).toBe(rangedWeapon.instanceId);
const playerAuras = state.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID);
const rangedDamageAuras = playerAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => (
modifier.kind === "damage"
&& modifier.direction === "dealt"
&& modifier.attackKind === "ranged"
&& modifier.operation === "percent"
)));
expect(rangedDamageAuras).toHaveLength(1);
expect(resolveDamageValue(100, playerAuras, { direction: "dealt", school: "physical", attackKind: "ranged" }))
.toBeCloseTo(108);
expect(resolveStatValue(0, playerAuras, "ranged-hit")).toBe(8);
expect(resolveStatValue(0, playerAuras, "physical-hit")).toBe(0);
expect(resolveStatValue(0, playerAuras, "ranged-haste")).toBe(11);
const masteryDamageModifiers = abilityById("rom-scout-primary-2")!.effects.flatMap((effect) => (
effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []
)).filter((modifier) => modifier.kind === "damage"
&& modifier.direction === "dealt"
&& modifier.attackKind === "ranged"
&& modifier.operation === "percent");
const speedMasteryModifiers = abilityById("rom-scout-general-8")!.effects.flatMap((effect) => (
effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []
)).filter((modifier) => modifier.kind === "stat" && modifier.stat === "ranged-haste");
expect(masteryDamageModifiers).toHaveLength(1);
expect(speedMasteryModifiers).toHaveLength(1);
},
);
it("Cripple Joint roots on Joint Blow hits, not on other Scout attacks", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
raceId: "rom-human",
level: 50,
});
for (const id of ["shot-target", "joint-target"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
setMobPosition(id, [3, 0, 0]);
}
// Make the proc deterministic here; its catalog regression separately checks the sourced 55% chance.
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.definition.utilityTags?.includes("rom-cripple-joint")
? {
...aura,
definition: {
...aura.definition,
procs: aura.definition.procs?.map((proc) => ({ ...proc, chance: 1 })),
},
}
: aura),
}));
useCombatStore.getState().selectMob("shot-target");
expect(useCombatStore.getState().castAbility("rom-scout-general-0", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["shot-target"]?.statuses.some((status) => status.kind === "root")).toBe(false);
useCombatStore.getState().selectMob("joint-target");
expect(useCombatStore.getState().castAbility("rom-scout-general-3", undefined, 2_600)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["joint-target"]?.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "root", endsAt: 7_600, sourceAbilityId: "rom-scout-general-3" }),
]));
});
});
+83 -3
View File
@@ -1,6 +1,10 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { AuraDefinition, AuraModifier, AuraProcDefinition } from "./combatAuras";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
import {
ROM_ROGUE_DAGGER_MASTERY_TAG,
ROM_ROGUE_TREASURE_HUNTER_TAG,
} from "./romRogueMechanics";
function firstDamage(effects: readonly AbilityEffect[]): AbilityEffect[] {
return effects.filter((effect) => effect.kind === "damage").slice(0, 1);
@@ -30,6 +34,24 @@ function taggedPassive(
}];
}
function taggedRoguePassive(sourceId: number, name: string, tag: string): AbilityEffect[] {
return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-aura:${sourceId}:rogue-passive`,
name,
disposition: "buff",
durationMs: null,
utilityTags: [tag],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: sourceId,
}];
}
function customProc(
sourceId: number,
trigger: "hit" | "crit",
@@ -78,6 +100,35 @@ export function overrideRomScoutRogueEffects(
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
switch (row.sourceSkillId) {
case 490504:
return taggedRoguePassive(490504, "Dagger Mastery", ROM_ROGUE_DAGGER_MASTERY_TAG);
case 490316:
return taggedRoguePassive(490316, "Treasure Hunter", ROM_ROGUE_TREASURE_HUNTER_TAG);
case 490143:
// The source applies damage and accuracy only to off-hand attacks; there
// is no distinct RoM off-hand hit yet, so do not turn either value into a
// generic player melee modifier.
return [];
case 491552:
if (row.classId !== "rom-rogue") return null;
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-491552"] },
} : effect);
case 490445: // Bow/crossbow accuracy must not grant melee hit; keep its range scope explicit.
return translated.filter((effect) => effect.kind !== "apply-aura" || effect.sourceAuraType !== 54)
.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: {
...effect.aura,
modifiers: effect.aura.modifiers?.map((modifier) => modifier.kind === "stat"
&& modifier.stat === "physical-hit"
? { ...modifier, stat: "ranged-hit" }
: modifier),
},
} : effect);
case 490505: // Bow and crossbow each carry an equivalent +11% attack-speed modifier in the source.
return translated.filter((effect) => effect.kind !== "apply-aura" || effect.sourceAuraType !== 70);
case 490400: // Piercing Arrow: one projectile, then a line hit behind its target.
return firstDamage(translated);
case 490424: { // Combo Shot: three impacts of the same source shot.
@@ -88,6 +139,8 @@ export function overrideRomScoutRogueEffects(
const hit = firstDamage(translated)[0];
return hit?.kind === "damage" ? [{ ...hit, falloffPerJump: 0.5 }] : translated;
}
case 498803: // Soul Stab's first hit is unconditional; its second hit requires Weakened.
return translated.filter((effect) => effect.kind === "damage");
case 490354: // Vanish is an aggro/stealth transition, not a self-heal.
return translated.filter((effect) => effect.kind !== "heal").map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] } }
@@ -108,6 +161,9 @@ export function overrideRomScoutRogueEffects(
case 492916:
return taggedPassive(492916, "Exploiting Shot", "rom-exploiting-shot",
customProc(492916, "crit", "rom-exploiting-shot", { internalCooldownMs: 20_000 }));
case 493006:
return taggedPassive(493006, "Accurate Targeting", "rom-accurate-targeting",
customProc(493006, "hit", "rom-accurate-targeting", { internalCooldownMs: 30_000 }));
case 493007:
return taggedPassive(493007, "Deadly Counterattack", "rom-deadly-counterattack",
customProc(493007, "hit", "rom-deadly-counterattack", { internalCooldownMs: 20_000 }));
@@ -193,6 +249,15 @@ export function romScoutRogueCastEffects(
falloffPerJump: passives.has("rom-poisonous-spit") ? 1 : effect.falloffPerJump,
} : effect);
}
if (ability.dbcSpellId === 498803) {
let seenDamage = 0;
const weakened = target.has("rom-weakened");
return effects.filter((effect) => {
if (effect.kind !== "damage") return true;
seenDamage += 1;
return seenDamage === 1 || weakened;
});
}
if (ability.dbcSpellId === 490451 && passives.has("rom-meridian-attack")) {
// 499537/623061 replaces Wrist Attack: -30 physical and magical damage for 10s.
return effects.map((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster"
@@ -298,9 +363,24 @@ export type RomScoutRogueProcActionSpec =
export function romScoutRogueProcActionSpec(
actionId: string,
triggeringSourceSkillId: number,
_casterPoisonous: boolean,
casterPoisonous: boolean,
): RomScoutRogueProcActionSpec | null {
switch (actionId) {
case "rom-accurate-targeting": {
if (triggeringSourceSkillId !== 490423) return null;
return {
kind: "aura",
recipient: "caster",
aura: {
id: "rom-scout-accurate-targeting",
name: "Accurate Targeting",
disposition: "buff",
durationMs: 15_000, // Source component 503255.
modifiers: [{ kind: "stat", stat: "physical-attack", operation: "flat", value: 50 }],
},
provenance: "source",
};
}
case "rom-exploiting-shot":
if (triggeringSourceSkillId !== 490423) return null;
return {
@@ -332,7 +412,7 @@ export function romScoutRogueProcActionSpec(
provenance: "hybrid-estimate",
};
case "rom-poisonous-spit":
if (![490457, 490424].includes(triggeringSourceSkillId)) return null;
if (!casterPoisonous || ![490457, 490424].includes(triggeringSourceSkillId)) return null;
return {
kind: "dot",
recipient: "target",
@@ -346,7 +426,7 @@ export function romScoutRogueProcActionSpec(
intervalMs: 2_000,
tag: "rom-poisonous-spit",
},
provenance: "source", // 499535/503522. Poison Powder is supplied without inventory gating.
provenance: "source", // 503522; the live Poison Powder aura gates the rider.
};
case "rom-reclaim-energy":
if (triggeringSourceSkillId === 490306) return {
+67 -5
View File
@@ -1,10 +1,14 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog";
import { abilitiesForCharacter, abilitiesForClass, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog";
import { applyAura } from "./combatAuras";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { romScoutRogueCastEffects, romScoutRogueProcActionSpec, romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides";
const scoutRogue = abilitiesForCharacter("rom-scout", "rom-rogue");
const scoutWarrior = abilitiesForCharacter("rom-scout", "rom-warrior");
const roguePrimary = abilitiesForClass("rom-rogue");
const rogueWarrior = abilitiesForCharacter("rom-rogue", "rom-warrior");
function skill(abilities: readonly AbilityDefinition[], id: string): AbilityDefinition {
@@ -37,6 +41,20 @@ function prime(primary: "rom-scout" | "rom-rogue", level: number): void {
useCombatStore.getState().selectMob("target");
}
function primeScoutWarrior(level: number): void {
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
secondaryClassId: "rom-warrior",
raceId: "rom-elf",
level,
});
for (const [id, x, z] of [["target", 3, 0], ["behind", 6, 0], ["nearby", 3, 3]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
setMobPosition(id, [x, 0, z]);
}
useCombatStore.getState().selectMob("target");
}
describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
beforeEach(() => clearMobRuntimeRegistry());
@@ -159,9 +177,10 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
});
it("proc resolver poisons Reflected or Combo Shot with unlimited Poison Powder", () => {
it("Poisonous Spit only adds its source poison while Poison Powder is active", () => {
for (const sourceId of [490457, 490424]) {
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toMatchObject({
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, false)).toBeNull();
expect(romScoutRogueProcActionSpec("rom-poisonous-spit", sourceId, true)).toMatchObject({
kind: "dot", recipient: "target", provenance: "source",
effect: { kind: "dot", ticks: 4, intervalMs: 2_000, basePoints: 7.1, pointsPerLevel: 2.1 },
});
@@ -171,14 +190,57 @@ describe("accepted RoM Scout/Rogue and Rogue/Warrior pairings", () => {
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0]?.internalCooldownMs : null).toBe(20_000);
});
it("executes the Scout/Rogue poison rider on a successful reflected hit", () => {
it("does not execute Poisonous Spit without Poison Powder, then poisons after it is applied", () => {
prime("rom-scout", 60);
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 2_600))
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-poisonous-spit"
&& effect.targetId === "target")).toBe(false);
const poisonPowder = roguePrimary.find((ability) => ability.dbcSpellId === 490329);
if (!poisonPowder) throw new Error("Missing Poison Powder");
const powderAura = poisonPowder.effects.find((effect) => effect.kind === "apply-aura");
if (powderAura?.kind !== "apply-aura") throw new Error("Poison Powder has no aura");
const applied = applyAura(useCombatStore.getState().auras, {
definition: powderAura.aura,
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
now: 3_000,
});
useCombatStore.setState({ auras: applied.auras });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-poisonous")))
.toBe(true);
expect(useCombatStore.getState().castAbility("rom-scout-primary-11", undefined, 22_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-poisonous-spit"
&& effect.targetId === "target")).toBe(true);
});
it("executes Accurate Targeting on a successful Shot for its sourced duration and modifier", () => {
primeScoutWarrior(60);
const passive = scoutWarrior.find((ability) => ability.dbcSpellId === 493006);
if (!passive) throw new Error("Missing Accurate Targeting");
const proc = passive.effects.find((effect) => effect.kind === "apply-aura");
expect(proc?.kind === "apply-aura" ? proc.aura.procs?.[0] : null).toMatchObject({
internalCooldownMs: 30_000,
action: { kind: "custom", id: "rom-accurate-targeting" },
});
expect(romScoutRogueProcActionSpec("rom-accurate-targeting", 490457, false)).toBeNull();
expect(romScoutRogueProcActionSpec("rom-accurate-targeting", 490423, false)).toMatchObject({
kind: "aura", recipient: "caster", provenance: "source", aura: {
durationMs: 15_000,
modifiers: [{ kind: "stat", stat: "physical-attack", operation: "flat", value: 50 }],
},
});
const shot = scoutRogue.find((ability) => ability.dbcSpellId === 490423);
if (!shot) throw new Error("Missing Shot");
expect(useCombatStore.getState().castAbility(shot.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-scout-accurate-targeting"
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "physical-attack" && modifier.value === 50))).toBe(true);
});
it("proc resolver routes Reclaim Energy by trigger skill", () => {
expect(romScoutRogueProcActionSpec("rom-reclaim-energy", 490306, false)).toMatchObject({
kind: "resource", resource: "energy", amount: 15, provenance: "hybrid-estimate",
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
const SMASH_ID = "rom-knight-rom-rogue-elite-20";
function registerTarget(): void {
useCombatStore.getState().registerMob("smash-target", {
name: "Smash target",
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition("smash-target", [2, 0, 0]);
useCombatStore.getState().selectMob("smash-target");
}
function playerDamageEvent(critical: boolean, id: number, sourceActorId = PLAYER_AGGRO_ID) {
return {
id,
occurredAt: id * 1_000,
kind: "damage" as const,
sourceActorId,
targetActorId: "smash-target",
abilityId: "rom-knight-primary-1",
school: "holy" as const,
rawAmount: 100,
effectiveAmount: 100,
overheal: 0,
absorbed: 0,
critical,
outcome: critical ? "crit" : "hit",
};
}
describe("RuneWaker Smash prerequisite", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
level: 80,
xp: 0,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("rom-smash-test", 80, useCombatStore.getState().health);
registerTarget();
});
it("rejects Smash without a preceding player critical damage event without spending health or cooldown", () => {
const before = useCombatStore.getState();
const result = before.castAbility(SMASH_ID, undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met", abilityId: SMASH_ID });
expect(useCombatStore.getState().health).toBe(before.health);
expect(useCombatStore.getState().cooldowns[SMASH_ID] ?? 0).toBe(0);
expect(useCombatStore.getState().mobs["smash-target"]?.health).toBe(1_000_000);
});
it("allows Smash after a real player critical hit and ignores the later cast event", () => {
useCombatStore.setState({
gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 1_000_000, hitRating: 1_000_000 },
});
const holySeal = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-knight-primary-1");
expect(holySeal).toBeDefined();
expect(useCombatStore.getState().castAbility(holySeal!.id, undefined, 2_000).ok).toBe(true);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.critical
&& event.effectiveAmount > 0
))).toBe(true);
const result = useCombatStore.getState().castAbility(SMASH_ID, undefined, 4_000);
expect(result).toMatchObject({ ok: true, abilityId: SMASH_ID });
expect(useCombatStore.getState().cooldowns[SMASH_ID]).toBe(9_000);
});
it("requires the latest player damage event to be a landed crit", () => {
useCombatStore.setState({ combatEvents: [
playerDamageEvent(true, 1),
playerDamageEvent(false, 2),
] });
expect(useCombatStore.getState().castAbility(SMASH_ID, undefined, 3_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState({ combatEvents: [playerDamageEvent(true, 1, "party-member")] });
expect(useCombatStore.getState().castAbility(SMASH_ID, undefined, 3_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
});
});
+142
View File
@@ -0,0 +1,142 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export const ROM_WARDEN_WILL_ATTACK_TAG = "rom-will-attack";
export const ROM_WARDEN_DAMAGE_TRANSFER_TAG = "rom-warden-damage-transfer";
export const ROM_WARDEN_DAMAGE_TRANSFER_FRACTION = 0.3;
const classPair = (ability: AbilityDefinition, primary: string, secondary: string): boolean => (
ability.classId === primary && ability.secondaryClassId === secondary
);
function casterAura(
ability: AbilityDefinition,
id: string,
name: string,
durationMs: number | null,
options: Pick<Extract<AbilityEffect, { kind: "apply-aura" }>["aura"], "controlImmunities" | "utilityTags">,
): Extract<AbilityEffect, { kind: "apply-aura" }> {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id,
name,
disposition: "buff",
durationMs,
...options,
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: ability.dbcSpellId,
};
}
/** Select source-described Warden and Warden-linked class payloads. */
export function romWardenCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
naturePower: number,
passiveTags: readonly string[] = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return effects;
if (ability.dbcSpellId === 493391 && ability.classId === "rom-warden") {
// The source row includes Will Attack's component, but it belongs on the
// target only when the Warden has learned that passive.
const reduction = effects.find((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173);
const baseEffects = effects.filter((effect) => !(effect.kind === "apply-aura" && effect.sourceAuraType === 173));
if (!passiveTags.includes(ROM_WARDEN_WILL_ATTACK_TAG) || !reduction || reduction.kind !== "apply-aura") {
return baseEffects;
}
return [...baseEffects, {
...reduction,
recipient: "ability-target",
aura: {
...reduction.aura,
id: "rom-warden-will-attack",
name: "Will Attack",
disposition: "debuff",
utilityTags: [...(reduction.aura.utilityTags ?? []), ROM_WARDEN_WILL_ATTACK_TAG],
},
}];
}
if (ability.dbcSpellId === 493399 && ability.classId === "rom-warden") {
return effects.map((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "taken"
&& modifier.operation === "percent"
&& modifier.value === -ROM_WARDEN_DAMAGE_TRANSFER_FRACTION)
? {
...effect,
aura: {
...effect.aura,
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_WARDEN_DAMAGE_TRANSFER_TAG],
},
}
: effect);
}
if (ability.dbcSpellId === 494043 && classPair(ability, "rom-rogue", "rom-warden")) {
// The source describes a chance on the second hit but contains no proc rate.
// Keep the guaranteed base strike and do not turn an unknown chance into 100%.
let keptBaseHit = false;
return effects.filter((effect) => {
if (effect.kind !== "damage") return true;
if (keptBaseHit) return false;
keptBaseHit = true;
return true;
});
}
if (ability.dbcSpellId === 499495 && classPair(ability, "rom-warrior", "rom-warden")) {
return [
casterAura(ability, "rom-warden-awakening-root-slow-immunity", ability.name, 10_000, {
controlImmunities: ["root", "slow"],
utilityTags: ["rom-awakening-of-the-wild-immunity"],
}),
casterAura(ability, "rom-warden-awakening-next-critical", ability.name, null, {
utilityTags: ["next-offensive-critical", "rom-awakening-of-the-wild-next-critical"],
}),
];
}
if (ability.dbcSpellId === 494323 && classPair(ability, "rom-druid", "rom-warden")) {
const heals = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
if (!heals.length) return effects;
if (naturePower < 1) return [heals[0]!];
return [
...heals.slice(0, 2),
{ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" },
];
}
if (ability.dbcSpellId === 499936 && classPair(ability, "rom-druid", "rom-warden")) {
const points = Math.max(0, Math.floor(naturePower));
if (points < 1) return [];
const heal = effects.find((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
if (!heal) return effects;
return [
{
...heal,
coefficient: 0,
percentMaxHealth: Math.min(1, points * 0.1),
basePoints: 0,
dieSides: 0,
pointsPerLevel: 0,
coefficientPerLevel: 0,
bonusPowerCoefficient: 0,
},
{ kind: "resource-drain", amount: points, burn: false, resource: "nature-power" },
];
}
if (ability.dbcSpellId === 494372 && classPair(ability, "rom-druid", "rom-warden")) {
return effects.map((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.length
? { ...effect, scope: "party" }
: effect);
}
return effects;
}
+300
View File
@@ -0,0 +1,300 @@
import { beforeEach, describe, expect, it } from "vitest";
import { ROM_ALL_ABILITIES, romTriggeredAbilityBySpellId } from "./romAbilityCatalog";
import { applyAura, absorbDamage } from "./combatAuras";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { usePartyStore } from "./partyStore";
import { romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
import {
ROM_WARDEN_DAMAGE_TRANSFER_FRACTION,
ROM_WARDEN_DAMAGE_TRANSFER_TAG,
ROM_WARDEN_WILL_ATTACK_TAG,
} from "./romWardenOverrides";
const emptyContext: RomCastMechanics = {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 2_000,
targetHealthPercent: 1,
};
function effectsFor(spellId: number, context: Partial<RomCastMechanics> = {}) {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === spellId)!;
expect(ability).toBeDefined();
return romEffectsForCast(ability, { ...emptyContext, ...context });
}
function initializeRomCharacter(
classId: "rom-warden" | "rom-warrior" | "rom-druid",
secondaryClassId?: "rom-warden" | "rom-warrior",
) {
useCombatStore.getState().initializeCharacter({
classId,
...(secondaryClassId ? { secondaryClassId } : {}),
raceId: classId === "rom-warrior" ? "rom-human" : "rom-elf",
level: 80,
});
useCombatStore.setState((state) => ({
resource: 1_000,
maxResource: 1_000,
resourcePools: { ...state.resourcePools, mana: 1_000, rage: 1_000, "nature-power": 0 },
maxResourcePools: { ...state.maxResourcePools, mana: 1_000, rage: 1_000, "nature-power": 100 },
}));
}
describe("RoM Warden and Warden-linked source behaviors", () => {
beforeEach(() => {
clearPartyRuntimeRegistry();
clearMobRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("gates Will Attack's 10% damage reduction on the passive and places it on Frantic Briar's target", () => {
const passive = effectsFor(494610).find((effect) => effect.kind === "apply-aura");
expect(passive?.kind).toBe("apply-aura");
if (passive?.kind === "apply-aura") {
expect(passive.aura.utilityTags).toContain(ROM_WARDEN_WILL_ATTACK_TAG);
expect(passive.aura.modifiers ?? []).toHaveLength(0);
}
const withoutPassive = effectsFor(493391);
expect(withoutPassive.some((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173)).toBe(false);
expect(withoutPassive.some((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 24)).toBe(true);
expect(withoutPassive.filter((effect) => effect.kind === "damage")).toHaveLength(2);
const withPassive = effectsFor(493391, { passiveTags: [ROM_WARDEN_WILL_ATTACK_TAG] });
expect(withPassive.filter((effect) => effect.kind === "damage")).toHaveLength(2);
expect(withPassive.filter((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 173)).toHaveLength(1);
expect(withPassive.find((effect) => effect.kind === "apply-aura" && effect.aura.id === "rom-warden-will-attack"))
.toMatchObject({
kind: "apply-aura",
recipient: "ability-target",
aura: {
name: "Will Attack",
disposition: "debuff",
durationMs: 10_000,
modifiers: [{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "percent", value: -0.1 }],
},
});
});
it("reduces a Frantic Briar target's melee hit only while Will Attack is learned", () => {
const incomingDamage = (withWillAttack: boolean) => {
clearMobRuntimeRegistry();
initializeRomCharacter("rom-warden", "rom-warrior");
if (!withWillAttack) {
useCombatStore.setState((state) => ({
auras: state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG)),
}));
}
const mobId = "will-attack-target";
useCombatStore.getState().registerMob(mobId, {
name: "Will Attack target",
maxHealth: 100_000,
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "will-attack-swing", initialCooldownMs: [0, 0] }],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
const hasPassive = useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(ROM_WARDEN_WILL_ATTACK_TAG)
));
expect(hasPassive).toBe(withWillAttack);
expect(useCombatStore.getState().castAbility("rom-warden-primary-11", undefined, 1_000))
.toMatchObject({ ok: true });
const targetDebuff = useCombatStore.getState().auras.some((aura) => (
aura.targetId === mobId && aura.definition.id === "rom-warden-will-attack"
));
expect(targetDebuff).toBe(withWillAttack);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
return useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]);
};
const normalDamage = incomingDamage(false);
const reducedDamage = incomingDamage(true);
expect(normalDamage).toBeGreaterThan(0);
expect(reducedDamage).toBeCloseTo(normalDamage * 0.9, 0);
});
it("keeps Nature Crystal active and makes its owner immune while blocking attack skills", () => {
initializeRomCharacter("rom-warden");
const now = Date.now();
expect(useCombatStore.getState().castAbility("rom-warden-primary-6", undefined, now)).toMatchObject({ ok: true });
useCombatStore.getState().tick(4, now + 4_000);
const crystal = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102325)!;
expect(crystal.expiresAt).toBeGreaterThan(now + 60_000);
const health = useCombatStore.getState().health;
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(0);
expect(useCombatStore.getState().health).toBe(health);
useCombatStore.getState().registerMob("warden-crystal-target", { name: "Target", maxHealth: 10_000 });
useCombatStore.getState().selectMob("warden-crystal-target");
expect(useCombatStore.getState().castAbility("rom-warden-primary-0", undefined, now + 6_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
});
it("scales Crystalline Protection to 10% of each recipient's own maximum health", () => {
initializeRomCharacter("rom-warden");
usePartyStore.getState().initializeParty("warden-crystalline-protection", 80, useCombatStore.getState().health, "healer", 3, "rom");
const [first, second] = usePartyStore.getState().members;
expect(first).toBeDefined();
expect(second).toBeDefined();
const members = usePartyStore.getState().members.map((member, index) => ({
...member,
maxHealth: index === 0 ? 2_000 : 5_000,
health: index === 0 ? 1_000 : 2_500,
}));
usePartyStore.setState({ members });
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [2 + index, 0, 0]));
const playerMaxHealthBeforeCast = useCombatStore.getState().maxHealth;
expect(useCombatStore.getState().castAbility("rom-warden-primary-23", undefined, Date.now())).toMatchObject({ ok: true });
const auras = useCombatStore.getState().auras;
const absorbFor = (targetId: string) => auras.find((aura) => aura.targetId === targetId
&& aura.definition.name === "Crystalline Protection")?.absorbs[0]?.remaining;
expect(absorbFor(PLAYER_AURA_ENTITY_ID)).toBe(Math.round(playerMaxHealthBeforeCast * 0.1));
expect(absorbFor(members[0]!.id)).toBe(200);
expect(absorbFor(members[1]!.id)).toBe(500);
});
it("absorbs exactly two Heart of the Oak attack packets and leaves ordinary point shields unchanged", () => {
initializeRomCharacter("rom-warden");
expect(useCombatStore.getState().castAbility("rom-warden-primary-10", undefined, Date.now())).toMatchObject({ ok: true });
expect(useCombatStore.getState().damagePlayer(100, "physical")).toBe(0);
expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(0);
expect(useCombatStore.getState().damagePlayer(100, "physical")).toBeGreaterThan(0);
const flatShield = applyAura([], {
definition: {
id: "test:flat-absorb",
name: "Flat absorb",
disposition: "buff",
durationMs: 10_000,
absorbs: [{ id: "pool", amount: 5 }],
},
sourceId: "test-source",
targetId: "target",
now: 1,
}).auras;
const result = absorbDamage(flatShield, {
targetId: "target",
amount: 10,
school: "physical",
now: 2,
countsAsAttack: true,
});
expect(result).toMatchObject({ absorbed: 5, remainingDamage: 5 });
});
it("transfers 30% of incoming damage to a nearby Warden pet and disables the reduction without one", () => {
const incomingHealthLoss = (withPet: boolean, petDistance = 0) => {
initializeRomCharacter("rom-warden");
const now = Date.now();
if (withPet) {
const summonAbility = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493333)!;
expect(useCombatStore.getState().castAbility(summonAbility.id, undefined, now)).toMatchObject({ ok: true });
const summonCast = useCombatStore.getState().activeCast;
if (summonCast) useCombatStore.getState().tick(0.1, summonCast.completesAt);
useCombatStore.setState((state) => ({
summons: state.summons.map((summon) => summon.creatureId === 102297
? { ...summon, expiresAt: now + 20_000 }
: summon),
}));
}
const petBefore = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102297);
if (petBefore && petDistance > 0) {
useCombatStore.setState((state) => ({
summons: state.summons.map((summon) => summon.id === petBefore.id
? { ...summon, position: [petDistance, 0, 0] }
: summon),
}));
}
const transferAbility = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 493399)!;
expect(useCombatStore.getState().castAbility(transferAbility.id, undefined, now + 6_000)).toMatchObject({ ok: true });
const playerHealthBefore = useCombatStore.getState().health;
const petHealthBefore = useCombatStore.getState().summons.find((summon) => summon.creatureId === 102297)?.health ?? 0;
useCombatStore.getState().damagePlayer(500, "physical", 80, now + 7_000);
const state = useCombatStore.getState();
const petHealthAfter = state.summons.find((summon) => summon.creatureId === 102297)?.health ?? 0;
return {
playerDamage: playerHealthBefore - state.health,
petDamage: petHealthBefore - petHealthAfter,
};
};
const unshared = incomingHealthLoss(false);
const unsharedDamage = unshared.playerDamage;
const outOfRange = incomingHealthLoss(true, 301);
expect(unshared.petDamage).toBe(0);
expect(outOfRange.playerDamage).toBe(unsharedDamage);
expect(outOfRange.petDamage).toBe(0);
const shared = incomingHealthLoss(true);
expect(shared.petDamage).toBeGreaterThan(0);
expect(shared.playerDamage + shared.petDamage).toBeCloseTo(unsharedDamage, -1);
expect(shared.petDamage / (shared.playerDamage + shared.petDamage))
.toBeCloseTo(ROM_WARDEN_DAMAGE_TRANSFER_FRACTION, 1);
});
it("removes the unquantified Weak Point Strike bonus and models Awakening immunity and next critical", () => {
const weakPoint = effectsFor(494043);
expect(weakPoint.filter((effect) => effect.kind === "damage")).toHaveLength(1);
const awakening = effectsFor(499495);
const auras = awakening.filter((effect) => effect.kind === "apply-aura");
expect(auras).toHaveLength(2);
expect(auras).toEqual(expect.arrayContaining([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({ durationMs: 10_000, controlImmunities: ["root", "slow"] }),
}),
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({
durationMs: null,
utilityTags: expect.arrayContaining(["next-offensive-critical"]),
}),
}),
]));
expect(auras.some((effect) => effect.kind === "apply-aura" && effect.control)).toBe(false);
});
it("spends one Nature Power for Healing Ripple's additional heal", () => {
const baseline = effectsFor(494323, { naturePower: 0 });
const empowered = effectsFor(494323, { naturePower: 1 });
expect(baseline.filter((effect) => effect.kind === "heal")).toHaveLength(1);
expect(baseline.some((effect) => effect.kind === "resource-drain")).toBe(false);
expect(empowered.filter((effect) => effect.kind === "heal")).toHaveLength(2);
expect(empowered).toContainEqual({ kind: "resource-drain", amount: 1, burn: false, resource: "nature-power" });
});
it("scales Gift Pulse by spent Nature Power and targets nearby party members", () => {
expect(effectsFor(499936, { naturePower: 0 })).toEqual([]);
const empowered = effectsFor(499936, { naturePower: 3 });
const empoweredHeal = empowered.find((effect) => effect.kind === "heal");
expect(empoweredHeal?.kind).toBe("heal");
if (empoweredHeal?.kind === "heal") expect(empoweredHeal.percentMaxHealth).toBeCloseTo(0.3);
expect(empowered.find((effect) => effect.kind === "resource-drain"))
.toEqual({ kind: "resource-drain", amount: 3, burn: false, resource: "nature-power" });
expect(effectsFor(499936, { naturePower: 20 }).find((effect) => effect.kind === "heal"))
.toMatchObject({ percentMaxHealth: 1 });
expect(ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 499936))
.toMatchObject({ targeting: { shape: "circle", radius: 10 } });
});
it("puts Mysterious Grace modifier auras on the party, and leaves Revival's absent state unresolved", () => {
const grace = effectsFor(494372);
const modifierAuras = grace.filter((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.length);
expect(modifierAuras.length).toBeGreaterThan(0);
expect(modifierAuras.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true);
expect(grace.filter((effect) => effect.kind === "apply-aura" && !effect.aura.modifiers?.length)
.every((effect) => effect.kind === "apply-aura" && effect.scope === undefined)).toBe(true);
expect(romTriggeredAbilityBySpellId(499544)).toBeNull();
});
});
+195
View File
@@ -0,0 +1,195 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
export const ROM_WARLOCK_STATE_TAGS = Object.freeze({
heartFence: "rom-warlock-state-501577",
perceptionExtraction: "rom-warlock-state-497960",
soulPain: "rom-warlock-state-497964",
soulPoisonedFang: "rom-warlock-state-498670",
} as const);
const CRIME_AND_PUNISHMENT_STATE_TAGS = [
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
ROM_WARLOCK_STATE_TAGS.soulPain,
ROM_WARLOCK_STATE_TAGS.soulPoisonedFang,
] as const;
function appendAuraTag(effect: AbilityEffect, tag: string): AbilityEffect {
if (effect.kind !== "apply-aura") return effect;
return {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), tag] },
};
}
/** Source-specific state markers and recipient mapping for the Warlock's paired skills. */
export function overrideRomWarlockEffects(
row: RomGeneratedAbilityLike,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.classId !== "rom-warlock") return null;
if (row.sourceSkillId === 498549) {
// Defense Net's active component supplies the 5-second magic mitigation;
// the source tooltip also describes immunity to Helpless, Stun, Root, and
// Slow. In this runtime, Helpless maps to fear control.
return translated.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 142
? {
...effect,
aura: {
...effect.aura,
controlImmunities: ["fear", "stun", "root", "slow"],
},
}
: effect);
}
if (row.sourceSkillId === 498710 || row.sourceSkillId === 498662) {
const targetAbilityName = row.sourceSkillId === 498710 ? "Flaming Heart Strike" : "Rising Tide";
return [
...translated.filter((effect) => effect.kind !== "cooldown-reset"),
{ kind: "cooldown-reset", mode: "reset", abilityName: targetAbilityName },
];
}
if ([498634, 498648, 498668].includes(row.sourceSkillId ?? 0)) {
// These passive tooltips describe event-gated resets. Keep their cooldown
// riders out of the generic cast path until their source events are wired.
return translated.filter((effect) => effect.kind !== "cooldown-reset");
}
if (row.sourceSkillId === 498695) {
let selectedTargetThreatAura = false;
const selectedTargetEffects: AbilityEffect[] = translated.flatMap((effect): AbilityEffect[] => {
if (effect.kind !== "apply-aura") return [effect];
const threatModifiers = effect.aura.modifiers?.filter((modifier) => (
modifier.kind === "stat" && modifier.stat === "threat"
)) ?? [];
const threatModifier = threatModifiers[0];
if (!threatModifier || threatModifier.kind !== "stat") return [effect];
if (threatModifier.value > 0) {
// 621654 repeats the +5% modifier on a hidden script component; the tooltip
// describes one buff on the selected friendly target.
if (selectedTargetThreatAura) return [];
selectedTargetThreatAura = true;
return [{ ...effect, recipient: "ability-target" }];
}
if (threatModifier.value < 0) {
return [{ ...effect, recipient: "caster", scope: "party" }];
}
return [effect];
});
const reductionComponent = row.source?.components?.find((component) => component.id === 621433);
const reductionModifier = reductionComponent?.abilityModifiers?.find((modifier) => modifier.type === 138);
if (!reductionComponent || !reductionModifier) return selectedTargetEffects;
const partyReductionAura: AbilityEffect = {
kind: "apply-aura",
aura: {
id: "rom-warlock-liquidation-party-threat-reduction",
name: `${row.name}: party threat reduction`,
disposition: "buff",
durationMs: Math.max(0, Math.round((reductionComponent.effectTime ?? 10) * 1_000)),
modifiers: [{
kind: "stat",
stat: "threat",
operation: "percent",
value: reductionModifier.value / 100,
}],
},
recipient: "caster",
scope: "party",
sourceEffectType: -1,
sourceAuraType: 138,
miscValue: reductionModifier.value,
triggerSpellId: row.sourceSkillId ?? 0,
};
return [...selectedTargetEffects, partyReductionAura];
}
const stateTag = row.sourceSkillId === 497960
? ROM_WARLOCK_STATE_TAGS.perceptionExtraction
: row.sourceSkillId === 497964
? ROM_WARLOCK_STATE_TAGS.soulPain
: row.sourceSkillId === 498670
? ROM_WARLOCK_STATE_TAGS.soulPoisonedFang
: null;
if (stateTag) {
return translated.map((effect) => effect.kind === "dot"
? { ...effect, tag: stateTag }
: appendAuraTag(effect, stateTag));
}
if (row.source?.components?.some((component) => component.id === 501577)) {
return translated.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 226
? appendAuraTag(effect, ROM_WARLOCK_STATE_TAGS.heartFence)
: effect);
}
return null;
}
function scaleDamage(effect: AbilityEffect, factor: number): AbilityEffect {
if (effect.kind !== "damage") return effect;
return {
...effect,
coefficient: effect.coefficient * factor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * factor }),
};
}
/** Add only the source-described conditional riders for the Warlock primary build. */
export function romWarlockCastEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
targetTags: readonly string[],
learnedPassiveSpellIds: readonly number[] = [],
): readonly AbilityEffect[] {
if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return effects;
if (ability.dbcSpellId === 498549
&& learnedPassiveSpellIds.includes(498648)) {
// Mind Shield (498648) reduces Defense Net's sourced five-second aura by one second.
return effects.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 142
? {
...effect,
aura: { ...effect.aura, durationMs: Math.max(0, (effect.aura.durationMs ?? 0) - 1_000) },
}
: effect);
}
if (ability.dbcSpellId === 498646 && ability.secondaryClassId === "rom-warrior"
&& targetTags.includes(ROM_WARLOCK_STATE_TAGS.heartFence)) {
return [...effects, { kind: "resource", amount: 100, percentage: true, resource: "rage" }];
}
if (ability.dbcSpellId === 498700 && ability.secondaryClassId === "rom-rogue") {
const matchedStates = CRIME_AND_PUNISHMENT_STATE_TAGS.filter((tag) => targetTags.includes(tag));
const additionalStates = Math.max(0, matchedStates.length - 1);
return additionalStates > 0
? effects.map((effect) => scaleDamage(effect, 1 + additionalStates * 0.25))
: effects;
}
return effects;
}
/** Return state tags whose source durations are refreshed by this successful cast. */
export function romWarlockStateRefreshTags(
ability: AbilityDefinition,
targetTags: readonly string[],
): readonly string[] {
if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return [];
if (ability.dbcSpellId === 498646 && ability.secondaryClassId === "rom-warrior") {
return targetTags.includes(ROM_WARLOCK_STATE_TAGS.heartFence)
? [ROM_WARLOCK_STATE_TAGS.heartFence]
: [];
}
if (ability.dbcSpellId === 498700 && ability.secondaryClassId === "rom-rogue") {
return CRIME_AND_PUNISHMENT_STATE_TAGS.filter((tag) => targetTags.includes(tag));
}
return [];
}
+324
View File
@@ -0,0 +1,324 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { AbilityEffect } from "./abilityCatalog";
import { abilitiesForCharacter, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { cooldownResetCandidates } from "./cooldownReset";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { romEffectsForCast } from "./romCombatMechanics";
import {
ROM_WARLOCK_STATE_TAGS,
} from "./romWarlockOverrides";
const TARGET_ID = "rom-warlock-runtime-target";
function ability(spellId: number) {
const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId);
expect(result, `RoM Warlock spell ${spellId}`).toBeDefined();
return result!;
}
function finishCast(now: number) {
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt);
}
function prepareWarlock(secondaryClassId: "rom-warrior" | "rom-rogue" | "rom-priest") {
useCombatStore.getState().initializeCharacter({
classId: "rom-warlock",
secondaryClassId,
raceId: "rom-human",
level: 80,
});
useCombatStore.setState((state) => ({
resource: 100,
maxResource: 100,
resourcePools: {
...state.resourcePools,
focus: 100,
rage: 0,
energy: 100,
psi: 0,
},
maxResourcePools: {
...state.maxResourcePools,
focus: 100,
rage: 100,
energy: 100,
psi: 6,
},
}));
}
function registerTarget() {
useCombatStore.getState().registerMob(TARGET_ID, { name: "Warlock Test Target", maxHealth: 100_000 });
setMobPosition(TARGET_ID, [5, 0, 0]);
}
describe("RuneWaker Warlock runtime", () => {
beforeAll(async () => {
await preloadAbilityCatalogsForClasses(["rom-warlock"]);
});
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("converts the RoM Warlock/Warrior Rage pool to psi at 35:1", () => {
prepareWarlock("rom-warrior");
useCombatStore.setState((state) => ({
resourcePools: { ...state.resourcePools, rage: 70, psi: 1 },
}));
const mindTransference = ability(498642);
expect(mindTransference.costs).toEqual([]);
expect(useCombatStore.getState().castAbility(mindTransference.id, { now: 1_000 }).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
expect(useCombatStore.getState().resourcePools.psi).toBe(3);
});
it("consumes all Rage for Heart Fence and extends its duration by one second per 20 Rage", () => {
prepareWarlock("rom-warrior");
usePartyStore.getState().initializeParty(
"rom-warlock-heart-fence", 80, useCombatStore.getState().health, "damage", 3, "rom",
);
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [4, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 45 } }));
const heartFence = ability(498645);
expect(useCombatStore.getState().castAbility(heartFence.id, {
targetActorId: member.id,
now: 1_000,
}).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
const defenseAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id
&& aura.definition.name.startsWith("Heart Fence")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken"));
expect(defenseAuras).toHaveLength(2);
expect(defenseAuras.every((aura) => aura.expiresAt === 5_000)).toBe(true);
});
it("only refreshes state 501577 and restores Rage when Consistent Heart Strike finds it", () => {
prepareWarlock("rom-warrior");
registerTarget();
const heartStrike = ability(498646);
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 25 } }));
expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 500 }).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(25);
const soulTrauma = ability(498550);
const traumaCast = useCombatStore.getState().castAbility(soulTrauma.id, { targetActorId: TARGET_ID, now: 2_000 });
expect(traumaCast, JSON.stringify(traumaCast)).toMatchObject({ ok: true });
const heartStatesAfterTrauma = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence));
expect(heartStatesAfterTrauma.some((aura) => aura.expiresAt === 17_000)).toBe(true);
useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 20 } }));
expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 5_000 }).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(100);
const heartStatesAfterStrike = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence));
expect(heartStatesAfterStrike.length).toBeGreaterThan(0);
expect(heartStatesAfterStrike.every((aura) => aura.expiresAt === 20_000)).toBe(true);
});
it("scales Crime and Punishment by 25% per state after the first", () => {
const crimeAndPunishment = ability(498700);
const baseDamage = crimeAndPunishment.effects.find((effect) => effect.kind === "damage");
if (baseDamage?.kind !== "damage") throw new Error("Missing Crime and Punishment damage effect");
const effectsFor = (targetTags: readonly string[]) => romEffectsForCast(crimeAndPunishment, {
sealStacks: 0,
bleedCount: 0,
charged: false,
mainHandDagger: false,
casterMaxHealth: 10_000,
targetHealthPercent: 1,
targetTags,
});
const oneState = effectsFor([
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
]).find((effect) => effect.kind === "damage");
const twoStates = effectsFor([
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
ROM_WARLOCK_STATE_TAGS.soulPain,
]).find((effect) => effect.kind === "damage");
const threeStates = effectsFor([
ROM_WARLOCK_STATE_TAGS.perceptionExtraction,
ROM_WARLOCK_STATE_TAGS.soulPain,
ROM_WARLOCK_STATE_TAGS.soulPoisonedFang,
]).find((effect) => effect.kind === "damage");
expect(oneState).toEqual(baseDamage);
expect(twoStates).toMatchObject({
coefficient: baseDamage.coefficient * 1.25,
basePoints: baseDamage.basePoints! * 1.25,
});
expect(threeStates).toMatchObject({
coefficient: baseDamage.coefficient * 1.5,
basePoints: baseDamage.basePoints! * 1.5,
});
});
it("refreshes the active three Warlock state effects after Crime and Punishment", () => {
prepareWarlock("rom-rogue");
registerTarget();
for (const spellId of [497960, 497964, 498670]) {
const stateSpell = ability(spellId);
const castAt = spellId === 497960 ? 1_000 : spellId === 497964 ? 2_500 : 4_000;
expect(useCombatStore.getState().castAbility(stateSpell.id, { targetActorId: TARGET_ID, now: castAt }).ok).toBe(true);
finishCast(castAt);
}
useCombatStore.getState().tick(2.1, 6_100);
const before = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot"
&& effect.targetId === TARGET_ID
&& ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`));
expect(before.map((effect) => effect.remainingTicks).every((ticks) => ticks > 0)).toBe(true);
expect(before).toHaveLength(3);
expect(before.every((effect) => {
const sourceDot = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId)
?.effects.find((entry): entry is Extract<AbilityEffect, { kind: "dot" }> => (
entry.kind === "dot" && entry.tag === effect.tag
));
if (sourceDot?.kind !== "dot") return false;
return effect.remainingTicks < sourceDot.ticks;
})).toBe(true);
const crimeAndPunishment = ability(498700);
expect(useCombatStore.getState().castAbility(crimeAndPunishment.id, { targetActorId: TARGET_ID, now: 6_200 }).ok).toBe(true);
const after = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot"
&& effect.targetId === TARGET_ID
&& ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`));
expect(after).toHaveLength(before.length);
expect(after.every((effect) => effect.nextTickAt > 6_200)).toBe(true);
expect(after.map((effect) => effect.remainingTicks)).toEqual(before.map((effect) => {
const source = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId);
return source?.effects.find((entry): entry is Extract<AbilityEffect, { kind: "dot" }> => (
entry.kind === "dot" && entry.tag === effect.tag
))?.ticks;
}));
});
it("applies Liquidation Suffering to its selected ally and the rest of the party", () => {
prepareWarlock("rom-rogue");
usePartyStore.getState().initializeParty(
"rom-warlock-liquidation-suffering", 80, useCombatStore.getState().health, "damage", 3, "rom",
);
const [selectedAlly, otherAlly] = usePartyStore.getState().members;
expect(selectedAlly).toBeDefined();
expect(otherAlly).toBeDefined();
registerPartyRuntimePosition(selectedAlly!.id, [4, 0, 0]);
registerPartyRuntimePosition(otherAlly!.id, [6, 0, 0]);
useCombatStore.getState().synchronizeActors();
const liquidation = ability(498695);
expect(useCombatStore.getState().castAbility(liquidation.id, {
targetActorId: selectedAlly!.id,
now: 1_000,
}).ok).toBe(true);
const threatAuras = useCombatStore.getState().auras.filter((aura) => (
aura.definition.name.startsWith("Liquidation Suffering")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat")
));
const modifierFor = (targetId: string) => threatAuras.find((aura) => aura.targetId === targetId)
?.definition.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat");
expect(modifierFor(selectedAlly!.id)).toMatchObject({ value: 0.05 });
expect(modifierFor(PLAYER_AURA_ENTITY_ID)).toMatchObject({ value: -0.05 });
expect(modifierFor(otherAlly!.id)).toMatchObject({ value: -0.05 });
expect(threatAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "threat" && modifier.value > 0
)))).toHaveLength(1);
expect(threatAuras.some((aura) => aura.targetId === selectedAlly!.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "threat" && modifier.value < 0))).toBe(false);
});
it("grants Defense Net's source-described control immunity to its friendly target", () => {
prepareWarlock("rom-warrior");
usePartyStore.getState().initializeParty(
"rom-warlock-defense-net", 80, useCombatStore.getState().health, "damage", 3, "rom",
);
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [4, 0, 0]);
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, controlMechanic: "stun" as const, controlledUntil: 5_000 }
: candidate),
}));
useCombatStore.getState().synchronizeActors();
const defenseNet = ability(498549);
expect(useCombatStore.getState().castAbility(defenseNet.id, {
targetActorId: member.id,
now: 1_000,
}).ok).toBe(true);
finishCast(1_000);
const updatedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id);
expect(updatedMember).toMatchObject({ controlMechanic: null, controlledUntil: 0 });
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id
&& candidate.definition.name.startsWith("Defense Net"));
expect(aura).toMatchObject({
expiresAt: 6_000,
definition: {
controlImmunities: ["fear", "stun", "root", "slow"],
modifiers: expect.arrayContaining([expect.objectContaining({ direction: "taken", value: -0.5 })]),
},
});
});
it("reduces Defense Net to four seconds only for the Warlock/Priest Mind Shield pair", () => {
prepareWarlock("rom-priest");
usePartyStore.getState().initializeParty(
"rom-warlock-mind-shield", 80, useCombatStore.getState().health, "damage", 3, "rom",
);
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [4, 0, 0]);
useCombatStore.getState().synchronizeActors();
const defenseNet = ability(498549);
expect(useCombatStore.getState().castAbility(defenseNet.id, {
targetActorId: member.id,
now: 1_000,
}).ok).toBe(true);
finishCast(1_000);
expect(useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id
&& candidate.definition.name.startsWith("Defense Net"))?.expiresAt).toBe(5_000);
});
it("resolves Warlock paired named resets to only their source-named spells", () => {
const expectations = new Map<number, number[]>([
[498744, [491156]], // Static Resonance -> Electrostatic Charge
[498758, [490225, 490251]], // Deep Inspiration -> Intensification and Energy Well
[498662, [490256]], // Touch of Faith -> Rising Tide
[498710, [498697]], // Soul Brand Sting -> Flaming Heart Strike
]);
for (const [sourceSpellId, expectedTargets] of expectations) {
const source = ability(sourceSpellId);
const activePair = abilitiesForCharacter(source.classId, source.secondaryClassId);
const targets = source.effects.flatMap((effect) => effect.kind === "cooldown-reset"
? cooldownResetCandidates(activePair, source.id, effect.abilityName).map((candidate) => candidate.dbcSpellId)
: []);
expect(targets.sort((left, right) => left - right), `source ${sourceSpellId}`).toEqual(
[...expectedTargets].sort((left, right) => left - right),
);
}
for (const sourceSpellId of [498634, 498648, 498668]) {
expect(ability(sourceSpellId).effects.some((effect) => effect.kind === "cooldown-reset"), `source ${sourceSpellId}`)
.toBe(false);
}
expect(ROM_ALL_ABILITIES.filter((candidate) => candidate.classId === "rom-warlock")
.flatMap((candidate) => candidate.effects.filter((effect) => effect.kind === "cooldown-reset"
&& effect.abilityName === undefined).map((effect) => candidate.dbcSpellId))).toEqual([]);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import type { AbilityEffect } from "./abilityCatalog";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { overrideRomWarriorEffects } from "./romWarriorOverrides";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
describe("RoM Warrior/Priest Defender's Roar party scope", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("marks only Defender's Roar aura effects as party scoped for the exact class pair", () => {
const maxHealthAura: AbilityEffect = {
kind: "apply-aura",
recipient: "caster",
aura: {
id: "test-aura",
name: "test",
disposition: "buff",
durationMs: 120_000,
modifiers: [{ kind: "stat", stat: "maximum-health", operation: "flat", value: 300 }],
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 0,
};
const unrelatedAura: AbilityEffect = {
kind: "apply-aura",
recipient: "caster",
aura: { id: "test-proc", name: "proc", disposition: "buff", durationMs: null },
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: 123,
};
const unrelated: AbilityEffect = { kind: "damage", coefficient: 1, school: "physical" };
const row = {
sourceSkillId: 491466,
name: "Defender's Roar",
description: "your and your party members' maximum HP",
target: "self" as const,
classId: "rom-warrior",
secondaryClassId: "rom-priest",
};
const overridden = overrideRomWarriorEffects(row, [maxHealthAura, unrelatedAura, unrelated]);
expect(overridden?.[0]).toMatchObject({ kind: "apply-aura", scope: "party" });
expect(overridden?.[1]).toBe(unrelatedAura);
expect(overridden?.[2]).toBe(unrelated);
expect(overrideRomWarriorEffects({ ...row, classId: "rom-priest" }, [maxHealthAura])).toBeNull();
expect(overrideRomWarriorEffects({ ...row, secondaryClassId: "rom-scout" }, [maxHealthAura])).toBeNull();
expect(overrideRomWarriorEffects({ ...row, sourceSkillId: 490122 }, [maxHealthAura])).toBeNull();
});
it("fans both source-backed maximum-health auras out to party members", () => {
const ability = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === 491466)!;
expect(ability).toMatchObject({
id: "rom-warrior-rom-priest-elite-15",
classId: "rom-warrior",
secondaryClassId: "rom-priest",
});
const partyAuras = ability.effects.filter((effect) => effect.kind === "apply-aura");
const maximumHealthAuras = partyAuras.filter((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "maximum-health"));
expect(maximumHealthAuras).toHaveLength(2);
expect(maximumHealthAuras.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true);
expect(partyAuras.some((effect) => effect.kind === "apply-aura" && !effect.aura.modifiers?.length
&& effect.scope !== "party")).toBe(true);
expect(maximumHealthAuras.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.modifiers ?? [] : []))
.toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stat", stat: "maximum-health", operation: "flat", value: 300 }),
expect.objectContaining({ kind: "stat", stat: "maximum-health", operation: "percent", value: 0.01 }),
]));
const rankMaximumHealthAuras = ability.ranks?.[0]?.effects.filter((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "maximum-health"));
expect(rankMaximumHealthAuras).toHaveLength(2);
expect(rankMaximumHealthAuras?.every((effect) => effect.kind === "apply-aura" && effect.scope === "party")).toBe(true);
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior",
secondaryClassId: "rom-priest",
raceId: "rom-human",
level: 30,
});
usePartyStore.getState().initializeParty(
"warrior-defenders-roar-party-scope",
30,
useCombatStore.getState().health,
"damage",
2,
"rom",
);
const member = usePartyStore.getState().members[0]!;
expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true });
const appliedAuras = useCombatStore.getState().auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
&& aura.definition.name.startsWith("Defender's Roar:"));
expect(appliedAuras).toHaveLength(4);
expect(appliedAuras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)).toHaveLength(2);
expect(appliedAuras.filter((aura) => aura.targetId === member.id)).toHaveLength(2);
expect(appliedAuras.every((aura) => aura.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "maximum-health"
)))).toBe(true);
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { AbilityEffect } from "./abilityCatalog";
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
type RomWarriorGeneratedRow = RomGeneratedAbilityLike & {
readonly classId?: string;
readonly secondaryClassId?: string;
};
/** Give the source-defined Warrior/Priest Defender's Roar buffs their party scope. */
export function overrideRomWarriorEffects(
row: RomWarriorGeneratedRow,
translated: readonly AbilityEffect[],
): readonly AbilityEffect[] | null {
if (row.classId !== "rom-warrior"
|| row.secondaryClassId !== "rom-priest"
|| row.sourceSkillId !== 491466) return null;
return translated.map((effect) => effect.kind === "apply-aura"
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "maximum-health")
? { ...effect, scope: "party" }
: effect);
}
+78
View File
@@ -67,6 +67,49 @@ describe("talent runtime modifiers", () => {
]));
});
it("scopes Improved Rejuvenation's effect bonus to Rejuvenation healing", () => {
const improvedRejuvenation = TALENT_NODES.find((node) => node.id === "talent-830")!;
const modifiers = talentModifiersForRanks({ [improvedRejuvenation.id]: 3 }, "druid");
expect(modifiers).toContainEqual(expect.objectContaining({
kind: "healing-percent",
amount: 20,
sourceTalentId: "talent-830",
rank: 3,
}));
expect(applyTalentModifierValue(100, modifiers, "healing-percent", {
abilityName: "Rejuvenation",
tags: ["periodic", "healing", "nature"],
})).toBe(120);
expect(applyTalentModifierValue(100, modifiers, "healing-percent", {
abilityName: "Regrowth",
tags: ["periodic", "healing", "nature"],
})).toBe(100);
});
it("keeps Warrior named-ability damage and cooldown bonuses on their source abilities", () => {
const improvedExecute = TALENT_NODES.find((node) => node.name === "Improved Execute")!;
const executeModifiers = talentModifiersForRanks({ [improvedExecute.id]: 1 }, "warrior");
expect(applyTalentModifierValue(100, executeModifiers, "damage-percent", { abilityName: "Execute" })).toBe(108);
expect(applyTalentModifierValue(100, executeModifiers, "damage-percent", { abilityName: "Heroic Strike" })).toBe(100);
expect(applyTalentModifierValue(15, executeModifiers, "resource-cost-percent", { abilityName: "Heroic Strike" })).toBe(15);
const unrelentingAssault = TALENT_NODES.find((node) => node.name === "Unrelenting Assault")!;
const assaultModifiers = talentModifiersForRanks({ [unrelentingAssault.id]: 1 }, "warrior");
for (const abilityName of ["Overpower", "Revenge"]) {
expect(applyTalentModifierValue(100, assaultModifiers, "damage-percent", { abilityName })).toBeCloseTo(110);
expect(applyTalentModifierValue(10_000, assaultModifiers, "cooldown-flat-ms", { abilityName })).toBe(9_500);
}
expect(applyTalentModifierValue(100, assaultModifiers, "damage-percent", { abilityName: "Bloodthirst" })).toBe(100);
expect(applyTalentModifierValue(10_000, assaultModifiers, "cooldown-flat-ms", { abilityName: "Bloodthirst" })).toBe(10_000);
const improvedDisciplines = TALENT_NODES.find((node) => node.name === "Improved Disciplines")!;
const disciplineModifiers = talentModifiersForRanks({ [improvedDisciplines.id]: 1 }, "warrior");
expect(applyTalentModifierValue(300_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Shield Wall" })).toBe(290_000);
expect(applyTalentModifierValue(30_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Retaliation" })).toBe(20_000);
expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000);
});
it("keeps critical damage bonuses out of ordinary hit damage", () => {
const modifiers = parseTalentModifiers("Increases the critical damage of your spells by 150%.")
.map((modifier) => ({ ...modifier, sourceTalentId: "test", sourceTalentName: "Thunderous", rank: 1, description: "" }));
@@ -88,4 +131,39 @@ describe("talent runtime modifiers", () => {
tags: ["cast-time", "holy"],
})).toBe(20);
});
it("keeps Paladin activation and aura text out of persistent modifiers", () => {
for (const name of ["Aura Mastery", "Divine Favor"]) {
const talent = TALENT_NODES.find((node) => node.name === name);
expect(talent, name).toBeDefined();
expect(talentModifiersForRanks({ [talent!.id]: 1 }, "paladin"), name).toEqual([]);
}
});
it("scopes Improved Flash Heal critical chance and keeps its Inner Focus proc out of static cooldowns", () => {
const improvedFlashHeal = TALENT_NODES.find((node) => node.name === "Improved Flash Heal")!;
const modifiers = talentModifiersForRanks({ [improvedFlashHeal.id]: 3 }, "priest");
expect(applyTalentModifierValue(5, modifiers, "critical-chance-percent", { abilityName: "Flash Heal" })).toBe(20);
expect(applyTalentModifierValue(5, modifiers, "critical-chance-percent", { abilityName: "Greater Heal" })).toBe(5);
expect(modifiers.filter((modifier) => modifier.kind === "cooldown-flat-ms")).toEqual([]);
});
it("applies Black Ice to either Frost or Shadow damage", () => {
const blackIce = TALENT_NODES.find((node) => node.name === "Black Ice")!;
const modifiers = talentModifiersForRanks({ [blackIce.id]: blackIce.maxRank }, "death-knight");
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
abilityName: "Icy Touch",
tags: ["damage", "frost"],
})).toBeCloseTo(110);
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
abilityName: "Death Coil",
tags: ["damage", "shadow"],
})).toBeCloseTo(110);
expect(applyTalentModifierValue(100, modifiers, "damage-percent", {
abilityName: "Plague Strike",
tags: ["damage", "physical"],
})).toBe(100);
});
});
+112 -13
View File
@@ -43,12 +43,36 @@ interface ParsedModifier extends Omit<TalentModifier, "sourceTalentId" | "source
const EXPLICIT_ACTIVATION_TALENTS = new Set([
"Inner Focus",
// 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",
"Serendipity",
"Shadow Weaving",
// Eradication and Nightfall are conditional proc states, not passive haste,
// damage, or resource-cost modifiers from their tooltip text.
"Eradication",
"Nightfall",
// Backlash has passive crit chance plus a conditional next-cast proc. Its
// proc damage and cost clauses must not become always-on bonuses.
"Backlash",
// 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",
// The Beast Within activates from a pet Bestial Wrath proc; its tooltip
// percentages must not become always-on modifiers.
"The Beast Within",
// These Shaman talents activate temporary effects from combat events;
// their conditional percentages are applied by the Shaman runtime.
"Elemental Focus",
"Reverberation",
"Maelstrom Weapon",
]);
/**
* These talents are modeled as on-use abilities and must never be interpreted
* as always-on scalar bonuses merely because their activation tooltip contains
* phrases such as "reduces cast time" or "reducing mana cost".
* These talents have activation or proc behavior and must never be interpreted
* as always-on scalar bonuses merely because their tooltip contains phrases
* such as "reduces cast time" or "reducing mana cost".
*/
export function talentUsesExplicitActivation(talentName: string): boolean {
return EXPLICIT_ACTIVATION_TALENTS.has(talentName);
@@ -91,6 +115,11 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
add({ kind: "damage-percent", amount, unit: "percent", scope });
}
if (/\bhealing\b|heals? (?:done|cast)/.test(lower)) add({ kind: "healing-percent", amount, unit: "percent", scope });
// The generated Druid tooltip for Improved Rejuvenation describes its
// healing as an increase to the spell's "effect" rather than naming heal.
if (/\beffect of your rejuvenation spell\b/.test(lower)) {
add({ kind: "healing-percent", amount, unit: "percent", scope });
}
if (/\barmor\b/.test(lower)) add({ kind: "armor-percent", amount, unit: "percent", scope });
if (/\brange\b/.test(lower)) add({ kind: "range-percent", amount, unit: "percent", scope });
if (/\bduration\b|lasts? longer/.test(lower)) add({ kind: "duration-percent", amount, unit: "percent", scope });
@@ -134,7 +163,9 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
const castTime = sentence.match(/reduces? (?:the )?(?:casting|cast) time (?:of |for )?.*? by (\d+(?:\.\d+)?) sec/i);
if (castTime) add({ kind: "cast-time-flat-ms", amount: -Number(castTime[1]) * 1000, unit: "milliseconds", scope: sentence });
const cooldown = sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i);
const cooldown = /\bcasts?\s+reduce(?:s)?\s+(?:the\s+)?cooldown\b/i.test(sentence)
? null
: sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i);
if (cooldown) add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[1]) * 1000, unit: "milliseconds", scope: sentence });
}
@@ -143,17 +174,74 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): readonly TalentModifier[] {
return activeTalentRankEffects(ranks, classId).flatMap((active) => {
if (active.talentName === "Backlash") {
return [{
kind: "critical-chance-percent" as const,
amount: active.rank,
unit: "percent" as const,
scope: "your spells and abilities",
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}];
}
if (talentUsesExplicitActivation(active.talentName)) return [];
// Its healing and damage bonuses only apply below a target-health threshold;
// the scalar parser cannot encode that condition. CombatStore applies the
// rank-aware bonus at the individual target instead.
if (active.talentName === "Test of Faith") return [];
const override = talentBalanceOverride(active.talentId);
const scale = (override?.effectScalePercent ?? 100) / 100;
const parsed = parseTalentModifiers(active.description).map((modifier) => ({
...modifier,
amount: modifier.amount * scale,
const parsed = parseTalentModifiers(active.description)
// Improved Shadow Bolt's 5% spell crit applies to the debuffed target;
// only its named Shadow Bolt damage bonus is a persistent caster modifier.
.filter((modifier) => active.talentName !== "Improved Shadow Bolt" || modifier.kind !== "critical-chance-percent")
.filter((modifier) => !(classId === "warrior" && active.talentName === "Unrelenting Assault"
&& ["damage-percent", "cooldown-percent"].includes(modifier.kind)))
.flatMap((modifier) => {
if (classId === "warrior" && active.talentName === "Improved Execute") {
// This tooltip combines a flat Rage reduction, damage percent, and a
// conditional capstone crit. The broad parser merges the first two
// clauses, so rebuild only the source-backed damage component here;
// combatStore applies the flat Rage reduction when casting Execute.
if (["resource-cost-percent", "damage-percent", "critical-chance-percent"].includes(modifier.kind)) {
if (modifier.kind !== "damage-percent") return [];
const increase = active.description.match(/increases its damage by (\d+(?:\.\d+)?)%/i);
if (!increase) return [];
return [{
...modifier,
amount: Number(increase[1]) * 2 * scale,
scope: "damage of your Execute ability",
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}];
}
}
return [{
...modifier,
amount: modifier.amount * scale,
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}];
});
const unrelentingAssaultDamage = classId === "warrior" && active.talentName === "Unrelenting Assault"
? active.description.match(/increases the damage done by both abilities by (\d+(?:\.\d+)?)%/i)
: null;
const explicitScoped = unrelentingAssaultDamage ? [{
kind: "damage-percent" as const,
amount: Number(unrelentingAssaultDamage[1]) * scale,
unit: "percent" as const,
scope: "damage of your Overpower and Revenge abilities",
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}));
}] : [];
const supplemental = Object.entries(override?.modifiersPerRank ?? {}).flatMap(([kind, amount]) => {
if (!Number.isFinite(amount) || amount === 0) return [];
return [{
@@ -167,7 +255,7 @@ export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId):
description: active.description,
}];
});
return [...parsed, ...supplemental];
return [...parsed, ...explicitScoped, ...supplemental];
});
}
@@ -181,8 +269,12 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex
const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase()));
if (scope.includes("instant") && !tags.has("instant")) return false;
if (scope.includes("periodic") && !tags.has("periodic")) return false;
for (const school of ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const) {
if (scope.includes(school) && !tags.has(school)) return false;
const schools = (["arcane", "fire", "frost", "holy", "nature", "shadow"] as const)
.filter((school) => scope.includes(school));
if (schools.length > 1) {
if (!schools.some((school) => tags.has(school))) return false;
} else if (schools.length === 1 && !tags.has(schools[0])) {
return false;
}
if (context.abilityName && /\b(?:spell|ability)\b/.test(scope)) {
const normalizedName = context.abilityName.toLowerCase();
@@ -190,10 +282,17 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex
if (namedScope && !namedScope.includes("your") && !scope.includes(normalizedName)) return false;
}
if (context.abilityName) {
const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time).*?\b(?:by|of|with) your\b/.test(scope);
const normalizedName = context.abilityName.toLowerCase();
const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time|effect|critical (?:effect |strike )?chance|chance to critically).*?\b(?:by|of|with) your\b/.test(scope);
const genericScope = /\byour (?:all |instant |periodic |healing |damage |helpful |harmful )*(?:spells?|abilities?|attacks?)\b/.test(scope)
|| /\byour (?:arcane|fire|frost|holy|nature|shadow) spells?\b/.test(scope);
if (namedScope && !genericScope && !scope.includes(context.abilityName.toLowerCase())) return false;
const namedAbilityList = genericScope
? undefined
: scope.match(/\byour\s+(.+?)\s+abilit(?:y|ies)\b/)?.[1]
?.split(/\s*(?:,|\band\b|\bor\b)\s*/)
.map((name) => name.trim());
if (namedAbilityList?.length && !namedAbilityList.includes(normalizedName)) return false;
if (namedScope && !genericScope && !scope.includes(normalizedName)) return false;
}
return true;
}
+301 -8
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import {
WOW335_ABILITIES_BY_CLASS,
WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID,
} from "./wow335AbilityCatalog";
import generatedData from "./wow335AbilityData.generated.json";
import { talentNodeById } from "./talentCatalog";
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
@@ -39,6 +42,99 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(byId("wow335-priest-power-word-shield").gcdMs).toBe(1_500);
});
it("keeps Bloodthirst's player cast, next-hit aura, and triggered heal on their source rows", () => {
const bloodthirst = abilityAtLevel(byId("wow335-warrior-bloodthirst"), 80);
expect(bloodthirst).toMatchObject({
dbcSpellId: 23881,
target: "hostile",
range: { min: 0, max: 5 },
cost: { resource: "rage", amount: 20 },
cooldownMs: 4_000,
});
expect(effectsOfKind(bloodthirst, "damage")).toHaveLength(1);
const bloodthirstAura = effectsOfKind(bloodthirst, "apply-aura")
.find((effect) => effect.sourceAuraType === 42);
expect(bloodthirstAura).toMatchObject({
recipient: "caster",
aura: {
durationMs: 8_000,
procs: [expect.objectContaining({
triggers: ["hit", "crit"],
attackKinds: ["melee"],
charges: 3,
action: { kind: "custom", id: "wow335-trigger-spell", data: { spellId: 23880 } },
})],
},
});
expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[23880]?.effects).toEqual([
expect.objectContaining({ kind: "heal", percentMaxHealth: 0.01 }),
]);
});
it("selects Sweeping Strikes' player self-buff row instead of its damage child", () => {
const source = byId("wow335-warrior-sweeping-strikes");
const sweepingStrikes = abilityAtLevel(source, 80);
expect(source).toMatchObject({ unlockLevel: 30, sourceUnlockLevel: 30 });
expect(sweepingStrikes).toMatchObject({
dbcSpellId: 12328,
target: "self",
range: { min: 0, max: 0 },
cost: { resource: "rage", amount: 30 },
cooldownMs: 30_000,
});
expect(effectsOfKind(sweepingStrikes, "damage")).toEqual([]);
expect(effectsOfKind(sweepingStrikes, "apply-aura")).toEqual([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({
durationMs: 30_000,
procs: [expect.objectContaining({
triggers: ["hit", "crit"],
attackKinds: ["melee"],
charges: 5,
action: { kind: "custom", id: "wow335-sweeping-strikes-secondary-hit" },
})],
}),
}),
]);
});
it("maps Battle Shout and Commanding Shout to nearby party-wide buffs", () => {
for (const [abilityId, auraCount] of [
["wow335-warrior-battle-shout", 2],
["wow335-warrior-commanding-shout", 1],
] as const) {
const ability = abilityAtLevel(byId(abilityId), 80);
const auras = effectsOfKind(ability, "apply-aura");
expect(ability).toMatchObject({ target: "self", radius: 30 });
expect(targetingRulesForAbility(ability)).toMatchObject({ shape: "circle", radius: 30 });
expect(auras).toHaveLength(auraCount);
expect(auras.every((effect) => effect.scope === "party")).toBe(true);
}
});
it("maps Nature's Swiftness to one next-Nature-spell cast-time aura and its shared cooldown", () => {
const natureSwiftness = abilityAtLevel(byId("wow335-shaman-nature-s-swiftness"), 80);
const druidNatureSwiftness = abilityAtLevel(byId("wow335-druid-nature-s-swiftness"), 80);
const elementalMastery = abilityAtLevel(byId("wow335-shaman-elemental-mastery"), 80);
expect(natureSwiftness.cooldownGroup).toBe("shaman-nature-swiftness");
expect(elementalMastery.cooldownGroup).toBe("shaman-nature-swiftness");
expect(natureSwiftness.cooldownMs).toBe(180_000);
expect(druidNatureSwiftness.cooldownMs).toBe(180_000);
for (const ability of [natureSwiftness, druidNatureSwiftness]) {
const auraEffect = effectsOfKind(ability, "apply-aura")[0];
expect(auraEffect).toMatchObject({
recipient: "caster",
aura: {
durationMs: null,
utilityTags: ["wow335-next-nature-instant"],
},
});
if (auraEffect?.kind === "apply-aura") expect(auraEffect.aura.modifiers).toBeUndefined();
}
});
it("combines each Holy Nova damage rank with its matching healing payload", () => {
const nova = byId("wow335-priest-holy-nova");
expect(nova.ranks).toHaveLength(9);
@@ -55,6 +151,47 @@ describe("WoW 3.3.5 executable effect mapping", () => {
});
});
it("keeps Holy Shock's cast wrapper while combining damage and healing ranks", () => {
const shock = byId("wow335-paladin-holy-shock");
expect(shock.ranks).toHaveLength(7);
for (const rank of shock.ranks ?? []) {
expect(effectsOfKind(rank, "damage")).toHaveLength(1);
expect(effectsOfKind(rank, "heal")).toHaveLength(1);
expect(rank.cost.amount).toBeGreaterThan(0);
expect(rank.cooldownMs).toBe(6_000);
}
expect(abilityAtLevel(shock, 80)).toMatchObject({
dbcSpellId: 48825,
target: "any",
range: { min: 0, max: 40 },
castMode: "instant",
gcdMs: 1_500,
cooldownMs: 6_000,
});
});
it("models Life Tap as a health cost and mana restoration, not a heal", () => {
const lifeTap = abilityAtLevel(byId("wow335-warlock-life-tap"), 80);
expect(lifeTap).toMatchObject({
dbcSpellId: 57946,
target: "self",
cost: { resource: "health", amount: 1_490 },
});
expect(effectsOfKind(lifeTap, "resource")).toContainEqual(
{ kind: "resource", resource: "mana", amount: 1_490 },
);
expect(effectsOfKind(lifeTap, "heal")).toEqual([]);
for (const id of [
"wow335-warlock-create-healthstone",
"wow335-warlock-create-soulstone",
"wow335-warlock-demonic-empowerment",
]) {
expect(effectsOfKind(abilityAtLevel(byId(id), 80), "heal"), id).toEqual([]);
}
});
it("maps Envenom to Deadly Poison dose damage and its poison-frequency buff", () => {
const envenom = abilityAtLevel(byId("wow335-rogue-envenom"), 80);
expect(effectsOfKind(envenom, "finisher-damage")).toEqual([
@@ -143,7 +280,7 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(byId("wow335-priest-divine-hymn").ranks?.map((rank) => rank.spellId)).toEqual([64843]);
expect(byId("wow335-death-knight-death-coil").ranks?.map((rank) => rank.spellId)).not.toContain(47632);
expect(byId("wow335-death-knight-death-coil")).toMatchObject({
target: "hostile",
target: "any",
effects: expect.arrayContaining([expect.objectContaining({ kind: "damage" })]),
});
});
@@ -185,15 +322,16 @@ describe("WoW 3.3.5 executable effect mapping", () => {
it("provides mechanics for previously silent class scripts and hidden proc spells", () => {
expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-bloodthirst"), "damage")).toContainEqual(
expect.objectContaining({ coefficient: 1.2 }),
expect.objectContaining({ coefficient: 1, basePoints: 50, bonusPowerCoefficient: 1 }),
);
expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "resource")).toEqual([
{ kind: "resource", resource: "rage", amount: 20 },
]);
expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "heal")).toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-execute"), "execute")).toContainEqual(
expect.objectContaining({ coefficient: 1.8 }),
expect(effectsOfKind(abilityAtLevel(byId("wow335-warrior-execute"), 80), "damage")).toContainEqual(
expect.objectContaining({ basePoints: 1_456, coefficient: 1 }),
);
expect(effectsOfKind(byId("wow335-warrior-execute"), "execute")).toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-bladestorm"), "dot")).toEqual([
expect.objectContaining({ coefficient: 0.6, ticks: 6, intervalMs: 1_000 }),
@@ -209,13 +347,25 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect.objectContaining({ coefficient: 2 }),
);
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "combo")).not.toEqual([]);
expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "dot")).toContainEqual(
expect.objectContaining({ coefficient: 0.5, ticks: 5 }),
expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "dot")).toEqual([]);
expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "utility")).toContainEqual(
expect.objectContaining({ action: "class-script", durationMs: 2_000 }),
);
expect(byId("wow335-rogue-killing-spree").target).toBe("hostile");
expect(effectsOfKind(byId("wow335-warlock-conflagrate"), "damage")).toEqual([
expect.objectContaining({ coefficient: 1.4 }),
expect.objectContaining({ coefficient: 0, school: "fire" }),
]);
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]);
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "taunt")).toEqual([
{ kind: "taunt", durationMs: 3_000 },
]);
expect(effectsOfKind(byId("wow335-death-knight-chains-of-ice"), "status")).toEqual([
{ kind: "status", status: "slow", durationMs: 10_000, magnitude: 0.95 },
]);
expect(effectsOfKind(byId("wow335-death-knight-pestilence"), "damage")).toEqual([]);
expect(effectsOfKind(byId("wow335-death-knight-pestilence"), "utility")).toContainEqual(
expect.objectContaining({ action: "class-script", effectType: 77 }),
);
expect(effectsOfKind(byId("wow335-shaman-windfury-weapon"), "apply-aura")[0]?.aura.procs).not.toEqual([]);
expect(effectsOfKind(byId("wow335-shaman-lightning-shield"), "apply-aura")[0]?.aura.procs).toEqual(expect.arrayContaining([
expect.objectContaining({ triggers: "damage-taken", charges: 3 }),
@@ -249,6 +399,92 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(hurricane.cost.amount).toBeGreaterThan(0);
});
it("makes Empower Rune Weapon activate all six runes from the player spell", () => {
const empowerRuneWeapon = abilityAtLevel(byId("wow335-death-knight-empower-rune-weapon"), 80);
expect(empowerRuneWeapon.dbcSpellId).toBe(47568);
expect(effectsOfKind(empowerRuneWeapon, "rune")).toEqual([
{ kind: "rune", action: "activate", count: 6 },
]);
expect(effectsOfKind(empowerRuneWeapon, "resource")).toEqual([
{ kind: "resource", resource: "runic-power", amount: 25 },
]);
});
it("models Death Knight source healing, rune, charge, and magic-shell effects", () => {
const deathCoil = abilityAtLevel(byId("wow335-death-knight-death-coil"), 80);
expect(deathCoil.target).toBe("any");
expect(effectsOfKind(deathCoil, "damage")).not.toEqual([]);
expect(effectsOfKind(deathCoil, "heal")).not.toEqual([]);
const deathStrike = abilityAtLevel(byId("wow335-death-knight-death-strike"), 80);
expect(effectsOfKind(deathStrike, "heal")).toEqual([
expect.objectContaining({ coefficient: 0, percentMaxHealth: 0.05 }),
]);
const antiMagicShell = abilityAtLevel(byId("wow335-death-knight-anti-magic-shell"), 80);
expect(effectsOfKind(antiMagicShell, "apply-aura")).toEqual([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({
durationMs: 5_000,
absorbs: [expect.objectContaining({
amount: 0.5,
fraction: 0.75,
schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"],
})],
}),
}),
]);
const antiMagicZone = abilityAtLevel(byId("wow335-death-knight-anti-magic-zone"), 80);
expect(antiMagicZone).toMatchObject({
target: "self",
description: expect.stringContaining("75% of incoming magic damage"),
targeting: { shape: "ground", radius: 10, groundRange: 30 },
});
expect(effectsOfKind(antiMagicZone, "summon")).toEqual([
expect.objectContaining({
creatureId: 28306,
durationMs: 10_000,
antiMagicZone: {
radius: 10,
capacityBase: 10_000,
capacityPerAttackPower: 2,
absorbFraction: 0.75,
intervalMs: 250,
},
}),
]);
const boneShield = abilityAtLevel(byId("wow335-death-knight-bone-shield"), 80);
expect(effectsOfKind(boneShield, "apply-aura")).toEqual([
expect.objectContaining({ aura: expect.objectContaining({
durationMs: 300_000,
initialStacks: 4,
maxStacks: 4,
modifiers: expect.arrayContaining([
expect.objectContaining({ kind: "damage", direction: "taken", value: -0.2, perStack: false }),
expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.02, perStack: false }),
]),
procs: [expect.objectContaining({
triggers: "damage-taken",
charges: 4,
action: { kind: "custom", id: "wow335-death-knight-bone-shield-charge" },
})],
}) }),
]);
expect(effectsOfKind(abilityAtLevel(byId("wow335-death-knight-blood-tap"), 80), "rune")).toEqual([
{ kind: "rune", action: "activate", count: 1, runeType: "blood", convertToDeathMs: 20_000 },
]);
expect(effectsOfKind(abilityAtLevel(byId("wow335-death-knight-deathchill"), 80), "apply-aura")).toEqual([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({ utilityTags: expect.arrayContaining(["wow335-deathchill-next-critical"]) }),
}),
]);
});
it("keeps every rank of imported channels in channel mode", () => {
const mismatches = Object.values(WOW335_ABILITIES_BY_CLASS).flat()
.filter((ability) => ability.castMode === "channel" && ability.ranks?.some((rank) => rank.castMode !== "channel"))
@@ -321,6 +557,14 @@ describe("WoW 3.3.5 executable effect mapping", () => {
.toEqual(expect.arrayContaining([expect.objectContaining({ stat: "incoming-ranged-hit", value: -25 })]));
});
it("keeps Blink's root and stun breaks without inventing target-relative teleport movement", () => {
const blink = byId("wow335-mage-blink");
expect(blink.dbcSpellId).toBe(1953);
expect(effectsOfKind(blink, "movement")).toEqual([]);
expect(effectsOfKind(blink, "apply-aura").map((effect) => effect.aura.controlImmunities))
.toEqual([["stun"], ["root"]]);
});
it("keeps triggered shield damage children out of learned Shaman ranks", () => {
const lightningShield = abilityAtLevel(byId("wow335-shaman-lightning-shield"), 80);
expect(lightningShield.dbcSpellId).toBe(49281);
@@ -427,6 +671,36 @@ describe("WoW 3.3.5 executable effect mapping", () => {
});
});
it("maps Paladin revive, healing riders, class-group blessings, and aura mechanics", () => {
const redemption = byId("wow335-paladin-redemption");
expect(redemption.target).toBe("friendly");
expect(effectsOfKind(redemption, "resurrection")).toContainEqual(expect.objectContaining({
flatHealth: 65,
}));
const divineStorm = byId("wow335-paladin-divine-storm");
expect(divineStorm.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "damage", coefficient: 1.1, school: "physical" }),
expect.objectContaining({ kind: "heal", percentOfDamage: 0.25, scope: "party", maximumTargets: 3 }),
]));
const beacon = abilityAtLevel(byId("wow335-paladin-beacon-of-light"), 80);
expect(effectsOfKind(beacon, "apply-aura")[0]?.aura.utilityTags).toContain("beacon-of-light");
const greaterBlessing = byId("wow335-paladin-greater-blessing-of-kings");
expect(effectsOfKind(greaterBlessing, "apply-aura")[0]?.scope).toBe("party");
const avengersShield = byId("wow335-paladin-avenger-s-shield");
expect(avengersShield.targeting).toMatchObject({ shape: "chain", maximumTargets: 3, jumpRange: 10 });
const auraMastery = byId("wow335-paladin-aura-mastery");
expect(effectsOfKind(auraMastery, "apply-aura").every((effect) => (
effect.scope === "party" && effect.aura.utilityTags?.includes("wow335-aura-mastery-silence-interrupt")
))).toBe(true);
const divineFavor = byId("wow335-paladin-divine-favor");
expect(effectsOfKind(divineFavor, "apply-aura")[0]?.aura.utilityTags)
.toContain("wow335-divine-favor-next-holy-heal");
});
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
const pain = byId("wow335-priest-shadow-word-pain");
const painAura = effectsOfKind(pain, "apply-aura")
@@ -491,4 +765,23 @@ describe("WoW 3.3.5 executable effect mapping", () => {
aura: { disposition: "debuff", dispelCategory: "magic" },
});
});
it("maps Soulshatter to its source 50-yard self-centered threat reduction", () => {
const soulshatter = byId("wow335-warlock-soulshatter");
const source = generatedData.classes.warlock.find((chain) => chain.name === "Soulshatter")!;
expect(source.ranks[0]).toMatchObject({
spellId: 29858,
description: expect.stringContaining("Reduces threat by 50%"),
effects: [expect.objectContaining({ effectType: 3, basePoints: -50, radius: 50 })],
});
expect(soulshatter).toMatchObject({
dbcSpellId: 29858,
target: "self",
radius: 50,
});
expect(effectsOfKind(soulshatter, "threat")).toEqual([
{ kind: "threat", mode: "percent", amount: -50 },
]);
});
});
File diff suppressed because it is too large. Load diff
+249
View File
@@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura, resolveStatValue } from "./combatAuras";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
const TARGET_ID = "druid-proc-target";
function unit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function findDruidProcStart(name: string, startAt: number, chance: number, procTag: string): number {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
for (let start = startAt; start < startAt + 100_000; start += 100) {
const impactAt = start + ability!.castTimeMs;
const directSeed = `${PLAYER_AGGRO_ID}:${ability!.id}:${TARGET_ID}:${impactAt}:direct`;
if (unit(`${directSeed}:outcome`) > 0.1 && unit(`${directSeed}:${procTag}`) < chance) return start;
}
throw new Error(`No deterministic ${name} proc seed found.`);
}
function castAndFinish(name: string, startedAt: number) {
const id = abilityId(name);
const result = useCombatStore.getState().castAbility(id, { targetActorId: TARGET_ID, now: startedAt });
expect(result.ok, JSON.stringify(result)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast?.abilityId === id) {
useCombatStore.getState().tick(Math.max(0.1, (activeCast.completesAt - startedAt) / 1_000), activeCast.completesAt);
}
return useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === id
)).at(-1);
}
describe("WoW Druid proc runtime", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["druid"]);
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-druid-proc-tests", 1, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", maxHealth: 1_000_000 });
setMobPosition(TARGET_ID, [4, 0, 0]);
useCombatStore.getState().selectMob(TARGET_ID);
});
it("procs Solar Eclipse from Starfire and gives Wrath its source 30% damage bonus", () => {
const baseline = castAndFinish("Wrath", 1_000);
expect(baseline?.kind).toBe("damage");
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1924": 3 } }));
castAndFinish("Starfire", 5_000);
const solar = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-eclipse-solar")
));
expect(solar?.expiresAt).toBe(23_500);
expect(useCombatStore.getState().cooldowns["wow335-druid-eclipse-solar"]).toBe(38_500);
const buffed = castAndFinish("Wrath", 10_000);
expect(buffed?.kind).toBe("damage");
expect(buffed?.rawAmount).toBe(Math.round(baseline!.rawAmount * 1.3));
});
it("procs Lunar Eclipse from Wrath and adds 30 percentage points to Starfire crit chance", () => {
const baselineEvent = castAndFinish("Starfire", 1_000);
expect(baselineEvent?.kind).toBe("damage");
const baselineCritChance = useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent;
expect(baselineCritChance).toBeDefined();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1924": 3 } }));
const wrathStart = findDruidProcStart("Wrath", 6_000, 0.6, "druid-eclipse");
castAndFinish("Wrath", wrathStart);
const lunar = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-eclipse-lunar")
));
expect(lunar).toBeDefined();
castAndFinish("Starfire", wrathStart + 2_100);
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent)
.toBe(baselineCritChance! + 30);
});
it("grants Nature's Grace after a direct healing critical and applies 20% cast haste for four seconds", () => {
const healingTouch = abilityId("Healing Touch");
let startedAt = 1_000;
while (unit(`${PLAYER_AGGRO_ID}:${healingTouch}:${PLAYER_AURA_ENTITY_ID}:${startedAt + 3_000}:heal:druid-omen-of-clarity`) >= 0.06) {
startedAt += 100;
}
useCombatStore.setState((state) => ({
health: Math.round(state.maxHealth / 2),
talentRanks: { ...state.talentRanks, "talent-789": 3, "talent-827": 3 },
auras: [...applyAura(state.auras, {
definition: {
id: "test-druid-guaranteed-spell-crit",
name: "Test Critical Chance",
disposition: "buff",
durationMs: null,
modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 100 }],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now: startedAt,
}).auras],
}));
expect(useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: startedAt }).ok).toBe(true);
const firstCast = useCombatStore.getState().activeCast;
expect(firstCast?.abilityId).toBe(healingTouch);
useCombatStore.getState().tick(0.1, firstCast!.completesAt);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing" && event.abilityId === healingTouch && event.critical
))).toBe(true);
const natureGrace = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-natures-grace")
));
expect(natureGrace?.expiresAt).toBe(firstCast!.completesAt + 4_000);
expect(resolveStatValue(0, [natureGrace!], "haste")).toBe(20);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
))).toBe(true);
const manaAfterFirstHeal = useCombatStore.getState().resource;
const nextStart = firstCast!.completesAt + 1_500;
useCombatStore.setState((state) => ({
lastResourceSpentAt: { ...state.lastResourceSpentAt, mana: nextStart },
}));
expect(useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: nextStart }).ok).toBe(true);
expect(useCombatStore.getState().resource).toBe(manaAfterFirstHeal);
const hastedCast = useCombatStore.getState().activeCast;
expect(hastedCast?.originalDurationMs).toBe(Math.round(3_000 / 1.2));
useCombatStore.getState().tick(0.1, hastedCast!.completesAt);
const overhealClearcasting = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
));
expect(overhealClearcasting?.expiresAt).toBe(hastedCast!.completesAt + 15_000);
expect(useCombatStore.getState().cooldowns["wow335-druid-omen-of-clarity"])
.toBe(hastedCast!.completesAt + 2_000);
});
it("procs Omen of Clarity from damage, makes the next eligible cast free, and respects its two-second ICD", () => {
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-827": 3 } }));
const moonfire = useCombatStore.getState().abilities.find((ability) => ability.name === "Moonfire");
expect(moonfire).toBeDefined();
const start = (() => {
for (let now = 1_000; now < 100_000; now += 1) {
const seed = `${PLAYER_AGGRO_ID}:${moonfire!.id}:${TARGET_ID}:${now}:direct`;
if (unit(`${seed}:outcome`) > 0.1 && unit(`${seed}:druid-omen-of-clarity`) < 0.06) return now;
}
throw new Error("No deterministic Omen of Clarity proc seed found.");
})();
const first = useCombatStore.getState().castAbility(moonfire!.id, { targetActorId: TARGET_ID, now: start });
expect(first.ok, JSON.stringify(first)).toBe(true);
const manaAfterFirst = useCombatStore.getState().resource;
const clearcasting = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
));
expect(clearcasting?.expiresAt).toBe(start + 15_000);
expect(useCombatStore.getState().cooldowns["wow335-druid-omen-of-clarity"]).toBe(start + 2_000);
const second = useCombatStore.getState().castAbility(moonfire!.id, {
targetActorId: TARGET_ID,
now: start + 1_500,
});
expect(second.ok, JSON.stringify(second)).toBe(true);
expect(useCombatStore.getState().resource).toBe(manaAfterFirst);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
))).toBe(false);
});
it("allows a successful healing spell that fully overheals to proc Omen of Clarity", () => {
const healingTouch = abilityId("Healing Touch");
useCombatStore.setState((state) => ({
health: state.maxHealth,
talentRanks: { ...state.talentRanks, "talent-827": 3 },
}));
let startedAt = 1_000;
while (unit(`${PLAYER_AGGRO_ID}:${healingTouch}:${PLAYER_AURA_ENTITY_ID}:${startedAt + 3_000}:heal:druid-omen-of-clarity`) >= 0.06) {
startedAt += 100;
}
const result = useCombatStore.getState().castAbility(healingTouch, { targetActorId: null, now: startedAt });
expect(result.ok, JSON.stringify(result)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
expect(activeCast?.abilityId).toBe(healingTouch);
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
const healingEvent = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "healing" && event.abilityId === healingTouch && event.occurredAt === activeCast!.completesAt
));
expect(healingEvent).toMatchObject({ rawAmount: expect.any(Number), effectiveAmount: 0 });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
))).toBe(true);
});
it("allows landed player auto attacks to trigger Omen of Clarity", () => {
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-827": 3 } }));
let now = 1_000;
let seed = `druid-auto-attack:${now}`;
while (unit(`${seed}:druid-omen-of-clarity`) >= 0.06) {
now += 1;
seed = `druid-auto-attack:${now}`;
}
const result = useCombatStore.getState().damageMob(TARGET_ID, 100, now, undefined, "physical", {
attackKind: "melee",
seed,
rules: { isAutoAttack: true, canMiss: false, canDodge: false, canParry: false, canBlock: false, canCrit: false },
});
expect(result.amount).toBeGreaterThan(0);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-druid-clearcasting")
))).toBe(true);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityTalentNodeId, type AbilityDefinition } from "./abilityCatalog";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore, type TimedCombatEffect } from "./combatStore";
function prepareSwiftmend(): AbilityDefinition {
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
useCombatStore.setState((state) => ({ health: 1_000, resource: state.maxResource }));
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Swiftmend");
if (!ability) throw new Error("Druid Swiftmend is missing from the learned ability catalog.");
const talentId = abilityTalentNodeId(ability);
if (!talentId) throw new Error("Druid Swiftmend has no active talent node.");
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [talentId]: 1 } }));
return ability;
}
function hot(
sourceName: "Rejuvenation" | "Regrowth",
sourceActorId: string,
tickHealing: number,
remainingTicks: number,
nextTickAt: number,
id: string,
fixedAmount?: number,
): TimedCombatEffect {
const sourceAbility = useCombatStore.getState().abilities.find((ability) => ability.name === sourceName);
if (!sourceAbility) throw new Error(`Druid ${sourceName} is missing from the learned ability catalog.`);
return {
id,
kind: "hot",
sourceAbilityId: sourceAbility.id,
sourceActorId,
targetId: null,
coefficient: 0,
basePoints: tickHealing,
dieSides: 1,
pointsPerLevel: 0,
sourceLevel: 80,
...(fixedAmount === undefined ? {} : { fixedAmount }),
remainingTicks,
intervalMs: 3_000,
nextTickAt,
};
}
function castSwiftmend(ability: AbilityDefinition, now = 1_000) {
const result = useCombatStore.getState().castAbility(ability.id, { targetActorId: null, now });
expect(result.ok).toBe(true);
const event = useCombatStore.getState().combatEvents.filter((candidate) => (
candidate.kind === "healing" && candidate.abilityId === ability.id
)).at(-1);
if (!event || event.kind !== "healing") throw new Error("Swiftmend did not record its healing event.");
return event;
}
function expectCriticalAdjustedRawHealing(rawAmount: number, critical: boolean, baseAmount: number): void {
expect(rawAmount).toBe(baseAmount * (critical ? 1.5 : 1));
}
describe("WoW Druid active talent runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
});
it.each([
["Rejuvenation", 100, 5, 400],
["Regrowth", 150, 7, 900],
] as const)("Swiftmend uses its source 12/18-second amount for %s", (sourceName, tickHealing, remainingTicks, expected) => {
const swiftmend = prepareSwiftmend();
const sourceHot = hot(sourceName, PLAYER_AGGRO_ID, tickHealing, remainingTicks, 4_000, "swiftmend-source-hot");
useCombatStore.setState({ effects: [sourceHot] });
const event = castSwiftmend(swiftmend);
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, expected);
expect(useCombatStore.getState().effects).toEqual([]);
});
it("prefers its own Hot over a shorter foreign Hot", () => {
const swiftmend = prepareSwiftmend();
const foreignShort = hot("Rejuvenation", "party-druid-short", 25, 1, 1_500, "swiftmend-foreign-short", 25);
const casterHot = hot("Regrowth", PLAYER_AGGRO_ID, 100, 7, 16_000, "swiftmend-caster-long");
useCombatStore.setState({ effects: [foreignShort, casterHot] });
const event = castSwiftmend(swiftmend);
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 600);
expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([foreignShort.id]);
});
it("uses the shortest foreign Rejuvenation or Regrowth when no caster Hot exists", () => {
const swiftmend = prepareSwiftmend();
const longerRegrowth = hot("Regrowth", "party-druid-long", 50, 7, 4_000, "swiftmend-foreign-long", 50);
const shorterRejuvenation = hot("Rejuvenation", "party-druid-short", 100, 5, 4_000, "swiftmend-foreign-short", 100);
useCombatStore.setState({ effects: [longerRegrowth, shorterRejuvenation] });
const event = castSwiftmend(swiftmend);
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400);
expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([longerRegrowth.id]);
});
});
+376
View File
@@ -0,0 +1,376 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { cooldownResetCandidates } from "./cooldownReset";
import { talentModifiersForRanks } from "./talentModifiers";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { usePartyStore } from "./partyStore";
describe("WoW 3.3.5 Hunter Readiness", () => {
it("preserves Readiness's Bestial Wrath cooldown exception", () => {
const hunterAbilities = WOW335_ABILITIES_BY_CLASS.hunter;
const readiness = hunterAbilities.find((ability) => ability.name === "Readiness");
expect(readiness).toBeDefined();
const reset = readiness!.effects.find((effect) => effect.kind === "cooldown-reset");
expect(reset).toMatchObject({
kind: "cooldown-reset",
mode: "reset",
excludeAbilityNames: ["Bestial Wrath"],
});
if (!reset || reset.kind !== "cooldown-reset") return;
const candidates = cooldownResetCandidates(
hunterAbilities,
readiness!.id,
reset.abilityName,
reset.excludeAbilityNames,
);
expect(candidates.some((ability) => ability.name === "Bestial Wrath")).toBe(false);
expect(candidates.some((ability) => ability.name === "Readiness")).toBe(false);
expect(candidates.some((ability) => ability.name === "Rapid Fire")).toBe(true);
});
it("does not turn The Beast Within's conditional tooltip into a permanent modifier", () => {
expect(talentModifiersForRanks({ "talent-1803": 1 }, "hunter")).toEqual([]);
expect(talentModifiersForRanks({ "talent-1803": 2 }, "hunter")).toEqual([]);
});
});
function hunterAbilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function unlockHunterTalent(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, name).toBeTruthy();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [nodeId!]: 1 },
}));
return ability!.id;
}
function hunterProcRoll(seed: string): number {
let hash = 2_166_136_261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function hunterProcTime(abilityId: string, petId: string, shouldProc: boolean, after: number): number {
for (let now = after; now < after + 60_000; now += 1) {
const rolls = hunterProcRoll(`${PLAYER_AGGRO_ID}:${abilityId}:${petId}:${now}:the-beast-within`);
if ((rolls < 0.5) === shouldProc) return now;
}
throw new Error("Could not find a deterministic rank-one proc test time.");
}
function registerHunterMob(id: string, position: readonly [number, number, number], maxHealth = 100_000): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
}
describe("WoW 3.3.5 Hunter pet mechanics", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["hunter"]);
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty(
"wow335-hunter-mechanics-tests",
1,
useCombatStore.getState().health,
);
});
it("resets Hunter cooldowns while leaving Bestial Wrath's cooldown untouched", () => {
const readinessId = unlockHunterTalent("Readiness");
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
const rapidFireId = hunterAbilityId("Rapid Fire");
useCombatStore.setState((state) => ({
cooldowns: {
...state.cooldowns,
[readinessId]: 0,
[bestialWrathId]: 90_000,
[rapidFireId]: 30_000,
},
}));
const result = useCombatStore.getState().castAbility(readinessId, undefined, 1_000);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(useCombatStore.getState().cooldowns[bestialWrathId]).toBe(90_000);
expect(useCombatStore.getState().cooldowns[rapidFireId]).toBe(0);
expect(useCombatStore.getState().cooldowns[readinessId]).toBeGreaterThan(1_000);
});
it("rejects Bestial Wrath without an active pet and does not proc The Beast Within", () => {
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1803": 2 },
}));
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false);
const startingMana = useCombatStore.getState().resourcePools.mana;
const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana);
expect(useCombatStore.getState().cooldowns[bestialWrathId] ?? 0).toBe(0);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false);
});
it("requires a live pet before accepting pet-command and Mend Pet casts", () => {
registerHunterMob("petless-target", [0, 0, 3]);
const petDependentCasts = [
{ id: unlockHunterTalent("Bestial Wrath"), targetActorId: undefined },
{ id: unlockHunterTalent("Intimidation"), targetActorId: "petless-target" },
{ id: hunterAbilityId("Kill Command"), targetActorId: "petless-target" },
{ id: hunterAbilityId("Mend Pet"), targetActorId: undefined },
];
const startingMana = useCombatStore.getState().resourcePools.mana;
for (const [index, cast] of petDependentCasts.entries()) {
const result = useCombatStore.getState().castAbility(cast.id, {
...(cast.targetActorId ? { targetActorId: cast.targetActorId } : {}),
now: 1_000 + index * 2_000,
});
expect(result, JSON.stringify(result)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana);
expect(useCombatStore.getState().cooldowns[cast.id] ?? 0).toBe(0);
}
});
it("places Bestial Wrath on the pet and applies the rank-two Beast Within payload to the hunter", () => {
const callPetId = hunterAbilityId("Call Pet");
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1803": 2 },
}));
const callResult = useCombatStore.getState().castAbility(callPetId, undefined, 1_000);
expect(callResult, JSON.stringify(callResult)).toMatchObject({ ok: true });
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 3_000);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
const auras = useCombatStore.getState().auras;
const petAuras = auras.filter((aura) => aura.targetId === pet!.id && aura.definition.id.includes(":19574:"));
expect(petAuras).toHaveLength(3);
expect(auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.id.includes(":19574:"))).toBe(false);
const beastWithinAuras = auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.id.includes(":34471:"));
expect(beastWithinAuras).toHaveLength(3);
expect(beastWithinAuras.every((aura) => aura.expiresAt === 21_000)).toBe(true);
expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.2 }),
expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.1 }),
]));
});
it("uses The Beast Within's 50% rank-one proc chance", () => {
const callPetId = hunterAbilityId("Call Pet");
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1803": 1 },
}));
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
const noProcAt = hunterProcTime(bestialWrathId, pet!.id, false, 3_000);
expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, noProcAt).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.id.includes(":34471:"))).toBe(false);
useCombatStore.setState((state) => ({
cooldowns: { ...state.cooldowns, [bestialWrathId]: 0 },
globalCooldownEndsAt: 0,
}));
const procAt = hunterProcTime(bestialWrathId, pet!.id, true, noProcAt + 5_000);
expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, procAt).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.id.includes(":34471:"))).toBe(true);
});
it("stores Intimidation on the pet and spends it on the pet's next melee hit", () => {
registerHunterMob("intimidation-target", [0, 0, 3]);
const callPetId = hunterAbilityId("Call Pet");
const intimidationId = unlockHunterTalent("Intimidation");
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
const cast = useCombatStore.getState().castAbility(intimidationId, { targetActorId: "intimidation-target", now: 3_000 });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
const intimidationAura = useCombatStore.getState().auras.find((aura) => (
aura.definition.id.includes(":19577:")
));
expect(intimidationAura?.targetId).toBe(pet!.id);
expect(useCombatStore.getState().mobs["intimidation-target"]?.statuses).toHaveLength(0);
useCombatStore.getState().tick(1, 4_000);
const target = useCombatStore.getState().mobs["intimidation-target"];
expect(target?.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "stun", sourceActorId: pet!.id, endsAt: 7_000 }),
]));
expect(target?.threatByActor[pet!.id]).toBe(580);
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === intimidationAura?.instanceId)).toBe(false);
});
it("only enables Counterattack after a parry and consumes that readiness on use", () => {
const counterattackId = unlockHunterTalent("Counterattack");
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, parryRating: 100_000 },
}));
useCombatStore.getState().registerMob("counterattack-target", {
name: "counterattack-target",
maxHealth: 100_000,
xpReward: 0,
attacks: [DEFAULT_ENEMY_ATTACK],
});
setMobPosition("counterattack-target", [2, 0, 0]);
const unavailable = useCombatStore.getState().castAbility(counterattackId, {
targetActorId: "counterattack-target", now: 1_000,
});
expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.getState().damageMob("counterattack-target", 1, 1_100);
let parryAt: number | null = null;
for (let now = 1_450; now < 20_000; now += 100) {
useCombatStore.getState().advanceMobCombat(now);
if (useCombatStore.getState().lastCombatResult?.parried) {
parryAt = now;
break;
}
}
expect(parryAt).not.toBeNull();
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready")
))).toBe(true);
const available = useCombatStore.getState().castAbility(counterattackId, {
targetActorId: "counterattack-target", now: parryAt! + 100,
});
expect(available, JSON.stringify(available)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready")
))).toBe(false);
});
it("does not consume Kill Command's special-attack charges on a generic pet melee swing", () => {
registerHunterMob("kill-command-target", [0, 0, 3]);
const callPetId = hunterAbilityId("Call Pet");
const killCommandId = hunterAbilityId("Kill Command");
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
const cast = useCombatStore.getState().castAbility(killCommandId, { targetActorId: "kill-command-target", now: 3_000 });
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
const aura = useCombatStore.getState().auras.find((entry) => (
entry.definition.utilityTags?.includes("wow335-hunter-kill-command")
));
expect(aura?.targetId).toBe(pet!.id);
expect(aura?.stacks).toBe(60);
expect(aura?.definition.modifiers?.some((modifier) => modifier.kind === "damage") ?? false).toBe(false);
useCombatStore.getState().tick(1, 4_000);
expect(useCombatStore.getState().auras.find((entry) => entry.instanceId === aura?.instanceId)?.stacks).toBe(60);
});
it("arms Freezing Arrow at its ground point and freezes only an approaching target", () => {
registerHunterMob("freezing-target", [0, 0, 10]);
useCombatStore.getState().registerMob("freezing-later", { name: "freezing-later", maxHealth: 100_000, xpReward: 0 });
setMobPosition("freezing-later", [0, 0, 12]);
const freezingArrowId = hunterAbilityId("Freezing Arrow");
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === freezingArrowId)!;
expect(ability.targeting).toMatchObject({ shape: "ground", groundRange: 40, radius: 2 });
const cast = useCombatStore.getState().castAbility(freezingArrowId, {
origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000,
});
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0);
expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("freezing");
setMobPosition("freezing-target", [0, 0, 1]);
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "freeze", endsAt: 22_000, breakOnDamage: true }),
]));
expect(useCombatStore.getState().mobs["freezing-later"]?.statuses).toHaveLength(0);
expect(useCombatStore.getState().areaEffects.some((area) => area.hunterTrap?.kind === "freezing")).toBe(false);
useCombatStore.getState().damageMob("freezing-target", 1, 2_001);
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0);
});
it("arms Explosive Trap, then deals its rank fire impact and periodic AoE ticks", () => {
registerHunterMob("explosive-center", [0, 0, 0]);
useCombatStore.getState().registerMob("explosive-edge", { name: "explosive-edge", maxHealth: 100_000, xpReward: 0 });
setMobPosition("explosive-edge", [0, 0, 8]);
useCombatStore.getState().registerMob("explosive-outside", { name: "explosive-outside", maxHealth: 100_000, xpReward: 0 });
setMobPosition("explosive-outside", [0, 0, 11]);
const abilityId = hunterAbilityId("Explosive Trap");
const cast = useCombatStore.getState().castAbility(abilityId, {
origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000,
});
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
const beforeCenter = useCombatStore.getState().mobs["explosive-center"]!.health;
const beforeEdge = useCombatStore.getState().mobs["explosive-edge"]!.health;
expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("explosive");
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(beforeCenter);
expect(useCombatStore.getState().mobs["explosive-edge"]!.health).toBeLessThan(beforeEdge);
expect(useCombatStore.getState().mobs["explosive-outside"]!.health).toBe(100_000);
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
expect.objectContaining({
occurredAt: 2_000,
kind: "damage",
abilityId,
targetActorId: "explosive-center",
school: "fire",
}),
]));
expect(useCombatStore.getState().areaEffects[0]).toMatchObject({ radius: 10, tickIntervalMs: 2_000 });
const afterImpact = useCombatStore.getState().mobs["explosive-center"]!.health;
useCombatStore.getState().tick(1, 4_000);
expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(afterImpact);
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
expect.objectContaining({
occurredAt: 4_000,
kind: "damage",
abilityId,
targetActorId: "explosive-center",
school: "fire",
}),
]));
});
});
+125
View File
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { WOW335_MAGE_IMPACT_TAG } from "./wow335MageTalentRuntime";
const TARGET_ID = "mage-impact-target";
const PROC_SEED = "mage-impact-seed-0";
const NO_PROC_SEED = "mage-impact-no-proc-0";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function setImpactRank(rank: number): void {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-30": rank },
}));
}
function impactAura() {
return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_IMPACT_TAG));
}
function damageSpell(name: string, now: number, seed: string, rules = {}) {
const spell = useCombatStore.getState().abilities.find((ability) => ability.name === name);
expect(spell, name).toBeDefined();
return useCombatStore.getState().damageMob(TARGET_ID, 1_000, now, undefined,
name === "Frostbolt" ? "frost" : "fire", {
attackKind: "spell",
seed,
abilityId: spell!.id,
rules: {
canMiss: false,
canCrit: false,
canDodge: false,
canParry: false,
canBlock: false,
...rules,
},
});
}
describe("WoW 3.3.5 Mage Impact runtime", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["mage"]);
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-mage-impact-tests", 1, useCombatStore.getState().health);
useCombatStore.getState().registerMob(TARGET_ID, { name: "Target", maxHealth: 100_000 });
useCombatStore.getState().selectMob(TARGET_ID);
});
it("uses the active rank-three 4% proc on direct damaging spells without requiring a critical", () => {
setImpactRank(3);
const result = damageSpell("Fireball", 1_000, PROC_SEED);
expect(result.combatResult?.landed).toBe(true);
expect(result.combatResult?.critical).toBe(false);
expect(impactAura()?.expiresAt).toBe(11_000);
useCombatStore.getState().consumeActorAuraTag(PLAYER_AURA_ENTITY_ID, WOW335_MAGE_IMPACT_TAG);
damageSpell("Fireball", 2_000, NO_PROC_SEED);
expect(impactAura()).toBeUndefined();
});
it("does not activate the child stun proc before rank three", () => {
for (const rank of [1, 2]) {
setImpactRank(rank);
damageSpell("Fireball", 1_000 + rank, PROC_SEED);
expect(impactAura()).toBeUndefined();
}
});
it("preserves the proc through a missed Fire Blast, then consumes it on a landed Fire Blast", () => {
setImpactRank(3);
damageSpell("Fireball", 1_000, PROC_SEED);
expect(impactAura()).toBeDefined();
const miss = damageSpell("Fire Blast", 2_000, NO_PROC_SEED, {
canMiss: true,
baseMissChancePercent: 100,
});
expect(miss.combatResult?.landed).toBe(false);
expect(impactAura()).toBeDefined();
expect(useCombatStore.getState().mobs[TARGET_ID]?.statuses.some((status) => status.kind === "stun"))
.toBe(false);
const hit = damageSpell("Fire Blast", 3_000, NO_PROC_SEED);
expect(hit.combatResult?.landed).toBe(true);
expect(impactAura()).toBeUndefined();
expect(useCombatStore.getState().mobs[TARGET_ID]?.statuses).toContainEqual(expect.objectContaining({
kind: "stun",
sourceAbilityId: abilityId("Fire Blast"),
endsAt: 6_000,
}));
});
it("consumes the Fire Blast proc charge against a stun-immune target", () => {
setImpactRank(3);
useCombatStore.getState().registerMob("mage-impact-immune", {
name: "Stun immune target",
maxHealth: 100_000,
mechanicImmunities: ["stun"],
});
damageSpell("Fireball", 1_000, PROC_SEED);
useCombatStore.getState().damageMob("mage-impact-immune", 1_000, 2_000, undefined, "fire", {
attackKind: "spell",
seed: NO_PROC_SEED,
abilityId: abilityId("Fire Blast"),
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
});
expect(impactAura()).toBeUndefined();
expect(useCombatStore.getState().mobs["mage-impact-immune"]?.statuses).toEqual([]);
});
});
+490
View File
@@ -0,0 +1,490 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, resolvedAbilityCosts, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { abilityAtLevel, abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { resolveDamageValue, resolveStatValue } from "./combatAuras";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import {
WOW335_MAGE_FINGERS_OF_FROST_TAG,
WOW335_MAGE_FOCUS_MAGIC_TAG,
WOW335_MAGE_HOT_STREAK_TAG,
WOW335_MAGE_IMPROVED_SCORCH_TAG,
} from "./wow335MageTalentRuntime";
function runtimeAbilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function unlockTalentAbility(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, name).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
return ability!.id;
}
describe("WoW 3.3.5 Mage runtime mappings", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["mage"]);
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-mage-runtime-tests", 1, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
useCombatStore.getState().registerMob("cold-snap-target", { name: "Target", maxHealth: 100_000 });
setMobPosition("cold-snap-target", [4, 0, 0]);
useCombatStore.getState().selectMob("cold-snap-target");
});
it("resets Mage Frost spell cooldowns and leaves Fire and Arcane cooldowns intact", () => {
const coldSnap = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Cold Snap");
expect(coldSnap?.effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Frost Nova" }),
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Frost Ward" }),
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Fire Ward" }),
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Ice Block" }),
expect.objectContaining({ kind: "cooldown-reset", mode: "reset", abilityName: "Deep Freeze" }),
]));
const cast = useCombatStore.getState().castAbility;
for (const [name, now] of [
["Frost Ward", 1_000],
["Fire Blast", 3_000],
["Arcane Power", 5_000],
["Frost Nova", 7_000],
] as const) {
if (name === "Arcane Power") unlockTalentAbility(name);
const result = cast(runtimeAbilityId(name), undefined, now);
expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true);
}
expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Frost Ward")]).toBe(31_000);
expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Fire Ward")]).toBe(31_000);
expect(useCombatStore.getState().cooldowns[runtimeAbilityId("Frost Nova")]).toBe(32_000);
const coldSnapResult = cast(unlockTalentAbility("Cold Snap"), undefined, 9_000);
expect(coldSnapResult.ok, JSON.stringify(coldSnapResult)).toBe(true);
const cooldowns = useCombatStore.getState().cooldowns;
expect(cooldowns[runtimeAbilityId("Frost Ward")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Fire Ward")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Frost Nova")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Ice Block")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Deep Freeze")]).toBe(0);
expect(cooldowns[runtimeAbilityId("Fire Blast")]).toBe(11_000);
expect(cooldowns[runtimeAbilityId("Arcane Power")]).toBe(125_000);
expect(cooldowns[runtimeAbilityId("Cold Snap")]).toBe(489_000);
});
it("applies Arcane Power's source 20% magic damage and mana cost modifiers", () => {
const arcanePower = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Arcane Power");
const modeledAura = arcanePower?.effects.filter((effect) => effect.kind === "apply-aura"
&& effect.aura.id.endsWith(":arcane-power"));
expect(modeledAura).toHaveLength(1);
expect(modeledAura?.[0]).toMatchObject({
kind: "apply-aura",
recipient: "caster",
aura: {
durationMs: 15_000,
modifiers: [
{ kind: "damage", direction: "dealt", value: 0.2 },
{ kind: "resource", resource: "mana", property: "cost", value: 0.2 },
],
},
});
const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!;
const baseManaCost = resolvedAbilityCosts(
fireBlast,
useCombatStore.getState().talentModifiers,
useCombatStore.getState(),
)[0]!.amount;
const arcanePowerId = unlockTalentAbility("Arcane Power");
const result = useCombatStore.getState().castAbility(arcanePowerId, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const state = useCombatStore.getState();
const aura = state.auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID
&& candidate.definition.id.endsWith(":arcane-power"));
expect(aura).toBeDefined();
for (const school of ["fire", "frost", "arcane"] as const) {
expect(resolveDamageValue(100, [aura!], { direction: "dealt", school, attackKind: "spell" })).toBe(120);
}
const buffedManaCost = resolvedAbilityCosts(fireBlast, state.talentModifiers, state)[0]!.amount;
expect(buffedManaCost).toBe(Math.round(baseManaCost * 1.2));
});
it("applies Focus Magic to an ally and grants the Mage 3% spell crit for 10 seconds when the ally spell-crits", () => {
const focusMagic = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Focus Magic");
const targetBuff = focusMagic?.effects.find((effect) => effect.kind === "apply-aura");
expect(targetBuff).toMatchObject({
kind: "apply-aura",
aura: {
modifiers: [expect.objectContaining({
kind: "stat",
stat: "spell-critical-strike",
operation: "flat",
value: 3,
})],
},
});
const ally = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(ally.id, [2, 0, 0]);
const focusMagicId = unlockTalentAbility("Focus Magic");
const focusMagicCast = useCombatStore.getState().castAbility(focusMagicId, {
targetActorId: ally.id,
now: 1_000,
});
expect(focusMagicCast, JSON.stringify(focusMagicCast)).toMatchObject({ ok: true });
const allyAura = useCombatStore.getState().auras.find((aura) => aura.targetId === ally.id
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
&& modifier.stat === "spell-critical-strike"));
expect(allyAura, JSON.stringify(useCombatStore.getState().auras.map((aura) => ({
targetId: aura.targetId,
name: aura.definition.name,
modifiers: aura.definition.modifiers,
procs: aura.definition.procs,
})))).toBeDefined();
expect(allyAura?.definition.procs ?? []).toContainEqual(expect.objectContaining({
triggers: "crit",
attackKinds: ["spell"],
action: { kind: "custom", id: "wow335-mage-focus-magic-proc" },
}));
const fireballId = runtimeAbilityId("Fireball");
const allyCritical = useCombatStore.getState().damageMob("cold-snap-target", 100, 2_000, {
actorId: ally.id,
role: ally.role,
abilityId: fireballId,
threatScale: 1,
}, "fire", {
attackKind: "spell",
seed: "mage-focus-magic-ally-crit",
abilityId: fireballId,
criticalChanceBonusPercent: 1_000,
rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false },
});
expect(allyCritical.combatResult?.critical).toBe(true);
const procAura = useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_FOCUS_MAGIC_TAG));
expect(procAura?.expiresAt).toBe(12_000);
expect(resolveStatValue(0, [procAura!], "spell-critical-strike")).toBe(3);
});
it("ticks rank-five Ignite for the DBC 40% burn total on two fixed Fire damage ticks", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-34": 5 },
}));
const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!;
const critical = useCombatStore.getState().damageMob("cold-snap-target", 100, 1_000, undefined, "fire", {
attackKind: "spell",
seed: "mage-ignite-rank-five-crit",
abilityId: fireball.id,
criticalChanceBonusPercent: 1_000,
rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false },
});
expect(critical.combatResult?.critical).toBe(true);
// Spell effect ranks 11119 through 12848 encode an 8%-per-rank burn. The
// generated talent tooltip's 30%/healing-reduction wording conflicts with
// those rows; no healing modifier is inferred from the spell data.
const igniteTicks = useCombatStore.getState().effects.filter((effect) => (
effect.tag === "wow335-mage-ignite" && effect.targetId === "cold-snap-target"
));
expect(igniteTicks).toHaveLength(2);
expect(igniteTicks.map((effect) => effect.nextTickAt)).toEqual([3_000, 5_000]);
expect(igniteTicks.reduce((sum, effect) => sum + (effect.fixedAmount ?? 0), 0))
.toBe(Math.round(critical.amount * 0.4));
useCombatStore.getState().tick(0.1, 3_000);
expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-mage-ignite"))
.toHaveLength(1);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === fireball.id && event.occurredAt === 3_000
))).toBe(true);
useCombatStore.getState().tick(0.1, 5_000);
expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "wow335-mage-ignite"))
.toHaveLength(0);
});
it("maps Presence of Mind to a non-expiring next cast-time Mage spell aura", () => {
const presenceOfMind = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Presence of Mind");
expect(presenceOfMind?.effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
recipient: "caster",
aura: expect.objectContaining({
durationMs: null,
utilityTags: ["wow335-next-mage-instant"],
}),
}),
]));
});
it("keeps Presence of Mind through instant and channel casts, then makes the next eligible Mage cast instant", () => {
const presenceOfMindId = unlockTalentAbility("Presence of Mind");
expect(useCombatStore.getState().castAbility(presenceOfMindId, undefined, 1_000)).toMatchObject({ ok: true });
const hasPresenceOfMind = () => useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-next-mage-instant")
));
expect(hasPresenceOfMind()).toBe(true);
expect(useCombatStore.getState().castAbility(runtimeAbilityId("Fire Blast"), undefined, 2_000))
.toMatchObject({ ok: true });
expect(hasPresenceOfMind()).toBe(true);
const missiles = runtimeAbilityId("Arcane Missiles");
expect(useCombatStore.getState().castAbility(missiles, undefined, 4_000)).toMatchObject({ ok: true });
const channel = useCombatStore.getState().activeCast;
expect(channel?.mode).toBe("channel");
expect(hasPresenceOfMind()).toBe(true);
useCombatStore.getState().tick(0.1, channel!.completesAt);
expect(hasPresenceOfMind()).toBe(true);
const fireball = useCombatStore.getState().castAbility(runtimeAbilityId("Fireball"), undefined, 10_000);
expect(fireball.ok, JSON.stringify(fireball)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(hasPresenceOfMind()).toBe(false);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === runtimeAbilityId("Fireball")
))).toBe(true);
});
it("uses Arcane Blast child 36032 stacking values and consumes its aura after another Arcane damage cast", () => {
const arcaneBlastDefinition = WOW335_ABILITIES_BY_CLASS.mage.find((ability) => ability.name === "Arcane Blast");
const arcaneBlastAura = arcaneBlastDefinition?.effects.find((effect) => (
effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("wow335-arcane-blast-stack")
));
expect(arcaneBlastAura).toMatchObject({
kind: "apply-aura",
recipient: "caster",
aura: {
disposition: "buff",
durationMs: 10_000,
maxStacks: 3,
stackBehavior: "add",
modifiers: [
{ kind: "damage", direction: "dealt", school: "arcane", operation: "percent", value: 0.15, perStack: true },
{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 2, perStack: true },
],
},
});
useCombatStore.setState((state) => ({
resource: 100_000,
maxResource: 100_000,
resourcePools: { ...state.resourcePools, mana: 100_000 },
maxResourcePools: { ...state.maxResourcePools, mana: 100_000 },
}));
const arcaneBlastId = runtimeAbilityId("Arcane Blast");
const arcaneBlast = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneBlastId)!;
const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!;
const baseArcaneBlastCost = resolvedAbilityCosts(arcaneBlast, [], useCombatStore.getState())[0]!.amount;
const baseFireballCost = resolvedAbilityCosts(fireball, [], useCombatStore.getState())[0]!.amount;
const castArcaneBlastAndFinish = (startedAt: number) => {
const result = useCombatStore.getState().castAbility(arcaneBlastId, undefined, startedAt);
expect(result.ok, JSON.stringify(result)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
expect(activeCast?.abilityId).toBe(arcaneBlastId);
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
};
for (const startedAt of [1_000, 4_000, 7_000, 10_000]) castArcaneBlastAndFinish(startedAt);
const currentAura = () => useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-arcane-blast-stack")
));
expect(currentAura()?.stacks).toBe(3);
expect(resolveDamageValue(100, [currentAura()!], {
direction: "dealt", school: "arcane", attackKind: "spell",
})).toBe(145);
expect(resolveDamageValue(100, [currentAura()!], {
direction: "dealt", school: "fire", attackKind: "spell",
})).toBe(100);
expect(resolvedAbilityCosts(arcaneBlast, [], useCombatStore.getState())[0]!.amount)
.toBe(Math.round(baseArcaneBlastCost * 7));
expect(resolvedAbilityCosts(fireball, [], useCombatStore.getState())[0]!.amount).toBe(baseFireballCost);
const fireballResult = useCombatStore.getState().castAbility(fireball.id, undefined, 13_000);
expect(fireballResult.ok, JSON.stringify(fireballResult)).toBe(true);
const fireballCast = useCombatStore.getState().activeCast;
expect(fireballCast?.abilityId).toBe(fireball.id);
useCombatStore.getState().tick(0.1, fireballCast!.completesAt);
expect(currentAura()?.stacks).toBe(3);
const arcaneBarrageId = unlockTalentAbility("Arcane Barrage");
const barrageResult = useCombatStore.getState().castAbility(arcaneBarrageId, undefined, 17_000);
expect(barrageResult.ok, JSON.stringify(barrageResult)).toBe(true);
expect(currentAura()).toBeUndefined();
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === arcaneBarrageId
))).toBe(true);
});
it("applies Improved Scorch's rank-three 30-second crit-vulnerability debuff", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-25": 3 },
}));
const scorch = useCombatStore.getState().abilities.find((ability) => ability.name === "Scorch")!;
useCombatStore.getState().damageMob("cold-snap-target", 100, 5_000, undefined, "fire", {
attackKind: "spell",
seed: "mage-scorch-vulnerability",
abilityId: scorch.id,
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
});
const vulnerability = useCombatStore.getState().auras.find((aura) => (
aura.targetId === "cold-snap-target"
&& aura.definition.utilityTags?.includes(WOW335_MAGE_IMPROVED_SCORCH_TAG)
));
expect(vulnerability?.expiresAt).toBe(35_000);
});
it("grants two 15-second Fingers of Frost charges and consumes one per successful Mage spell", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1853": 2 },
}));
const deepFreezeId = unlockTalentAbility("Deep Freeze");
expect(useCombatStore.getState().castAbility(deepFreezeId, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
const frostbolt = useCombatStore.getState().abilities.find((ability) => ability.name === "Frostbolt")!;
let procAt = 0;
for (let index = 0; index < 100; index += 1) {
procAt = 2_000 + index;
useCombatStore.getState().damageMob("cold-snap-target", 100, procAt, undefined, "frost", {
attackKind: "spell",
seed: `mage-fingers-of-frost-${index}`,
abilityId: frostbolt.id,
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
});
if (useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG)
))) break;
}
const fingers = () => useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_FINGERS_OF_FROST_TAG)
));
expect(fingers()?.stacks).toBe(2);
expect(fingers()?.expiresAt).toBe(procAt + 15_000);
const fireballId = runtimeAbilityId("Fireball");
expect(useCombatStore.getState().castAbility(fireballId, undefined, procAt + 1_000))
.toMatchObject({ ok: true });
const fireballCast = useCombatStore.getState().activeCast;
expect(fireballCast).not.toBeNull();
useCombatStore.getState().tick(0.1, fireballCast!.completesAt);
expect(fingers()?.stacks).toBe(1);
expect(useCombatStore.getState().castAbility(deepFreezeId, undefined, fireballCast!.completesAt + 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "stun"))
.toBe(true);
expect(fingers()).toBeUndefined();
});
it("procs Hot Streak from two consecutive eligible fire spell crits and empowers one Pyroblast", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1850": 3 },
}));
const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!;
const pyroblastId = unlockTalentAbility("Pyroblast");
const pyroblast = useCombatStore.getState().abilities.find((ability) => ability.id === pyroblastId)!;
const rankedPyroblast = abilityAtLevel(pyroblast, useCombatStore.getState().level, 1);
const baseCost = resolvedAbilityCosts(rankedPyroblast, [], useCombatStore.getState())[0]!.amount;
const forceCrit = (now: number) => useCombatStore.getState().damageMob(
"cold-snap-target",
100,
now,
{
actorId: PLAYER_AURA_ENTITY_ID,
role: usePartyStore.getState().playerRole,
abilityId: fireball.id,
threatScale: 1,
},
"fire",
{
attackKind: "spell",
seed: `mage-hot-streak-${now}`,
abilityId: fireball.id,
criticalChanceBonusPercent: 1_000,
rules: { canMiss: false, canCrit: true, canDodge: false, canParry: false, canBlock: false },
},
);
expect(forceCrit(1_000).combatResult?.critical).toBe(true);
expect(forceCrit(2_000).combatResult?.critical).toBe(true);
const stateWithHotStreak = useCombatStore.getState();
const hotStreak = stateWithHotStreak.auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)
));
expect(hotStreak?.expiresAt).toBe(12_000);
expect(resolvedAbilityCosts(rankedPyroblast, stateWithHotStreak.talentModifiers, stateWithHotStreak)[0]!.amount)
.toBe(Math.round(baseCost * 0.5));
const manaBefore = stateWithHotStreak.resourcePools.mana!;
const result = stateWithHotStreak.castAbility(pyroblast.id, undefined, 3_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(manaBefore - useCombatStore.getState().resourcePools.mana!).toBe(Math.round(baseCost * 0.5));
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(WOW335_MAGE_HOT_STREAK_TAG)
))).toBe(false);
});
it("lets Mage Frostbite freeze a target after landed Frost spell damage at its rank chance", () => {
const frostbolt = useCombatStore.getState().abilities.find((ability) => ability.name === "Frostbolt")!;
const fireball = useCombatStore.getState().abilities.find((ability) => ability.name === "Fireball")!;
const applySpellDamage = (abilityId: string, school: "fire" | "frost", now: number) => (
useCombatStore.getState().damageMob(
"cold-snap-target",
100,
now,
undefined,
school,
{ attackKind: "spell", seed: `mage-frostbite-${abilityId}-${now}`, abilityId, periodic: false },
)
);
applySpellDamage(frostbolt.id, "frost", 1_000);
expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze"))
.toBe(false);
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-38": 3 } }));
for (let index = 0; index < 20; index += 1) {
applySpellDamage(fireball.id, "fire", 2_000 + index);
}
expect(useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze"))
.toBe(false);
let procAt = 0;
for (let index = 0; index < 200; index += 1) {
procAt = 3_000 + index;
applySpellDamage(frostbolt.id, "frost", procAt);
if (useCombatStore.getState().mobs["cold-snap-target"]?.statuses.some((status) => status.kind === "freeze")) break;
}
const freeze = useCombatStore.getState().mobs["cold-snap-target"]?.statuses.find((status) => (
status.kind === "freeze" && status.sourceAbilityId === frostbolt.id
));
expect(freeze?.endsAt).toBe(procAt + 4_000);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { AuraDefinition } from "./combatAuras";
export const WOW335_MAGE_HOT_STREAK_TAG = "wow335-mage-hot-streak";
export const WOW335_MAGE_FINGERS_OF_FROST_TAG = "wow335-mage-fingers-of-frost";
export const WOW335_MAGE_IMPROVED_SCORCH_TAG = "wow335-mage-improved-scorch";
export const WOW335_MAGE_IMPACT_TAG = "wow335-mage-impact-next-fire-blast";
export const WOW335_MAGE_FOCUS_MAGIC_TAG = "wow335-mage-focus-magic-proc";
export const WOW335_MAGE_HOT_STREAK_AURA: AuraDefinition = {
id: "wow335:mage:hot-streak",
name: "Hot Streak",
description: "Your next Pyroblast within 10 seconds is instant, deals 40% more damage, and costs 50% less mana.",
disposition: "buff",
durationMs: 10_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [WOW335_MAGE_HOT_STREAK_TAG],
};
export const WOW335_MAGE_FINGERS_OF_FROST_AURA: AuraDefinition = {
id: "wow335:mage:fingers-of-frost",
name: "Fingers of Frost",
description: "Your next two Mage spells treat their target as Frozen for 15 seconds.",
disposition: "buff",
durationMs: 15_000,
maxStacks: 2,
initialStacks: 2,
stackBehavior: "replace",
utilityTags: [WOW335_MAGE_FINGERS_OF_FROST_TAG],
};
export const WOW335_MAGE_IMPROVED_SCORCH_AURA: AuraDefinition = {
id: "wow335:mage:improved-scorch",
name: "Improved Scorch",
description: "Increases spell critical strike chance against this target by 5% for 30 seconds.",
disposition: "debuff",
durationMs: 30_000,
maxStacks: 1,
stackBehavior: "refresh",
uniqueBySource: false,
utilityTags: [WOW335_MAGE_IMPROVED_SCORCH_TAG],
};
export const WOW335_MAGE_IMPACT_AURA: AuraDefinition = {
id: "wow335:mage:impact",
name: "Impact",
description: "Your next Fire Blast stuns its target for 3 seconds.",
disposition: "buff",
durationMs: 10_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [WOW335_MAGE_IMPACT_TAG],
};
export const WOW335_MAGE_FOCUS_MAGIC_PROC_AURA: AuraDefinition = {
id: "wow335:mage:focus-magic-proc",
name: "Focus Magic",
description: "Your spell critical strike chance is increased by 3% for 10 seconds.",
disposition: "buff",
durationMs: 10_000,
maxStacks: 1,
stackBehavior: "refresh",
modifiers: [{ kind: "stat", stat: "spell-critical-strike", operation: "flat", value: 3 }],
utilityTags: [WOW335_MAGE_FOCUS_MAGIC_TAG],
};
+552
View File
@@ -0,0 +1,552 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { applyAura } from "./combatAuras";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function unlockTalentAbility(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, name).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
return ability!.id;
}
function setTalentRank(name: string, rank: number): void {
const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name);
expect(talent, name).toBeDefined();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [talent!.id]: rank },
}));
}
describe("WoW 3.3.5 Paladin source-backed runtime mechanics", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["paladin"]);
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-paladin-runtime-tests", 80, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
});
it("blocks silence with Aura Mastery for its sourced 10-second window, then allows silence after expiry", () => {
const mobId = "paladin-aura-mastery-silence";
useCombatStore.getState().registerMob(mobId, {
name: "Silence attacker",
maxHealth: 10_000,
attacks: [{
id: "silence",
name: "Silence",
delivery: "area",
target: "primary",
school: "shadow",
animation: "cast",
castTimeMs: 0,
range: 30,
cooldownMs: 10_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [10_000, 10_000],
damageMultiplier: 0,
effect: { kind: "control", mechanic: "silence", durationMs: 3_000 },
}],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const activate = useCombatStore.getState().castAbility(unlockTalentAbility("Aura Mastery"), undefined, 1_000);
expect(activate).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.controlImmunities?.includes("silence")
&& aura.expiresAt === 11_000
))).toBe(true);
const partyMember = usePartyStore.getState().members[0]!;
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === partyMember.id
&& aura.definition.controlImmunities?.includes("silence")
&& aura.expiresAt === 11_000
))).toBe(true);
useCombatStore.getState().advanceMobCombat(1_450);
expect(useCombatStore.getState().controlMechanic).toBeNull();
expect(useCombatStore.getState().schoolLockedUntil).toEqual({});
useCombatStore.getState().tick(0.1, 11_001);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.controlImmunities?.includes("silence")
))).toBe(false);
useCombatStore.getState().advanceMobCombat(11_450);
expect(useCombatStore.getState().controlMechanic).toBe("silence");
expect(useCombatStore.getState().controlledUntil).toBe(14_450);
});
it("lets Redemption target and revive a defeated party member at the rank's source health", () => {
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 0, status: "down" as const }
: candidate),
}));
updatePartyRuntimePosition(member.id, 4, 0, 0);
usePartyStore.getState().selectMember(member.id);
const cast = useCombatStore.getState().castAbility(abilityId("Redemption"), undefined, 1_000);
expect(cast).toMatchObject({ ok: true });
const activeCast = useCombatStore.getState().activeCast;
expect(activeCast?.targetId).toBe(member.id);
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(1_760);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "resurrection" && event.targetActorId === member.id
))).toBe(true);
});
it("blocks Redemption while any living enemy is engaged", () => {
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 0, status: "down" as const }
: candidate),
}));
updatePartyRuntimePosition(member.id, 4, 0, 0);
usePartyStore.getState().selectMember(member.id);
const targetId = "paladin-redemption-combat-gate";
useCombatStore.getState().registerMob(targetId, { name: "Engaged enemy", maxHealth: 10_000 });
setMobPosition(targetId, [4, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, engaged: true } },
}));
const result = useCombatStore.getState().castAbility(abilityId("Redemption"), undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().activeCast).toBeNull();
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(0);
});
it("enforces Hammer of Wrath's 20% execute gate", () => {
const targetId = "paladin-hammer-gate-target";
useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 10_000 });
setMobPosition(targetId, [4, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, health: 2_001 } },
selectedTargetId: targetId,
}));
const result = useCombatStore.getState().castAbility(abilityId("Hammer of Wrath"), undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
});
it("requires a shield before Holy Shield can be activated", () => {
useCombatStore.setState({ equipment: {}, inventory: [] });
expect(useCombatStore.getState().abilities.find((candidate) => candidate.name === "Holy Shield")?.target).toBe("self");
const result = useCombatStore.getState().castAbility(unlockTalentAbility("Holy Shield"), undefined, 1_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
});
it("procs three 8-second Reckoning charges from melee hits and adds one landed autoattack per charge", () => {
setTalentRank("Reckoning", 5);
const mobId = "paladin-reckoning-trigger";
useCombatStore.getState().registerMob(mobId, {
name: "Reckoning attacker",
level: 80,
maxHealth: 100_000,
attacks: [{
id: "reckoning-melee",
name: "Melee attack",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
castTimeMs: 0,
range: 8,
cooldownMs: 500,
initialCooldownMs: [0, 0],
repeatCooldownMs: [500, 500],
damageMultiplier: 0.1,
}],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const ensureMeleeHit = applyAura(useCombatStore.getState().auras, {
definition: {
id: "test:ensure-reckoning-melee-hit",
name: "Ensure incoming melee hit",
disposition: "buff",
durationMs: null,
modifiers: [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 100 }],
},
sourceId: mobId,
targetId: PLAYER_AURA_ENTITY_ID,
now: 1_000,
}).auras;
useCombatStore.setState((state) => ({ auras: [...ensureMeleeHit] }));
let procAt: number | null = null;
for (let now = 1_450; now <= 30_000; now += 250) {
useCombatStore.getState().advanceMobCombat(now);
if (useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
))) {
procAt = now;
break;
}
}
expect(procAt).not.toBeNull();
const reckoning = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
));
expect(reckoning).toMatchObject({ stacks: 3, expiresAt: procAt! + 8_000 });
useCombatStore.setState((state) => ({
autoAttackTargetId: mobId,
nextAutoAttackAt: procAt! + 100,
gearStats: { ...state.gearStats, hitRating: 1_000_000, expertiseRating: 1_000_000 },
}));
let autoAttackAt = procAt! + 100;
for (let charge = 2; charge >= 0; charge -= 1) {
useCombatStore.getState().tick(0.1, autoAttackAt);
const swingEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage"
&& event.sourceActorId === PLAYER_AURA_ENTITY_ID
&& event.targetActorId === mobId
&& event.occurredAt === autoAttackAt
));
expect(swingEvents).toHaveLength(2);
const remaining = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
));
if (charge > 0) expect(remaining?.stacks).toBe(charge);
else expect(remaining).toBeUndefined();
autoAttackAt = useCombatStore.getState().nextAutoAttackAt;
}
});
it("reflects the sourced rank percentage of incoming critical damage with Eye for an Eye", () => {
const mobId = "paladin-eye-for-an-eye-crit";
useCombatStore.getState().registerMob(mobId, {
name: "Critical attacker",
level: 80,
maxHealth: 100_000,
damageMultiplier: 1,
attacks: [{
id: "eye-crit-18",
name: "Critical attack",
delivery: "area",
target: "primary",
school: "fire",
animation: "cast",
castTimeMs: 0,
range: 30,
cooldownMs: 10_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [10_000, 10_000],
damageMultiplier: 1,
}],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const ensureSpellHit = applyAura([], {
definition: {
id: "test:ensure-incoming-spell-hit",
name: "Ensure incoming spell hit",
disposition: "buff",
durationMs: null,
modifiers: [{ kind: "stat", stat: "incoming-spell-hit", operation: "flat", value: 100 }],
},
sourceId: mobId,
targetId: PLAYER_AURA_ENTITY_ID,
now: 1_000,
}).auras;
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-1632": 3 },
auras: [...state.auras, ...ensureSpellHit],
}));
// This seeded instant spell attack rolls inside the crit slice of the
// equal-level attack table, so the test does not rely on chance.
useCombatStore.getState().advanceMobCombat(1_450);
useCombatStore.getState().advanceMobCombat(3_100);
const events = useCombatStore.getState().combatEvents;
const incoming = events.find((event) => event.kind === "damage"
&& event.sourceActorId === mobId && event.targetActorId === PLAYER_AURA_ENTITY_ID);
expect(incoming?.critical, JSON.stringify(incoming)).toBe(true);
const reflected = events.find((event) => event.kind === "damage"
&& event.targetActorId === mobId && event.abilityId === "talent-1632");
expect(reflected?.rawAmount).toBe(Math.round((incoming?.effectiveAmount ?? 0) * 0.15));
});
it("bounces Avenger's Shield to three total targets without damage falloff", () => {
const shieldId = unlockTalentAbility("Avenger's Shield");
const targetIds = [
"paladin-avenger-target",
"paladin-avenger-bounce-a",
"paladin-avenger-bounce-b",
"paladin-avenger-bounce-c",
"paladin-avenger-out-of-range",
];
for (const id of targetIds) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
}
setMobPosition(targetIds[0]!, [4, 0, 0]);
setMobPosition(targetIds[1]!, [10, 0, 0]);
setMobPosition(targetIds[2]!, [16, 0, 0]);
setMobPosition(targetIds[3]!, [22, 0, 0]);
setMobPosition(targetIds[4]!, [10, 0, 12]);
useCombatStore.getState().selectMob(targetIds[0]!);
const result = useCombatStore.getState().castAbility(shieldId, undefined, 1_000);
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(result.affectedMobIds).toEqual(targetIds.slice(0, 3));
expect(result.affectedMobIds).not.toContain(targetIds[3]);
expect(result.affectedMobIds).not.toContain(targetIds[4]);
const damageEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === shieldId
&& result.affectedMobIds.includes(event.targetActorId)
));
expect(damageEvents).toHaveLength(3);
const normalizedRawDamage = damageEvents.map((event) => Math.round(event.rawAmount / (event.critical ? 2 : 1)));
expect(new Set(normalizedRawDamage).size).toBe(1);
});
it("applies a Greater Blessing to the selected ally's class group", () => {
const members = usePartyStore.getState().members;
const target = members[0]!;
usePartyStore.setState((party) => ({
members: party.members.map((candidate, index) => ({
...candidate,
classId: index < 2 ? "mage" : "priest",
})),
}));
for (const member of members) updatePartyRuntimePosition(member.id, 1, 0, 0);
usePartyStore.getState().selectMember(target.id);
const result = useCombatStore.getState().castAbility(abilityId("Greater Blessing of Kings"), undefined, 1_000);
expect(result).toMatchObject({ ok: true });
const blessedIds = new Set(useCombatStore.getState().auras
.filter((aura) => aura.definition.name.includes("Greater Blessing of Kings"))
.map((aura) => aura.targetId));
expect(blessedIds).toEqual(new Set([target.id, members[1]!.id]));
expect(blessedIds.has(PLAYER_AURA_ENTITY_ID)).toBe(false);
});
it("heals at most three party actors from Divine Storm damage", () => {
const members = usePartyStore.getState().members;
usePartyStore.setState((party) => ({
members: party.members.map((member) => ({ ...member, health: 1 })),
}));
members.forEach((member, index) => updatePartyRuntimePosition(member.id, index + 1, 0, 0));
useCombatStore.setState((state) => ({ health: Math.max(1, Math.round(state.maxHealth * 0.1)) }));
const targetId = "paladin-divine-storm-target";
const nearbyTargetId = "paladin-divine-storm-nearby-target";
const distantTargetId = "paladin-divine-storm-distant-target";
useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 100_000 });
useCombatStore.getState().registerMob(nearbyTargetId, { name: "Nearby Target", maxHealth: 100_000 });
useCombatStore.getState().registerMob(distantTargetId, { name: "Distant Target", maxHealth: 100_000 });
setMobPosition(targetId, [4, 0, 0]);
setMobPosition(nearbyTargetId, [7, 0, 0]);
setMobPosition(distantTargetId, [13, 0, 0]);
useCombatStore.setState((state) => ({ selectedTargetId: targetId }));
const divineStorm = unlockTalentAbility("Divine Storm");
const result = useCombatStore.getState().castAbility(divineStorm, { targetActorId: targetId, now: 1_000 });
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
expect(result.affectedMobIds).toEqual(expect.arrayContaining([targetId, nearbyTargetId]));
expect(result.affectedMobIds).not.toContain(distantTargetId);
const healEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === divineStorm && event.effectiveAmount > 0
));
expect(healEvents.length).toBeGreaterThan(0);
expect(healEvents.length).toBeLessThanOrEqual(3);
expect(healEvents.every((event) => event.rawAmount > 0)).toBe(true);
});
it("transfers direct healing to the active Beacon of Light target", () => {
const member = usePartyStore.getState().members[0]!;
updatePartyRuntimePosition(member.id, 4, 0, 0);
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 100 }
: candidate),
}));
usePartyStore.getState().selectMember(member.id);
const beaconResult = useCombatStore.getState().castAbility(unlockTalentAbility("Beacon of Light"), undefined, 1_000);
expect(beaconResult).toMatchObject({ ok: true });
const beaconCast = useCombatStore.getState().activeCast;
if (beaconCast) useCombatStore.getState().tick(0.1, beaconCast.completesAt);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === member.id && aura.definition.utilityTags?.includes("beacon-of-light")
))).toBe(true);
useCombatStore.getState().damagePlayer(5_000);
const beforeTransfer = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
const heal = useCombatStore.getState().castAbility(abilityId("Flash of Light"), undefined, 3_000);
expect(heal).toMatchObject({ ok: true });
const healCast = useCombatStore.getState().activeCast;
if (healCast) useCombatStore.getState().tick(0.1, healCast.completesAt);
const afterTransfer = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
expect(afterTransfer).toBeGreaterThan(beforeTransfer);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("beacon-of-light"))).toBe(true);
});
it("limits Divine Sacrifice to the remaining portion of its shared damage cap", () => {
const member = usePartyStore.getState().members[0]!;
updatePartyRuntimePosition(member.id, 0, 0, 0);
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: candidate.maxHealth }
: candidate),
}));
usePartyStore.getState().selectMember(member.id);
const sacrifice = unlockTalentAbility("Divine Sacrifice");
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
const cap = Math.round(useCombatStore.getState().maxHealth * 1.5);
const anchor = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
));
expect(anchor?.definition.redirectDamageCapPercent).toBe(1.5);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === member.id && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
))).toBe(true);
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.instanceId === anchor!.instanceId
? { ...aura, redirectedDamage: cap - 20 }
: aura),
}));
const mobId = "paladin-divine-sacrifice-partial";
useCombatStore.getState().registerMob(mobId, { name: "Cap test attacker", maxHealth: 100_000 });
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000, { actorId: member.id, role: "tank" });
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
}));
const playerBefore = useCombatStore.getState().health;
const memberBefore = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
useCombatStore.getState().advanceMobCombat(1_450);
useCombatStore.getState().advanceMobCombat(3_350);
expect(playerBefore - useCombatStore.getState().health).toBe(20);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health).toBeLessThan(memberBefore);
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(cap);
});
it("stops Divine Sacrifice redirects after the shared damage cap is exhausted", () => {
const member = usePartyStore.getState().members[0]!;
updatePartyRuntimePosition(member.id, 0, 0, 0);
usePartyStore.setState((party) => ({
members: party.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: candidate.maxHealth }
: candidate),
}));
usePartyStore.getState().selectMember(member.id);
const sacrifice = unlockTalentAbility("Divine Sacrifice");
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
const anchor = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
));
expect(anchor).toBeDefined();
const cap = Math.round(useCombatStore.getState().maxHealth * 1.5);
useCombatStore.setState((state) => ({
auras: state.auras.map((aura) => aura.instanceId === anchor!.instanceId
? { ...aura, redirectedDamage: cap }
: aura),
}));
const mobId = "paladin-divine-sacrifice-exhausted";
useCombatStore.getState().registerMob(mobId, { name: "Cap test attacker", maxHealth: 100_000 });
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000, { actorId: member.id, role: "tank" });
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
}));
const playerBefore = useCombatStore.getState().health;
const memberBefore = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
useCombatStore.getState().advanceMobCombat(1_450);
const resolvedDamage = useCombatStore.getState().advanceMobCombat(3_350);
expect(resolvedDamage, JSON.stringify(useCombatStore.getState().mobs[mobId])).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBe(playerBefore);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health).toBeLessThan(memberBefore);
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(cap);
});
it("does not redirect damage from an attack that targets the Paladin", () => {
const sacrifice = unlockTalentAbility("Divine Sacrifice");
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
const anchor = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
));
expect(anchor).toBeDefined();
const mobId = "paladin-divine-sacrifice-self-hit";
useCombatStore.getState().registerMob(mobId, { name: "Self hit attacker", maxHealth: 100_000 });
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
}));
const playerBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBeLessThan(playerBefore);
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(0);
});
it("makes the next eligible Divine Favor heal critical and consumes its aura", () => {
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.getState().damagePlayer(Math.round(maxHealth * 0.4));
const activate = useCombatStore.getState().castAbility(unlockTalentAbility("Divine Favor"), undefined, 1_000);
expect(activate).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal")
))).toBe(true);
const flashOfLight = abilityId("Flash of Light");
const heal = useCombatStore.getState().castAbility(flashOfLight, undefined, 3_000);
expect(heal).toMatchObject({ ok: true });
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing" && event.abilityId === flashOfLight && event.critical
))).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal")
))).toBe(false);
});
});
+672
View File
@@ -0,0 +1,672 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityAtLevel, abilitiesForClass, abilityTalentNodeId } from "./abilityCatalog";
import { resolvedAbilityCosts, useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { TALENT_NODES } from "./talentCatalog";
import { talentModifiersForRanks } from "./talentModifiers";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
function priestAbility(name: string) {
const ability = abilitiesForClass("priest").find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!;
}
function unlock(name: string): void {
const nodeId = abilityTalentNodeId(priestAbility(name));
expect(nodeId, `${name} talent node`).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
}
function setPriestTalentRanks(ranks: Record<string, number>, criticalChance?: number): void {
const modifiers = talentModifiersForRanks(ranks, "priest");
useCombatStore.setState({
talentRanks: ranks,
talentModifiers: criticalChance === undefined ? modifiers : [...modifiers, {
kind: "critical-chance-percent",
amount: criticalChance,
unit: "percent",
scope: "your spells",
sourceTalentId: "test:priest-critical-chance",
sourceTalentName: "Test critical chance",
rank: 1,
description: "Test-only critical chance modifier.",
}],
});
}
function setMemberHealth(memberId: string, health: number, maxHealth: number): void {
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === memberId
? { ...member, health, maxHealth }
: member),
}));
useCombatStore.setState((state) => ({
actors: {
...state.actors,
[memberId]: { ...state.actors[memberId]!, health, maxHealth, alive: health > 0 },
},
}));
}
function castFriendly(name: string, targetId: string, now: number) {
useCombatStore.getState().selectFriendlyActor(targetId);
const result = useCombatStore.getState().castAbility(priestAbility(name).id, { targetActorId: targetId, now });
const activeCast = useCombatStore.getState().activeCast;
if (result.ok && activeCast?.abilityId === priestAbility(name).id) {
useCombatStore.getState().tick((activeCast.completesAt - now) / 1_000, activeCast.completesAt);
}
return result;
}
function castHostile(name: string, targetId: string, now: number) {
useCombatStore.getState().selectMob(targetId);
const result = useCombatStore.getState().castAbility(priestAbility(name).id, { targetActorId: targetId, now });
const activeCast = useCombatStore.getState().activeCast;
if (result.ok && activeCast?.abilityId === priestAbility(name).id) {
useCombatStore.getState().tick((activeCast.completesAt - now) / 1_000, activeCast.completesAt);
}
return result;
}
describe("WoW Priest mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-priest-mechanics", 80, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
useCombatStore.getState().synchronizeActors();
usePartyStore.getState().members.forEach((member, index) => {
registerPartyRuntimePosition(member.id, [index + 2, 0, 0]);
});
});
it("uses self targeting for Desperate Prayer and dual relationship for Dispel Magic", () => {
const desperatePrayer = priestAbility("Desperate Prayer");
const dispelMagic = priestAbility("Dispel Magic");
const rank988 = abilityAtLevel(dispelMagic, 38);
expect(desperatePrayer.target).toBe("self");
expect(dispelMagic.target).toBe("any");
expect(rank988.dbcSpellId).toBe(988);
expect(rank988.effects).toContainEqual(expect.objectContaining({
kind: "dispel", mode: "cleanse", relationship: "target", categories: ["magic"], maxCount: 2,
}));
});
it("lets Dispel Magic rank 988 cleanse a party member's magic debuff", () => {
const member = usePartyStore.getState().members[0]!;
const carrier = priestAbility("Prayer of Fortitude");
expect(useCombatStore.getState().applyActorAura(PLAYER_AGGRO_ID, member.id, carrier, {
kind: "apply-aura",
aura: {
id: "test:priest-magic-debuff", name: "Test magic debuff", disposition: "debuff",
dispelCategory: "magic", durationMs: 30_000, maxStacks: 1, stackBehavior: "refresh",
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}, 1_000)).toBe(true);
expect(useCombatStore.getState().selectFriendlyActor(member.id)).toBe(true);
const result = useCombatStore.getState().castAbility(priestAbility("Dispel Magic").id, {
targetActorId: member.id, now: 2_000,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "test:priest-magic-debuff")).toBe(false);
});
it("casts Desperate Prayer on the priest and applies Pain Suppression's threat reduction to its ally", () => {
unlock("Desperate Prayer");
useCombatStore.getState().damagePlayer(500);
const playerBefore = useCombatStore.getState().health;
const desperatePrayer = useCombatStore.getState().castAbility(priestAbility("Desperate Prayer").id, [0, 0, 0], 1_000);
expect(desperatePrayer.ok, JSON.stringify(desperatePrayer)).toBe(true);
expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore);
const member = usePartyStore.getState().members[0]!;
unlock("Pain Suppression");
useCombatStore.getState().registerMob("priest-threat-test", { name: "Threat test", maxHealth: 1_000, xpReward: 0 });
setMobPosition("priest-threat-test", [4, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, "priest-threat-test": {
...state.mobs["priest-threat-test"]!, engaged: true,
threatByActor: { [member.id]: 100 }, targetActorId: member.id,
} },
}));
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(priestAbility("Pain Suppression").id, {
targetActorId: member.id, now: 3_000,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().mobs["priest-threat-test"]?.threatByActor[member.id]).toBe(60);
});
it("applies Prayer of Fortitude to the priest and all party members", () => {
const result = useCombatStore.getState().castAbility(priestAbility("Prayer of Fortitude").id, [0, 0, 0], 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const recipients = useCombatStore.getState().auras
.filter((aura) => aura.definition.id.includes(":48162:"))
.map((aura) => aura.targetId);
expect(recipients).toContain(PLAYER_AURA_ENTITY_ID);
for (const member of usePartyStore.getState().members) expect(recipients).toContain(member.id);
});
it("reduces only magic damage while an eligible Test of Faith cast is active", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const ranks = { [talent!.id]: 1 };
let castId = 0;
const damageTaken = (rank: number, school: "physical" | "shadow", abilityName: string, expired = false): number => {
const now = Date.now();
const maxHealth = useCombatStore.getState().maxHealth;
const selectedRanks = rank > 0 ? { [talent!.id]: rank } : {};
const completesAt = expired ? now - 1 : now + 2_000;
useCombatStore.setState({
health: maxHealth,
shield: 0,
auras: [],
talentRanks: selectedRanks,
talentModifiers: talentModifiersForRanks(selectedRanks, "priest"),
activeCast: {
id: ++castId,
abilityId: priestAbility(abilityName).id,
targetId: null,
mode: "cast",
startedAt: now - 500,
completesAt,
originalDurationMs: 2_500,
pushbackHits: 0,
school: "holy",
canCastWhileMoving: false,
origin: [0, 0, 0],
groundPosition: null,
totalTicks: 1,
ticksCompleted: 0,
nextTickAt: completesAt,
},
});
useCombatStore.getState().damagePlayer(1_000, school);
return maxHealth - useCombatStore.getState().health;
};
const baselineMagic = damageTaken(0, "shadow", "Flash Heal");
expect(damageTaken(1, "shadow", "Flash Heal")).toBe(Math.round(baselineMagic * 0.9));
expect(damageTaken(1, "physical", "Flash Heal")).toBe(damageTaken(0, "physical", "Flash Heal"));
expect(damageTaken(1, "shadow", "Prayer of Fortitude")).toBe(baselineMagic);
expect(damageTaken(1, "shadow", "Flash Heal", true)).toBe(baselineMagic);
expect(ranks[talent!.id]).toBe(1);
});
it("applies Test of Faith to magic hits resolved through enemy combat", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const incomingHit = (rank: number): number => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const selectedRanks = rank > 0 ? { [talent!.id]: rank } : {};
useCombatStore.setState({
talentRanks: selectedRanks,
talentModifiers: talentModifiersForRanks(selectedRanks, "priest"),
activeCast: {
id: 1,
abilityId: priestAbility("Flash Heal").id,
targetId: null,
mode: "cast",
startedAt: 1_000,
completesAt: 8_000,
originalDurationMs: 7_000,
pushbackHits: 0,
school: "holy",
canCastWhileMoving: false,
origin: [0, 0, 0],
groundPosition: null,
totalTicks: 1,
ticksCompleted: 0,
nextTickAt: 8_000,
},
});
const mobId = "priest-test-faith-magic-hit";
useCombatStore.getState().registerMob(mobId, {
name: "Test of Faith magic hit",
maxHealth: 100_000,
xpReward: 0,
attacks: [{
...DEFAULT_ENEMY_ATTACK,
id: "priest-test-shadow-cast",
name: "Shadow cast",
delivery: "projectile",
animation: "cast",
school: "shadow",
range: 30,
castTimeMs: 0,
}],
});
setMobPosition(mobId, [8, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const before = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]);
return before - useCombatStore.getState().health;
};
const baseline = incomingHit(0);
expect(baseline).toBeGreaterThan(0);
expect(incomingHit(1)).toBe(Math.round(baseline * 0.9));
});
it("applies Test of Faith only to spells whose target is at or below half health", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const ranks = { [talent!.id]: 3 };
expect(talentModifiersForRanks(ranks, "priest")).not.toContainEqual(expect.objectContaining({
kind: "healing-percent", sourceTalentName: "Test of Faith",
}));
expect(talentModifiersForRanks(ranks, "priest")).not.toContainEqual(expect.objectContaining({
kind: "damage-percent", sourceTalentName: "Test of Faith",
}));
const member = usePartyStore.getState().members[0]!;
const prepareTarget = (health: number) => {
usePartyStore.setState((state) => ({
members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, health, maxHealth: 20_000 }
: candidate),
}));
useCombatStore.setState((state) => ({
actors: {
...state.actors,
[member.id]: { ...state.actors[member.id]!, health, maxHealth: 20_000, alive: true },
},
}));
};
const castFlashHeal = (health: number, now: number, withTalent: boolean) => {
prepareTarget(health);
useCombatStore.setState({
talentRanks: withTalent ? ranks : {},
talentModifiers: talentModifiersForRanks(withTalent ? ranks : {}, "priest"),
});
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(priestAbility("Flash Heal").id, {
targetActorId: member.id, now,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
const completesAt = useCombatStore.getState().activeCast?.completesAt;
if (completesAt) useCombatStore.getState().tick((completesAt - now) / 1_000, completesAt);
const event = useCombatStore.getState().combatEvents.filter((candidate) => (
candidate.kind === "healing"
&& candidate.abilityId === priestAbility("Flash Heal").id
&& candidate.targetActorId === member.id
)).at(-1);
expect(event).toBeDefined();
return event!.rawAmount / (event!.critical ? 1.5 : 1);
};
const base = castFlashHeal(9_000, 1_000, false);
const boosted = castFlashHeal(9_000, 4_000, true);
const aboveThreshold = castFlashHeal(11_000, 7_000, true);
expect(boosted).toBeCloseTo(base * 1.12, 0);
expect(aboveThreshold).toBeCloseTo(base, 0);
const smite = priestAbility("Smite");
useCombatStore.getState().registerMob("priest-test-faith-damage", {
name: "Test of Faith target", maxHealth: 20_000, xpReward: 0,
});
setMobPosition("priest-test-faith-damage", [4, 0, 0]);
const castSmite = (health: number, now: number, withTalent: boolean) => {
useCombatStore.setState((state) => ({
talentRanks: withTalent ? ranks : {},
talentModifiers: talentModifiersForRanks(withTalent ? ranks : {}, "priest"),
mobs: {
...state.mobs,
"priest-test-faith-damage": {
...state.mobs["priest-test-faith-damage"]!, health, maxHealth: 20_000, dead: false,
},
},
}));
useCombatStore.getState().selectMob("priest-test-faith-damage");
const result = useCombatStore.getState().castAbility(smite.id, [0, 0, 0], now);
expect(result.ok, JSON.stringify(result)).toBe(true);
const completesAt = useCombatStore.getState().activeCast?.completesAt;
if (completesAt) useCombatStore.getState().tick((completesAt - now) / 1_000, completesAt);
const event = useCombatStore.getState().combatEvents.filter((candidate) => (
candidate.kind === "damage"
&& candidate.abilityId === smite.id
&& candidate.targetActorId === "priest-test-faith-damage"
)).at(-1);
expect(event).toBeDefined();
return event!.rawAmount / (event!.critical ? 1.5 : 1);
};
const baseDamage = castSmite(9_000, 10_000, false);
const lowHealthDamage = castSmite(9_000, 13_000, true);
const healthyDamage = castSmite(11_000, 16_000, true);
expect(lowHealthDamage).toBeCloseTo(baseDamage * 1.12, 0);
expect(healthyDamage).toBeCloseTo(baseDamage, 0);
});
it("keeps proc-based Priest talent values out of passive talent modifiers", () => {
const procTalentNames = ["Holy Concentration", "Serendipity", "Shadow Weaving"];
const ranks = Object.fromEntries(procTalentNames.map((name) => {
const talent = TALENT_NODES.find((node) => node.name === name);
expect(talent, name).toBeDefined();
return [talent!.id, talent!.maxRank];
}));
const modifiers = talentModifiersForRanks(ranks, "priest");
for (const name of procTalentNames) {
expect(modifiers.filter((modifier) => modifier.sourceTalentName === name), name).toEqual([]);
}
});
it("limits Inner Focus to its three named spells and empowers Greater Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus");
expect(talent).toBeDefined();
setPriestTalentRanks({ [talent!.id]: 1 }, 75);
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 },
}));
useCombatStore.getState().registerMob("priest-inner-focus-damage", {
name: "Inner Focus target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("priest-inner-focus-damage", [4, 0, 0]);
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility(priestAbility("Smite").id, {
targetActorId: "priest-inner-focus-damage", now: 1_001,
})).toMatchObject({ ok: false, reason: "not-enough-resource" });
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
))).toBe(true);
const manaSpentAt = useCombatStore.getState().lastResourceSpentAt.mana;
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, member.maxHealth);
useCombatStore.getState().selectFriendlyActor(member.id);
const greaterHeal = priestAbility("Greater Heal");
const cast = useCombatStore.getState().castAbility(greaterHeal.id, {
targetActorId: member.id, now: 1_002,
});
expect(cast.ok, JSON.stringify(cast)).toBe(true);
const activeCast = useCombatStore.getState().activeCast!;
expect(activeCast.completesAt - activeCast.startedAt).toBe(Math.round(greaterHeal.castTimeMs * 0.5));
useCombatStore.getState().tick((activeCast.completesAt - activeCast.startedAt) / 1_000, activeCast.completesAt);
expect(useCombatStore.getState().lastResourceSpentAt.mana).toBe(manaSpentAt);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
))).toBe(false);
const heal = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === greaterHeal.id && event.targetActorId === member.id
)).at(-1)!;
expect(heal.critical).toBe(true);
});
it("gives Inner Focus's Mind Blast rider and reduces Power Word: Shield's Weakened Soul", () => {
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
setPriestTalentRanks({ [talent.id]: 1 });
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 },
}));
useCombatStore.getState().registerMob("priest-inner-focus-mind-blast", {
name: "Inner Focus Mind Blast target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("priest-inner-focus-mind-blast", [4, 0, 0]);
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 1_000).ok).toBe(true);
const mindBlast = priestAbility("Mind Blast");
expect(useCombatStore.getState().castAbility(mindBlast.id, {
targetActorId: "priest-inner-focus-mind-blast", now: 1_001,
}).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.completesAt).toBe(1_001 + mindBlast.castTimeMs / 2);
useCombatStore.getState().tick(mindBlast.castTimeMs / 2_000, 1_001 + mindBlast.castTimeMs / 2);
expect(useCombatStore.getState().mobs["priest-inner-focus-mind-blast"]?.statuses).toContainEqual(expect.objectContaining({
kind: "slow",
magnitude: 0.7,
endsAt: 1_001 + mindBlast.castTimeMs / 2 + 5_000,
}));
const member = usePartyStore.getState().members[0]!;
useCombatStore.setState((state) => ({
cooldowns: { ...state.cooldowns, [priestAbility("Inner Focus").id]: 0 },
}));
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 8_000).ok).toBe(true);
expect(castFriendly("Power Word: Shield", member.id, 8_001).ok).toBe(true);
const weakenedSoul = useCombatStore.getState().auras.find((aura) => (
aura.targetId === member.id && aura.definition.id === "wow335:priest:weakened-soul"
));
expect(weakenedSoul?.expiresAt).toBe(28_001);
});
it.each([
{ rank: 1, percent: 0.04 },
{ rank: 2, percent: 0.07 },
{ rank: 3, percent: 0.1 },
])("grants Divine Aegis rank $rank from a critical Holy heal, including reduced overheal", ({ rank, percent }) => {
const talent = TALENT_NODES.find((node) => node.name === "Divine Aegis")!;
const ranks = { [talent.id]: rank };
setPriestTalentRanks(ranks, 100);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 20_000);
const result = castFriendly("Flash Heal", member.id, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const heal = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === priestAbility("Flash Heal").id && event.targetActorId === member.id
)).at(-1)!;
expect(heal.critical).toBe(true);
const expected = Math.min(2_000, Math.round((heal.effectiveAmount + heal.overheal * 0.25) * percent * 2));
const aegis = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("wow335-priest-divine-aegis"));
expect(aegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBe(expected);
expect(aegis?.expiresAt).toBe(heal.occurredAt + 6_000);
});
it("caps a target's Divine Aegis absorb at ten percent maximum health", () => {
const talent = TALENT_NODES.find((node) => node.name === "Divine Aegis")!;
setPriestTalentRanks({ [talent.id]: 3 }, 100);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 500);
expect(castFriendly("Flash Heal", member.id, 1_000).ok).toBe(true);
const aegis = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("wow335-priest-divine-aegis"));
expect(aegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeLessThanOrEqual(50);
});
it("makes Surge of Light's next Smite instant, free, stronger, and unable to crit", () => {
const talent = TALENT_NODES.find((node) => node.name === "Surge of Light")!;
useCombatStore.getState().registerMob("priest-surge-test", { name: "Surge target", maxHealth: 200_000, xpReward: 0 });
setMobPosition("priest-surge-test", [4, 0, 0]);
setPriestTalentRanks({}, -100);
expect(castHostile("Smite", "priest-surge-test", 1_000).ok).toBe(true);
const baseline = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Smite").id && event.targetActorId === "priest-surge-test"
)).at(-1)!;
expect(baseline.critical).toBe(false);
setPriestTalentRanks({ [talent.id]: 2 }, 100);
const member = usePartyStore.getState().members[0]!;
let procAt = 0;
for (let attempt = 0; attempt < 16; attempt += 1) {
const now = 6_000 + attempt * 4_000;
setMemberHealth(member.id, 1, 20_000);
expect(castFriendly("Flash Heal", member.id, now).ok).toBe(true);
if (useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))) {
procAt = now + priestAbility("Flash Heal").castTimeMs;
break;
}
}
expect(procAt).toBeGreaterThan(0);
const manaBefore = useCombatStore.getState().resource;
expect(castHostile("Smite", "priest-surge-test", procAt + 4_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().resource).toBe(manaBefore);
const surged = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Smite").id && event.targetActorId === "priest-surge-test"
)).at(-1)!;
expect(surged.critical).toBe(false);
expect(surged.rawAmount).toBe(Math.round(baseline.rawAmount * 1.6));
});
it("also triggers Surge of Light from critical direct damage and spends it on Flash Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Surge of Light")!;
useCombatStore.getState().registerMob("priest-surge-crit-test", {
name: "Surge critical target", maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition("priest-surge-crit-test", [4, 0, 0]);
setPriestTalentRanks({ [talent.id]: 2 }, 100);
let procAt = 0;
for (let attempt = 0; attempt < 16; attempt += 1) {
const now = 1_000 + attempt * 4_000;
expect(castHostile("Smite", "priest-surge-crit-test", now).ok).toBe(true);
if (useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))) {
procAt = now + priestAbility("Smite").castTimeMs;
break;
}
}
expect(procAt).toBeGreaterThan(0);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 20_000);
setPriestTalentRanks({}, 100);
const manaBefore = useCombatStore.getState().resource;
expect(castFriendly("Flash Heal", member.id, procAt + 4_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().resource).toBe(manaBefore);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))).toBe(false);
});
it("stacks Serendipity to three charges and consumes all charges on Greater Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Serendipity")!;
const ranks = { [talent.id]: 3 };
setPriestTalentRanks(ranks);
const member = usePartyStore.getState().members[0]!;
for (const now of [1_000, 5_000, 9_000]) {
setMemberHealth(member.id, 1, 20_000);
expect(castFriendly("Flash Heal", member.id, now).ok).toBe(true);
}
const procAura = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"));
expect(procAura?.stacks).toBe(3);
const greaterHeal = abilityAtLevel(priestAbility("Greater Heal"), 80);
useCombatStore.setState((state) => ({
resource: 5_000,
maxResource: 5_000,
resourcePools: { ...state.resourcePools, mana: 5_000 },
maxResourcePools: { ...state.maxResourcePools, mana: 5_000 },
gearStats: { ...state.gearStats, spirit: 0, mp5: 0 },
}));
const baseCost = resolvedAbilityCosts(greaterHeal, [], useCombatStore.getState())
.find((cost) => cost.resource === "mana")!.amount;
const manaBefore = useCombatStore.getState().resource;
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(greaterHeal.id, { targetActorId: member.id, now: 14_000 });
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast!;
expect(cast.completesAt - cast.startedAt).toBe(Math.round(greaterHeal.castTimeMs * 0.55));
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"))?.stacks).toBe(3);
useCombatStore.getState().castAbility(greaterHeal.id, {
targetActorId: member.id, now: cast.completesAt, completingCast: cast,
});
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"))).toBe(false);
expect(manaBefore - useCombatStore.getState().resource).toBe(Math.round(baseCost * 0.55));
});
it("adds Shadow Weaving on Shadow damage and increases later ticks up to five stacks", () => {
const talent = TALENT_NODES.find((node) => node.name === "Shadow Weaving")!;
useCombatStore.getState().registerMob("priest-shadow-weaving-test", {
name: "Shadow Weaving target", maxHealth: 200_000, xpReward: 0,
});
setMobPosition("priest-shadow-weaving-test", [4, 0, 0]);
setPriestTalentRanks({ [talent.id]: 3 }, -100);
expect(castHostile("Shadow Word: Pain", "priest-shadow-weaving-test", 1_000).ok).toBe(true);
useCombatStore.getState().tick(18, 19_000);
const ticks = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Shadow Word: Pain").id
&& event.targetActorId === "priest-shadow-weaving-test"
));
expect(ticks.length).toBeGreaterThanOrEqual(2);
expect(ticks[1]!.rawAmount).toBe(Math.round(ticks[0]!.rawAmount * 1.02));
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-shadow-weaving"))?.stacks).toBe(5);
});
it("procs Holy Concentration on a critical heal and extends nearby Renew by at most six seconds", () => {
const talent = TALENT_NODES.find((node) => node.name === "Holy Concentration")!;
setPriestTalentRanks({ [talent.id]: 3 }, 100);
const [first, second] = usePartyStore.getState().members;
expect(castFriendly("Renew", first!.id, 1_000).ok).toBe(true);
expect(castFriendly("Renew", second!.id, 4_000).ok).toBe(true);
const beforeExtensions = new Map(useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id)
.map((effect) => [effect.targetId ?? PLAYER_AURA_ENTITY_ID,
effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs]));
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 7_000).ok).toBe(true);
const concentration = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-holy-concentration"));
expect(concentration?.expiresAt).toBeGreaterThan(0);
expect(concentration?.definition.modifiers).toContainEqual(expect.objectContaining({
kind: "stat", stat: "spirit", operation: "percent", value: 0.3,
}));
let renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) {
const target = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
expect(effect.extensionTicks).toBe(1);
expect(effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs).toBe(beforeExtensions.get(target)! + 3_000);
}
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 11_000).ok).toBe(true);
renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) expect(effect.extensionTicks).toBe(2);
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 15_000).ok).toBe(true);
renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) expect(effect.extensionTicks).toBe(2);
});
it("gives the three Prayer buffs party scope and Pain Suppression its target threat reduction", () => {
for (const name of ["Prayer of Fortitude", "Prayer of Shadow Protection", "Prayer of Spirit"]) {
const rank = abilityAtLevel(priestAbility(name), 80);
expect(rank.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"), name).toBe(true);
}
const painSuppression = abilityAtLevel(priestAbility("Pain Suppression"), 80);
expect(painSuppression.effects).toContainEqual({
kind: "threat", mode: "percent", amount: -40, recipient: "ability-target",
});
});
it("summons a nonattacking Lightwell and heals for six seconds per click", () => {
unlock("Lightwell");
const ability = priestAbility("Lightwell");
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
expect(completion).toBeDefined();
useCombatStore.getState().tick(1, completion!);
const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id);
expect(well).toMatchObject({ clickCharges: 10, expiresAt: completion! + 180_000, attackDamage: 0 });
useCombatStore.getState().damagePlayer(500);
const healthBefore = useCombatStore.getState().health;
const clickAmount = useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1);
expect(clickAmount).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBe(healthBefore);
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
kind: "hot",
sourceAbilityId: ability.id,
remainingTicks: 6,
intervalMs: 1_000,
fixedAmount: expect.any(Number),
nextTickAt: completion! + 1_001,
}));
useCombatStore.getState().tick(6, completion! + 6_001);
expect(useCombatStore.getState().health).toBeGreaterThan(healthBefore);
expect(useCombatStore.getState().summons.find((summon) => summon.id === well!.id)?.clickCharges).toBe(9);
for (let click = 1; click < 10; click += 1) {
useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1 + click);
}
expect(useCombatStore.getState().summons.some((summon) => summon.id === well!.id)).toBe(false);
expect(useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 20)).toBe(0);
});
});
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityAtLevel, abilitiesForClass } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
const PRAYER_RANKS = [
{ level: 68, spellId: 33076, healAmount: 800 },
{ level: 74, spellId: 48112, healAmount: 905 },
{ level: 79, spellId: 48113, healAmount: 1043 },
] as const;
function prayerOfMendingAbility() {
const ability = abilitiesForClass("priest").find((candidate) => candidate.name === "Prayer of Mending");
expect(ability, "Prayer of Mending").toBeDefined();
return ability!;
}
function damageActor(actorId: string, now: number): number {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
for (let attempt = 0; attempt < 12; attempt += 1) {
const mobId = `pom-attacker-${actorId}-${now}-${attempt}`;
const combat = useCombatStore.getState();
combat.registerMob(mobId, { name: "Prayer of Mending attacker", maxHealth: 1_000, xpReward: 0 });
setMobPosition(mobId, [2, 0, 0]);
combat.damageMob(mobId, 1, now, {
actorId,
role: member?.role ?? "healer",
abilityId: "pom-test-threat",
});
useCombatStore.setState((state) => ({
mobs: state.mobs[mobId]
? { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, nextAttackAt: now + 450 } }
: state.mobs,
}));
const damage = useCombatStore.getState().advanceMobCombat(now + 450);
useCombatStore.getState().resetMobForNavigation(mobId, true, now + 450);
if (damage > 0) return damage;
}
throw new Error(`No enemy attack landed on ${actorId} after 12 attempts`);
}
describe("WoW Priest Prayer of Mending", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-prayer-of-mending", 80, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
useCombatStore.getState().synchronizeActors();
usePartyStore.getState().members.forEach((member, index) => {
registerPartyRuntimePosition(member.id, [index + 1, 0, 0]);
});
});
it("maps every player-facing rank to its DBC base heal and five damage charges", () => {
const base = prayerOfMendingAbility();
for (const expected of PRAYER_RANKS) {
const rank = abilityAtLevel(base, expected.level);
expect(rank.dbcSpellId).toBe(expected.spellId);
expect(rank.effects).toHaveLength(1);
const aura = rank.effects.find((effect) => effect.kind === "apply-aura");
expect(aura?.kind).toBe("apply-aura");
if (aura?.kind !== "apply-aura") continue;
expect(aura.aura.procs?.[0]).toMatchObject({
triggers: "damage-taken",
charges: 5,
action: { kind: "custom", id: "wow335-prayer-of-mending", data: { healAmount: expected.healAmount } },
});
}
});
it("heals the aura holder and transfers through injured allies for five total activations", () => {
const combat = useCombatStore.getState();
const members = usePartyStore.getState().members;
const initialTarget = members[0]!;
const memberHealth = [1_900, 1_800, 1_700, 1_600];
const injuredMembers = members.map((member, index) => ({
...member,
maxHealth: 2_000,
health: memberHealth[index] ?? 1_500,
}));
usePartyStore.setState({ members: injuredMembers });
useCombatStore.setState((state) => ({
actors: {
...state.actors,
...Object.fromEntries(injuredMembers.flatMap((member) => {
const actor = state.actors[member.id];
return actor ? [[member.id, {
...actor,
health: member.health,
maxHealth: member.maxHealth,
alive: member.health > 0,
}]] : [];
})),
},
}));
useCombatStore.setState((state) => ({ maxHealth: 2_000, health: 1_980 }));
const cast = combat.castAbility(prayerOfMendingAbility().id, {
targetActorId: initialTarget.id,
now: 1_000,
});
expect(cast.ok, JSON.stringify(cast)).toBe(true);
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === "wow335:48113:prayer-of-mending"))
.toMatchObject({ targetId: initialTarget.id, procStates: [{ remainingCharges: 5 }] });
let auraTarget = initialTarget.id;
let now = 2_000;
for (let activation = 1; activation <= 5; activation += 1) {
const previousTarget = auraTarget;
damageActor(previousTarget, now);
now += 1_000;
const healingEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === prayerOfMendingAbility().id
));
expect(healingEvents).toHaveLength(activation);
const active = useCombatStore.getState().auras.find((aura) => (
aura.definition.id === "wow335:48113:prayer-of-mending"
));
if (activation === 5) {
expect(active).toBeUndefined();
break;
}
expect(active).toBeDefined();
expect(active?.targetId).not.toBe(previousTarget);
const combatState = useCombatStore.getState();
const injured = [
...(combatState.health > 0 && combatState.health < combatState.maxHealth
? [{ id: PLAYER_AGGRO_ID, health: combatState.health, maxHealth: combatState.maxHealth }]
: []),
...usePartyStore.getState().members.flatMap((member) => {
const actor = combatState.actors[member.id];
return actor && actor.health > 0 && actor.health < actor.maxHealth
? [{ id: member.id, health: actor.health, maxHealth: actor.maxHealth }]
: [];
}),
].filter((candidate) => candidate.id !== previousTarget)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
expect(active?.targetId).toBe(injured[0]?.id);
auraTarget = active!.targetId;
}
});
it("does not spend a charge for unrelated outgoing damage", () => {
const member = usePartyStore.getState().members[0]!;
expect(useCombatStore.getState().castAbility(prayerOfMendingAbility().id, {
targetActorId: member.id,
now: 1_000,
}).ok).toBe(true);
useCombatStore.getState().registerMob("pom-unrelated", { name: "Unrelated target", maxHealth: 1_000 });
setMobPosition("pom-unrelated", [4, 0, 0]);
useCombatStore.getState().damageMob("pom-unrelated", 1, 2_000);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.definition.id === "wow335:48113:prayer-of-mending"
));
expect(aura).toMatchObject({ targetId: member.id, procStates: [{ remainingCharges: 5 }] });
});
});
+212
View File
@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityTalentNodeId } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
describe("WoW Rogue class mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-rogue-mechanics-tests", 1,
useCombatStore.getState().health);
});
const ability = (name: string) => {
const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(found, `missing Rogue ability ${name}`).toBeDefined();
return found!;
};
const learnAbilityTalent = (name: string) => {
const nodeId = abilityTalentNodeId(ability(name));
expect(nodeId, `${name} should have a source talent node`).toBeTruthy();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [nodeId!]: 1 },
}));
};
const addTarget = (id: string, position: readonly [number, number, number] = [3, 0, 0]) => {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
};
it("gates stealth openers and allows Pick Pocket through Shadow Dance", () => {
addTarget("rogue-opener-target");
const backstab = ability("Backstab");
const pocket = ability("Pick Pocket");
expect(useCombatStore.getState().castAbility(backstab.id, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility(pocket.id, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
learnAbilityTalent("Shadow Dance");
const dance = ability("Shadow Dance");
expect(useCombatStore.getState().castAbility(dance.id, undefined, 2_000).ok).toBe(true);
const result = useCombatStore.getState().castAbility(pocket.id, undefined, 5_000);
expect(result).toMatchObject({ ok: true, abilityId: pocket.id });
expect(useCombatStore.getState().feedback).toMatchObject({ kind: "loot", message: expect.stringContaining("succeeds") });
});
it.each([["Ambush", 2], ["Garrote", 1], ["Cheap Shot", 2]] as const)(
"grants Initiative's source-ranked extra combo point on %s",
(openerName, baseComboPoints) => {
addTarget("rogue-initiative-target");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-245": 3 },
}));
expect(useCombatStore.getState().castAbility("wow335-rogue-vanish", undefined, 1_000).ok).toBe(true);
const result = useCombatStore.getState().castAbility(ability(openerName).id, undefined, 5_000);
expect(result).toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(baseComboPoints + 1);
expect(useCombatStore.getState().comboTargetId).toBe("rogue-initiative-target");
},
);
it("grants Ruthlessness's finisher point and observes its 9-second cooldown", () => {
addTarget("rogue-ruthlessness-target");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-273": 3 },
comboPoints: 3,
comboTargetId: "rogue-ruthlessness-target",
}));
const eviscerate = ability("Eviscerate");
expect(useCombatStore.getState().castAbility(eviscerate.id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(1);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(
"wow335-rogue-ruthlessness-cooldown",
) && aura.expiresAt === 10_000)).toBe(true);
useCombatStore.setState({ comboPoints: 2, comboTargetId: "rogue-ruthlessness-target" });
expect(useCombatStore.getState().castAbility(eviscerate.id, undefined, 2_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(0);
});
it("grants Honor Among Thieves' rank-3 direct-damage combo point no more than once every 3 seconds", () => {
addTarget("rogue-honor-among-thieves-target");
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-2078": 3 },
resource: 1_000,
maxResource: 1_000,
}));
const sinisterStrike = ability("Sinister Strike");
expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(2);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-rogue-honor-among-thieves-cooldown")
&& aura.expiresAt === 4_000)).toBe(true);
expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 2_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(3);
expect(useCombatStore.getState().castAbility(sinisterStrike.id, undefined, 4_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(5);
});
it("redirects only threat generated after Tricks of the Trade is cast", () => {
addTarget("rogue-tricks-target", [8, 0, 0]);
const member = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(member.id, [2, 0, 0]);
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
"rogue-tricks-target": {
...state.mobs["rogue-tricks-target"]!,
engaged: true,
threatByActor: { [PLAYER_AGGRO_ID]: 70 },
},
},
}));
const tricks = ability("Tricks of the Trade");
const result = useCombatStore.getState().castAbility(tricks.id, { targetActorId: member.id, now: 1_000 });
expect(result).toMatchObject({ ok: true, abilityId: tricks.id });
expect(useCombatStore.getState().mobs["rogue-tricks-target"]?.threatByActor)
.toEqual({ [PLAYER_AGGRO_ID]: 70 });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === member.id && aura.definition.utilityTags?.includes("wow335-rogue-tricks-damage-buff")
))).toBe(true);
useCombatStore.getState().damageMob("rogue-tricks-target", 100, 2_000);
const threat = useCombatStore.getState().mobs["rogue-tricks-target"]!.threatByActor;
expect(threat[PLAYER_AGGRO_ID]).toBe(70);
expect(threat[member.id]).toBeGreaterThan(0);
});
it("executes five two-weapon Killing Spree assaults against a single reachable target", () => {
addTarget("rogue-killing-spree-target", [3, 0, 0]);
learnAbilityTalent("Killing Spree");
const spree = ability("Killing Spree");
const result = useCombatStore.getState().castAbility(spree.id, undefined, 1_000);
expect(result).toMatchObject({ ok: true, abilityId: spree.id });
const strikes = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.targetActorId === "rogue-killing-spree-target" && event.abilityId === spree.id
));
expect(strikes).toHaveLength(10);
expect(useCombatStore.getState().mobs["rogue-killing-spree-target"]?.health).toBeLessThan(100_000);
expect(useCombatStore.getState().playerPosition).not.toEqual([0, 0, 0]);
});
it("Shadowsteps behind its target and applies the registered triggered rider", () => {
addTarget("rogue-shadowstep-target", [8, 0, 0]);
const shadowstep = ability("Shadowstep");
const result = useCombatStore.getState().castAbility(shadowstep.id, undefined, 1_000);
expect(result).toMatchObject({ ok: true, abilityId: shadowstep.id });
expect(useCombatStore.getState().playerPosition).toEqual([8, 0, -1.5]);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-rogue-shadowstep-rider"))).toBe(true);
});
it("keeps the Riposte cast gated until a parry grants its ready state", () => {
addTarget("rogue-riposte-target");
learnAbilityTalent("Riposte");
const riposte = ability("Riposte");
expect(useCombatStore.getState().castAbility(riposte.id, undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-rogue-riposte-ready"))).toBe(false);
useCombatStore.setState((state) => ({
auras: [...state.auras, {
instanceId: "test-riposte-ready",
definition: {
id: "wow335:rogue:riposte-ready",
name: "Riposte Ready",
disposition: "buff" as const,
durationMs: 5_000,
maxStacks: 1,
stackBehavior: "refresh" as const,
utilityTags: ["wow335-rogue-riposte-ready"],
},
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
appliedAt: 1_500,
expiresAt: 6_500,
stacks: 1,
absorbs: [],
procStates: [],
}],
}));
expect(useCombatStore.getState().castAbility(riposte.id, undefined, 2_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(
"wow335-rogue-riposte-ready",
))).toBe(false);
});
});
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
describe("WoW Rogue Vanish and Preparation runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-rogue-vanish-preparation-tests", 1,
useCombatStore.getState().health);
});
it.each(["root", "slow"] as const)("Vanish grants stealth, clears %s, and does not relocate the rogue", (mechanic) => {
useCombatStore.getState().registerMob("vanish-target", { name: "vanish-target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("vanish-target", [8, 0, 0]);
useCombatStore.getState().selectMob("vanish-target");
useCombatStore.setState({ controlledUntil: 15_000, controlMechanic: mechanic });
const result = useCombatStore.getState().castAbility("wow335-rogue-vanish", undefined, 1_000);
expect(result).toMatchObject({ ok: true, abilityId: "wow335-rogue-vanish" });
const state = useCombatStore.getState();
expect(state.auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("stealth")
&& aura.expiresAt === null)).toBe(true);
expect(state).toMatchObject({ controlledUntil: 0, controlMechanic: null, playerPosition: [0, 0, 0] });
});
it("Preparation resets only its five source-listed cooldowns", () => {
const abilities = useCombatStore.getState().abilities;
const byName = (name: string) => {
const ability = abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!;
};
const resetNames = ["Evasion", "Sprint", "Vanish", "Cold Blood", "Shadowstep"];
const resetIds = resetNames.map((name) => byName(name).id);
const unrelatedId = byName("Kick").id;
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-284": 1 },
cooldowns: {
...state.cooldowns,
...Object.fromEntries([...resetIds, unrelatedId].map((id) => [id, 60_000])),
},
}));
const result = useCombatStore.getState().castAbility("wow335-rogue-preparation", undefined, 1_000);
expect(result).toMatchObject({ ok: true, abilityId: "wow335-rogue-preparation" });
const cooldowns = useCombatStore.getState().cooldowns;
for (const id of resetIds) expect(cooldowns[id], id).toBe(0);
expect(cooldowns[unrelatedId]).toBe(60_000);
expect(cooldowns["wow335-rogue-preparation"]).toBe(601_000);
});
});
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AURA_ENTITY_ID, useCombatStore, resolvedAbilityCosts } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function shamanAbility(name: string) {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, `Shaman ${name}`).toBeDefined();
return ability!;
}
function setTalentRank(talentId: string, rank: number): void {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [talentId]: rank },
}));
}
function castAndFinish(abilityId: string, targetActorId: string | null | undefined, now: number): void {
const result = useCombatStore.getState().castAbility(
abilityId,
targetActorId !== undefined ? { targetActorId, now } : { now },
);
expect(result.ok, JSON.stringify(result)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
}
function setupShaman(): void {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-shaman-conditional-talent-tests", 80, useCombatStore.getState().health);
useCombatStore.getState().registerMob("shaman-conditional-target", {
name: "Shaman conditional talent target",
level: 80,
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition("shaman-conditional-target", [8, 0, 0]);
useCombatStore.getState().selectMob("shaman-conditional-target");
}
describe("WoW Shaman conditional talent mechanics", () => {
beforeEach(setupShaman);
it("grants two 30% mana-cost reductions after a critical elemental spell", () => {
setTalentRank("talent-574", 2);
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
}));
const lightningBolt = shamanAbility("Lightning Bolt");
castAndFinish(lightningBolt.id, undefined, 1_000);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === lightningBolt.id && event.critical
))).toBe(true);
const clearcasting = () => useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-shaman-elemental-focus-clearcasting")
));
expect(clearcasting()?.stacks).toBe(2);
const healingWave = shamanAbility("Healing Wave");
const before = useCombatStore.getState();
const fullCost = resolvedAbilityCosts(healingWave, before.talentModifiers, { ...before, auras: [] })
.find((cost) => cost.resource === "mana")?.amount;
expect(fullCost).toBeGreaterThan(0);
const discountedCost = Math.round(fullCost! * 0.7);
expect(resolvedAbilityCosts(healingWave, before.talentModifiers, before)
.find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost);
castAndFinish(healingWave.id, null, 10_000);
expect(clearcasting()?.stacks).toBe(1);
const oneChargeState = useCombatStore.getState();
expect(resolvedAbilityCosts(healingWave, oneChargeState.talentModifiers, oneChargeState)
.find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost);
castAndFinish(healingWave.id, null, 20_000);
expect(clearcasting()).toBeUndefined();
});
it("uses Reverberation's rank chance and 20% bonus on the next different Shock", () => {
setTalentRank("talent-575", 3);
const earthShock = shamanAbility("Earth Shock");
const frostShock = shamanAbility("Frost Shock");
castAndFinish(earthShock.id, undefined, 1_000);
const initialReverberation = useCombatStore.getState().auras.find((aura) => (
aura.definition.utilityTags?.includes("wow335-shaman-reverberation")
));
expect(initialReverberation?.expiresAt).toBe(9_000);
expect(initialReverberation?.definition.utilityTags).toContain(`wow335-shaman-reverberation:from:${earthShock.id}`);
castAndFinish(frostShock.id, undefined, 6_000);
const frostShockHit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.abilityId === frostShock.id
));
expect(frostShockHit?.effectiveAmount).toBeGreaterThan(0);
const refreshedReverberation = useCombatStore.getState().auras.find((aura) => (
aura.definition.utilityTags?.includes("wow335-shaman-reverberation")
));
expect(refreshedReverberation?.definition.utilityTags)
.toContain(`wow335-shaman-reverberation:from:${frostShock.id}`);
const buffedRawDamage = frostShockHit?.rawAmount;
setupShaman();
const baselineBolt = shamanAbility("Lightning Bolt");
const baselineFrostShock = shamanAbility("Frost Shock");
castAndFinish(baselineBolt.id, undefined, 1_000);
castAndFinish(baselineFrostShock.id, undefined, 6_000);
const baselineHit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.abilityId === baselineFrostShock.id
));
expect(baselineHit?.rawAmount).toBeGreaterThan(0);
expect(buffedRawDamage).toBe(Math.round((baselineHit?.rawAmount ?? 0) * 1.2));
});
});
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
const HEALING_STREAM_RANKS = [
{ level: 20, spellId: 5394, heal: 6 },
{ level: 30, spellId: 6375, heal: 8 },
{ level: 40, spellId: 6377, heal: 10 },
{ level: 50, spellId: 10462, heal: 12 },
{ level: 60, spellId: 10463, heal: 14 },
{ level: 69, spellId: 25567, heal: 18 },
{ level: 71, spellId: 58755, heal: 20 },
{ level: 76, spellId: 58756, heal: 23 },
{ level: 80, spellId: 58757, heal: 25 },
] as const;
function healingStreamAbility() {
const ability = abilitiesForClass("shaman").find((candidate) => candidate.name === "Healing Stream Totem");
expect(ability, "Healing Stream Totem").toBeDefined();
return ability!;
}
describe("WoW Shaman Healing Stream Totem", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-healing-stream-test", 80, useCombatStore.getState().health);
});
it("uses each source rank’s 2-second heal amount", () => {
const base = healingStreamAbility();
for (const expected of HEALING_STREAM_RANKS) {
const ranked = abilityAtLevel(base, expected.level);
const summon = ranked.effects.find((effect) => effect.kind === "summon");
expect(ranked.dbcSpellId).toBe(expected.spellId);
expect(summon?.kind).toBe("summon");
if (summon?.kind !== "summon") continue;
expect(summon.healingPulse).toEqual({ amount: expected.heal, intervalMs: 2_000, radius: 10 });
expect(summon.durationMs).toBe(300_000);
}
});
it("heals every nearby party member and never attacks the selected enemy", () => {
const state = useCombatStore.getState();
const members = usePartyStore.getState().members;
useCombatStore.setState((current) => ({ health: current.maxHealth - 25 }));
usePartyStore.setState((current) => ({
members: current.members.map((member) => ({ ...member, health: member.maxHealth - 25 })),
}));
members.forEach((member, index) => {
registerPartyRuntimePosition(member.id, index === 0 ? [5, 0, 0] : index === 1 ? [9, 0, 0] : [10.1 + index, 0, 0]);
});
useCombatStore.getState().registerMob("healing-stream-nearby-enemy", { name: "Nearby enemy", maxHealth: 10_000 });
setMobPosition("healing-stream-nearby-enemy", [4, 0, 0]);
useCombatStore.getState().selectMob("healing-stream-nearby-enemy");
const cast = useCombatStore.getState().castAbility(healingStreamAbility().id, undefined, 1_000);
expect(cast.ok, JSON.stringify(cast)).toBe(true);
const totem = useCombatStore.getState().summons.find((summon) => (
summon.sourceAbilityId === healingStreamAbility().id
));
expect(totem).toBeDefined();
expect(totem?.kind).toBe("totem");
expect(totem?.maxHealth).toBe(5);
expect(totem?.attackDamage).toBe(0);
expect(totem?.expiresAt).toBe(301_000);
useCombatStore.getState().tick(0, 3_000);
const after = useCombatStore.getState();
expect(after.health).toBe(after.maxHealth);
expect(usePartyStore.getState().members[0]?.health).toBe(usePartyStore.getState().members[0]?.maxHealth);
expect(usePartyStore.getState().members[1]?.health).toBe(usePartyStore.getState().members[1]?.maxHealth);
expect(usePartyStore.getState().members[2]?.health).toBe((usePartyStore.getState().members[2]?.maxHealth ?? 0) - 25);
expect(after.mobs["healing-stream-nearby-enemy"]?.health).toBe(10_000);
expect(after.combatEvents.filter((event) => event.kind === "healing"
&& event.abilityId === healingStreamAbility().id)).toHaveLength(3);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { usePartyStore } from "./partyStore";
function shamanAbility(name: string) {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, `Shaman ${name}`).toBeDefined();
return ability!;
}
function finishCast(abilityId: string, targetId: string | undefined, startedAt: number): void {
const requestTargetId = targetId === PLAYER_AURA_ENTITY_ID ? null : targetId;
const result = useCombatStore.getState().castAbility(
abilityId,
requestTargetId !== undefined
? { targetActorId: requestTargetId, now: startedAt }
: { now: startedAt },
);
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick(0.1, cast.completesAt);
}
describe("WoW Shaman source-backed proc talents", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-shaman-proc-tests", 80, useCombatStore.getState().health);
for (const [index, member] of usePartyStore.getState().members.entries()) {
registerPartyRuntimePosition(member.id, [4 + index, 0, 0]);
}
});
it.each([
[1, 500],
[2, 1_000],
[3, 1_500],
])("reduces the shared Shock cooldown by %i rank(s) (%i ms) with Reverberation", (rank, reductionMs) => {
const flameShock = shamanAbility("Flame Shock");
const shockAbilities = useCombatStore.getState().abilities.filter((ability) => (
ability.cooldownGroup === "shaman-shock"
));
expect(shockAbilities.length).toBeGreaterThan(1);
const target = `reverberation-target-${rank}`;
useCombatStore.getState().registerMob(target, {
name: "Reverberation target",
level: 80,
maxHealth: 100_000,
xpReward: 0,
});
setMobPosition(target, [8, 0, 0]);
useCombatStore.getState().selectMob(target);
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-575": rank },
}));
const result = useCombatStore.getState().castAbility(flameShock.id, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick(0.1, cast.completesAt);
const expectedReadyAt = 1_000 + 6_000 - reductionMs;
for (const ability of shockAbilities) {
expect(useCombatStore.getState().cooldowns[ability.id], ability.name).toBe(expectedReadyAt);
}
});
it("uses the ranked Ancestral Healing reduction and heals the lowest-health ally from Ancestral Awakening", () => {
const members = usePartyStore.getState().members;
const mostInjured = members[0]!;
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === mostInjured.id
? { ...member, health: Math.round(member.maxHealth * 0.4) }
: member),
}));
useCombatStore.setState((state) => ({
health: state.maxHealth - 1,
gearStats: { ...state.gearStats, criticalStrikeRating: 100_000 },
talentRanks: { ...state.talentRanks, "talent-2061": 3, "talent-581": 3 },
}));
const heal = shamanAbility("Healing Wave");
finishCast(heal.id, PLAYER_AURA_ENTITY_ID, 1_000);
const events = useCombatStore.getState().combatEvents;
const directHeal = events.find((event) => event.kind === "healing" && event.abilityId === heal.id);
expect(directHeal?.critical).toBe(true);
const awakeningHeal = events.find((event) => (
event.kind === "healing" && event.abilityId === "wow335-shaman-ancestral-awakening"
));
expect(awakeningHeal).toMatchObject({ targetActorId: mostInjured.id, school: "nature" });
expect(awakeningHeal?.rawAmount).toBe(Math.round((directHeal?.rawAmount ?? 0) * 0.3));
expect(usePartyStore.getState().members.find((member) => member.id === mostInjured.id)?.health)
.toBeGreaterThan(Math.round(mostInjured.maxHealth * 0.4));
const ancestralHealing = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-shaman-ancestral-healing")
));
expect(ancestralHealing).toMatchObject({ expiresAt: directHeal!.occurredAt + 15_000 });
expect(ancestralHealing?.definition.modifiers).toContainEqual({
kind: "damage",
direction: "taken",
school: "physical",
operation: "percent",
value: -0.1,
});
});
it("reduces Elemental Mastery and Thunderstorm cooldowns on damaging Lightning Bolt hits", () => {
const state = useCombatStore.getState();
const lightningBolt = shamanAbility("Lightning Bolt");
const sourceCooldownAbilities = ["Elemental Mastery", "Thunderstorm"].map((name) => (
WOW335_ABILITIES_BY_CLASS.shaman.find((ability) => ability.name === name)
));
expect(sourceCooldownAbilities.every(Boolean)).toBe(true);
const cooldownAbilities = sourceCooldownAbilities as NonNullable<typeof sourceCooldownAbilities[number]>[];
const target = "shamanism-target";
useCombatStore.getState().registerMob(target, { name: "Shamanism target", level: 80, maxHealth: 100_000, xpReward: 0 });
setMobPosition(target, [8, 0, 0]);
useCombatStore.getState().selectMob(target);
useCombatStore.setState((current) => ({
talentRanks: { ...current.talentRanks, "talent-2252": 3 },
abilities: [...current.abilities, ...cooldownAbilities.filter((ability) => (
!current.abilities.some((candidate) => candidate.id === ability.id)
))],
cooldowns: {
...current.cooldowns,
[cooldownAbilities[0]!.id]: 20_000,
[cooldownAbilities[1]!.id]: 20_000,
},
}));
const result = useCombatStore.getState().castAbility(lightningBolt.id, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast;
if (cast) useCombatStore.getState().tick(0.1, cast.completesAt);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === lightningBolt.id && event.effectiveAmount > 0
))).toBe(true);
expect(useCombatStore.getState().cooldowns[cooldownAbilities[0]!.id]).toBe(19_000);
expect(useCombatStore.getState().cooldowns[cooldownAbilities[1]!.id]).toBe(19_000);
});
});
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityTalentNodeId } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
function unlockShamanTalent(abilityName: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === abilityName);
expect(ability, abilityName).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, abilityName).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
return ability!.id;
}
describe("WoW Shaman Shamanistic Rage source proc", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-shamanistic-rage-runtime-tests", 1, useCombatStore.getState().health);
useCombatStore.getState().registerMob("shamanistic-rage-target", { name: "Shamanistic Rage target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("shamanistic-rage-target", [2, 0, 0]);
useCombatStore.getState().selectMob("shamanistic-rage-target");
});
it("restores 15% attack power as mana on landed melee hits for the 15-second aura window", () => {
const rageId = unlockShamanTalent("Shamanistic Rage");
const rageAbility = useCombatStore.getState().abilities.find((candidate) => candidate.id === rageId);
expect(rageAbility?.dbcSpellId).toBe(30823);
useCombatStore.setState((state) => ({
resource: 300,
resourcePools: { ...state.resourcePools, mana: 300 },
}));
const rageCast = useCombatStore.getState().castAbility(rageId, undefined, 1_000);
expect(rageCast.ok).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.targetId === PLAYER_AURA_ENTITY_ID
&& candidate.definition.procs?.some((proc) => proc.action.kind === "custom"
&& proc.action.id === "wow335-shamanistic-rage-mana")
));
expect(aura).toBeDefined();
expect(aura?.expiresAt).toBe(16_000);
for (let now = 3_000; now < 16_000; now += 2_500) {
useCombatStore.setState({ autoAttackTargetId: "shamanistic-rage-target", nextAutoAttackAt: now });
useCombatStore.getState().tick(0.1, now);
if (useCombatStore.getState().combatEvents.some((event) => (
event.kind === "resource" && event.abilityId === rageId && event.effectiveAmount > 0
))) break;
}
const afterMelee = useCombatStore.getState();
expect(afterMelee.resourcePools.mana).toBeGreaterThan(0);
expect(afterMelee.combatEvents.some((event) => (
event.kind === "resource"
&& event.abilityId === rageId
&& event.effectiveAmount > 0
))).toBe(true);
const rageRestoresBeforeExpiry = afterMelee.combatEvents.filter((event) => (
event.kind === "resource" && event.abilityId === rageId
)).length;
useCombatStore.setState({
autoAttackTargetId: "shamanistic-rage-target",
nextAutoAttackAt: 20_000,
});
useCombatStore.getState().tick(0.1, 20_000);
expect(useCombatStore.getState().auras.some((entry) => entry.instanceId === aura?.instanceId)).toBe(false);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "resource" && event.abilityId === rageId
))).toHaveLength(rageRestoresBeforeExpiry);
});
});
+238
View File
@@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { applyAura } from "./combatAuras";
import { PLAYER_AGGRO_ID } from "./aggro";
import { usePartyStore } from "./partyStore";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function unlockTalentAbility(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, name).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
return ability!.id;
}
function setTalentRank(name: string, rank: number): void {
const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name);
expect(talent, name).toBeDefined();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [talent!.id]: rank },
}));
}
function castAndFinish(name: string, now: number): number {
const id = abilityId(name);
const result = useCombatStore.getState().castAbility(id, undefined, now);
expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast?.abilityId === id) {
useCombatStore.getState().tick(0.1, activeCast.completesAt);
return activeCast.completesAt;
}
return now;
}
function addNearbyMob(id: string, x: number, maxHealth = 100_000): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth });
setMobPosition(id, [x, 0, 0]);
}
function deterministicUnit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
describe("WoW 3.3.5 Warlock mechanics", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["warlock"]);
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warlock-mechanics-tests", 1, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
addNearbyMob("warlock-target", 4);
useCombatStore.getState().selectMob("warlock-target");
});
it("requires and consumes the caster's Immolate for Conflagrate", () => {
const target = useCombatStore.getState().mobs["warlock-target"]!;
const conflagrateId = unlockTalentAbility("Conflagrate");
const unavailable = useCombatStore.getState().castAbility(conflagrateId, undefined, 1_000);
expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" });
const immolateCompleteAt = castAndFinish("Immolate", 3_000);
const immolateId = abilityId("Immolate");
expect(useCombatStore.getState().effects.some((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id
))).toBe(true);
const conflagrate = useCombatStore.getState().castAbility(conflagrateId, undefined, immolateCompleteAt + 100);
expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true);
expect(conflagrate.damage).toBeGreaterThan(0);
expect(useCombatStore.getState().effects.some((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id
))).toBe(false);
});
it("models Shadowflame's missing periodic child so Conflagrate can consume it", () => {
const shadowflameCompleteAt = castAndFinish("Shadowflame", 3_000);
const shadowflameId = abilityId("Shadowflame");
const activeShadowflame = useCombatStore.getState().effects.find((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === shadowflameId && effect.targetId === "warlock-target"
));
expect(activeShadowflame).toMatchObject({ kind: "dot", intervalMs: 2_000, remainingTicks: 4, damageSchool: "fire" });
const conflagrate = useCombatStore.getState().castAbility(
unlockTalentAbility("Conflagrate"), undefined, shadowflameCompleteAt + 2_000,
);
expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true);
expect(conflagrate.damage).toBeGreaterThan(0);
expect(useCombatStore.getState().effects.some((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === shadowflameId
))).toBe(false);
});
it("ramps Curse of Agony ticks through half, full, then one-and-a-half damage", () => {
const curseId = abilityId("Curse of Agony");
const completeAt = castAndFinish("Curse of Agony", 1_000);
for (let tick = 1; tick <= 12; tick += 1) {
useCombatStore.getState().tick(0.1, completeAt + tick * 2_000);
}
const amounts = useCombatStore.getState().combatEvents
.filter((event) => event.kind === "damage" && event.abilityId === curseId && event.targetActorId === "warlock-target")
.map((event) => event.effectiveAmount);
expect(amounts).toHaveLength(12);
expect(new Set(amounts.slice(0, 4)).size).toBe(1);
expect(new Set(amounts.slice(4, 8)).size).toBe(1);
expect(new Set(amounts.slice(8, 12)).size).toBe(1);
expect(Math.abs(amounts[4]! - 2 * amounts[0]!)).toBeLessThanOrEqual(2);
expect(Math.abs(amounts[8]! - 3 * amounts[0]!)).toBeLessThanOrEqual(3);
});
it("uses Seed's DBC threshold and rank-matched area explosion on damage", () => {
const source = WOW335_ABILITIES_BY_CLASS.warlock.find((ability) => ability.name === "Seed of Corruption");
expect(source?.ranks?.map((rank) => rank.spellId)).toEqual([27_243, 47_835, 47_836]);
addNearbyMob("seed-neighbor", 8);
const completeAt = castAndFinish("Seed of Corruption", 1_000);
expect(useCombatStore.getState().effects.find((effect) => effect.kind === "dot" && effect.targetId === "warlock-target"))
.toMatchObject({ warlockSeed: { threshold: 1_518, explosionSpellId: 47_834, accumulatedDamage: 0 } });
const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health;
useCombatStore.getState().damageMob("warlock-target", 1_517, completeAt + 100, undefined, "shadow");
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBe(neighborHealth);
useCombatStore.getState().damageMob("warlock-target", 1, completeAt + 200, undefined, "shadow");
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" && effect.targetId === "warlock-target"))
.toBe(false);
});
it("detonates Seed when its carrier dies before the damage threshold", () => {
addNearbyMob("seed-neighbor", 8);
addNearbyMob("seed-carrier", 6, 1_000);
useCombatStore.getState().selectMob("seed-carrier");
const completeAt = castAndFinish("Seed of Corruption", 1_000);
const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health;
useCombatStore.getState().damageMob("seed-carrier", 1_000, completeAt + 100, undefined, "shadow");
expect(useCombatStore.getState().mobs["seed-carrier"]?.dead).toBe(true);
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth);
});
it("procs Backlash only from landed physical attacks, with its source duration and internal cooldown", () => {
setTalentRank("Backlash", 3);
const maxHealth = useCombatStore.getState().maxHealth;
const readyTag = "wow335-warlock-backlash-ready";
const cooldownTag = "wow335-warlock-backlash-cooldown";
useCombatStore.getState().damagePlayer(1, "shadow", 80, 20_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(readyTag))).toBe(false);
const procAt = Array.from({ length: 500 }, (_, offset) => 30_000 + offset)
.find((now) => deterministicUnit(
`${PLAYER_AGGRO_ID}:external:physical:${now}:${maxHealth}:wow335-warlock-backlash`,
) < 0.25);
expect(procAt, "a deterministic source-rank Backlash proc seed").toBeDefined();
useCombatStore.setState({ health: maxHealth });
useCombatStore.getState().damagePlayer(1, "physical", 80, procAt!);
const ready = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag));
expect(ready?.expiresAt).toBe(procAt! + 12_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(cooldownTag)
&& aura.expiresAt === procAt! + 8_000)).toBe(true);
useCombatStore.setState({ health: maxHealth });
useCombatStore.getState().damagePlayer(1, "physical", 80, procAt! + 1);
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag))?.expiresAt)
.toBe(procAt! + 12_000);
});
it("makes Backlash's next Shadow Bolt instant, half-cost at rank 3, and consumes its proc", () => {
setTalentRank("Backlash", 3);
const playerId = PLAYER_AURA_ENTITY_ID;
const shadowBolt = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Shadow Bolt")!;
const manaBefore = useCombatStore.getState().resourcePools.mana!;
const normalCast = useCombatStore.getState().castAbility(shadowBolt.id, undefined, 1_000);
expect(normalCast.ok, JSON.stringify(normalCast)).toBe(true);
const normalActiveCast = useCombatStore.getState().activeCast;
expect(normalActiveCast?.abilityId).toBe(shadowBolt.id);
useCombatStore.getState().tick(0.1, normalActiveCast!.completesAt);
const normalManaSpent = manaBefore - useCombatStore.getState().resourcePools.mana!;
expect(normalManaSpent).toBeGreaterThan(0);
const backlashCastAt = normalActiveCast!.completesAt + 1_000;
const readyAura = applyAura([], {
definition: {
id: "wow335:warlock:backlash-ready",
name: "Backlash",
disposition: "buff",
durationMs: 12_000,
utilityTags: ["wow335-warlock-backlash-ready"],
},
sourceId: playerId,
targetId: playerId,
now: backlashCastAt,
}).auras;
const afterNormal = useCombatStore.getState();
useCombatStore.setState({
auras: readyAura,
activeCast: null,
globalCooldownEndsAt: 0,
resource: afterNormal.maxResource,
resourcePools: { ...afterNormal.resourcePools, mana: afterNormal.maxResourcePools.mana! },
});
const procManaBefore = useCombatStore.getState().resourcePools.mana!;
const result = useCombatStore.getState().castAbility(shadowBolt.id, undefined, backlashCastAt);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(procManaBefore - useCombatStore.getState().resourcePools.mana!).toBe(Math.round(normalManaSpent * 0.5));
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(
"wow335-warlock-backlash-ready",
))).toBe(false);
expect(result.damage).toBeGreaterThan(0);
});
});
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
function soulshatterId(): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Soulshatter");
expect(ability, "Soulshatter").toBeDefined();
return ability!.id;
}
function setPlayerThreat(id: string, amount: number): void {
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[id]: {
...state.mobs[id],
threatByActor: { ...state.mobs[id].threatByActor, [PLAYER_AGGRO_ID]: amount },
},
},
}));
}
describe("WoW 3.3.5 Warlock Soulshatter runtime", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["warlock"]);
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warlock-soulshatter-tests", 1,
useCombatStore.getState().health);
});
it("cuts threat in half inside the caster's 50-yard radius and leaves outside enemies alone", () => {
useCombatStore.getState().registerMob("soulshatter-inside", { name: "Inside", maxHealth: 10_000 });
useCombatStore.getState().registerMob("soulshatter-outside", { name: "Outside", maxHealth: 10_000 });
setMobPosition("soulshatter-inside", [20, 0, 0]);
setMobPosition("soulshatter-outside", [60, 0, 0]);
setPlayerThreat("soulshatter-inside", 100);
setPlayerThreat("soulshatter-outside", 100);
const cast = useCombatStore.getState().castAbility(soulshatterId(), undefined, 1_000);
expect(cast).toMatchObject({ ok: true, affectedMobIds: ["soulshatter-inside"] });
expect(useCombatStore.getState().mobs["soulshatter-inside"].threatByActor[PLAYER_AGGRO_ID]).toBe(50);
expect(useCombatStore.getState().mobs["soulshatter-outside"].threatByActor[PLAYER_AGGRO_ID]).toBe(100);
});
it("does not reduce distant enemies when nobody is within the caster-centered radius", () => {
useCombatStore.getState().registerMob("soulshatter-far", { name: "Far", maxHealth: 10_000 });
setMobPosition("soulshatter-far", [60, 0, 0]);
setPlayerThreat("soulshatter-far", 100);
const cast = useCombatStore.getState().castAbility(soulshatterId(), undefined, 1_000);
expect(cast).toMatchObject({ ok: true, affectedMobIds: [] });
expect(useCombatStore.getState().mobs["soulshatter-far"].threatByActor[PLAYER_AGGRO_ID]).toBe(100);
});
});
+185
View File
@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { talentModifiersForRanks } from "./talentModifiers";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
function setTalentRank(name: string, rank: number): void {
const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name);
expect(talent, name).toBeDefined();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [talent!.id]: rank },
}));
}
function addTarget(id = "warlock-proc-target"): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [4, 0, 0]);
useCombatStore.getState().selectMob(id);
}
function castAndFinish(name: string, now: number): number {
const id = abilityId(name);
const result = useCombatStore.getState().castAbility(id, undefined, now);
expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast?.abilityId === id) {
useCombatStore.getState().tick(0.1, activeCast.completesAt);
return activeCast.completesAt;
}
return now;
}
describe("WoW 3.3.5 Warlock talent procs", () => {
beforeEach(async () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
await preloadAbilityCatalogsForClasses(["warlock"]);
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warlock-proc-tests", 1, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
addTarget();
});
it("heals the active pet for 15% of effective spell damage with Fel Synergy", () => {
setTalentRank("Fel Synergy", 2);
castAndFinish("Summon Imp", 1_000);
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
expect(pet).toBeDefined();
useCombatStore.setState((state) => ({
summons: state.summons.map((summon) => summon.id === pet!.id
? { ...summon, health: 1, nextActionAt: Number.MAX_SAFE_INTEGER }
: summon),
}));
const target = useCombatStore.getState().mobs["warlock-proc-target"]!;
const playerPet = useCombatStore.getState().summons.find((summon) => summon.id === pet!.id)!;
const completesAt = castAndFinish("Shadow Bolt", 12_000);
const damage = target.health - useCombatStore.getState().mobs[target.id]!.health;
const updatedPet = useCombatStore.getState().summons.find((summon) => summon.id === playerPet.id)!;
expect(damage).toBeGreaterThan(0);
expect(updatedPet.health).toBe(Math.min(updatedPet.maxHealth, 1 + Math.round(damage * 0.15)));
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing" && event.targetActorId === playerPet.id && event.occurredAt === completesAt
))).toBe(true);
});
it("heals from eligible direct Destruction hits and observes Soul Leech's three-second cooldown", () => {
setTalentRank("Soul Leech", 3);
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.setState((state) => ({
health: state.maxHealth - 1_000,
}));
const target = useCombatStore.getState().mobs["warlock-proc-target"]!;
const completesAt = castAndFinish("Shadow Bolt", 1_000);
const effectiveDamage = target.health - useCombatStore.getState().mobs[target.id]!.health;
const expectedHeal = Math.min(Math.round(effectiveDamage * 0.15), Math.round(maxHealth * 0.15));
expect(useCombatStore.getState().health).toBe(maxHealth - 1_000 + expectedHeal);
const initialSoulLeechEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === abilityId("Shadow Bolt")
));
expect(initialSoulLeechEvents).toHaveLength(1);
expect(initialSoulLeechEvents[0]?.effectiveAmount).toBe(expectedHeal);
castAndFinish("Searing Pain", completesAt + 100);
const followupSoulLeechEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === abilityId("Searing Pain")
));
expect(followupSoulLeechEvents).toHaveLength(0);
});
it("applies Improved Shadow Bolt vulnerability only after a critical Shadow Bolt", () => {
setTalentRank("Improved Shadow Bolt", 5);
const firstCompleteAt = castAndFinish("Shadow Bolt", 1_000);
const shadowBoltId = abilityId("Shadow Bolt");
const firstHit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.abilityId === shadowBoltId
));
expect(firstHit?.critical).toBe(false);
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === "warlock-proc-target"
&& aura.definition.utilityTags?.includes("wow335-warlock-improved-shadow-bolt")
))).toBe(false);
useCombatStore.setState((state) => ({
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
}));
const completesAt = castAndFinish("Shadow Bolt", firstCompleteAt + 100);
const criticalHit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.abilityId === shadowBoltId && event.occurredAt === completesAt
));
expect(criticalHit?.critical).toBe(true);
const targetAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === "warlock-proc-target"
&& aura.definition.utilityTags?.includes("wow335-warlock-improved-shadow-bolt")
));
expect(targetAura).toMatchObject({ expiresAt: completesAt + 30_000 });
});
it("keeps conditional Warlock procs out of global scalar modifiers", () => {
const improvedShadowBolt = WOW335_TALENT_NODES.find((candidate) => candidate.name === "Improved Shadow Bolt")!;
const modifiers = talentModifiersForRanks({
"talent-1002": 2,
"talent-1878": 3,
[improvedShadowBolt.id]: 5,
}, "warlock");
expect(modifiers.some((modifier) => modifier.sourceTalentName === "Nightfall")).toBe(false);
expect(modifiers.some((modifier) => modifier.sourceTalentName === "Eradication")).toBe(false);
expect(modifiers.filter((modifier) => modifier.sourceTalentName === "Improved Shadow Bolt"))
.toEqual([expect.objectContaining({ kind: "damage-percent", amount: 15 })]);
});
it("keeps Backlash's next-cast damage and cost conditional while applying its passive critical chance", () => {
const modifiers = talentModifiersForRanks({ "talent-1817": 3 }, "warlock");
expect(modifiers.filter((modifier) => modifier.sourceTalentName === "Backlash"))
.toEqual([expect.objectContaining({ kind: "critical-chance-percent", amount: 3 })]);
});
it("procs rank-scaled Eradication from Corruption damage and applies haste to cast time", () => {
setTalentRank("Eradication", 3);
let nextCastAt = 1_000;
let procAt: number | null = null;
for (let application = 0; application < 3 && procAt === null; application += 1) {
const completesAt = castAndFinish("Corruption", nextCastAt);
const dot = useCombatStore.getState().effects.find((effect) => (
effect.kind === "dot" && effect.sourceAbilityId === abilityId("Corruption")
));
expect(dot, "Corruption periodic effect").toBeDefined();
for (let tick = 1; tick <= dot!.remainingTicks && procAt === null; tick += 1) {
nextCastAt = completesAt + tick * dot!.intervalMs;
useCombatStore.getState().tick(0.1, nextCastAt);
if (useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("wow335-warlock-eradication")
&& (aura.expiresAt === null || aura.expiresAt > nextCastAt)
))) procAt = nextCastAt;
}
nextCastAt += 1_000;
}
expect(procAt).not.toBeNull();
const shadowBoltId = abilityId("Shadow Bolt");
const shadowBolt = useCombatStore.getState().abilities.find((ability) => ability.id === shadowBoltId)!;
const sourceCastTimeMs = shadowBolt.ranks?.at(-1)?.castTimeMs ?? shadowBolt.castTimeMs;
const result = useCombatStore.getState().castAbility(shadowBoltId, undefined, procAt! + 100);
expect(result.ok).toBe(true);
expect(useCombatStore.getState().activeCast?.completesAt! - (procAt! + 100))
.toBe(Math.round(sourceCastTimeMs / 1.2));
});
});
+269
View File
@@ -0,0 +1,269 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityTalentNodeId } from "./abilityCatalog";
import { resolveStatValue } from "./combatAuras";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
function unlockWarriorTalent(abilityId: string): void {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === abilityId);
expect(ability, abilityId).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
expect(nodeId, abilityId).toBeTruthy();
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, [nodeId!]: 1 },
}));
}
function registerTarget(id: string, position: readonly [number, number, number], maxHealth = 100_000): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
setMobPosition(id, position);
}
describe("WoW 3.3.5 Warrior source-row mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warrior-runtime-tests", 1, useCombatStore.getState().health);
});
it("casts Bloodthirst from 23881 and heals the warrior on the next landed melee hit", () => {
unlockWarriorTalent("wow335-warrior-bloodthirst");
registerTarget("bloodthirst-target", [2, 0, 0]);
const state = useCombatStore.getState();
const startingHealth = state.maxHealth - 500;
useCombatStore.setState((current) => ({
health: startingHealth,
resource: 100,
resourcePools: { ...current.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("wow335-warrior-bloodthirst", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(80);
expect(useCombatStore.getState().cooldowns["wow335-warrior-bloodthirst"]).toBe(5_000);
const healAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.procs?.some((proc) => proc.action.kind === "custom"
&& proc.action.id === "wow335-trigger-spell"
&& proc.action.data?.spellId === 23880)
));
expect(healAura).toBeDefined();
const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000);
expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true });
const afterMeleeHit = useCombatStore.getState();
expect(afterMeleeHit.health).toBeGreaterThan(startingHealth);
expect(afterMeleeHit.auras.find((aura) => aura.instanceId === healAura?.instanceId)?.procStates[0]?.remainingCharges)
.toBe(2);
expect(afterMeleeHit.combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === "wow335-trigger-23880"
))).toBe(false);
});
it("arms five Sweeping Strikes charges and copies each landed melee hit to the nearest nearby enemy", () => {
unlockWarriorTalent("wow335-warrior-sweeping-strikes");
registerTarget("sweeping-primary", [2, 0, 0], 1_000_000);
registerTarget("sweeping-secondary", [3, 0, 0], 1_000_000);
registerTarget("sweeping-distant", [10, 0, 0], 1_000_000);
useCombatStore.getState().selectMob("sweeping-primary");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("wow335-warrior-sweeping-strikes", undefined, 1_000).ok).toBe(true);
const armedAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes("wow335-sweeping-strikes")
));
expect(armedAura?.procStates[0]?.remainingCharges).toBe(5);
expect(useCombatStore.getState().mobs["sweeping-primary"].health).toBe(1_000_000);
const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000);
expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true });
const afterHit = useCombatStore.getState();
expect(afterHit.mobs["sweeping-primary"].health).toBeLessThan(1_000_000);
expect(afterHit.mobs["sweeping-secondary"].health).toBeLessThan(1_000_000);
expect(afterHit.mobs["sweeping-distant"].health).toBe(1_000_000);
expect(afterHit.auras.find((aura) => aura.instanceId === armedAura?.instanceId)?.procStates[0]?.remainingCharges)
.toBe(4);
});
it.each([
[1, 1_000],
[2, 2_000],
])("reduces Whirlwind cooldown by %i rank(s) (%i ms) with Improved Whirlwind", (rank, reductionMs) => {
registerTarget(`improved-whirlwind-target-${rank}`, [2, 0, 0]);
useCombatStore.getState().selectMob(`improved-whirlwind-target-${rank}`);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
talentRanks: { ...state.talentRanks, "talent-1655": rank },
}));
const whirlwind = useCombatStore.getState().abilities.find((ability) => ability.name === "Whirlwind");
expect(whirlwind).toBeDefined();
expect(whirlwind?.cooldownGroup).toBeUndefined();
const mortalStrike = useCombatStore.getState().abilities.find((ability) => ability.name === "Mortal Strike");
expect(mortalStrike).toBeDefined();
unlockWarriorTalent(mortalStrike!.id);
const unrelatedResult = useCombatStore.getState().castAbility(mortalStrike!.id, undefined, 1_000);
expect(unrelatedResult.ok, JSON.stringify(unrelatedResult)).toBe(true);
expect(useCombatStore.getState().cooldowns[mortalStrike!.id]).toBe(7_000);
const result = useCombatStore.getState().castAbility(whirlwind!.id, undefined, 3_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().cooldowns[whirlwind!.id]).toBe(3_000 + 10_000 - reductionMs);
});
it("requires Execute targets below 20% health and applies ranked damage instead of killing outright", () => {
registerTarget("execute-target", [2, 0, 0], 100_000);
useCombatStore.getState().selectMob("execute-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
const targetAtThreshold = useCombatStore.getState().mobs["execute-target"];
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, "execute-target": { ...targetAtThreshold, health: 20_000 } },
}));
expect(useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, 1_000)).toMatchObject({
ok: false,
reason: "condition-not-met",
});
expect(useCombatStore.getState().mobs["execute-target"].health).toBe(20_000);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, "execute-target": { ...state.mobs["execute-target"], health: 19_000 } },
}));
expect(useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, 3_000).ok).toBe(true);
const afterExecute = useCombatStore.getState();
expect(afterExecute.mobs["execute-target"].health).toBeGreaterThan(0);
expect(afterExecute.mobs["execute-target"].health).toBeLessThan(19_000);
expect(afterExecute.resourcePools.rage).toBe(85);
expect(afterExecute.combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === "wow335-warrior-execute"
))).toBe(true);
});
it("stacks Improved Execute's rank-3 critical chance after casts below 20% health", () => {
registerTarget("execute-critical-target", [2, 0, 0], 1_000_000);
useCombatStore.getState().selectMob("execute-critical-target");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
talentRanks: { ...state.talentRanks, "talent-1542": 3 },
mobs: {
...state.mobs,
"execute-critical-target": {
...state.mobs["execute-critical-target"],
health: 190_000,
},
},
}));
for (const [index, now] of [1_000, 3_000, 5_000, 7_000].entries()) {
const result = useCombatStore.getState().castAbility("wow335-warrior-execute", undefined, now);
expect(result.ok, JSON.stringify(result)).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.targetId === PLAYER_AURA_ENTITY_ID
&& candidate.definition.id === "wow335:warrior:improved-execute-critical"
));
expect(aura?.stacks).toBe(Math.min(index + 1, 3));
expect(aura?.expiresAt).toBe(now + 5_000);
expect(resolveStatValue(0, aura ? [aura] : [], "critical-strike")).toBe(Math.min(index + 1, 3) * 10);
}
});
it("applies Battle Stance's source-row armor penetration to physical attacks", () => {
const runAttack = (withBattleStance: boolean): number => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow335-warrior-armor-penetration-tests", 1, useCombatStore.getState().health);
registerTarget("armor-penetration-target", [2, 0, 0], 1_000_000);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
mobs: {
...state.mobs,
"armor-penetration-target": { ...state.mobs["armor-penetration-target"], armor: 6_000 },
},
}));
useCombatStore.getState().selectMob("armor-penetration-target");
if (withBattleStance) {
const stance = useCombatStore.getState().castAbility("wow335-warrior-battle-stance", undefined, 1_000);
expect(stance, JSON.stringify(stance)).toMatchObject({ ok: true });
}
const hit = useCombatStore.getState().castAbility("warrior-heroic-strike", undefined, 3_000);
expect(hit, JSON.stringify(hit)).toMatchObject({ ok: true });
return [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "damage" && event.abilityId === "warrior-heroic-strike"
))?.effectiveAmount ?? 0;
};
const withoutStance = runAttack(false);
const withStance = runAttack(true);
expect(withoutStance).toBeGreaterThan(0);
expect(withStance).toBeGreaterThan(withoutStance);
});
it.each([
["wow335-warrior-battle-shout", "Battle Shout"],
["wow335-warrior-commanding-shout", "Commanding Shout"],
])("applies %s to party members inside its source 30-yard radius", (abilityId, abilityName) => {
const [nearbyMember, distantMember] = usePartyStore.getState().members;
expect(nearbyMember).toBeDefined();
expect(distantMember).toBeDefined();
registerPartyRuntimePosition(nearbyMember!.id, [5, 0, 0]);
registerPartyRuntimePosition(distantMember!.id, [40, 0, 0]);
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
const result = useCombatStore.getState().castAbility(abilityId, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const auraTargets = useCombatStore.getState().auras
.filter((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID
&& aura.definition.name.startsWith(`${abilityName} (DBC`))
.map((aura) => aura.targetId);
expect(auraTargets).toContain(PLAYER_AURA_ENTITY_ID);
expect(auraTargets).toContain(nearbyMember!.id);
expect(auraTargets).not.toContain(distantMember!.id);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.name.startsWith(abilityName))).toBe(true);
});
it.each([
["warrior-heroic-strike", "talent-124", 1, 2, 15],
["warrior-heroic-strike", "talent-124", 3, 6, 15],
["wow335-warrior-execute", "talent-1542", 1, 2, 15],
["wow335-warrior-execute", "talent-1542", 3, 6, 15],
])("applies the rank-scaled flat Rage discount to %s", (abilityId, talentId, rank, reduction, baseCost) => {
registerTarget(`flat-rage-cost-${abilityId}-${rank}`, [2, 0, 0], 100_000);
useCombatStore.getState().selectMob(`flat-rage-cost-${abilityId}-${rank}`);
if (abilityId === "wow335-warrior-execute") {
const target = useCombatStore.getState().mobs[`flat-rage-cost-${abilityId}-${rank}`];
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, [target.id]: { ...target, health: 19_000 } },
}));
}
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
talentRanks: { ...state.talentRanks, [talentId]: rank },
}));
const result = useCombatStore.getState().castAbility(abilityId, undefined, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().resourcePools.rage).toBe(100 - baseCost + reduction);
});
});
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
describe("WoW Druid Nature's Swiftness", () => {
beforeEach(() => {
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-druid-nature-swiftness-test", 80, useCombatStore.getState().health);
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((state) => ({
members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 1 }
: candidate),
}));
registerPartyRuntimePosition(member.id, [5, 0, 0]);
usePartyStore.getState().selectMember(member.id);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
talentRanks: { ...state.talentRanks, "talent-831": 1 },
}));
});
it("makes the next eligible Druid Nature heal instant until it is successfully cast", () => {
const state = useCombatStore.getState();
const swift = state.abilities.find((ability) => ability.name === "Nature's Swiftness")!;
const heal = state.abilities.find((ability) => ability.name === "Healing Touch")!;
expect(swift).toBeDefined();
expect(heal).toBeDefined();
const activation = state.castAbility(swift.id, undefined, 1_000);
expect(activation.ok, JSON.stringify(activation)).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.targetId === PLAYER_AGGRO_ID
&& candidate.definition.utilityTags?.includes("wow335-next-nature-instant")
));
expect(aura?.definition.durationMs).toBeNull();
const cast = useCombatStore.getState().castAbility(heal.id, [0, 0, 0], 1_001);
expect(cast.ok, JSON.stringify(cast)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().auras.some((candidate) => (
candidate.definition.utilityTags?.includes("wow335-next-nature-instant")
))).toBe(false);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing" && event.abilityId === heal.id && event.sourceActorId === PLAYER_AGGRO_ID
))).toBe(true);
});
});
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
const NATURE_SWIFTNESS_ID = "wow335-shaman-nature-s-swiftness";
const ELEMENTAL_MASTERY_ID = "wow335-shaman-elemental-mastery";
const NON_NATURE_SPELL_ID = "shaman-flame-shock";
const HEALING_WAVE_ID = "shaman-healing-wave";
describe("WoW Shaman Nature's Swiftness runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-nature-swiftness-test", 80, useCombatStore.getState().health);
useCombatStore.setState((state) => ({
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
}));
});
it("makes the next cast-time Nature spell instant, preserves the proc through Flame Shock, and shares the 3-minute cooldown", () => {
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-591": 1, "talent-573": 1 },
}));
const member = usePartyStore.getState().members[0]!;
usePartyStore.setState((state) => ({
members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: 1 }
: candidate),
}));
usePartyStore.getState().selectMember(member.id);
registerPartyRuntimePosition(member.id, [5, 0, 0]);
useCombatStore.getState().registerMob("nature-swiftness-target", { name: "Target", maxHealth: 10_000 });
setMobPosition("nature-swiftness-target", [10, 0, 0]);
useCombatStore.getState().selectMob("nature-swiftness-target");
const activation = useCombatStore.getState().castAbility(NATURE_SWIFTNESS_ID, undefined, 1_000);
expect(activation.ok, JSON.stringify(activation)).toBe(true);
expect(useCombatStore.getState().cooldowns[ELEMENTAL_MASTERY_ID]).toBe(181_000);
expect(useCombatStore.getState().castAbility(ELEMENTAL_MASTERY_ID, undefined, 2_000))
.toMatchObject({ ok: false, reason: "cooldown" });
expect(useCombatStore.getState().castAbility(NON_NATURE_SPELL_ID, undefined, 2_000)).toMatchObject({ ok: true });
const natureSwiftnessAura = useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes("wow335-next-nature-instant")
));
expect(natureSwiftnessAura).toBeDefined();
expect(natureSwiftnessAura?.definition.durationMs).toBeNull();
expect(natureSwiftnessAura?.definition.modifiers ?? []).not.toContainEqual(
expect.objectContaining({ kind: "stat", stat: "haste" }),
);
const healed = useCombatStore.getState().castAbility(HEALING_WAVE_ID, [0, 0, 0], 4_000);
expect(healed.ok, JSON.stringify(healed)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes("wow335-next-nature-instant")
))).toBe(false);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "healing"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.abilityId === HEALING_WAVE_ID
&& event.targetActorId === member.id
))).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function abilityId(name: string): string {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!.id;
}
describe("WoW Shaman Tidal Force", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-tidal-force-test", 80, useCombatStore.getState().health);
const members = usePartyStore.getState().members;
usePartyStore.setState((state) => ({
members: state.members.map((member) => ({ ...member, health: 1 })),
}));
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [4 + index, 0, 0]));
useCombatStore.setState((state) => ({
health: 1,
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
resource: state.maxResource,
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
talentRanks: { ...state.talentRanks, "talent-582": 1, "talent-591": 1 },
}));
useCombatStore.getState().registerMob("tidal-force-target", { name: "Target", maxHealth: 1_000_000 });
setMobPosition("tidal-force-target", [8, 0, 0]);
useCombatStore.getState().selectMob("tidal-force-target");
if (members[0]) usePartyStore.getState().selectMember(members[0].id);
});
it("adds 60% crit to its three healing spells and spends one charge for each critical heal", () => {
const tidalForceId = abilityId("Tidal Force");
const natureSwiftnessId = abilityId("Nature's Swiftness");
const flameShockId = abilityId("Flame Shock");
const chainHealId = abilityId("Chain Heal");
expect(useCombatStore.getState().castAbility(tidalForceId, undefined, 1_000).ok).toBe(true);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.targetId === PLAYER_AGGRO_ID
&& candidate.definition.utilityTags?.includes("wow335-tidal-force")
));
expect(aura?.stacks).toBe(3);
expect(aura?.definition.durationMs).toBe(2_000);
expect(useCombatStore.getState().castAbility(flameShockId, undefined, 1_001).ok).toBe(true);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.kind === "damage" && event.abilityId === flameShockId && event.critical
))).toBe(true);
expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura?.instanceId)?.stacks)
.toBe(3);
const now = 2_502;
expect(useCombatStore.getState().castAbility(natureSwiftnessId, undefined, now).ok).toBe(true);
const instantHeal = useCombatStore.getState().castAbility(chainHealId, undefined, now + 1);
expect(instantHeal.ok, JSON.stringify(instantHeal)).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
const criticalChainHeals = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === chainHealId && event.critical
));
expect(criticalChainHeals.length).toBeGreaterThanOrEqual(3);
expect(useCombatStore.getState().auras.some((candidate) => (
candidate.definition.utilityTags?.includes("wow335-tidal-force")
))).toBe(false);
});
});
+29
View File
@@ -79,10 +79,39 @@ function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon;
</group>;
}
function LightwellMember({ summon }: { readonly summon: CombatSummon }) {
const click = (event: { stopPropagation: () => void }) => {
event.stopPropagation();
useCombatStore.getState().clickLightwell(summon.id);
};
return <group name={`lightwell:${summon.id}`} position={summon.position}>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.025, 0]} onPointerDown={click}>
<ringGeometry args={[0.35, 0.52, 24]} />
<meshBasicMaterial color="#c7a76a" transparent opacity={0.28} depthWrite={false} />
</mesh>
<mesh position={[0, 0.16, 0]} onPointerDown={click}>
<cylinderGeometry args={[0.24, 0.3, 0.24, 12]} />
<meshStandardMaterial color="#786b53" roughness={0.8} />
</mesh>
<mesh position={[0, 0.46, 0]} onPointerDown={click}>
<cylinderGeometry args={[0.075, 0.11, 0.42, 10]} />
<meshStandardMaterial color="#9f8e6c" roughness={0.72} />
</mesh>
<mesh position={[0, 0.76, 0]} onPointerDown={click}>
<sphereGeometry args={[0.17, 12, 10]} />
<meshStandardMaterial color="#ddca9b" emissive="#9a7541" emissiveIntensity={0.32} roughness={0.5} />
</mesh>
<pointLight position={[0, 0.76, 0]} color="#c7a76a" intensity={0.22} distance={1.6} />
</group>;
}
/** Player and companion combat summons live outside the static dungeon population. */
export function CombatSummonPopulation({ active }: { readonly active: boolean }) {
const summons = useCombatStore((state) => state.summons);
return <group name="combat-summon-population">
{summons.filter((summon) => summon.sourceAbilityId === "wow335-priest-lightwell"
&& summon.clickCharges && summon.health > 0)
.map((summon) => <LightwellMember key={summon.id} summon={summon} />)}
{summons.filter((summon) => summon.creatureId === SHADOWFIEND_CREATURE_ID && summon.health > 0)
.map((summon) => <ShadowfiendMember key={summon.id} summon={summon} active={active} />)}
</group>;
+3
View File
@@ -11,6 +11,7 @@ import {
import { Canvas, useThree } from "@react-three/fiber";
import { BiteSizedEditorMarkers } from "./BiteSizedEditorMarkers";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
import { focusBiteSizedEditorScene } from "../game/biteSizedEditorInput";
import { Physics } from "@react-three/rapier";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { spawnMatchesDifficulty } from "../game/mobPopulation";
@@ -321,6 +322,8 @@ export function GameScene() {
return (
<div
className="scene-shell"
tabIndex={-1}
onPointerDownCapture={() => { if (useBiteSizedLayoutsStore.getState().editorMode) focusBiteSizedEditorScene(); }}
onPointerDown={beginMouseLook}
onLostPointerCapture={endMouseLook}
onContextMenu={(event) => event.preventDefault()}
+1 -1
View File
@@ -833,7 +833,7 @@ function LoadedOriginalCombatEffect({
{graph.kind === "runewaker-ros-particle-archive" ? (
<RunewakerParticleEffect graph={graph} textures={item.sourceTextures}
phase={event.phase} quality={quality} active={active} eventId={event.id}
playbackRef={staticMeshPlaybackRef} />
playbackRef={staticMeshPlaybackRef} opacity={tuning.opacity} />
) : graph.kind === "wow-m2-static-mesh" ? (
<OriginalStaticMesh
graph={graph}
+15 -11
View File
@@ -156,7 +156,7 @@ function spawn(emitter: RuneEmitterController, position: RuneVector3,
}
const RuneEmitter = memo(function RuneEmitter({
graph, phase, layer, texture, budget, active, eventId, timelineRef,
graph, phase, layer, texture, budget, active, eventId, timelineRef, opacity,
}: {
readonly graph: RunewakerParticleGraph;
readonly phase: CombatPresentationPhase;
@@ -166,6 +166,7 @@ const RuneEmitter = memo(function RuneEmitter({
readonly active: boolean;
readonly eventId: number;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly opacity: number;
}) {
const system = useRef({ particles: Array<Particle | null>(budget).fill(null), debt: 0, births: 0, lastTime: 0 });
const geometry = useMemo(() => {
@@ -261,7 +262,7 @@ const RuneEmitter = memo(function RuneEmitter({
particle.origin[1] + particle.velocity[1] * age + particle.acceleration[1] * ageSquared,
particle.origin[2] + particle.velocity[2] * age + particle.acceleration[2] * ageSquared);
colors.setXYZW(index, color[0], color[1], color[2],
Math.max(0, Math.min(1, alpha * color[3] * globalAlpha)));
Math.max(0, Math.min(1, alpha * color[3] * globalAlpha * opacity)));
sizes.setXY(index, Math.max(0.001, scale * layer.sprite.quadScale[0]),
Math.max(0.001, scale * layer.sprite.quadScale[1]));
rotations.setX(index, layer.sprite.orientToTravel
@@ -276,7 +277,7 @@ const RuneEmitter = memo(function RuneEmitter({
});
const RuneEntityEmitter = memo(function RuneEntityEmitter({
graph, emitter, visualizer, texture, budget, active, eventId, timelineRef,
graph, emitter, visualizer, texture, budget, active, eventId, timelineRef, opacity,
}: {
readonly graph: RunewakerParticleGraph;
readonly emitter: RuneEmitterController;
@@ -286,6 +287,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({
readonly active: boolean;
readonly eventId: number;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly opacity: number;
}) {
const system = useRef({ particles: Array<Particle | null>(budget).fill(null), debt: 0, births: 0, lastTime: 0 });
const pool = useMemo(() => {
@@ -365,7 +367,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({
mesh.scale.setScalar(Math.max(0.001, scale));
mesh.rotation.y = next.orientation;
mesh.material.color.setRGB(color[0], color[1], color[2]);
mesh.material.opacity = Math.max(0, Math.min(1, alpha * color[3]));
mesh.material.opacity = Math.max(0, Math.min(1, alpha * color[3] * opacity));
mesh.visible = scale > 0.001 && mesh.material.opacity > 0.002;
}
});
@@ -376,7 +378,7 @@ const RuneEntityEmitter = memo(function RuneEntityEmitter({
});
const RuneGroundDecal = memo(function RuneGroundDecal({
decal, alpha, color, texture, active, timelineRef,
decal, alpha, color, texture, active, timelineRef, opacity,
}: {
readonly decal: RuneDecalController;
readonly alpha: RuneGlobalAlphaController;
@@ -384,6 +386,7 @@ const RuneGroundDecal = memo(function RuneGroundDecal({
readonly texture: Texture;
readonly active: boolean;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly opacity: number;
}) {
const meshRef = useRef<Mesh<PlaneGeometry, MeshBasicMaterial>>(null);
const geometry = useMemo(() => new PlaneGeometry(2, 2), []);
@@ -405,10 +408,10 @@ const RuneGroundDecal = memo(function RuneGroundDecal({
const time = timelineRef.current.elapsedMs / 1_000;
const radius = runeSample(decal.tracks[2], time, 0);
const rotation = runeSample(decal.tracks[1], time, 0);
const opacity = runeSample(alpha.alphaTrack, Math.max(0, time - alpha.startTime), 0);
const sourceOpacity = runeSample(alpha.alphaTrack, Math.max(0, time - alpha.startTime), 0);
const tint = runeSample(color.tracks[0], time, [1, 1, 1, 1]);
material.color.setRGB(tint[0], tint[1], tint[2]);
material.opacity = Math.max(0, Math.min(1, opacity * tint[3]));
material.opacity = Math.max(0, Math.min(1, sourceOpacity * tint[3] * opacity));
mesh.scale.set(radius, radius, 1);
mesh.rotation.z = rotation;
mesh.visible = time <= alpha.startTime + alpha.duration && material.opacity > 0.002;
@@ -421,7 +424,7 @@ const RuneGroundDecal = memo(function RuneGroundDecal({
});
export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
graph, textures, phase, quality, active, eventId, playbackRef,
graph, textures, phase, quality, active, eventId, playbackRef, opacity = 1,
}: {
readonly graph: RunewakerParticleGraph;
readonly textures: ReadonlyMap<string, Texture>;
@@ -430,6 +433,7 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
readonly active: boolean;
readonly eventId: number;
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly opacity?: number;
}) {
const layers = useMemo(() => runeLayers(graph, phase), [graph, phase]);
const motion = useMemo(() => runeMotion(graph, phase), [graph, phase]);
@@ -457,18 +461,18 @@ export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
const budget = Math.max(1, Math.min(sourceMaximum, Math.floor(limit / Math.max(1, layers.length))));
return <RuneEmitter key={`${layer.sprite.anchor}:${index}`} graph={graph} phase={phase}
layer={layer} texture={texture}
budget={budget} active={active} eventId={eventId + index * 271}
budget={budget} active={active} eventId={eventId + index * 271} opacity={opacity}
timelineRef={playbackRef} />;
})}
{graph.runeMesh && runeEntity && runeEmitter && textures.get(graph.runeMesh.textureAssetUrl)
? <RuneEntityEmitter graph={graph} emitter={runeEmitter} visualizer={runeEntity}
texture={textures.get(graph.runeMesh.textureAssetUrl)!}
budget={quality === "high" ? 12 : 6} active={active} eventId={eventId + 617}
budget={quality === "high" ? 12 : 6} active={active} eventId={eventId + 617} opacity={opacity}
timelineRef={playbackRef} />
: null}
{decal && decalAlpha && decalColor && textures.get(decal.textureAssetUrl)
? <RuneGroundDecal decal={decal} alpha={decalAlpha} color={decalColor}
texture={textures.get(decal.textureAssetUrl)!} active={active} timelineRef={playbackRef} />
texture={textures.get(decal.textureAssetUrl)!} active={active} opacity={opacity} timelineRef={playbackRef} />
: null}
</group>;
});
+28
View File
@@ -50,6 +50,22 @@ function enqueue(
}
describe("combat-effect runtime budget", () => {
it("softens Flash Heal's impact without shrinking its cast visual", () => {
for (const abilityId of ["priest-flash-heal", "wow335-priest-flash-heal"]) {
expect(combatEffectVisualTuning(event(1, { abilityId })))
.toEqual({ scale: 0.38, opacity: 0.22 });
expect(combatEffectVisualTuning(event(2, { abilityId, phase: "cast-start" })))
.toEqual({ scale: 1, opacity: 1 });
}
});
it("keeps Arcane Explosion's large cast sphere readable", () => {
expect(combatEffectVisualTuning(event(1, { abilityId: "wow335-mage-arcane-explosion", phase: "cast-start" })))
.toEqual({ scale: 0.65, opacity: 0.5 });
expect(combatEffectVisualTuning(event(2, { abilityId: "wow335-mage-arcane-explosion", phase: "impact" })))
.toEqual({ scale: 1, opacity: 1 });
});
it("contains the Dispel Magic impact without shrinking Mass Dispel", () => {
expect(combatEffectVisualTuning(event(1, { abilityId: "wow335-priest-dispel-magic" })))
.toEqual({ scale: 0.45, opacity: 0.4 });
@@ -59,6 +75,18 @@ describe("combat-effect runtime budget", () => {
.toEqual({ scale: 1, opacity: 1 });
});
it("softens the RoM Mage electric impacts without changing cast cues", () => {
for (const [sourceSpellId, expected] of [
[491168, { scale: 0.45, opacity: 0.32 }],
[491169, { scale: 0.6, opacity: 0.45 }],
] as const) {
expect(combatEffectVisualTuning(event(1, { source: "runewaker", sourceSpellId })))
.toEqual(expected);
expect(combatEffectVisualTuning(event(2, { source: "runewaker", sourceSpellId, phase: "cast-start" })))
.toEqual({ scale: 1, opacity: 1 });
}
});
it("delays source-backed target impacts until their projectiles arrive", () => {
const release = event(1, { source: "runewaker", sourceSpellId: 490256,
phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] });
+12 -3
View File
@@ -17,13 +17,22 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
high: 32,
};
/** Keep the single-target Dispel Magic flash within the target silhouette. */
/** Keep bright single-target Priest impacts within the target silhouette. */
export function combatEffectVisualTuning(
event: Pick<CombatPresentationEvent, "abilityId" | "phase">,
event: Pick<CombatPresentationEvent, "abilityId" | "phase" | "source" | "sourceSpellId">,
): { readonly scale: number; readonly opacity: number } {
if ((event.abilityId === "priest-flash-heal" || event.abilityId === "wow335-priest-flash-heal")
&& event.phase === "impact") {
return { scale: 0.6, opacity: 0.4 };
return { scale: 0.38, opacity: 0.22 };
}
if (event.abilityId === "wow335-mage-arcane-explosion" && event.phase === "cast-start") {
return { scale: 0.65, opacity: 0.5 };
}
if (event.source === "runewaker" && event.phase === "impact" && event.sourceSpellId === 491168) {
return { scale: 0.45, opacity: 0.32 };
}
if (event.source === "runewaker" && event.phase === "impact" && event.sourceSpellId === 491169) {
return { scale: 0.6, opacity: 0.45 };
}
return event.abilityId === "wow335-priest-dispel-magic" && event.phase === "impact"
? { scale: 0.45, opacity: 0.4 }
+2
View File
@@ -9,6 +9,7 @@ import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
import { validateChangedBiteSizedSpots } from "../game/biteSizedLayoutValidation";
import { FrontSurface } from "./FrontSurface";
import { releasePointerLock } from "../game/inputManager";
import { focusBiteSizedEditorScene } from "../game/biteSizedEditorInput";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import "./biteSizedEditor.css";
@@ -96,6 +97,7 @@ export function BiteSizedEditor({ inGame = false }: { readonly inGame?: boolean
<button onClick={() => inGame ? leaveBiteSizedEditor() : useShellStore.getState().returnToMainMenu()}>{inGame ? "Spot browser" : "Main menu"}</button></header>
{!collapsed && <div className="bite-sized-editor__body">
<p className="bite-sized-editor__status">{layouts.dirty ? "Unsaved draft" : `Published revision ${layouts.revision}`} · {layouts.editorMode === "test" ? `${testComplete ? "Test complete" : "Test run"} — rewards disabled` : inGame ? "Combat disabled · walk to place points" : "Choose a spot to visit"}</p>
{inGame && <div><button onClick={focusBiteSizedEditorScene}>Walk in scene</button><p className="bite-sized-editor__hint">Click the world or Walk in scene, then use WASD. Hold right mouse to look; Space jumps. Panel controls pause movement.</p></div>}
{!inGame && <label>Dungeon<select aria-label="Dungeon" value={activeDungeon} onChange={(e) => { setDungeonId(e.target.value); useBiteSizedLayoutsStore.setState({ editingSpotId: null }); }}>{BITE_SIZED_LAYOUT_CATALOG.dungeons.map((d) => <option key={d.id} value={d.id}>{d.title}</option>)}</select></label>}
<label>Spot<select aria-label="Spot" value={spot?.id ?? ""} disabled={busy} onChange={(e) => { select(e.target.value); if (inGame) void attempt(() => enterBiteSizedEditorSpot(e.target.value)); }}>{spots.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</select></label>
{spot && <>