From 4c768fbec69c25eb37f40de9bfde4d2e32058c3e Mon Sep 17 00:00:00 2001
From: phenom
Date: Wed, 30 Sep 2026 13:25:55 -0400
Subject: [PATCH] Update Healer Man server
---
docs/ACTIVE_CLASS_AUDIT.md | 11 +-
docs/ACTIVE_CLASS_MECHANICS_BACKLOG.md | 122 ++++++++++++++++++
src/app/accountRepository.test.ts | 38 ++++++
src/app/accountRepository.ts | 15 ++-
src/app/mainMenuCharacterEditing.test.ts | 28 ++++
src/app/mainMenuCharacterEditing.ts | 7 +-
src/app/shellStore.ts | 5 +-
src/app/types.ts | 2 +
src/game/CombatBridge.tsx | 3 +-
src/game/abilityCatalog.ts | 2 +-
src/game/combatStore.ts | 67 +++++++++-
src/game/partyRuntime.test.ts | 69 +++++++++-
src/game/partyRuntime.ts | 40 +++++-
src/game/partyStore.test.ts | 25 ++++
src/game/partyStore.ts | 7 +
src/game/wow335AbilityCatalog.test.ts | 29 +++++
src/game/wow335AbilityCatalog.ts | 33 +++++
src/game/wow335DruidRuntime.test.ts | 14 ++
src/game/wow335GlyphCatalog.ts | 2 +
...ShamanMaelstromWeaponCombatRuntime.test.ts | 79 ++++++++++++
src/styles.css | 6 +
src/ui/TalentGameplayPanel.tsx | 4 +
src/ui/TalentsPanel.test.ts | 43 ++++++
src/ui/TalentsPanel.tsx | 25 ++++
24 files changed, 654 insertions(+), 22 deletions(-)
create mode 100644 docs/ACTIVE_CLASS_MECHANICS_BACKLOG.md
create mode 100644 src/game/wow335GlyphCatalog.ts
create mode 100644 src/ui/TalentsPanel.test.ts
diff --git a/docs/ACTIVE_CLASS_AUDIT.md b/docs/ACTIVE_CLASS_AUDIT.md
index 9be83b7d..a252d648 100644
--- a/docs/ACTIVE_CLASS_AUDIT.md
+++ b/docs/ACTIVE_CLASS_AUDIT.md
@@ -60,7 +60,8 @@ Manifest coverage means an effect mapping exists; a graph means an original effe
- 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 preserves Bestial Wrath's cooldown exception. Bestial Wrath requires an active pet and places its source-row 19574 effects on that pet. The Beast Within (34471) now procs only on Bestial Wrath at its generated rank's 50%/100% chance and uses the generated tooltip's six seconds, +15% damage, and -50% mana cost; the populated generated talent entry takes precedence over the conflicting extracted spell row.
- WoW Priest Prayer of Mending ranks 33076/48112/48113 now heal for the extracted base values (800/905/1043), spend one of five damage-taken charges per activation, and transfer to the most injured eligible party member within 40 yards and line of sight. This models five total heals, with up to four transfers; unrelated outgoing damage does not spend a charge.
-- WoW Holy Shock retains the player button and both source heal/damage payloads; Death Grip pulls and taunts; Pestilence spreads the caster's two diseases; Chains of Ice keeps its slow without a bogus root; Life Tap and Create Healthstone no longer produce false heals; Swiftmend prefers a caster-owned eligible Rejuvenation/Regrowth or otherwise the shortest remaining one, heals exactly 4/6 ticks, and consumes that HoT. Glyph of Swiftmend state is not represented, so glyph preservation is unsupported.
+- WoW 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 and heals exactly 4/6 ticks. The player can configure Glyph of Swiftmend and preserve that HoT; explicitly configured party Druids can also carry the glyph, while generated companions remain unglyphed.
+- WoW Shaman Maelstrom Weapon now receives each landed player autoattack and Stormstrike hand's own weapon speed for its proc roll. Stormstrike uses the generated main-hand/off-hand children and skips its off-hand strike when no off-hand weapon is equipped. Companion Maelstrom stacks and hand-specific swings remain 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.
@@ -137,12 +138,12 @@ These are specific, source-backed gaps that still need implementation or a gamep
- **Hunter:** Freezing Arrow, Freezing Trap, Frost Trap, Immolation Trap, and Explosive Trap now have armed-trap runtime behavior for players and companions. Local DBC create-trap rows supply placement lifetime and trigger radius; Freezing Trap uses its local child-spell freeze duration by rank (10/15/20 seconds), and damage breaks the freeze. Companion self-placed traps are recorded at the Hunter's current position and retain their owner and learned rank; Frost Trap uses that owner's Entrapment rank, and Immolation/Explosive Trap damage uses owner level, gear, and talent values captured when the trap triggers. Frost Trap's local aura row supplies a 10-yard, 50% slow zone lasting 30 seconds with a two-second pulse; Entrapment uses generated rank slow bonuses (4/10/15%), local rank proc values (2/4/6%), and its three-second root child. Immolation Trap uses its local child-rank periodic values, five fire ticks every three seconds, and the missing numeric 2% ranged-attack-power-per-tick coefficient from [WotLKDB](https://wotlkdb.com/). Intimidation stores and consumes its next-melee proc on the pet. Beast Within follows the generated rank tooltip (50/100% proc on Bestial Wrath, six-second damage/mana riders) instead of conflicting extracted child amounts. Kill Command retains its generated 30-second pet aura, but its starting damage percentage remains unavailable: the generated tooltip says “listed amount” and the [WotLKDB row](https://wotlkdb.com/?spell=34026) exposes only a server-side dummy effect. The source's 1%-per-special-attack decay also awaits a pet-special event path. Ordinary melee hits do not consume that aura; no 60-stack or damage amount is invented.
- **Priest:** Desperate Prayer, allied Dispel Magic, party buffs, Pain Suppression threat reduction, Prayer of Mending, Divine Aegis, Surge of Light, Serendipity, Shadow Weaving, Holy Concentration, Test of Faith, Lightwell's rank-specific six-second click heal, and Pain and Suffering have focused runtime behavior. Pain and Suffering uses its generated 33/66/100% proc ranks after Mind Flay, Mind Sear, or Mind Blast and refreshes only the caster's Shadow Word: Pain on that target; the player and companion cast paths preserve the DoT payload. Spell Warding now reduces direct and periodic magic damage by its generated 1/2/3% ranks and leaves physical hits unchanged. Lightwell's generated 30%-of-max-health damage cancellation is implemented per recipient using effective HP damage; its visual remains unreviewed in live gameplay. The rank-6 Renew child (48085) has no local spell-power coefficient row. WotLKDB sibling Renew rows (including [spell 28276](https://wotlkdb.com/index.php?spell=28276)) list 62.6% per tick, while the local/AzerothCore coefficient used for the first five ranks is 33.33%; the exact rank-6 WotLKDB row was unavailable, so no sibling coefficient was inferred.
- **Death Knight:** Death Coil's friendly Undead heal, Death Strike disease-count self-heal, Anti-Magic Shell and Zone absorbs, Anti-Magic Shell Runic Power from actual absorbed magic damage, Bone Shield charges, Blood Tap rune restriction, Deathchill, Improved Rune Tap, Butchery's eligible-kill Runic Power, Rime's generated 5/10/15% Obliterate proc (Howling Blast cooldown reset and one persistent, one-use rune-free Howling Blast), Mark of Blood's generated 4%-max-health heal on landed damage (20 charges, 20-second duration), Scent of Blood, and Sudden Doom's generated 5/10/15% chance after landed Blood Strike/Heart Strike to immediately dispatch one free Death Coil at the same target have focused fixes/tests. Scent of Blood uses generated 15% proc chance after dodge, parry, or direct damage; ranks 1/2/3 grant that many melee-hit charges, each restoring 10 Runic Power. Player and party incoming-event/landed-melee paths are covered, one actual hit consumes only one charge even when the combat event has multiple damage packets, Runic Power respects its cap, and child 50421 expires after 20 seconds. Its trigger rank page [49509 on WotLKDB](https://wotlkdb.com/?spell=49509) shows the 15% proc links but no duration; the missing child duration comes from the matching 3.3.5 [spell 50421 row at WoWRoad](https://wowroad.info/?spell=50421), corroborated by [DVG's 80-level WLK record](https://80.dvg.cn/?spell=50421). Sudden Doom is covered for player and party Death Knights; the triggered player cast preserves the GCD and waives Runic Power, while party effect execution skips a second ability-use commit. Blood-Caked Blade and other conditional/pet/disease talents remain outside this pass. Populated local Hysteria talent and class spell rows disagree on its physical damage bonus (15% vs. 20%), and Dancing Rune Weapon rows disagree on the reduction to its duplicate attacks (50% vs. 10%); these conflicts remain unresolved rather than choosing one value. Spell visuals have not received a rendered class pass.
-- **Rogue:** stealth/dagger gates, five-assault Killing Spree (both weapons per assault), Shadowstep, Pick Pocket feedback, Riposte readiness, Unfair Advantage's rank 1/2 50/100% main-hand weapon-damage retaliation for player and party Rogues after dodge or parry, with a per-Rogue two-second limit, and future-only Tricks threat routing are covered. Initiative, Ruthlessness, and rank-3 Honor Among Thieves have proc hooks; Honor Among Thieves uses the Rogue's own landed direct-damage casts with a three-second limit. Shadow Walker also enables combat Sneak Attack and applies its sourced cost/cooldown changes only to the Rogue/Priest pair. Combat Potency now runs on distinct off-hand swings: ranks 1/2/3 use the local 33/66/100% chances for 5 Energy, and rank 3 reduces active Blade Flurry and Killing Spree cooldowns by one second per landed off-hand hit. Improved Kick's max-rank capstone grants 40 Energy and 35% attack speed for six seconds only after a successful interrupt, for player and party Rogues; the talent's movement-speed cap rider is not modeled because enemies have no movement-speed bonus state. The catalog has no Combat Readiness ability to receive the named reduction. Pick Pocket still has no loot/currency result model.
-- **Shaman:** Nature's Swiftness, Tidal Force, Healing Stream Totem, Shamanistic Rage, Ancestral Awakening, Ancestral Healing, Shamanism, rank-scaled Reverberation, Maelstrom Weapon, Elemental Oath, Improved Water Shield, Tidal Waves, and Riptide's Chain Heal rider have focused player and party behavior. Convection now applies its generated 3/6/9/12/15% mana-cost reduction to each modeled listed spell through explicit ability scopes; the source-listed Earthquake has no local Shaman ability row. Improved Water Shield restores the rank-scaled Water Shield orb mana on eligible critical Nature heals (double chance for Healing Wave); the generated talent ranks remain authoritative. Tidal Waves grants three Chain Heal or two Riptide charges at the generated rank chance; charges reduce Healing Wave cast time or add Lesser Healing Wave critical chance, and its 4/8/12% spell-power coefficient applies to both heals. Riptide's 25% Chain Heal increase applies only to the caster's matching active Riptide HoT on Chain Heal's primary target, which is then consumed. WotLKDB child 53390 fills only Tidal Waves' missing 15-second duration. Elemental Oath (`talent-2049`) adds its generated 1/2/3 percentage points once to Shaman and party spell-damage and healing critical chance; melee and ranged critical chance are unchanged. Reverberation follows its generated rank tooltip: a successful Shock has the rank's 33/66/100% chance to arm an eight-second, 20% bonus for the next different Shock or Elemental Blast; linked child 965977 is missing, but the generated tooltip contains the implemented mechanic. Elemental Focus grants rank-scaled Clearcasting with two discounted casts and expires after 15 seconds, matching patch-D child spell 16246 and SpellDuration row 8; its focused runtime test covers expiry. Maelstrom Weapon follows its generated eligible-spell list, 20% cast-time reduction per stack, five-stack cap, and whole-bundle next-spell consumption. WotLKDB fills the absent 4/8/12 procs-per-minute rank rates and 30-second duration; the per-hit chance scales with the represented weapon speed. The combat path does not identify which hand hit for ordinary autoattacks or Stormstrike, so those use main-hand speed. Fireball/Frostbolt are absent from the Shaman catalog, and its 2%-per-stack PvP damage reduction has no PvP runtime path. Other passives still need event-model coverage; no exhaustive class runtime or rendered VFX pass is claimed.
+- **Rogue:** stealth/dagger gates, five-assault Killing Spree (both weapons per assault), Shadowstep, Riposte readiness, Unfair Advantage's rank 1/2 50/100% main-hand weapon-damage retaliation for player and party Rogues after dodge or parry, with a per-Rogue two-second limit, and future-only Tricks threat routing are covered. Initiative, Ruthlessness, and rank-3 Honor Among Thieves have proc hooks; Honor Among Thieves uses the Rogue's own landed direct-damage casts with a three-second limit. Shadow Walker also enables combat Sneak Attack and applies its sourced cost/cooldown changes only to the Rogue/Priest pair. Combat Potency now runs on distinct off-hand swings: ranks 1/2/3 use the local 33/66/100% chances for 5 Energy, and rank 3 reduces active Blade Flurry and Killing Spree cooldowns by one second per landed off-hand hit. Improved Kick's max-rank capstone grants 40 Energy and 35% attack speed for six seconds only after a successful interrupt, for player and party Rogues; the talent's movement-speed cap rider is not modeled because enemies have no movement-speed bonus state. The catalog has no Combat Readiness ability to receive the named reduction. Pick Pocket remains excluded from this mechanics pass.
+- **Shaman:** Nature's Swiftness, Tidal Force, Healing Stream Totem, Shamanistic Rage, Ancestral Awakening, Ancestral Healing, Shamanism, rank-scaled Reverberation, Maelstrom Weapon, Elemental Oath, Improved Water Shield, Tidal Waves, and Riptide's Chain Heal rider have focused behavior. Convection now applies its generated 3/6/9/12/15% mana-cost reduction to each modeled listed spell through explicit ability scopes; the source-listed Earthquake has no local Shaman ability row. Improved Water Shield restores the rank-scaled Water Shield orb mana on eligible critical Nature heals (double chance for Healing Wave); the generated talent ranks remain authoritative. Tidal Waves grants three Chain Heal or two Riptide charges at the generated rank chance; charges reduce Healing Wave cast time or add Lesser Healing Wave critical chance, and its 4/8/12% spell-power coefficient applies to both heals. Riptide's 25% Chain Heal increase applies only to the caster's matching active Riptide HoT on Chain Heal's primary target, which is then consumed. WotLKDB child 53390 fills only Tidal Waves' missing 15-second duration. Elemental Oath (`talent-2049`) adds its generated 1/2/3 percentage points once to Shaman and party spell-damage and healing critical chance; melee and ranged critical chance are unchanged. Reverberation follows its generated rank tooltip: a successful Shock has the rank's 33/66/100% chance to arm an eight-second, 20% bonus for the next different Shock or Elemental Blast; linked child 965977 is missing, but the generated tooltip contains the implemented mechanic. Elemental Focus grants rank-scaled Clearcasting with two discounted casts and expires after 15 seconds, matching patch-D child spell 16246 and SpellDuration row 8; its focused runtime test covers expiry. Player Maelstrom Weapon follows its generated eligible-spell list, 20% cast-time reduction per stack, five-stack cap, and whole-bundle next-spell consumption. WotLKDB fills the absent 4/8/12 procs-per-minute rank rates and 30-second duration; landed player autoattacks and both generated Stormstrike weapon-hand children now use the attacking weapon's speed. Companion Maelstrom procs and per-hand attacks are not modeled. Fireball/Frostbolt are absent from the Shaman catalog, and its 2%-per-stack PvP damage reduction has no PvP runtime path. Other passives still need event-model coverage; no exhaustive class runtime or rendered VFX pass is claimed.
- **Paladin:** Redemption, Reckoning's rank-based proc and three-hit charge window, Eye for an Eye reflection/cap, Divine Storm healing, Beacon transfer, Greater Blessing fanout, Hammer of Wrath health gate, Holy Shield's shield requirement and eight source-defined block charges, Divine Sacrifice redirect cap, aura parsing, Aura Master's 10-second silence and interrupt immunity, Avenger's Shield's three-target/no-falloff chain, Redoubt, and Vindication have targeted fixes or runtime coverage. Redoubt follows generated ranks and requires a shield; its 10% block proc grants rank-scaled Strength and damage reduction. Vindication uses generated 17/34/65% rank chances and the local `4.5 × caster level − 3` attack-power reduction; WotLKDB child 67 supplies only the missing 10-second duration. The source-owned nonstacking debuff reduces the attack-power-derived portion of modeled enemy melee damage. Its capstone attack-power gain remains unresolved because the generated tooltip does not specify its amount or exact operation. Aura Master's amplification and active/UI duration/scope discrepancy also remain open.
- **Mage:** Blink root/stun clearing, Improved Scorch, Fingers of Frost, Hot Streak, Arcane Concentration/Clearcasting, Brain Freeze, Missile Barrage, active-row Impact's next-Fire-Blast stun, and Impact's 3/6/9% damage scope on Fire Blast and Meteor have focused coverage. Clearcasting follows generated 5/10/15% proc ranks, spends on the next successful Mage ability, and expires after 15 seconds (duration filled from WotLKDB). Brain Freeze follows its generated trigger ranks and applies a 15-second one-use proc that makes the next Fireball or Frostfire Bolt instant, free of Mana, and 25% stronger; Frozen Orb is absent from the learnable local catalog. Missile Barrage reads per-spell chances from its generated rank descriptions, triggers on landed direct damage from available listed spells or a successfully applied Slow debuff, and makes the next Arcane Missiles channel half as long with half-spaced pulses and no Mana cost; missing child duration comes from WotLKDB's 15-second row. Empowered Fire restores 2% of the caster's local level-based base Mana per positive Ignite tick, respects that caster's Mana cap, and grants rank 3 Living Bomb periodic criticals; its generated 7/14/20% coefficient increase is applied only to Fireball, Pyroblast, and Living Bomb for player and party Mages. Flame Throwing adds the generated 2/4/6 yards to hostile damaging-spell range without expanding healing range or AoE radius. Torment the Weak applies its generated 3/6/9% bonus only to Fireball, Frostbolt, Frostfire Bolt, Pyroblast, Arcane Missiles, Arcane Blast, and Arcane Barrage against a target with an active movement- or attack-speed slow; player and party direct/channel paths are covered. Improved Counterspell applies the generated 4/7/10-second cooldown reduction only to Counterspell and adds its generated two-second silence only after it interrupts an active cast; player and party paths are covered. Frostbite uses its generated 5/10/15% proc chance and four-second freeze for player Frost damage and now also party direct Frost hits and generated Blizzard channel pulses, using actual landed positive damage. Non-player Frost timed/area damage events have an owner-aware hook, but no learnable pure-Frost Mage ground effect currently reaches the area-tick path; no synthetic spell was added. The player path remains on the existing shared damage hook. These mechanics use generated local ranks, so no WotLKDB lookup was needed. Molten Fury suppresses its generic always-on scalar and applies the generated 6%/12% Fire, Frost, or Arcane damage bonus only below 35% target health for player direct/periodic/area damage and party direct/periodic damage; party direct damage includes same-event Sunder bonuses before multiplication, and the threshold is reevaluated per hit or tick. Arcane Orb, Moonfire, Starfire, and Starsurge are tooltip-listed but absent from the Mage catalog. The active generated Impact row (4% proc, 3-second stun) takes precedence over the legacy DBC row. Focus Magic runtime coverage applies +3% spell critical strike chance to the recipient using the generated class spell row (54646) and grants the Mage +3% for 10 seconds after that recipient lands a spell critical; the generated talent-node tooltip instead says the recipient receives +1%, so the +3% recipient value remains a documented source/UI conflict. Forward Blink movement remains unsupported while player facing is absent.
- **Warlock:** Conflagrate consumption, Shadowflame periodic damage, Seed of Corruption threshold/death explosion, Curse of Agony ramp, Soulshatter, Fel Synergy, Soul Leech healing and its generated 2-AP/10-second target debuff, Eradication, crit-gated Improved Shadow Bolt, Backlash's rank-based proc/cast rider, Death's Grasp's rank-scoped hit/haste effects, Nightfall, Metamorphosis, and RoM Ruthless Judgment's below-20% caster bonus now have focused coverage. The local talent row resolves child 900067's tooltip placeholders to −2 attack power for 10 seconds, although the child spell row itself is absent locally; WotLKDB's exact child URL is inaccessible and has no indexed search result. Its capstone text gives +30% crit after a physical hit while casting, but the generated ranks do not specify whether that rider is rank-gated or how the cast-scoped trigger is consumed, so it remains unresolved. Nightfall follows its generated rank chances, trigger list, next-ability scope, 25/50% mana discount, 20% Shadow Bolt damage rider, and five-second ICD; its missing 10-second aura duration comes from WotLKDB. Soul Link uses the populated talent tooltip rather than the conflicting dummy DBC row: it shares 6/8/10/12% of post-mitigation incoming damage with a controlled, living, in-range non-Imp demon. The transferred share bypasses absorbs and prevention, and damage beyond the demon's remaining health stays on the caster. Player, enemy-attack, and companion-target paths have tests.
-- **Druid:** Eclipse, Omen of Clarity (including successful overheal events), Nature's Grace, Living Seed (33/66/100% rank proc, 30% effective-crit-heal storage, 15-second duration, and bloom on the target's next landed direct hit), Primal Fury's rank-scaled Bear/Dire Bear Rage and Cat combo-point procs, the rank-scaled Improved Rejuvenation healing bonus, Savage Fury's generated 4/8/12% damage increase to Claw, Rake, Shred, Mangle (Cat), Mangle (Bear), and Maul, and Feral Instinct have source-backed behavior. Feral Instinct applies its 10/20/30% damage bonus only to Swipe (Bear), Swipe (Cat), and Ravage; its 1/2/3% Tiger's Fury damage and Enrage damage-taken bonuses are timed to those active buffs, and positive landed damage with Swipe or Ravage reduces both cooldowns by one second. Primal Fury excludes Cat auto-attacks, periodic ticks, and non-combo abilities and caps combo points at five. Living Seed covers modeled player, party, online, and summon recipients; Flourish and Lesser Healing Wave are named in the local tooltip but absent from the WoW Druid ability catalog. Glyph of Swiftmend state is not represented, so its HoT-preservation behavior remains unsupported.
+- **Druid:** Eclipse, Omen of Clarity (including successful overheal events), Nature's Grace, Living Seed (33/66/100% rank proc, 30% effective-crit-heal storage, 15-second duration, and bloom on the target's next landed direct hit), Primal Fury's rank-scaled Bear/Dire Bear Rage and Cat combo-point procs, the rank-scaled Improved Rejuvenation healing bonus, Savage Fury's generated 4/8/12% damage increase to Claw, Rake, Shred, Mangle (Cat), Mangle (Bear), and Maul, and Feral Instinct have source-backed behavior. Feral Instinct applies its 10/20/30% damage bonus only to Swipe (Bear), Swipe (Cat), and Ravage; its 1/2/3% Tiger's Fury damage and Enrage damage-taken bonuses are timed to those active buffs, and positive landed damage with Swipe or Ravage reduces both cooldowns by one second. Primal Fury excludes Cat auto-attacks, periodic ticks, and non-combo abilities and caps combo points at five. Living Seed covers modeled player, party, online, and summon recipients; Flourish and Lesser Healing Wave are named in the local tooltip but absent from the WoW Druid ability catalog. Glyph of Swiftmend is configurable for the player and explicitly configured party Druids; generated companions stay unglyphed by default, and glyph preservation applies only when active.
- **Warrior:** Battle Stance armor penetration, Battle/Commanding Shout party radius, Improved Heroic Strike/Execute Rage discounts, Improved Execute's below-20% gate, rank-scaled damage and conditional crit window, Unrelenting Assault's named ability scope, Bloodthirst/Sweeping Strikes, Improved Whirlwind's 1/2-second cooldown reduction, Blood Craze, Second Wind, and Anger Management have focused behavior tests. Anger Management grants 2/4/6 Rage per three seconds; Charge gains its 5/10/15 talent bonus atop local rank Rage and rank three applies four seconds of 30% damage reduction. Blood Craze and Second Wind follow their generated rank tooltips for proc triggers and base-health healing, using WotLKDB only for missing periodic cadence details. Unbridled Wrath remains unresolved: its generated rank descriptions say 2/4 additional Rage after landed Heroic Strike, Cleave, or auto-attack damage, but both generated rank-effect rows have `procChance: 0`; rank 1 triggers child 12964, whose [WotLKDB row](https://wotlkdb.com/?spell=12964) specifies only 1 Rage, while rank 2 references child 812964, for which no local or WotLKDB row is available. Thus both the trigger probability and the rank-2 payload are missing, and the rank-1 child amount conflicts with the generated tooltip. The proc remains unimplemented rather than guessed. Other passives/procs remain incomplete.
### RoM
@@ -172,4 +173,4 @@ These are specific, source-backed gaps that still need implementation or a gamep
## Focused test coverage
-The earlier consolidated class-related suite matched `^(wow335|rom|deathKnight|mageBlinkRuntime|wowDruid|wowNature|wowTidal|talentModifiers|combatAuras|combatStore)` plus `src/scene/combatEffectRuntime.test.ts`: **55 files and 711 tests passed**. The integrated focused pass for the prior phase passed **398 tests across 30 files**. The latest combined follow-up passed **125 tests across 12 files**, covering Mage Missile Barrage, Shaman Maelstrom Weapon, Warrior Blood Craze/Second Wind, Paladin Aura Mastery/Redoubt, and related regression coverage. This continuation's Priest/Warrior stance, Mage Flame Throwing/Empowered Fire, Warrior Anger Management/Charge, and party-runtime regressions passed **153 tests across 11 files**; Warlock Metamorphosis and talent regressions passed a further **39 tests across four files**. The latest focused pass for Soul Link, Champion Soul Forge, Priest party scopes, Disregard Danger, Master of Parry, and shared combat regressions passed **200 tests across five files**. The Hunter trap follow-up passed 23 tests across three files. The current combined Hunter, Warlock, Priest, Knight, and shared-combat regression run passed 270 tests across 14 files; the 8 GB TypeScript build and `git diff --check` passed, and `npm run release:check` passed for 105 changed paths. The Obsidian vault check passed in an earlier wave. The prior phase's `npm run build` passed; KTX2 found zero fallback textures and the environment-performance audit found no regressions. Vite emitted the existing dynamic-import and large-chunk warnings. These focused checks do not prove every ability/talent combination. The full `npm test` suite was not rerun; an earlier run had 15 failing files and 21 failed tests, including dungeon-count and balance expectation failures.
+The earlier consolidated class-related suite matched `^(wow335|rom|deathKnight|mageBlinkRuntime|wowDruid|wowNature|wowTidal|talentModifiers|combatAuras|combatStore)` plus `src/scene/combatEffectRuntime.test.ts`: **55 files and 711 tests passed**. The integrated focused pass for the prior phase passed **398 tests across 30 files**. The latest combined follow-up passed **125 tests across 12 files**, covering Mage Missile Barrage, Shaman Maelstrom Weapon, Warrior Blood Craze/Second Wind, Paladin Aura Mastery/Redoubt, and related regression coverage. This continuation's Priest/Warrior stance, Mage Flame Throwing/Empowered Fire, Warrior Anger Management/Charge, and party-runtime regressions passed **153 tests across 11 files**; Warlock Metamorphosis and talent regressions passed a further **39 tests across four files**. The latest focused pass for Soul Link, Champion Soul Forge, Priest party scopes, Disregard Danger, Master of Parry, and shared combat regressions passed **200 tests across five files**. The Hunter trap follow-up passed 23 tests across three files. The current combined Hunter, Warlock, Priest, Knight, and shared-combat regression run passed 270 tests across 14 files; the 8 GB TypeScript build and `git diff --check` passed, and `npm run release:check` passed for 105 changed paths. The current Glyph of Swiftmend, Shaman weapon-hand, and related account/catalog/party configuration regression run passed **151 tests across eight files**. This wave's 8 GB TypeScript/Vite production build passed; KTX2 found zero fallback textures and the environment-performance audit reported green with no regressions. Vite emitted existing dynamic-import/chunk-size warnings. `npm run release:check` passed for 24 changed paths, including untracked files. The Obsidian vault check passed in an earlier wave. These focused checks do not prove every ability/talent combination. The full `npm test` suite was not rerun; an earlier run had 15 failing files and 21 failed tests, including dungeon-count and balance expectation failures.
diff --git a/docs/ACTIVE_CLASS_MECHANICS_BACKLOG.md b/docs/ACTIVE_CLASS_MECHANICS_BACKLOG.md
new file mode 100644
index 00000000..5abb38b6
--- /dev/null
+++ b/docs/ACTIVE_CLASS_MECHANICS_BACKLOG.md
@@ -0,0 +1,122 @@
+# Active Class Mechanics Backlog
+
+Updated 2026-09-30. This document lists unresolved ability and talent mechanics for all ten WoW 3.3.5a classes and all ten RuneWaker primary classes. It excludes completed fixes and all visual or sound review. Some items need source confirmation or simulator support before they can be implemented; do not guess missing values. See [the full audit](ACTIVE_CLASS_AUDIT.md) for the supporting findings and source conflicts.
+
+## WoW 3.3.5a
+
+### Hunter
+
+- [ ] Resolve Kill Command's starting damage bonus from a matching source. Its generated tooltip leaves the amount unspecified, and the WotLKDB row is a server-side dummy effect.
+- [ ] Add a pet-special-attack event so Kill Command's source-described 1% damage decay can be applied per pet special attack.
+
+### Priest
+
+- [ ] Find the exact spell-power coefficient for Lightwell's rank-6 Renew child (48085). It has no local coefficient row; do not infer it from sibling ranks, whose listed coefficient differs from the local value used for ranks 1–5.
+
+### Death Knight
+
+- [ ] Continue the conditional, pet, and disease talent pass, including Blood-Caked Blade.
+- [ ] Resolve conflicting local values for Hysteria's physical damage bonus (15% versus 20%) and Dancing Rune Weapon's duplicate-attack damage reduction (50% versus 10%).
+
+### Rogue
+
+- [ ] Model the enemy movement-speed state needed for Improved Kick's movement-speed cap rider, or confirm that this effect is outside the simulator's supported scope.
+- [ ] Verify the Combat Readiness cooldown reference against the 3.3.5 catalog; the named ability is absent, so its intended receiver is unresolved.
+
+### Shaman
+
+- [ ] Review the remaining passive and proc talents for event-model coverage.
+- [ ] Confirm whether Convection's source-listed Earthquake reference has a corresponding Shaman ability row; one is absent from the local catalog.
+- [ ] Verify the absent Fireball and Frostbolt entries against the 3.3.5 Shaman source and class attribution before adding or mapping them.
+- [ ] Add a PvP damage path for the source-described 2%-per-stack damage reduction, if PvP targets are in scope.
+- [ ] Add companion Maelstrom Weapon procs, stack consumption, and hand-specific autoattacks/Stormstrike. Companion combat currently uses one generic weapon swing and has no Maelstrom stack path.
+
+### Paladin
+
+- [ ] Resolve Vindication's capstone attack-power gain amount and operation; the generated tooltip does not specify either.
+- [ ] Reconcile Aura Mastery's 10-second spell row with the 6-second talent tooltip and verify amplification scope for aura effects not represented by the current modifier model.
+
+### Mage
+
+- [ ] Resolve Focus Magic's recipient critical-chance conflict: class spell row 54646 says +3%, while the generated talent-node tooltip says +1%. Runtime currently follows the class spell row.
+- [ ] Verify Mage catalog references to Frozen Orb, Arcane Orb, Moonfire, Starfire, and Starsurge against the 3.3.5 source and class attribution before adding or remapping any entries.
+- [ ] Add player-facing direction state before implementing Blink's 20-yard forward movement.
+
+### Warlock
+
+- [ ] Resolve the capstone's +30% critical-chance rider after a physical hit while casting: rank gating and charge/consumption behavior are unspecified.
+- [ ] Find source-backed proc probabilities and event details for Breathing Control and the remaining Priest-pair passives. The hidden child 499869 and End of Thought's tick interval are also absent locally.
+- [ ] Reconcile the conflicting current CoA identities and values for Mind Transference, Deep Breathing Control, Divine Assistance, and related triggers against the local RuneWaker version before changing behavior.
+
+### Druid
+
+- [ ] Verify the local tooltip references to Flourish and Lesser Healing Wave against the 3.3.5 Druid catalog and class attribution before adding either ability.
+
+### Warrior
+
+- [ ] Resolve Unbridled Wrath's missing proc probability, rank-2 child 812964, and rank-1 amount conflict: the generated tooltip says 2/4 Rage, while WotLKDB child 12964 gives 1 Rage.
+- [ ] Continue the runtime pass over Warrior passives and procs not covered by the existing focused mechanics tests.
+
+## RuneWaker
+
+### Warrior
+
+- [ ] Find the matching local Cross Thorn Blow (494018) row and child 499956 before adding it as a Briar Technique trigger.
+- [ ] Reconcile Warrior/Knight Resist (493251)'s Blocking Stance reference: RuneWaker's active stance is 491480, while the referenced 493287 row does not match; the current CoA entry also omits the Rage proc chance and amount.
+- [ ] Confirm version and identity for Recovery of Life (494377), Exhaustion (493248), Speed Slash (499827), and Excellent Combat Training (499806). Current CoA payloads conflict with local RuneWaker mappings to missing rows 499828, 499825, and 499826.
+
+### Scout
+
+- [ ] Quantify Enhanced Throat Attack's extra range from a matching source.
+- [ ] Add Natural Poison Powder acquisition to the Scout/Rogue loadout model.
+- [ ] Add pet-specific stats and event hooks before implementing pet critical, dodge, and damage passives.
+- [ ] Review the remaining Scout paired-class passives for source-backed runtime coverage.
+
+### Rogue
+
+- [ ] Add off-hand swing events before implementing Off Hand Mastery (490143); its local component 620335 contains damage and accuracy modifiers.
+- [ ] Resolve Enchanted Throw child 491185's payload. The current runtime reuses Throw's local damage calculation because the child row is absent.
+- [ ] Add projectile-ammunition state if ammunition costs or consumption should affect Rogue ranged abilities.
+
+### Priest
+
+- [ ] Find Infused Light Chain (498610)'s on-hit chance, Rage amount, and damage increase. Its local passive has no source components; current CoA reuses the ID for a conflicting later Rage Control passive.
+- [ ] Quantify Protection Rune Seal's healing-strength rider from a matching source.
+- [ ] Resolve the local/source identity conflicts for Priest/Champion 498612 and Priest 490274–490276 before importing any later CoA behavior.
+- [ ] Review the remaining conditional Priest passives for complete source-backed triggers and effects.
+
+### Knight
+
+- [ ] Resolve God's Sword's “critical hit rate increased by 40%” as additive percentage points or a relative increase before integrating it into Holy Strike damage rolls.
+- [ ] Find Deadly Whirlwind's trigger or child attribution for its 10-second healing-reduction rider.
+- [ ] Find Holy Light's Fury's additional damage value; the current CoA page describes a conflicting later healing version.
+- [ ] Resolve Light-vulnerability's resistance direction, which conflicts between the tooltip and extracted component.
+- [ ] Identify unresolved periodic or triggered spell references before implementing their effects.
+
+### Warden
+
+- [ ] Resolve Damage Transfer (493399): component 503942 references missing child 493416 and gives a 10-second duration with 30% modifiers, while tooltip component 503643 gives 15 seconds; current CoA does not provide a matching child payload.
+- [ ] Resolve Revival (499542)'s missing trigger state 499544; local component 623078 has no numeric payload, and current CoA reuses the ID for a different skill.
+- [ ] Find Weak Point Strike's extra-hit chance; local components 620294/620295 contain the base and bonus hit formulas but no proc chance.
+- [ ] Add pet defense stats before implementing Tough Oak's pet-defense rider, and continue reviewing remaining reactive passives.
+
+### Mage
+
+- [ ] Verify Eruption's proc chance and damage calculation against a matching RuneWaker source. Party casts now follow the player runtime, but the existing 10% chance and damage-doubling interpretation remain unverified; later CoA values do not resolve the local version or formula.
+- [ ] Find whether rank 2 Catalysis should have a mana tick and quantify Flame Follower's critical-damage amount; neither value appears in the local source payload.
+
+### Druid
+
+- [ ] Add simulator support for Charm's faction conversion and Rebirth's debt reduction. Hostile player targets are unsupported, so Necrotic Wound's player-only healing reduction also needs a supported target path.
+- [ ] Find matching-version proc probabilities for Revival Slash, Quick Light Protection, Boiling Rock, Inspiration, Inspiration Display, Avalanche, Extended Recovery, and Spring Flower Eulogy. Current CoA Inspiration and Recovery descriptions conflict with RuneWaker triggers.
+
+### Warlock
+
+- [ ] Find proc probabilities and complete event details for Breathing Control and the remaining Priest-pair passives. Hidden child 499869 and End of Thought's tick interval are absent locally.
+- [ ] Reconcile current CoA conflicts for Mind Transference, Deep Breathing Control, Divine Assistance, and related triggers against the RuneWaker IDs and values before changing behavior.
+
+### Champion
+
+- [ ] Verify whether Shield Form has a player-learnable source skill in the RuneWaker version; none is present in the current catalog.
+- [ ] Find source-backed proc chances and payloads for Forge rank 2, Chain Drive, Dark Energy Strike, and the other incomplete Champion procs.
+- [ ] Confirm the RuneWaker identity for Rune Overload before using the current CoA Champion/Rogue page, which reuses the ID with later values and behavior.
diff --git a/src/app/accountRepository.test.ts b/src/app/accountRepository.test.ts
index 14156a1e..e47ea555 100644
--- a/src/app/accountRepository.test.ts
+++ b/src/app/accountRepository.test.ts
@@ -39,6 +39,7 @@ import {
} from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog";
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "../game/dungeonRunCheckpoint";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog";
class MemoryStorage implements StorageLike {
private values = new Map();
@@ -48,6 +49,43 @@ class MemoryStorage implements StorageLike {
}
describe("local profile repository", () => {
+ it("defaults new glyph loadouts to empty and persists explicit glyph choices", () => {
+ const storage = new MemoryStorage();
+ const character = createCharacter({
+ ownerId: "offline-roster",
+ name: "Leafkeeper",
+ categoryId: "wow",
+ raceId: "night-elf",
+ classId: "druid",
+ specializationId: "druid:restoration",
+ gender: "female",
+ appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
+ }, storage).character!;
+
+ expect(character.glyphIds).toEqual([]);
+
+ const legacyProfile = { ...character };
+ delete (legacyProfile as Partial).glyphIds;
+ const [migrated] = replaceCharactersForOwner("offline-roster", [legacyProfile], storage);
+ expect(migrated?.glyphIds).toEqual([]);
+
+ const updated = updateCharacterProgression("offline-roster", migrated.id, {
+ level: migrated.level,
+ experience: migrated.experience,
+ talentRanks: migrated.talentRanks,
+ glyphIds: [WOW335_GLYPH_OF_SWIFTMEND_ID, WOW335_GLYPH_OF_SWIFTMEND_ID, " "],
+ }, storage)!;
+ expect(updated.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+ expect(listCharacters("offline-roster", storage)[0]?.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+
+ const progressionOnly = updateCharacterProgression("offline-roster", migrated.id, {
+ level: updated.level,
+ experience: updated.experience,
+ talentRanks: updated.talentRanks,
+ }, storage)!;
+ expect(progressionOnly.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+ });
+
it("persists a safe dungeon checkpoint per character", () => {
const storage = new MemoryStorage();
const character = createCharacter({
diff --git a/src/app/accountRepository.ts b/src/app/accountRepository.ts
index 0b1a8abc..f81540e8 100644
--- a/src/app/accountRepository.ts
+++ b/src/app/accountRepository.ts
@@ -129,6 +129,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
actionBindings?: unknown;
experience?: unknown;
talentRanks?: unknown;
+ glyphIds?: unknown;
level?: unknown;
inventory?: unknown;
equipment?: unknown;
@@ -150,6 +151,11 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
.filter(([, rank]) => typeof rank === "number" && Number.isFinite(rank) && rank > 0)
.map(([id, rank]) => [id, Math.floor(rank as number)]))
: {};
+ const glyphIds = Array.isArray(legacy.glyphIds)
+ ? [...new Set(legacy.glyphIds.filter((glyphId): glyphId is string => (
+ typeof glyphId === "string" && glyphId.trim().length > 0
+ )))]
+ : [];
const actionBindings = legacy.actionBindings && typeof legacy.actionBindings === "object" && !Array.isArray(legacy.actionBindings)
? Object.fromEntries(Object.entries(legacy.actionBindings as Record)
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
@@ -188,6 +194,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
level,
experience,
talentRanks,
+ glyphIds,
actionBindings,
categoryId,
specializationId,
@@ -513,7 +520,7 @@ export function replaceCharactersForOwner(
}
export function createCharacter(
- profile: Omit & {
+ profile: Omit & {
specializationId?: string | null;
},
storage: StorageLike | null = browserLocalStorage(),
@@ -532,6 +539,7 @@ export function createCharacter(
level: 1,
experience: 0,
talentRanks: {},
+ glyphIds: [],
actionBindings: {},
inventory: [],
equipment: {},
@@ -565,7 +573,7 @@ export function touchCharacter(
export function updateCharacterProgression(
ownerId: string,
characterId: string,
- progression: Pick,
+ progression: Pick & Partial>,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage) return null;
@@ -578,6 +586,9 @@ export function updateCharacterProgression(
level: progression.level,
experience: progression.experience,
talentRanks: { ...progression.talentRanks },
+ glyphIds: progression.glyphIds === undefined
+ ? [...(character.glyphIds ?? [])]
+ : [...progression.glyphIds],
});
return updated;
});
diff --git a/src/app/mainMenuCharacterEditing.test.ts b/src/app/mainMenuCharacterEditing.test.ts
index ee3ac9cd..fb15aca2 100644
--- a/src/app/mainMenuCharacterEditing.test.ts
+++ b/src/app/mainMenuCharacterEditing.test.ts
@@ -10,6 +10,7 @@ import { beginMainMenuCharacterEditing } from "./mainMenuCharacterEditing";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useCombatStore } from "../game/combatStore";
import { talentNodesForTree } from "../game/talentCatalog";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog";
class MemoryStorage implements StorageLike {
values = new Map();
@@ -65,6 +66,33 @@ describe("main-menu character editing", () => {
expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 17, experience: 123 });
});
+ it("persists a glyph toggle and reloads it when the editor is reopened", () => {
+ const druid = createCharacter({
+ ownerId: "offline-roster", name: "Leafkeeper", classId: "druid",
+ raceId: "night-elf", gender: "female", appearance: createDefaultAppearance(),
+ specializationId: "druid:restoration",
+ }, storage).character!;
+ useShellStore.setState({
+ selectedCharacterId: druid.id,
+ characters: [druid],
+ });
+ stop = beginMainMenuCharacterEditing(storage);
+
+ expect(useCombatStore.getState().glyphIds).not.toContain(WOW335_GLYPH_OF_SWIFTMEND_ID);
+ expect(useCombatStore.getState().classId).toBe("druid");
+ expect(useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, true)).toBe(true);
+ expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+ expect(listCharacters("offline-roster", storage).find((character) => character.id === druid.id)?.glyphIds)
+ .toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+ expect(selectedCharacter(useShellStore.getState())?.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+
+ stop!();
+ stop = beginMainMenuCharacterEditing(storage);
+ expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+ useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, false);
+ expect(listCharacters("offline-roster", storage).find((character) => character.id === druid.id)?.glyphIds).toEqual([]);
+ });
+
it("persists controller assignments and reloads them without touching progression or equipment", () => {
const profile = selectedCharacter(useShellStore.getState())!;
stop = beginMainMenuCharacterEditing(storage);
diff --git a/src/app/mainMenuCharacterEditing.ts b/src/app/mainMenuCharacterEditing.ts
index 984706d7..2906123e 100644
--- a/src/app/mainMenuCharacterEditing.ts
+++ b/src/app/mainMenuCharacterEditing.ts
@@ -24,16 +24,19 @@ export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => voi
const profile = selectedCharacter(current);
if (current.phase !== "main-menu" || current.session?.ownerId !== ownerId
|| profile?.id !== characterId || profile.ownerId !== ownerId) return;
- if (combat.talentRanks === previous.talentRanks && combat.actionBindings === previous.actionBindings) return;
+ if (combat.talentRanks === previous.talentRanks
+ && combat.glyphIds === previous.glyphIds
+ && combat.actionBindings === previous.actionBindings) return;
try {
let updated = profile;
- if (combat.talentRanks !== previous.talentRanks) {
+ if (combat.talentRanks !== previous.talentRanks || combat.glyphIds !== previous.glyphIds) {
const saved = updateCharacterProgression(ownerId, characterId, {
// Menu edits cannot award experience or alter the character's level.
level: profile.level,
experience: profile.experience,
talentRanks: combat.talentRanks,
+ glyphIds: combat.glyphIds,
}, storage);
if (!saved) throw new Error("Character storage is unavailable.");
updated = saved;
diff --git a/src/app/shellStore.ts b/src/app/shellStore.ts
index f197874c..edb84a0e 100644
--- a/src/app/shellStore.ts
+++ b/src/app/shellStore.ts
@@ -114,7 +114,7 @@ export interface ShellState {
/** Compatibility alias for the original single-dungeon entry point. */
enterWorld: () => boolean;
returnToCharacters: () => void;
- saveActiveProgression: (level: number, experience: number, talentRanks: Record) => void;
+ saveActiveProgression: (level: number, experience: number, talentRanks: Record, glyphIds?: readonly string[]) => void;
saveActiveActionBindings: (actionBindings: Readonly>) => void;
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveAccountBossCards: (bossCards: BossCardCollection) => void;
@@ -533,13 +533,14 @@ export const useShellStore = create((set, get) => ({
ownerId: session.ownerId, characterId: selectedCharacterId, phase: "characters",
});
},
- saveActiveProgression: (level, experience, talentRanks) => {
+ saveActiveProgression: (level, experience, talentRanks, glyphIds) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterProgression(session.ownerId, activeCharacter.id, {
level,
experience,
talentRanks,
+ glyphIds,
});
if (!updated) return;
set((state) => ({
diff --git a/src/app/types.ts b/src/app/types.ts
index 57a6cde1..79e82299 100644
--- a/src/app/types.ts
+++ b/src/app/types.ts
@@ -37,6 +37,8 @@ export interface CharacterProfile {
level: number;
experience: number;
talentRanks: Record;
+ /** Active WoW glyph IDs in the character's saved build; normalized legacy profiles may omit this field. */
+ glyphIds?: readonly string[];
actionBindings: Record;
/** Bindings keyed by ordered `primary:secondary` class pair. */
actionLoadouts?: Record>;
diff --git a/src/game/CombatBridge.tsx b/src/game/CombatBridge.tsx
index a446f706..c097a003 100644
--- a/src/game/CombatBridge.tsx
+++ b/src/game/CombatBridge.tsx
@@ -93,11 +93,12 @@ export function CombatBridge() {
state.level !== previousState.level
|| state.xp !== previousState.xp
|| state.talentRanks !== previousState.talentRanks
+ || state.glyphIds !== previousState.glyphIds
) {
// Kills and talent changes are infrequent, so persist immediately. This
// also guarantees a fast return to Character Select cannot outrun a
// deferred save and lose the last level or talent point.
- shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
+ shell.saveActiveProgression(state.level, state.xp, state.talentRanks, state.glyphIds);
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
}
diff --git a/src/game/abilityCatalog.ts b/src/game/abilityCatalog.ts
index 502dc8bb..6129b552 100644
--- a/src/game/abilityCatalog.ts
+++ b/src/game/abilityCatalog.ts
@@ -162,7 +162,7 @@ export interface AbilityDamageResource {
}
export type AbilityEffect =
- | ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
+ | ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly weaponHand?: "main-hand" | "off-hand"; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
| {
readonly kind: "finisher-damage";
readonly baseCoefficient: number;
diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts
index 3387bc10..8c9e3ded 100644
--- a/src/game/combatStore.ts
+++ b/src/game/combatStore.ts
@@ -157,6 +157,7 @@ import {
wow335DruidFeralInstinctEnrageAura,
wow335DruidFeralInstinctTigerAura,
} from "./wow335DruidTalentRuntime";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import {
WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG,
WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG,
@@ -922,6 +923,7 @@ export interface CharacterCombatSeed {
/** Backward-compatible profile field accepted by the integration bridge. */
readonly experience?: number;
readonly talentRanks?: TalentRanks;
+ readonly glyphIds?: readonly string[];
readonly actionBindings?: unknown;
readonly inventory?: unknown;
readonly bossCards?: unknown;
@@ -1084,6 +1086,7 @@ export interface CombatState {
lastCombatResult: CombatResult | null;
bossLootRewards: readonly BossLootReward[];
talentRanks: TalentRanks;
+ glyphIds: readonly string[];
talentModifiers: readonly TalentModifier[];
actionBindings: ActionBindings;
inventory: readonly InventoryItem[];
@@ -1181,6 +1184,7 @@ export interface CombatState {
readonly preserveDefeatedIds?: readonly string[];
}) => void;
allocateTalent: (treeId: string) => boolean;
+ setGlyphActive: (glyphId: string, active: boolean) => boolean;
resetTalents: () => void;
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
resetActionBindingLayer: (layer: ActionBindingLayer) => void;
@@ -1763,6 +1767,7 @@ function talentAdjustedMaxHealth(
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
if (romWarriorPhysicalWeaponTooltip(ability)) return "melee";
+ if (ability.effects.some((effect) => effect.kind === "damage" && effect.weaponHand)) return "melee";
if (ability.source === "runewaker") {
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") {
@@ -7818,6 +7823,7 @@ function initialState() {
lastCombatResult: null,
bossLootRewards: [] as readonly BossLootReward[],
talentRanks,
+ glyphIds: [],
talentModifiers,
actionBindings: defaultActionBindings(defaultActionBarForClass(classId)),
inventory: [] as readonly InventoryItem[],
@@ -7843,6 +7849,9 @@ export const useCombatStore = create((set, get) => ({
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
const resource = resourceProfileForClass(classId);
const talentRanks = normalizeTalentRanks(classId, progression.level, profile.talentRanks ?? {});
+ const glyphIds = classId === "druid" && profile.glyphIds?.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)
+ ? [WOW335_GLYPH_OF_SWIFTMEND_ID]
+ : [];
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
const abilities = abilitiesForCharacter(classId, secondaryClassId);
const inventory = normalizeInventory(profile.inventory);
@@ -7898,6 +7907,7 @@ export const useCombatStore = create((set, get) => ({
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
talentRanks,
+ glyphIds,
talentModifiers,
actionBindings: withoutUnlearnedTalentBindings(
normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
@@ -10243,6 +10253,9 @@ export const useCombatStore = create((set, get) => ({
const shamanLavaLashOffhandSpeedMs = state.classId === "shaman" && ability.name === "Lava Lash"
? playerEquippedMeleeWeapon(state, "off-hand")?.speedMs
: undefined;
+ const shamanStormstrikeOffhandWeapon = state.classId === "shaman" && ability.name === "Stormstrike"
+ ? playerEquippedMeleeWeapon(state, "off-hand")
+ : null;
let shamanTidalWavesProcAttempted = false;
const combatEventIdBeforeAbilityEffects = work.combatEvents.at(-1)?.id ?? 0;
let suddenDoomTrigger: ReturnType = null;
@@ -10267,6 +10280,20 @@ export const useCombatStore = create((set, get) => ({
)
) continue;
if (effect.kind === "damage") {
+ const weaponHitProfile = effect.weaponHand === "main-hand"
+ ? playerAutoAttackProfile(
+ state,
+ aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
+ playerEquippedMeleeWeapon(state, "main-hand") ?? undefined,
+ )
+ : effect.weaponHand === "off-hand" && shamanStormstrikeOffhandWeapon
+ ? playerOffhandAutoAttackProfile(
+ state,
+ shamanStormstrikeOffhandWeapon,
+ aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
+ )
+ : null;
+ if (effect.weaponHand && !weaponHitProfile) continue;
const amount = talentAdjustedAmount(
(() => {
const conflagrateDot = state.classId === "warlock" && ability.name === "Conflagrate"
@@ -10274,7 +10301,9 @@ export const useCombatStore = create((set, get) => ({
: null;
const sourceAbility = conflagrateDot ? abilityById(conflagrateDot.sourceAbilityId) : null;
const burstSeconds = sourceAbility?.name === "Immolate" ? 12 : 8;
- const baseDamage = conflagrateDot && sourceAbility
+ const baseDamage = weaponHitProfile
+ ? weaponHitProfile.damage * effect.coefficient
+ : conflagrateDot && sourceAbility
? (conflagrateDot.fixedAmount ?? equipmentAdjustedCombatAmount(
conflagrateDot,
work.progression.level,
@@ -10326,13 +10355,19 @@ export const useCombatStore = create((set, get) => ({
castThreatSource,
effectSchool === "physical" ? attackKind : "spell",
effectSchool,
- `${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
+ `${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct${effect.weaponHand ? `:${effect.weaponHand}` : ""}`,
ability,
false,
ability.name === "Shiv" || (consumesSurgeOfLight && ability.name === "Smite")
? { canCrit: false } : {},
mageArcanePotencyCriticalChanceBonusPercent,
- shamanLavaLashOffhandSpeedMs,
+ effect.weaponHand === "main-hand"
+ ? playerEquippedMeleeWeapon(state, "main-hand")?.speedMs
+ ?? work.gearStats.meleeWeapon?.speedMs
+ ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS
+ : effect.weaponHand === "off-hand"
+ ? shamanStormstrikeOffhandWeapon?.speedMs
+ : shamanLavaLashOffhandSpeedMs,
);
if (state.classId === "death-knight"
&& effect.kind === "damage"
@@ -10678,7 +10713,7 @@ export const useCombatStore = create((set, get) => ({
healingTargetHealth,
healingTargetMaxHealth,
);
- if (swiftmendHot) {
+ if (swiftmendHot && !state.glyphIds.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)) {
work.effects = work.effects.filter((candidate) => candidate.id !== swiftmendHot.id);
}
const riptideAdjustedAmount = shamanRiptideHot
@@ -16405,10 +16440,11 @@ export const useCombatStore = create((set, get) => ({
&& (!autoAttackSource || isPositionInFacingArc(autoAttackSource, autoAttackTargetPosition ?? state.playerPosition, Math.PI))
);
if (canAutoAttack) {
- const mainHandWeapon = state.classId === "rogue"
+ const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "shaman";
+ const mainHandWeapon = supportsPlayerOffhandSwings
? playerEquippedMeleeWeapon(state, "main-hand")
: null;
- const offHandWeapon = state.classId === "rogue"
+ const offHandWeapon = supportsPlayerOffhandSwings
? playerEquippedMeleeWeapon(state, "off-hand")
: null;
const profile = playerAutoAttackProfile(
@@ -16433,6 +16469,9 @@ export const useCombatStore = create((set, get) => ({
}
if (now >= nextAutoAttackAt) {
const isDualWielding = Boolean(offHandWeapon);
+ const mainHandSpeedMs = mainHandWeapon?.speedMs
+ ?? state.gearStats.meleeWeapon?.speedMs
+ ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS;
const autoAttackSchool = mainHandWeapon?.damageSchool
?? (offHandWeapon ? "physical" : state.gearStats.meleeWeapon?.damageSchool)
?? "physical";
@@ -16449,6 +16488,8 @@ export const useCombatStore = create((set, get) => ({
undefined,
false,
{ isAutoAttack: true, isDualWielding },
+ 0,
+ mainHandSpeedMs,
);
let autoAttackKilled = result.killed;
const enchantedThrowActive = work.auras.some((aura) => (
@@ -16542,7 +16583,7 @@ export const useCombatStore = create((set, get) => ({
nextOffhandAutoAttackAt = 0;
}
}
- if (state.classId !== "rogue" || !offHandWeapon || !autoAttackTargetId) {
+ if (!supportsPlayerOffhandSwings || !offHandWeapon || !autoAttackTargetId) {
nextOffhandAutoAttackAt = 0;
} else if (now >= nextOffhandAutoAttackAt) {
const offhandProfile = playerOffhandAutoAttackProfile(
@@ -16565,6 +16606,8 @@ export const useCombatStore = create((set, get) => ({
undefined,
false,
{ isAutoAttack: true, isDualWielding: true },
+ 0,
+ offHandWeapon.speedMs,
);
if (result.combatResult?.landed) {
maybeProcRogueCombatPotencyInWork(work, offhandSeed, now);
@@ -17910,6 +17953,16 @@ export const useCombatStore = create((set, get) => ({
};
}),
+ setGlyphActive: (glyphId, active) => {
+ const state = get();
+ if (state.classId !== "druid" || glyphId !== WOW335_GLYPH_OF_SWIFTMEND_ID) return false;
+ const glyphIds = active
+ ? [WOW335_GLYPH_OF_SWIFTMEND_ID]
+ : state.glyphIds.filter((id) => id !== WOW335_GLYPH_OF_SWIFTMEND_ID);
+ if (glyphIds.length !== state.glyphIds.length) set({ glyphIds });
+ return true;
+ },
+
allocateTalent: (treeId) => {
const state = get();
const result = allocateTalentPoint(state.talentRanks, state.classId, treeId, state.level);
diff --git a/src/game/partyRuntime.test.ts b/src/game/partyRuntime.test.ts
index 67e99c53..059c8664 100644
--- a/src/game/partyRuntime.test.ts
+++ b/src/game/partyRuntime.test.ts
@@ -23,10 +23,11 @@ import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
-import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
+import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId } from "./abilityCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { baseClassFor } from "../app/characterCatalog";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
@@ -130,6 +131,72 @@ describe("party combat runtime", () => {
expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt);
});
+ it.each([false, true])("companion Swiftmend preserves the HoT only with the configured glyph (%s)", (glyphActive) => {
+ useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
+ const druidAbilities = abilitiesForClass("druid");
+ const swiftmendBase = druidAbilities.find((ability) => ability.name === "Swiftmend")!;
+ const swiftmendNodeId = abilityTalentNodeId(swiftmendBase)!;
+ const swiftmend = abilityAtLevel(swiftmendBase, 80);
+ const rejuvenation = abilityAtLevel(druidAbilities.find((ability) => ability.name === "Rejuvenation")!, 80);
+ const rejuvenationHot = rejuvenation.effects.find((effect) => effect.kind === "hot")!;
+ const druid = createConfiguredPartyMember({
+ name: "Configured Druid",
+ classId: "druid",
+ specialization: "Restoration",
+ specializationId: "druid:restoration",
+ talentTreeId: "druid-restoration",
+ talentRanks: { [swiftmendNodeId]: 1 },
+ glyphIds: glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : [],
+ role: "healer",
+ }, 80, 0, "party-swiftmend-glyph");
+ const target = createConfiguredPartyMember({
+ name: "Injured Tank",
+ classId: "warrior",
+ specialization: "Protection",
+ specializationId: "warrior:protection",
+ talentTreeId: "warrior-protection",
+ role: "tank",
+ }, 80, 1, "party-swiftmend-glyph");
+ const startedAt = 1_000;
+ usePartyStore.getState().initializeParty("party-swiftmend-glyph", 80,
+ useCombatStore.getState().health, "damage");
+ usePartyStore.setState({ members: [
+ { ...target, health: 100 },
+ {
+ ...druid,
+ activeCast: {
+ abilityId: swiftmend.id,
+ school: "nature",
+ mode: "cast",
+ startedAt,
+ completesAt: startedAt,
+ originalDurationMs: 0,
+ pushbackHits: 0,
+ origin: [0, 0, 0],
+ targetId: target.id,
+ },
+ activeCastTargetId: target.id,
+ status: "healing",
+ },
+ ] });
+ registerPartyRuntimePosition(druid.id, [0, 0, 0]);
+ registerPartyRuntimePosition(target.id, [0, 0, 0]);
+ useCombatStore.getState().synchronizeActors();
+ expect(useCombatStore.getState().applyTimedActorEffect(
+ druid.id, target.id, rejuvenation, rejuvenationHot, startedAt,
+ )).toBe(true);
+ const originalHot = useCombatStore.getState().effects.find((effect) => (
+ effect.kind === "hot" && effect.sourceActorId === druid.id && effect.targetId === target.id
+ ))!;
+
+ advanceDungeonPartyCombat(startedAt);
+
+ expect(useCombatStore.getState().actors[target.id]!.health).toBeGreaterThan(100);
+ expect(useCombatStore.getState().effects.some((effect) => effect.id === originalHot.id)).toBe(glyphActive);
+ expect(usePartyStore.getState().members.find((member) => member.id === druid.id)?.glyphIds)
+ .toEqual(glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : []);
+ });
+
it("refreshes a companion Priest's Shadow Word: Pain from its own completed Mind Blast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const priest = createConfiguredPartyMember({
diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts
index 652a749e..6f368d1c 100644
--- a/src/game/partyRuntime.ts
+++ b/src/game/partyRuntime.ts
@@ -37,6 +37,7 @@ import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore
import { baseClassFor, classById } from "../app/characterCatalog";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
import {
@@ -296,6 +297,24 @@ function partyShamanRiptideHotOnTarget(memberId: string, targetId: string, now:
&& effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs > now);
}
+function partySwiftmendHotOnTarget(memberId: string, targetId: string, now: number) {
+ const candidates = useCombatStore.getState().effects.filter((effect) => {
+ if (effect.kind !== "hot" || (effect.targetId ?? PLAYER_AGGRO_ID) !== targetId
+ || effect.remainingTicks <= 0
+ || effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs <= now) return false;
+ const name = abilityById(effect.sourceAbilityId)?.name;
+ return name === "Rejuvenation" || name === "Regrowth";
+ });
+ const own = candidates.filter((effect) => effect.sourceActorId === memberId);
+ const pool = own.length ? own : candidates;
+ return pool.reduce((shortest, candidate) => {
+ if (!shortest) return candidate;
+ const expiry = candidate.nextTickAt + Math.max(0, candidate.remainingTicks - 1) * candidate.intervalMs;
+ const shortestExpiry = shortest.nextTickAt + Math.max(0, shortest.remainingTicks - 1) * shortest.intervalMs;
+ return expiry < shortestExpiry ? candidate : shortest;
+ }, undefined);
+}
+
function maybeProcPartyShamanImprovedWaterShield(
member: PartyMember,
ability: AbilityDefinition,
@@ -1349,6 +1368,8 @@ export function partyAbilityTargetIsValid(
.flatMap((aura) => aura.definition.utilityTags ?? []);
if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false;
}
+ if (member.classId === "druid" && ability.name === "Swiftmend"
+ && !partySwiftmendHotOnTarget(member.id, targetId, now)) return false;
if (member.classId === "warlock") {
const metamorphosisActive = combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG)
@@ -2186,6 +2207,18 @@ function executePartyAbility(
&& targetIndex === 0
? partyShamanRiptideHotOnTarget(member.id, friendlyTargetId, now)
: undefined;
+ const swiftmendHot = member.classId === "druid" && ability.name === "Swiftmend"
+ ? partySwiftmendHotOnTarget(member.id, friendlyTargetId, now)
+ : undefined;
+ const swiftmendHotAbility = swiftmendHot ? abilityById(swiftmendHot.sourceAbilityId) : null;
+ const swiftmendHotEffect = swiftmendHotAbility?.effects.find((candidate) => candidate.kind === "hot");
+ const swiftmendTickCount = swiftmendHotAbility?.name === "Rejuvenation" ? 4
+ : swiftmendHotAbility?.name === "Regrowth" ? 6
+ : 0;
+ const swiftmendAmount = swiftmendHot && swiftmendHotAbility && swiftmendHotEffect?.kind === "hot"
+ ? (swiftmendHot.fixedAmount ?? partyAbilityAmount(runtimeMember, swiftmendHotAbility, swiftmendHotEffect))
+ * swiftmendTickCount
+ : null;
if (riptideHot) {
useCombatStore.setState((state) => ({
effects: state.effects.filter((candidate) => candidate.id !== riptideHot.id),
@@ -2196,7 +2229,7 @@ function executePartyAbility(
const healingSchool = member.classId === "shaman"
? abilityDamageSchool("shaman", ability)
: spellSchool;
- const requested = Math.round((partyAbilityAmount(runtimeMember, ability, effect)
+ const requested = Math.round(((swiftmendAmount ?? partyAbilityAmount(runtimeMember, ability, effect))
+ tidalWavesBonus
+ Math.round(actor.maxHealth * (effect.percentMaxHealth ?? 0)))
* (riptideHot ? PARTY_SHAMAN_RIPTIDE_CHAIN_HEAL_MULTIPLIER : 1));
@@ -2208,6 +2241,11 @@ function executePartyAbility(
seed: `${member.id}:${ability.id}:${friendlyTargetId}:${now}:heal`,
now,
});
+ if (swiftmendHot && !runtimeMember.glyphIds?.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)) {
+ useCombatStore.setState((state) => ({
+ effects: state.effects.filter((candidate) => candidate.id !== swiftmendHot.id),
+ }));
+ }
effectiveAmount += result.effectiveAmount;
directHealingRawAmount += result.rawAmount;
runtimeMember = maybeProcPartyShamanImprovedWaterShield(
diff --git a/src/game/partyStore.test.ts b/src/game/partyStore.test.ts
index 2a7ca6dd..7bdb3ac1 100644
--- a/src/game/partyStore.test.ts
+++ b/src/game/partyStore.test.ts
@@ -17,6 +17,7 @@ import { spentTalentPoints, talentPointsForLevel, talentTreeById } from "./talen
import type { InventoryItem } from "./lootTypes";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { DEFAULT_COMPANION_SPEC_READINESS_POLICY } from "./companionSpecReadiness";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
describe("dungeon party composition", () => {
beforeEach(() => usePartyStore.setState({
@@ -183,6 +184,30 @@ describe("dungeon party composition", () => {
expect(cat.resourcePools).toMatchObject({ mana: cat.maxResource, rage: 0, energy: 100 });
});
+ it("keeps generated companions unglyphed unless a configured Druid has Swiftmend glyph", () => {
+ const generated = createDungeonParty("unglyphed-defaults", 80);
+ expect(generated.every((member) => !member.glyphIds?.length)).toBe(true);
+
+ const configuredDruid = createConfiguredPartyMember({
+ name: "Glyphkeeper",
+ classId: "druid",
+ specialization: "Restoration",
+ specializationId: "druid:restoration",
+ role: "healer",
+ glyphIds: [WOW335_GLYPH_OF_SWIFTMEND_ID, WOW335_GLYPH_OF_SWIFTMEND_ID, "not-a-glyph"],
+ }, 80, 0, "configured-glyphs");
+ const unconfiguredDruid = createConfiguredPartyMember({
+ name: "Plainleaf",
+ classId: "druid",
+ specialization: "Restoration",
+ specializationId: "druid:restoration",
+ role: "healer",
+ }, 80, 1, "configured-glyphs");
+
+ expect(configuredDruid.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+ expect(unconfiguredDruid.glyphIds).toEqual([]);
+ });
+
it("is deterministic per character and scales every ally to the player level", () => {
const first = createDungeonParty("character-42", 18);
const second = createDungeonParty("character-42", 18);
diff --git a/src/game/partyStore.ts b/src/game/partyStore.ts
index 31c1fb9d..0a7728ee 100644
--- a/src/game/partyStore.ts
+++ b/src/game/partyStore.ts
@@ -21,6 +21,7 @@ import {
type DamageSchool,
type ItemStats,
} from "./itemStats";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import {
damageAfterEquipmentMitigation,
deriveCharacterStats,
@@ -87,6 +88,8 @@ export interface PartyMember {
readonly talentTreeId: string;
readonly talentProfileId: string | null;
readonly talentRanks: TalentRanks;
+ /** Explicitly configured WoW glyphs; generated companions have none by default. */
+ readonly glyphIds?: readonly string[];
readonly role: PartyRole;
readonly level: number;
readonly health: number;
@@ -134,6 +137,7 @@ export interface ConfiguredPartyMemberSeed {
readonly talentTreeId?: string;
readonly talentProfileId?: string | null;
readonly talentRanks?: TalentRanks;
+ readonly glyphIds?: readonly string[];
readonly role: PartyRole;
}
@@ -438,6 +442,9 @@ export function createConfiguredPartyMember(
...member,
specializationId: configuration.specializationId ?? member.specializationId,
talentRanks: configuration.talentRanks ?? member.talentRanks,
+ glyphIds: configuration.classId === "druid"
+ ? [...new Set((configuration.glyphIds ?? []).filter((glyphId) => glyphId === WOW335_GLYPH_OF_SWIFTMEND_ID))]
+ : [],
}));
}
diff --git a/src/game/wow335AbilityCatalog.test.ts b/src/game/wow335AbilityCatalog.test.ts
index beda8720..c012501f 100644
--- a/src/game/wow335AbilityCatalog.test.ts
+++ b/src/game/wow335AbilityCatalog.test.ts
@@ -42,6 +42,35 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(byId("wow335-priest-power-word-shield").gcdMs).toBe(1_500);
});
+ it("maps Stormstrike's generated weapon-hit children to their attacking hands", () => {
+ const stormstrike = abilityAtLevel(byId("wow335-shaman-stormstrike"), 80);
+ const chain = generatedData.classes.shaman.find((candidate) => candidate.name === "Stormstrike");
+ expect(stormstrike.dbcSpellId).toBe(17364);
+ expect(chain).toBeDefined();
+
+ const expectedHits = [32175, 32176].map((spellId) => {
+ const weaponEffect = chain!.ranks.find((rank) => rank.spellId === spellId)!
+ .effects.find((effect) => effect.effectType === 58)!;
+ expect(weaponEffect.coefficient).toBe(1);
+ return {
+ kind: "damage" as const,
+ coefficient: weaponEffect.coefficient,
+ school: "physical" as const,
+ weaponHand: spellId === 32175 ? "main-hand" as const : "off-hand" as const,
+ };
+ });
+
+ expect(effectsOfKind(stormstrike, "damage")).toEqual(expectedHits);
+ expect(stormstrike.effects).not.toEqual(expect.arrayContaining([
+ expect.objectContaining({ kind: "trigger-spell", spellId: 32175 }),
+ expect.objectContaining({ kind: "trigger-spell", spellId: 32176 }),
+ ]));
+ expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[32175]).toBeUndefined();
+ expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[32176]).toBeUndefined();
+ expect(WOW335_ABILITIES_BY_CLASS.shaman.some((ability) => [32175, 32176].includes(ability.dbcSpellId ?? 0)))
+ .toBe(false);
+ });
+
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({
diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts
index 0de6017c..1ebbadcb 100644
--- a/src/game/wow335AbilityCatalog.ts
+++ b/src/game/wow335AbilityCatalog.ts
@@ -753,6 +753,32 @@ function fallbackCoefficient(effect: Wow335Effect, rank: Wow335Rank): number {
return Math.max(0.08, Math.min(4, average / playerCombatPower(Math.max(1, rank.sourceLevel || rank.level))));
}
+function stormstrikeWeaponHitForTrigger(
+ spellId: number,
+): Extract | null {
+ const weaponHand = spellId === 32175
+ ? "main-hand"
+ : spellId === 32176
+ ? "off-hand"
+ : undefined;
+ if (!weaponHand) return null;
+
+ // Stormstrike's wrapper invokes these two DBC child spells. Their local
+ // generated SpellEffect rows contain the weapon-damage coefficient.
+ const childRank = data.classes.shaman
+ .find((chain) => chain.name === "Stormstrike")
+ ?.ranks.find((candidate) => candidate.spellId === spellId);
+ const weaponEffect = childRank?.effects.find((effect) => WEAPON_DAMAGE_EFFECTS.has(effect.effectType));
+ if (!weaponEffect) return null;
+
+ const coefficient = weaponEffect.coefficient > 0
+ ? weaponEffect.coefficient
+ : weaponEffect.effectType === 31
+ ? Math.max(0.1, Math.abs(weaponEffect.basePoints) / 100)
+ : 1;
+ return { kind: "damage", coefficient, school: "physical", weaponHand };
+}
+
function utilityAction(effectType: number): Extract["action"] {
if (effectType === 24) return "create-item";
if (effectType === 33) return "open-lock";
@@ -1877,6 +1903,13 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
continue;
}
if (TRIGGER_SPELL_EFFECTS.has(effect.effectType) && effect.triggerSpellId > 0) {
+ if (classId === "shaman" && abilityName === "Stormstrike" && rank.spellId === 17364) {
+ const weaponHit = stormstrikeWeaponHitForTrigger(effect.triggerSpellId);
+ if (weaponHit) {
+ effects.push(weaponHit);
+ continue;
+ }
+ }
effects.push({
kind: "trigger-spell",
spellId: effect.triggerSpellId,
diff --git a/src/game/wow335DruidRuntime.test.ts b/src/game/wow335DruidRuntime.test.ts
index b43a0487..ad8bef78 100644
--- a/src/game/wow335DruidRuntime.test.ts
+++ b/src/game/wow335DruidRuntime.test.ts
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityTalentNodeId, type AbilityDefinition } from "./abilityCatalog";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore, type TimedCombatEffect } from "./combatStore";
@@ -102,4 +103,17 @@ describe("WoW Druid active talent runtime", () => {
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400);
expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([longerRegrowth.id]);
});
+
+ it("preserves the selected HoT when Glyph of Swiftmend is active", () => {
+ const swiftmend = prepareSwiftmend();
+ const sourceHot = hot("Rejuvenation", PLAYER_AGGRO_ID, 100, 5, 4_000, "swiftmend-glyph-hot");
+ useCombatStore.setState({ effects: [sourceHot] });
+ expect(useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, true)).toBe(true);
+
+ const event = castSwiftmend(swiftmend);
+
+ expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400);
+ expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
+ expect(useCombatStore.getState().effects).toEqual([sourceHot]);
+ });
});
diff --git a/src/game/wow335GlyphCatalog.ts b/src/game/wow335GlyphCatalog.ts
new file mode 100644
index 00000000..e78f6027
--- /dev/null
+++ b/src/game/wow335GlyphCatalog.ts
@@ -0,0 +1,2 @@
+/** Stable character-build ID for the WotLK 3.3.5a Glyph of Swiftmend. */
+export const WOW335_GLYPH_OF_SWIFTMEND_ID = "wow335:54824";
diff --git a/src/game/wow335ShamanMaelstromWeaponCombatRuntime.test.ts b/src/game/wow335ShamanMaelstromWeaponCombatRuntime.test.ts
index 1cc97d0d..4135fccf 100644
--- a/src/game/wow335ShamanMaelstromWeaponCombatRuntime.test.ts
+++ b/src/game/wow335ShamanMaelstromWeaponCombatRuntime.test.ts
@@ -124,6 +124,85 @@ describe("WoW Shaman Maelstrom Weapon combat runtime", () => {
))).toBe(true);
});
+ it("uses the off-hand speed for Shaman off-hand auto-attack Maelstrom rolls", () => {
+ setMaelstromRank(3);
+ useCombatStore.setState((state) => ({
+ inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID
+ ? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } }
+ : item),
+ gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 },
+ autoAttackTargetId: TARGET_ID,
+ nextAutoAttackAt: 1_000,
+ nextOffhandAutoAttackAt: 1_000,
+ }));
+ const mainHandRoll = (now: number) => stableCombatUnit(
+ `${PLAYER_AURA_ENTITY_ID}:auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`,
+ );
+ const offHandRoll = (now: number) => stableCombatUnit(
+ `${PLAYER_AURA_ENTITY_ID}:offhand-auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`,
+ );
+ const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index)
+ .find((candidate) => mainHandRoll(candidate) >= 0.2
+ && offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!;
+ expect(now).toBeDefined();
+
+ useCombatStore.getState().tick(0.1, now);
+
+ const offhandEvents = useCombatStore.getState().combatEvents.filter((event) => (
+ event.kind === "damage" && event.abilityId === "player-auto-attack-offhand"
+ ));
+ expect(offhandEvents, JSON.stringify(offhandEvents)).toHaveLength(1);
+ expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2);
+ expect(offHandRoll(now)).toBeLessThan(0.4);
+ expect(maelstromAura(), JSON.stringify({
+ now,
+ roll: offHandRoll(now),
+ rank: useCombatStore.getState().talentRanks["talent-2057"],
+ weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon,
+ offhandEvents,
+ })).toMatchObject({ stacks: 1 });
+ });
+
+ it("resolves Stormstrike's hands independently and uses each weapon speed for Maelstrom", () => {
+ setMaelstromRank(3);
+ useCombatStore.setState((state) => ({
+ talentRanks: { ...state.talentRanks, "talent-901": 1 },
+ inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID
+ ? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } }
+ : item),
+ gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 },
+ }));
+ const stormstrike = shamanAbility("Stormstrike");
+ const mainHandRoll = (now: number) => stableCombatUnit(
+ `${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:main-hand:wow335-shaman-maelstrom-weapon`,
+ );
+ const offHandRoll = (now: number) => stableCombatUnit(
+ `${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:off-hand:wow335-shaman-maelstrom-weapon`,
+ );
+ const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index)
+ .find((candidate) => mainHandRoll(candidate) >= 0.2
+ && offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!;
+ expect(now).toBeDefined();
+
+ cast(stormstrike.id, now);
+
+ const hitEvents = useCombatStore.getState().combatEvents.filter((event) => (
+ event.kind === "damage" && event.abilityId === stormstrike.id && event.effectiveAmount > 0
+ ));
+ expect(hitEvents, JSON.stringify(useCombatStore.getState().combatEvents.filter((event) => (
+ event.kind === "damage" && event.abilityId === stormstrike.id
+ )))).toHaveLength(2);
+ expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2);
+ expect(offHandRoll(now)).toBeLessThan(0.4);
+ expect(maelstromAura(), JSON.stringify({
+ now,
+ roll: offHandRoll(now),
+ rank: useCombatStore.getState().talentRanks["talent-2057"],
+ weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon,
+ hitEvents,
+ })).toMatchObject({ stacks: 1 });
+ });
+
it("adds one stack per proc, refreshes the 30-second duration, and caps at five", () => {
setMaelstromRank(3);
const lavaLash = shamanAbility("Lava Lash");
diff --git a/src/styles.css b/src/styles.css
index 757a2c67..16f94e81 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1405,6 +1405,12 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.talents-panel__points strong { color: #f0d477; font: 700 27px Georgia, serif; }
.talents-panel__points span { max-width: 55px; color: var(--muted); font-size: 8px; line-height: 1.1; text-transform: uppercase; }
.talents-panel__intro { margin: 9px 0; color: var(--muted); font-size: 10px; }
+.talents-panel__glyphs { margin: 0 0 8px; padding: 7px 9px; border: 1px solid rgba(191,211,158,.15); border-radius: 6px; background: rgba(0,0,0,.22); }
+.talents-panel__glyph-option { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; }
+.talents-panel__glyph-option input { margin: 2px 0 0; accent-color: #a9d47f; }
+.talents-panel__glyph-option span { display: grid; gap: 2px; }
+.talents-panel__glyph-option strong { color: var(--ink); font-size: 10px; }
+.talents-panel__glyph-option small { color: var(--muted); font-size: 8px; line-height: 1.35; }
.talents-panel__tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-bottom: 8px; }
.talent-tree-tab { --talent-color: #76965e; display: grid; min-width: 0; min-height: 36px; grid-template-columns: 29px minmax(0, 1fr); align-items: center; gap: 7px; padding: 4px 7px; border: 1px solid rgba(193,211,160,.12); border-radius: 7px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; }
.talent-tree-tab.is-active { border-color: color-mix(in srgb, var(--talent-color) 55%, #d7dcbd 18%); background: color-mix(in srgb, var(--talent-color) 22%, rgba(7,17,12,.9)); }
diff --git a/src/ui/TalentGameplayPanel.tsx b/src/ui/TalentGameplayPanel.tsx
index 9c83d592..fa7f3e39 100644
--- a/src/ui/TalentGameplayPanel.tsx
+++ b/src/ui/TalentGameplayPanel.tsx
@@ -21,6 +21,8 @@ export default function TalentGameplayPanel({ readOnly = false, open, onClose }:
const classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
+ const glyphIds = useCombatStore((state) => state.glyphIds);
+ const setGlyphActive = useCombatStore((state) => state.setGlyphActive);
const classDefinition = classById(classId);
const catalog = loadedTalentUiCatalog(classId) ?? use(loadTalentUiCatalog(classId));
const talentTrees = useMemo(() => catalog.trees.map((tree) => ({
@@ -41,6 +43,8 @@ export default function TalentGameplayPanel({ readOnly = false, open, onClose }:
return undefined,
+ onReset: () => undefined,
+ onGlyphActiveChange: () => undefined,
+ onClose: () => undefined,
+ }));
+}
+
+describe("Glyph of Swiftmend build control", () => {
+ it("shows the glyph unchecked by default for Druids", () => {
+ const markup = render("druid");
+ expect(markup).toContain("Glyph of Swiftmend");
+ expect(markup).not.toContain('type="checkbox" checked=""');
+ });
+
+ it("reflects a persisted active glyph and hides the option for other classes", () => {
+ expect(render("druid", [WOW335_GLYPH_OF_SWIFTMEND_ID])).toContain('type="checkbox" checked=""');
+ expect(render("mage", [WOW335_GLYPH_OF_SWIFTMEND_ID])).not.toContain("Glyph of Swiftmend");
+ });
+});
diff --git a/src/ui/TalentsPanel.tsx b/src/ui/TalentsPanel.tsx
index 2088b266..08defad5 100644
--- a/src/ui/TalentsPanel.tsx
+++ b/src/ui/TalentsPanel.tsx
@@ -9,6 +9,7 @@ import type {
TalentAllocationFailure,
TalentRanks,
} from "../game/talentCatalog";
+import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction, type MenuDirection } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
@@ -29,6 +30,8 @@ export interface TalentsPanelProps {
readOnly?: boolean;
trees: readonly TalentTreeView[];
ranks: TalentRanks;
+ glyphIds?: readonly string[];
+ onGlyphActiveChange?: (glyphId: string, active: boolean) => void;
classId: ClassId;
playerLevel: number;
availablePoints: number;
@@ -102,6 +105,8 @@ function failureText(
function TalentsDialog({
trees,
ranks,
+ glyphIds = [],
+ onGlyphActiveChange,
classId,
playerLevel,
availablePoints,
@@ -251,6 +256,26 @@ function TalentsDialog({
: "Your first talent point arrives at level 10. Every five points spent in a tree unlocks its next row."}
+ {classId === "druid" && !isCoa && (
+
+ )}
+