Update Healer Man server
This commit is contained in:
1 parent
4c768fbec6
commit
b16821e92e
330 files changed
+35302
-1656
No files matched your search
@@ -0,0 +1,3 @@
|
||||
import { ROM_ALL_ABILITIES } from "./src/game/romAbilityCatalog";
|
||||
console.log("total", ROM_ALL_ABILITIES.length);
|
||||
console.log(ROM_ALL_ABILITIES.filter((entry) => entry.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot") && entry.castMode === "channel").map((entry) => `${entry.id}: ${entry.name} (${entry.effects.map((effect) => effect.kind).join(",")})`).join("\n"));
|
||||
@@ -4,6 +4,20 @@ 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-30 Dungeon graphics options
|
||||
|
||||
- `scripts/playtest-graphics-options.cjs` entered WoW Wailing Caverns and RoM
|
||||
Forsaken Abbey in headless Edge with an isolated temporary database. It changed
|
||||
render resolution to 200%, texture filtering to 16×, shadows to High, and
|
||||
ambient fill to High through the Options panel. No browser page errors occurred.
|
||||
- The canvas measured 1.38× pixel density under adaptive scaling and 2× after
|
||||
the fixed setting, including after switching dungeons. The High setting
|
||||
rendered both dungeon scenes without missing world geometry or textures.
|
||||
- Evidence: `artifacts/playtest/graphics-options/{wow-default,wow-high,rom-high,graphics-options,graphics-options-thor}.png`
|
||||
and `results.json`. The screenshots show visual operation, not a controlled
|
||||
lighting fidelity or frame-rate benchmark. Higher-resolution source textures
|
||||
and original client lightmaps are not shipped as alternate runtime tiers.
|
||||
|
||||
## 2026-09-27 Bite-Sized editor movement regression
|
||||
|
||||
- The original edit-mode action blocker also suppressed continuous movement and
|
||||
|
||||
+82
-35
@@ -1,11 +1,11 @@
|
||||
# Active class behavior and spell visual audit
|
||||
|
||||
Updated 2026-09-30. This is a source/runtime audit of the shipped WoW 3.3.5a and RuneWaker catalogs in Healer Man. It records confirmed gaps and the fixes made in this pass. It is not a claim that every ability has passed a live-game playtest or a screenshot-based visual review.
|
||||
Updated 2026-10-02. This is a source/runtime audit of the shipped WoW 3.3.5a and RuneWaker catalogs in Healer Man. It records confirmed gaps and the fixes made in this pass. It is not a claim that every ability has passed a live-game playtest or a screenshot-based visual review.
|
||||
|
||||
## Class-mechanics source precedence
|
||||
|
||||
- For WoW, use the existing generated class, rank, and talent entries wherever they contain the relevant data. Consult [WotLKDB 3.3.5a](https://wotlkdb.com/) only to fill a missing tooltip or spell detail; do not replace a populated generated value with a conflicting database row.
|
||||
- For RoM, keep populated RuneWaker generated entries and local source mappings authoritative. Consult the [Chronicles of Arcadia Wiki](https://chroniclesofarcadia.com/en/wiki/skills/list) only when tooltip or spell details are genuinely missing; do not import a conflicting later version into the local RuneWaker mechanic.
|
||||
- For RoM, use populated RuneWaker generated entries and local source mappings as the baseline. When the current [Chronicles of Arcadia Wiki](https://chroniclesofarcadia.com/en/wiki/skills/list) or its patch notes clearly identifies the same ability or an explicit rework, prefer the current CoA behavior over a conflicting RuneWaker value. Verify the skill name and class pairing before treating a reused ID as the same ability. Document the selected source, the conflict, and any simulator approximation; leave missing triggers or values unresolved rather than guessing.
|
||||
- This mechanics follow-up excludes visual and sound tuning. The visual findings below record work from the earlier visual pass.
|
||||
|
||||
## Reviewed scope
|
||||
@@ -45,6 +45,10 @@ WoW inventory by generated catalog class:
|
||||
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
|
||||
- WoW Paladin Illumination (`talent-1461`) now refunds half the selected local Spell.dbc rank's base Mana cost on critical Flash of Light, Holy Light, or Holy Shock, using generated 33/66/100% rank chances. Player and companion casts snapshot the base cost before discounts at cast start. Generated local rank text takes precedence over WotLKDB's conflicting stock 20% proc / 30% refund; the unsupported Light's Hammer cooldown rider remains out because there is no local catalog row. Existing paid-rank semantics are unchanged.
|
||||
- WoW Paladin Vengeance (`talent-1402`) now applies a 1/2/3% per-stack generated bonus (five stacks, 15 seconds) after positive landed player/companion Paladin critical damage from weapon swings, spells, or abilities. It modifies Holy damage and exact local Crusader Strike/Divine Storm ability rows. Generated tooltip values supersede the conflicting linked child Spell.dbc values (three stacks, 30 seconds, Physical+Holy); periodic events without a critical result and critical heals do not proc.
|
||||
- WoW Mage Frost Warding (`talent-70`) has two scoped mechanics. The local Mage Fire Ward/Frost Ward proc uses generated 25/50% proc chances, half damage reduction, and a 1:1 Mana refund for player and companion; applying reduction before ordinary Ward absorption is an explicit simulator inference because the generated tooltip leaves ordering unspecified, and stock full-absorb behavior is not imported. Its generated 25/50% Frost Armor/Ice Armor rider scales only positive armor/resistance modifiers on those exact Mage aura effects, for player and companion, while each aura is active. Generic scalar parsing remains guarded; no local Mage Arcane Ward row exists and Shadow Ward remains Warlock-only.
|
||||
- WoW Hunter Go for the Throat (`talent-1818`) now grants generated 15/30 pet Focus after qualifying player/companion Hunter melee/ranged critical strikes, with a 2-second Hunter-owner cooldown and active living pet Focus cap. The owner-scoped cooldown persisting through pet swaps is an architecture inference. Companion crit setup covers the baseline ranged swing and exact generated direct weapon ability IDs; integration tests exercise the swing and Arcane Shot. Raptor Strike is mapped but not selected by the current Marksmanship AI; traps, Stings, Volley, and unclassified spells are excluded, including a ranged-labeled Explosive Trap. This is a selection coverage limit, not a claim that every mapped strike has an AI-driven event.
|
||||
|
||||
- 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.
|
||||
@@ -54,22 +58,24 @@ Manifest coverage means an effect mapping exists; a graph means an original effe
|
||||
- WoW Shaman Healing Stream Totem ranks 5394–58757 now summon a five-health, nonattacking totem for five minutes. It heals injured party members in 10 yards every two seconds, with each rank's extracted child-spell amount (6–25).
|
||||
- WoW Death Knight Empower Rune Weapon (47568) now activates all six runes with a single source-aligned effect. Black Ice now treats Frost and Shadow as alternative schools rather than requiring both on one spell.
|
||||
- WoW Death Knight Anti-Magic Shell now grants 2% Runic Power per point actually absorbed by its live magic-damage shield, respecting the source absorb cap and the Runic Power maximum; damage the shell does not absorb grants none.
|
||||
- WoW Death Knight Desolation (`talent-2285`) now gives landed Blood Strike hits a self-buff from the local generated child rank: +1/2/3/4/5% damage with all attacks for 20 seconds. Talent rows provide 100% child links 63583/66800/66801/66802/66803 and the generated Blood Strike is spell 45902; no WotLKDB fallback was needed. Local generated data does not define the proc phase, so the landed/main-hand gate is corroborated by the WotLK simulator's [`blood_strike.go`](https://github.com/wowsims/wotlk/blob/master/sim/deathknight/blood_strike.go), not claimed as a primary server-core citation. Player and generated-companion ranks/aura ownership have focused coverage. The current simulator does not emit a distinct off-hand Blood Strike, so only its modeled primary strike can activate the talent.
|
||||
- WoW Death Knight Blade Barrier (`talent-2017`) reads generated ranks for 1/2/3/4/5% all-damage reduction over 10 seconds and linked child IDs 51789/64855/64856/64858/64859. For players and generated companions, the effect activates when a committed action actually sends a ready base Blood rune into cooldown and leaves both base Blood runes cooling. The hook compares rune slots before and after spending, rather than relying on a Blood cost label: a Death Rune paying Blood cost while both original Blood runes are already cooling does not refresh Blade Barrier. Source data is local generated talent metadata; no WotLKDB fallback was needed. Focused coverage: four helper tests and two player/companion integration tests.
|
||||
- WoW Rogue Vanish now enters stealth, clears active root/slow control, and leaves the player in place; Preparation resets only Evasion, Sprint, Vanish, Cold Blood, and Shadowstep.
|
||||
- WoW Warlock Soulshatter (29858) now cuts each nearby enemy's threat by the source-defined 50% in a caster-centered 50-yard radius; targets outside it remain unchanged, including when the area is empty.
|
||||
- WoW Warlock Death's Grasp now applies its rank 1/2/3% hit bonus to direct player ability attacks and its matching spell haste to spell casts; auto-attacks, periodic ticks, and other classes do not receive the hit bonus.
|
||||
- WoW Mage Blink now removes root/stun control and can be cast while stunned; the false movement-away behavior is removed. The intended 20-yard forward teleport remains unsupported until player facing is represented.
|
||||
- WoW Mage Blink now moves forward up to the generated 20-yard distance using the live player-facing yaw, projects to ground, stops before a line-of-sight blocker, and preserves root/stun removal. Its relocation updates the active player position without replacing the run's saved spawn.
|
||||
- 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 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 Shaman Maelstrom Weapon now receives landed player and companion weapon hits using the attacking hand's own speed. Companion Shamans track equipped main/off-hand autoattacks independently; Stormstrike resolves its generated hand-tagged children and skips an unequipped off-hand, while Lava Lash retains its generated damage and rolls using the off-hand speed. Eligible companion casts reduce cast time by 20% per stack and consume the full bundle only after cast commitment, including instant casts and Lesser Healing Wave support casts.
|
||||
- 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 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 (492636) heals every eligible modeled party member and applies six 3-second HoT ticks over 18 seconds; its caster-only +10% Elemental Damage is mapped to the Priest/Scout Water analogue (`frost`), an explicit subclass-based inference.
|
||||
- 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 Mage/Druid Green Guardian now preserves the two RuneWaker HoT payloads, including its target-max-health percentage, and schedules both for five two-second ticks when either rank of Mother Earth's Protection expires. Player and party casters are supported, and healing goes to the protection recipient, including a party ally; no CoA Wiki fallback was needed.
|
||||
- RoM Mage/Druid Green Guardian (494331) now follows the exact-pair current CoA active stack skill: player and generated-companion actors gain one stack per full observed stationary second, up to 20, for +1% movement speed per stack. Companion AI uses the zero-cost self skill on its first available combat action when the aura is absent; that selection is a simulator policy. The old Mother Earth's Protection expiry HoTs are suppressed only for Mage-primary/Druid-secondary, and remain available to other local RuneWaker rows. The one-full-stationary-second post-movement reset is a documented timing approximation; CoA does not define pause duration, stack-loss events, or the MP refund basis, so no decay/refund is modeled. See [the source and runtime note](ROM_DRUID_MAGE_GREEN_GUARDIAN.md).
|
||||
- RoM Warrior/Scout Calm now grants 20 physical hit for 15 seconds only after its paired Skull Breaker fails; landing the attack, failing another ability, and using Warrior without Scout do not trigger it.
|
||||
- RoM Scout/Warden Thorn Arrow now requires the Briar Shield buff before it can be cast.
|
||||
- RoM Warden Briar Shield (493642) now applies RuneWaker component 503958's 900-second self aura and +5% armor without dealing damage on cast. A landed physical melee or ranged attack reflects component 503959's level-scaled fraction of main-hand weapon DPS to its attacker within 50 range. The local `runewakerOrgPower(-0.01, characterLevel, 8)` conversion yields 7.4% at level 80 and 9.4% at level 105; no later Chronicles of Arcadia trigger or stat values were imported.
|
||||
@@ -79,6 +85,7 @@ Manifest coverage means an effect mapping exists; a graph means an original effe
|
||||
- RoM Warlock/Priest Ring of Comfort heals up to six nearby allies instead of damaging the selected hostile target.
|
||||
- Champion Rune Pulse's initial Rage-aura mapping was later corrected to its landed-hit behavior in the follow-up below.
|
||||
- RoM Champion Kinetic Burn, Vacuum Wave, and Light Pulse resolve their self-centered nearby areas; Shock Strike uses a forward cone; Overrule clears control and grants the stated short control immunity.
|
||||
- WoW Shaman Maelstrom Weapon applies its generated 2%-per-consumed-stack PvP damage reduction to eligible damage spells, capped at 10% for five stacks. Player and companion instant, cast-time, and channel execution preserve the stack snapshot; only character PvP targets are reduced, while NPC damage and Lesser Healing Wave healing are unchanged. The generated [talent-2057 row](../src/game/wow335Talents.generated.ts#L82504) supplies the rider, so no WotLKDB value was substituted. Focused validation passed 31 tests across the helper, player combat, and companion suites.
|
||||
|
||||
### Second-pass class follow-ups
|
||||
|
||||
@@ -86,24 +93,27 @@ Manifest coverage means an effect mapping exists; a graph means an original effe
|
||||
- WoW Priest Focused Will preserves its generated +1/2/3 percentage points of spell critical chance; each landed incoming critical hit grants +3% healing received per stack for 10 seconds, up to three stacks, for player and party Priests. Its conditional healing clause is excluded from unconditional talent modifiers. Player and companion incoming-hit regressions pass.
|
||||
- WoW Priest Psychic Scream now applies its fear status once per cast, avoiding duplicate boss diminishing-return consumption, and Pain Suppression now remains eligible for low-health mitigation even though it also reduces threat. The Priest AI/control regression suites passed 45 tests across three files.
|
||||
- WoW Paladin Blessed Life's generated rank tooltips say that all attacks against the Paladin have a 4%/7%/10% chance (ranks 1/2/3) to cause half damage. Player and party enemy-hit paths use stable per-target/per-mob/per-attack/per-revision/per-resolution rolls only after a landed result, after `resolveEnemyDamageResult` and `romWardenWillAttackDamage`, and before mitigation and absorbs. Regressions cover both target paths, rank thresholds, misses, and unrelated party targets.
|
||||
- WoW Paladin Eye for an Eye reflects the talent rank's 5/10/15% of incoming critical damage, capped at 50% of maximum health. Incoming critical-chance modifiers increase enemy attack crit chance. Divine Sacrifice tracks its source-defined 150%-of-caster-max-health redirect cap. Redemption enforces its out-of-combat gate, and Aura Mastery grants eligible recipients ten-second silence and interrupt immunities while doubling modifiers from the same Paladin's active aura on each recipient. For duration, the runtime follows local effect duration index 32 and the generated 31821 spell row (10 seconds); the generated talent-rank tooltip instead says 6 seconds, so that local source conflict remains explicit. Aura amplification applies only to the existing modifier model and does not supply missing Paladin aura mechanics. Redoubt follows the generated rank tooltip: shield-equipped block chance is 4/7/10%, each block has a 10% proc chance, and the rank-matched Strength/damage reduction lasts 10 seconds; WotLKDB supplied the missing child duration only. Vindication now uses generated rank chances (17/34/65%) and the local `4.5 × caster level − 3` reduction, with WotLKDB child 67 filling only its missing ten-second duration. Its source-owned debuff reduces the attack-power-derived part of modeled enemy melee damage. The capstone AP gain remains unresolved because its amount/operation is unstated. Focused Paladin tests cover these changes.
|
||||
- WoW Paladin Eye for an Eye reflects the talent rank's 5/10/15% of incoming critical damage, capped at 50% of maximum health. Incoming critical-chance modifiers increase enemy attack crit chance. Divine Sacrifice tracks its source-defined 150%-of-caster-max-health redirect cap. Redemption enforces its out-of-combat gate. Aura Mastery now follows its generated talent tooltip and [WotLKDB's 3.3.5a spell 31821](https://wotlkdb.com/?spell=31821) at six seconds; it reduces represented confuse/fear/sleep duration by 50% on the Paladin, grants silence/interrupt immunity to affected allies, and doubles modeled modifiers from that same Paladin's active aura on each recipient. Its Retribution Aura damage shield uses generated rank base damage as Holy damage and doubles while the matching Aura Mastery is active; player and party incoming-hit paths are covered. Retribution Aura's DBC +0.033 spell-power coefficient now applies to player and companion proc damage. Concentration Aura reduces the existing incoming pushback penalties for player and generated-companion casters using the exact Aura 149 instance and its Paladin source: the generated 35% base plus that source's local Improved Concentration Aura +7/14/20-point rank. Cast delay and channel loss are scaled after spell-specific pushback resistance; the two-hit cap remains, and Aura Mastery does not multiply the reduction. Stock WotLKDB ranks (+5/10/15) are not used. Redoubt follows the generated rank tooltip: shield-equipped block chance is 4/7/10%, each block has a 10% proc chance, and the rank-matched Strength/damage reduction lasts 10 seconds; WotLKDB supplied the missing child duration only. Vindication now uses generated rank chances (17/34/65%) and the local `4.5 × caster level − 3` reduction, with WotLKDB child 67 filling only its missing ten-second duration. Its source-owned debuff reduces the attack-power-derived part of modeled enemy melee damage. Rank 3's capstone AP gain remains unwired: its current DBC has two self-targeted DUMMY effects without an AP operation or child; separate SLS row 209451's child 200067 cannot be borrowed. Focused Paladin tests cover these changes.
|
||||
- WoW Mage wires Improved Scorch, Fingers of Frost, Hot Streak, and Arcane Concentration from rank-specific data. Clearcasting procs at 5/10/15%, lasts 15 seconds, and makes the next successful active Mage ability free of Mana, Energy, and Rage cost; the missing duration comes from WotLKDB. Interrupted timed casts retain the proc, while channels consume it at their upfront-cost start. Empowered Fire restores 2% of the local level-based Mage base Mana on each positive Ignite tick, capped at the caster's Mana maximum; rank 3 also allows that caster's Living Bomb periodic damage to critically strike. Its generated 7/14/20% rider now increases only the spell-power coefficient of Fireball, Pyroblast, and Living Bomb, leaving DBC base points and die ranges unchanged for player and party Mages.
|
||||
- RoM Warlock Ruthless Judgment (498544) preserves its RuneWaker damage payload and grants the caster the sourced 20-second, +5% spell-damage aura only when the target is below 20% health. The target does not receive the aura; player and companion casts are covered. Its focused runtime suite passed 12 tests.
|
||||
- WoW Shaman now wires Ancestral Awakening, Ancestral Healing, Shamanism, rank-scaled Reverberation, Improved Water Shield, Tidal Waves, and Riptide's target-scoped Chain Heal rider for player and party casters. Improved Water Shield restores the local Water Shield orb mana on qualifying critical Nature heals and doubles its chance for Healing Wave. Tidal Waves uses generated ranks for charges, Healing Wave cast-time reduction, Lesser Healing Wave crit chance, and its 4/8/12% spell-power coefficient; WotLKDB child 53390 fills only the missing 15-second duration. Riptide's 25% Chain Heal boost consumes only the caster's matching HoT on the primary target. Party Shaman heal events now use their actual Nature school for proc checks. Combined Shaman/player/party focused suites passed 54 tests.
|
||||
- WoW Shaman now wires Ancestral Awakening, Ancestral Healing, Shamanism, rank-scaled Reverberation, Improved Water Shield, Improved Shields, Tidal Waves, and Riptide's target-scoped Chain Heal rider for player and party casters. Improved Water Shield restores the local Water Shield orb mana on qualifying critical Nature heals and doubles its chance for Healing Wave. Tidal Waves uses generated ranks for charges, Healing Wave cast-time reduction, Lesser Healing Wave crit chance, and its 4/8/12% spell-power coefficient; WotLKDB child 53390 fills only the missing 15-second duration. Riptide's 25% Chain Heal boost consumes only the caster's matching HoT on the primary target. Party Shaman heal events now use their actual Nature school for proc checks. Combined Shaman/player/party focused suites passed 54 tests.
|
||||
- WoW Shaman Elemental Devastation follows generated talent node 1645 for player and party Shamans: a direct Fire, Frost, or Nature spell critical grants 4/8/12% melee and ranged critical chance for 10 seconds. Periodic damage does not trigger it.
|
||||
- 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 retains its generated 30-second pet aura, but the 1%-per-special-attack rider remains unsupported because the generated tooltip omits the starting bonus and the simulator emits no distinct pet-special event. Ordinary melee hits do not consume it; the fabricated 60-stack placeholder was removed. The focused Hunter suite passed 12 tests.
|
||||
- WoW Hunter's Intimidation proc is attributed to the pet and consumes on its successful melee hit. The current player/companion Wolf baseline selects the highest local Bite rank for pet level, regenerates Focus at five per second, pays Bite's 25 Focus cost, and models Bite as a distinct 1.5-second-GCD special alongside the ordinary swing. Kill Command follows local Spell.dbc rows 34026/34027 and triggered child 58914: +60% pet special damage for 30 seconds, decaying by 20 percentage points on each pet special execution, with a 60-second cooldown. Its 60/40/20% tiers are applied to Bite; autos do not consume them. Mend Pet's HoT targets the living Hunter pet in player and companion casts, companion Hunters cast it below 75% pet health if no HoT is pending, and Revive Pet restores a dead pet at 15% health; companion Hunters automatically revive their dead pet. Furious Howl selects the local level-appropriate Wolf rank and adds its AP/14 × 2-second weapon-speed bonus to pet autoattacks. Pet base health, Strength, AP, and autoattack damage now use local `pet_levelstats` entry 1 and stock TrinityCore 3.3.5 formulas for both player and generated companion pets. Owner stamina and ranged-AP inheritance (45% stamina with +10 health per point; 22% ranged AP to pet AP) now updates dynamically. Pets also inherit spell damage equal to 12.87% of owner ranged AP, and Bite uses local 0.119658 spell-damage and 7% pet-AP coefficients. Selectable families and remaining family utilities remain open. Pet critical inheritance is also unresolved: local pet passive 19591 has zero-valued melee/spell crit aura amounts, and stock 3.3.5 core calculates non-player crit from a 5% base plus pet-owned auras without an owner-crit formula. The only owner-melee-crit formula found is in an unmerged server issue, not stock behavior, so no crit inheritance was added.
|
||||
- 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 Priest/Scout Saint's Blessing (493039) now applies the current CoA +28% damage rider to player and generated-companion packets against living targets below 30% maximum health; direct and periodic damage are checked at each packet. CoA separately lists a Heal rider of +597 and +21% at skill level 105, but the local catalog has no matching Heal 493048 row or lower-rank curve. The older RuneWaker +30/+1%-for-10-seconds Heal aura remains a distinct legacy path and is not evidence for the current CoA rider. See [the source/runtime note](ROM_PRIEST_SCOUT_SAINTS_BLESSING.md).
|
||||
- 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. Weakening Seed and Withering Seed select their RuneWaker base or empowered modifier component instead of dropping or applying both branches. Holy Fury, Extended Recovery, and Knee Ulcer also use their sourced duration/root values and passive gate. Five focused Druid/RoM suites passed 115 tests; the Seed and caster-resource follow-up passed 86 tests across five files.
|
||||
- RoM Warden's Will Attack passive now gates Frantic Briar's 10-second, 10% enemy damage reduction and routes it to every affected mob; all modeled incoming melee recipient paths consume the debuff. Tough Oak (494207) also reduces Warden/Mage Punch and Sepal Stab cooldowns by one second each. Briar Shield (493642) applies its sourced 900-second +5% armor aura and reflects local formula 503959's level-scaled main-hand DPS fraction on landed physical melee/ranged attacks within 50 range. Its focused runtime suite passed 15 tests.
|
||||
- RoM Knight Divine Punishment stores its passive as a marker and applies the extracted 1% bonus only to Punishment damage/DoT components. Knight/Priest Holy Light's Fury (491635) now reduces Holy Seal cooldown by three seconds; its additional damage rider has no numeric payload. The current CoA patch changes the same skill identity to an 8% healing effect, so local RuneWaker values remain authoritative here. Knight periodic/mechanics suites passed 9 tests in this follow-up. The separate Light-vulnerability component remains unresolved because its extracted resistance direction conflicts with the tooltip.
|
||||
- RoM Knight Divine Punishment stores its passive as a marker and applies the extracted 1% bonus only to Punishment damage/DoT components. Knight/Warrior Fearless (490088) preserves RuneWaker's +3% Strength and Stamina auras and grants 20-second Fear immunity; the misleading Fear-control tooltip marker is filtered out. Player and companion regressions passed six tests across two files. Knight/Priest Holy Light's Fury (491635) now reduces Holy Seal cooldown by three seconds; its additional damage rider has no numeric payload. The current CoA patch changes the same skill identity to an 8% healing effect, so local RuneWaker values remain authoritative here. Knight periodic/mechanics suites passed 9 tests in this follow-up. The separate Light-vulnerability component remains unresolved because its extracted resistance direction conflicts with the tooltip.
|
||||
- WoW Warlock Fel Synergy heals its active pet for 15% of effective spell damage at 50%/100% proc ranks; Soul Leech heals on eligible direct single-target Destruction hits with rank-scaled chance, a 15%-of-damage heal capped at 15% max health, and a 3-second cooldown. It also applies the generated −2 attack-power debuff to the hit target for 10 seconds; modeled enemy melee damage now reduces only its attack-power-derived portion while this aura is active. Eradication procs from Corruption at 15% and grants 7/14/20% cast haste for 10 seconds; Improved Shadow Bolt requires a critical Shadow Bolt and applies a 5% spell-crit vulnerability for 30 seconds. The focused Warlock suite passed 42 tests across four files.
|
||||
- WoW Warlock Aftermath covers player and generated-companion Warlocks: generated ranks 18119/18120 (companion node `talent-982`) proc at 50%/100% only after a landed direct Conflagrate hit on its primary target, and the generated talent tooltip's custom payload applies a 50% movement slow for four seconds. Companion procs use a deterministic member-seeded roll. The party Conflagrate path derives its burst from that Warlock's active Immolate/Shadowflame DoT and consumes it only after a positive landed hit. This follows the project source preference; the stock [WotLKDB child 18118](https://wotlkdb.com/?spell=18118) instead gives 70% for five seconds. The party regression covers talent ranks 0 and 2, the exact burst amount, and DoT consumption.
|
||||
- WoW Warlock Metamorphosis now applies the generated 30-second form and its +130% armor contribution, +15% damage, −6 melee critical chance, and −25% stun/snare duration modifiers. It gates Fire/Shadow or shapeshift-safe abilities and adds Shadow Cleave, Challenging Howl, Demon Charge, and Immolation Aura; Immolation uses its local AP-reduction formula. The PvP-only +10% damage rider has no modeled player target. Four focused Warlock suites passed 39 tests.
|
||||
- WoW Death Knight Butchery now grants 10/20 Runic Power for eligible player killing blows at ranks 1/2, clamps to the resource cap, and ignores ineligible targets and party/pet kills. Its focused runtime test passed 15 cases.
|
||||
- WoW Mage Impact now uses the active rank-3 row's 4% landed direct-magic-spell proc to arm the next Fire Blast, which stuns for 3 seconds and consumes the charge on a landed cast; a miss preserves it. Impact's 3/6/9% rank damage bonus is scoped to Fire Blast and Meteor, not all Fire spells. The focused Mage/Impact suites passed 14 tests across two files. The local legacy DBC differs on proc chance and stun duration.
|
||||
- WoW Priest Lightwell now maps each summoned rank to its Renew child and applies its source-backed three ticks at two-second intervals over six seconds, including rank-specific base healing and the five ranks with local spell-power coefficient rows. Its generated rank tooltip's 30%-of-max-health cancellation rule now accumulates effective HP damage separately per recipient and removes that recipient's HoT at threshold; mitigated and absorbed damage do not count. The existing 180-second duration and ten-click limit remain.
|
||||
- WoW Mage Improved Fire Blast (talent-27) reduces Fire Blast's cooldown by 1/2 seconds and increases its damage against monsters by 5/10% for players and generated companions. Explicit rank modifiers prevent the mixed-unit tooltip parser from inventing values; focused Mage and party tests passed 49 tests across four files.
|
||||
- WoW Priest Lightwell now maps each summoned rank to its Renew child and applies its source-backed three ticks at two-second intervals over six seconds, including rank-specific base healing and all six coefficients: the first five come from local/AzerothCore rows, while rank 6 uses 62.60% per tick from the exact WotLKDB 3.3.5a entry for child 48085. Its generated rank tooltip's 30%-of-max-health cancellation rule now accumulates effective HP damage separately per recipient and removes that recipient's HoT at threshold; mitigated and absorbed damage do not count. The existing 180-second duration and ten-click limit remain.
|
||||
- RoM Priest/Warlock Soul Cleansing Ceremony now gates Cleanse's curse-removal category; without the passive, Cleanse remains limited to magic, poison, and disease. Its integration regression verifies that an ally curse is removed with the pair passive.
|
||||
- WoW Warlock Death's Grasp now gives 1/2/3% hit to direct player ability hits and 1/2/3% spell haste to spell casts; auto-attacks and periodic ticks do not receive the hit bonus.
|
||||
- WoW Death Knight Anti-Magic Shell now converts actual consumed magic absorb into Runic Power (2% of absorbed damage in the modeled resource scale), respecting shell capacity and resource caps.
|
||||
@@ -116,11 +126,12 @@ Manifest coverage means an effect mapping exists; a graph means an original effe
|
||||
- RoM Mage Elemental Weakness rank 1 (490247) now applies its sourced 30-second −30 all-resistance debuff to each of the five resistance fields used by combat damage; rank 2 keeps its separate magical-defense effect.
|
||||
- RoM Mage/Priest Grand Dispel (492928) now makes Mage/Priest Purify remove modeled harmful magic, curse, poison, or disease effects while preserving its one-target cleanse behavior.
|
||||
- RoM Priest/Warlock Soul Cleansing Ceremony (498771) now lets its pair-scoped passive expand Cleanse (491175) to curses; without the passive, Cleanse remains limited to magic, poison, and disease. Eight Priest/catalog suites passed 119 tests.
|
||||
- WoW Priest Lightwell now selects the matching Renew child per summoned rank and heals for three ticks at two-second intervals over six seconds, with source-backed per-tick base healing and the five sourced spell-power coefficients. Its generated tooltip's 30%-of-max-health damage cancellation now accumulates effective HP damage per recipient; fully mitigated/absorbed hits do not count. Its existing 180-second duration and ten-click limit remain.
|
||||
- WoW Priest Lightwell now selects the matching Renew child per summoned rank and heals for three ticks at two-second intervals over six seconds, with source-backed per-tick base healing and all six spell-power coefficients; rank-6 child 48085 uses the exact WotLKDB 3.3.5a value of 62.60% per tick. Its generated tooltip's 30%-of-max-health damage cancellation now accumulates effective HP damage per recipient; fully mitigated/absorbed hits do not count. Its existing 180-second duration and ten-click limit remain.
|
||||
- WoW Warrior's Improved Whirlwind now reduces Whirlwind's cooldown by 1/2 seconds at ranks 1/2 without affecting other attacks. Battle Stance's active 10% armor-penetration aura now applies to physical attacks. Battle Shout and Commanding Shout reach party members within their source 30-yard radius. Improved Heroic Strike and Improved Execute reduce Rage costs by 2/4/6 per rank; Improved Execute damage and Unrelenting Assault damage/cooldown are scoped to the named abilities. Execute keeps its below-20% gate and rank-scaled damage instead of mapping to an instant-kill effect. The focused Warrior runtime, catalog, and talent suites passed 57 tests across three files.
|
||||
- WoW Druid Improved Rejuvenation now parses its rank-scaled +7/14/20% bonus and applies it only to Rejuvenation healing. The focused talent and Druid runtime suites passed 12 tests across two files.
|
||||
- WoW Druid Improved Rejuvenation parses its rank-scaled +7/14/20% bonus for player healing, and companion Rejuvenation HoTs now snapshot the same owner talent modifier when applied. The focused talent and party runtime suites passed 54 tests across two files.
|
||||
- WoW Druid Savage Fury now scopes its generated 4/8/12% damage bonus to all six named abilities; its dedicated regression verifies those scopes at all three ranks and excludes unrelated attacks.
|
||||
- WoW Druid Living Seed now stores 30% of an eligible critical heal at talent ranks' 33/66/100% proc chances, lasts 15 seconds, and blooms on the target's next landed direct hit. The runtime covers the player and party targets; the two tooltip-listed heals absent from the local WoW Druid ability catalog cannot trigger.
|
||||
- WoW Druid Predatory Instincts (generated `talent-1795`, spells 33859/33866/33867) applies Aura 163's 5/10/15% increase to the bonus portion of critical damage and Aura 229's 4/7/10% reduction against area attacks; the generated “While in Cat Form” scope gates both. Local rank DBC effects provide the values, and [WotLKDB rank 2](https://wotlkdb.com/?spell=33866) confirms Cat Form and both aura types. Player and generated-companion damage paths are covered. The generic `damagePlayer` adapter lacks delivery metadata, so it cannot classify an untagged call as AoE. Predatory Instincts' “does not stack with other similar effects” clause is not independently exercised because no competing AoE-specific reduction is modeled. The focused Predatory Instincts and Master Shapeshifter regressions passed 16 tests across four 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.
|
||||
@@ -135,32 +146,65 @@ These are specific, source-backed gaps that still need implementation or a gamep
|
||||
|
||||
### WoW
|
||||
|
||||
- **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, 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 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.
|
||||
- **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. The current Wolf pet baseline implements level-ranked Bite, Focus cost/regeneration, distinct special and auto attacks, Call/Revive, Furious Howl, and Mend Pet targeting for players and companions; companion Hunters revive a dead pet and cast Mend Pet below 75% pet health when no HoT is pending. Local Spell.dbc rows 34026/34027 plus triggered child 58914 define Kill Command as +60% pet special-attack damage for 30 seconds, losing 20 percentage points each time a special is executed, with a 60-second cooldown; three Bites therefore use +60/40/20%, while autos leave the aura alone. The base pet table and AP/weapon formulas now match local entry 1 and TrinityCore 3.3.5. Owner stamina/ranged-AP inheritance, 12.87% ranged-AP-to-spell-damage inheritance, and Bite local coefficients of 0.119658 spell damage plus 0.07 pet AP are implemented; selectable families/utilities and pet critical inheritance remain unresolved.
|
||||
- Hunter pet incoming hits now include 35% of the owner's effective armor and 40% of the owner's effective Arcane, Fire, Frost, Nature, and Shadow resistance, added to the pinned level-based pet armor/Agility profile. Player and generated-companion pets use the same shared hit path; pet-owned defense modifiers apply before mitigation, then existing damage-taken auras and absorbs. Holy inheritance remains zero because the character-stat model has no Holy resistance. This formula follows the pinned TrinityCore 3.3.5/TDB server implementation, not direct Blizzard source. Focused coverage passed 11 tests, including an owner armor aura and unchanged non-Hunter summons; see [the source/runtime note](WOW_HUNTER_PET_DEFENSE_INHERITANCE.md).
|
||||
- Wolf Furious Howl uses the local ranks 24604 and 64491–64495: 20 Focus, a 20-second owner-and-pet AP buff, 40-second recovery, and no pet GCD. Its flat pet AP now contributes `rank AP × 2 / 14` damage to each two-second auto swing, following local stock [`Pet.cpp`](https://github.com/TrinityCore/TrinityCore/blob/3.3.5/src/server/game/Entities/Pet/Pet.cpp) and [`StatSystem.cpp`](https://github.com/TrinityCore/TrinityCore/blob/3.3.5/src/server/game/Entities/Unit/StatSystem.cpp). The same auto-cast and damage path covers player and party companion pets.
|
||||
- Player Hunter pets now expose Attack/Follow/Stay commands, Defensive/Aggressive/Passive reaction modes, and configurable Growl autocast (off by default). Their target policy consumes an executed owner's hostile-action event, keeps a valid current victim, and respects follow/stay return movement; generated companion Wolves use Follow/Defensive defaults and receive their own owner's action events. Growl selects the local Wolf rank, spends 15 Focus, uses its 5-second category cooldown, and adds the Spell.dbc flat-threat payload to pet-owned threat without a forced-target timer. Player and generated-companion pet regressions cover owner targeting, victim locking, Growl resource/threat behavior, and the absence of a hard taunt.
|
||||
- Bestial Discipline (`talent-1390`) increases the existing 5 Focus/second regeneration for the active tamed Hunter pet by the generated 50%/100% rank, yielding 7.5/10 Focus per second for player and generated-companion Hunters. The existing elapsed-time pool remains continuous because the source specifies a regeneration increase but no discrete tick; the effective rate is stored on the pet pool and remains capped. The separate Intimidation Mana-cost rider stays scoped by the talent modifier parser; companion-controlled Intimidation has a separate coverage limitation in the feature note.
|
||||
- **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. Rank-6 Renew child 48085 has no local or AzerothCore `spell_bonus_data.sql` row, so its coefficient is sourced directly from [WotLKDB 3.3.5a spell 48085](https://wotlkdb.com/index.php?spell=48085): 62.60% spell power per tick. The 33.33% AzerothCore coefficient belongs to sibling rank 5 child 48084 and was not transferred to rank 6.
|
||||
- **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, Sudden Doom, Blood-Caked Blade, and Epidemic have focused behavior/tests. Epidemic adds its local generated 3/6-second duration as one/two extra three-second ticks to Blood Plague and Frost Fever for players and companions. Blood-Caked Blade uses generated 10/20/30% rank chances; the local 50463 child row supplies 25% weapon damage plus 12.5% per active Frost Fever/Blood Plague from that Death Knight on the target. Player main/off-hand autos are separate rolls, companion procs use the companion's rank and disease ownership, and the triggered strike cannot critically hit. 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. Desolation (`talent-2285`) now applies the generated +1/2/3/4/5% all-attack damage aura for 20 seconds after a landed Blood Strike, reading the rank from the player or party actor. Its generated rows supply 100%-chance child links 63583/66800–66803 but omit proc phase, so the landed/main-hand gate is corroborated by the [WotLK simulator handler](https://github.com/wowsims/wotlk/blob/master/sim/deathknight/blood_strike.go); the simulator models one primary Blood Strike and has no separate off-hand event. Hysteria now uses the current Area-52 talent node's +15% physical damage, 1% max-health loss per second, and 30-second duration; the separate class row's +20% damage value is superseded. Dancing Rune Weapon (49028) locally gives 60 Runic Power, instant cast, a 30-yard range, 12-second Rune Weapon and 90-second cooldown; the source-linked child 51906 reduces the weapon's damage by 50%, while the parent 10-point effect is a dummy ([WotLKDB 3.3.5a](https://wotlkdb.com/index.php?spell=49028)). Its 12-second aura now activates for players and generated companions. Both runtimes mirror eligible landed direct hostile primary-target spells and melee autos at half of the owner's pre-target-mitigation raw damage; source gates and 50% scaling have 10 helper tests, with player Death Coil/window-expiry and companion aura/Death Coil/melee integration regressions. The [AzerothCore 3.3.5-derived handler](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp#L3260-L3419) supplies the spell-copy/melee-recalculation server reference and its target/utility/AoE restrictions; this is a fork handler, not stock TrinityCore behavior. Because no independent Rune Weapon stats are modeled, mirror damage reuses the owner's raw hit through the existing target damage path; periodic ticks and the separate summon actor are not modeled, and companion use remains a simulator extension. Spell visuals have not received a rendered class pass.
|
||||
- **Death Knight (continued):** Bloody Vengeance (`talent-1944`) reads its 1/2/3% physical damage per stack, three-stack maximum, 15/30/30-second durations, and linked child IDs (50447/50448/50449) from generated talent ranks. It triggers only after a critical hit has dealt positive damage, and applies/refreshes a source-owned physical damage aura after the triggering hit; player and generated companion criticals are supported. Companion autoattacks and generated direct damage abilities get combat-result roll contexts when the talent is learned. Generated channel damage can trigger only when its path emits a critical result; deterministic periodic ticks without a critical result do not trigger it. No external spell database was needed.
|
||||
- **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. Its custom patch-D child 415010 also explicitly names Combat Readiness (86378) as a one-second recipient, but that ability is absent from the stock 3.3.5 generated catalog. Improved Kick uses the stock two-rank 50%/100% chance to silence for 2 seconds. Its max-rank capstone restores up to 40 Energy and grants 35% attack speed for 6 seconds after a successful interrupt; both effects are modeled for player and party Rogues. The generated movement-speed cap rider is intentionally ignored. 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 applies its generated 3/6/9/12/15% mana-cost reduction to modeled Shaman spells through explicit ability scopes. Generated Shaman Convection (talent-564) mentions Earthquake, but local 3.3.5a Spell.dbc has no Earthquake SkillLineAbility assignment and the generated class catalog has no learnable Earthquake row, so no Shaman ability or cost mapping is synthesized. 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. Improved Shields adds the generated 5/10/15% mana bonus to Water Shield orbs for player and companion Shamans. 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 (`talent-574`) uses the local generated 50%/100% proc chances and 30% mana discount for the next two Fire/Frost/Nature spells or abilities; after a landed nonperiodic elemental spell critical, the player and generated-companion paths grant two charges that last 15 seconds (local SpellDuration row 8) and consume a charge at cast commitment. The new companion integration matches the existing player rules. Its focused profile and party suites passed 2 and 5 tests. Lightning Overload (`talent-1686`, rank spells 30675/30678/30679) uses [the generated local 3.3.5 rank row](../src/game/wow335Talents.generated.ts#L72628): landed direct Lightning Bolt or Chain Lightning hits use its 7/14/20% chance, tripled on critical hits (21/42/60%). Player and generated-companion paths resolve a same-target echo at half the initiating pre-defense amount, with no extra mana cost or threat; the echo gets its own hit, critical, resistance, and mitigation resolution and cannot recursively proc Overload. Since the local tooltip does not specify per-jump Chain Lightning event rules, the simulator checks each direct target hit in its existing effect loop. Companion Lightning Bolt/Chain Lightning hits now resolve Nature mechanically through `abilityDamageSchool`; the presentation-school field remains unchanged. The separate generated custom Unleashed Storm capstone rider is outside this implementation. Focused helper and player/companion integration validation passed 4 tests. Player and companion Maelstrom Weapon follow the 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/companion autoattacks and Stormstrike hand strikes use their own weapons' speeds; party tests also cover Lava Lash's off-hand speed and eligible Lesser Healing Wave support casts. Fireball and Frostbolt are Mage spells in the local 3.3.5a catalog: Fireball ranks are assigned to Mage Fire (SkillLine 8, class mask 128) and Frostbolt ranks to Mage Frost (SkillLine 6, class mask 128), with no assignments on Shaman SkillLines 373/374/375 (class mask 64). Their mention in generated Maelstrom Weapon (talent-2057) rank text does not establish Shaman class attribution, so no Shaman rows or gameplay mappings are added. The class-line mapping comes from [the ability generator](../scripts/generate-wow335-abilities.mjs#L16-L23). Its 2%-per-stack PvP damage reduction has no PvP runtime path. Elemental Warding (`talent-1640`, local generated rank spells 28996/28997/28998) reduces owner incoming Fire/Frost/Nature damage by 2/4/6%. Its narrow rank helper ignores the separate Tethered Elemental-only clauses; both player and generated-companion incoming hits apply the school-scoped modifier after existing race/Druid multipliers. The focused 6-test helper/parser suite and 7-test player/companion integration suite pass, covering all three affected schools and unchanged physical/Arcane/Holy/Shadow hits. No Tethered Elemental actor exists, so that pet's 5/10/1Line truncated
|
||||
- WoW Shaman Flurry (`talent-602`) now grants the actor's generated 4/6/10% melee haste after a landed direct melee critical, for the next three melee autoattack attempts across both hands. Local child Spell.dbc/SpellDuration.dbc rows supply the 15-second aura; WotLKDB's 12/18 child values conflict with the generated expanded tooltips, and rank two's raw basePoints also conflicts with its tooltip's 6%, so the implementation follows the generated tooltip values. Missed swing attempts consume a charge; a critical-triggering swing does not. Re-proc refresh behavior and same-time hand order are explicit assumptions pending stock server evidence. Player and companion integration coverage passed with the helper suite (8 tests); see [the source/runtime note](WOW_SHAMAN_FLURRY.md).
|
||||
- **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. Rank 3's capstone AP gain stays unwired: its current DBC effects are a reducer child plus two self-targeted DUMMY effects with no AP aura or child; the separate SLS row's 200067 link cannot be assigned to rank 3. 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. The catalog attribution check is complete: Moonfire and Starfire are Druid ranks (local class skill line 574); Arcane Orb DBC rows 34172/34190 are not mapped to a player class or talent; Frozen Orb and Starsurge have no named row in the local 3.3.5a Spell.dbc. WotLKDB's Frozen Orb 72082 row has no Mage class/trainer attribution, so none of these tooltip references warrants a Mage catalog addition or remap. The active generated Impact row (4% proc, 3-second stun) takes precedence over the legacy DBC row. Focus Magic now takes its recipient +1% spell critical-chance value and tooltip from generated talent node 2211, while its separate +3%/10-second Mage proc remains unchanged; the older enUS ability row 54646 recipient +3% is superseded. Shatter (talent-67) adds generated +17/34/50 percentage points of critical chance to nonperiodic Mage spell hits against frozen targets, including Fingers of Frost, for players and party Mages. 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 no generated rank links the unlearnable patch-D helper rows 830297–830299 (a one-charge current-cast buff and cast-finish cleanup); the incoming-hit trigger, rank gate, and consumption behavior remain unestablished, so the rider stays unwired. 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. The catalog references were verified: Lesser Healing Wave is a Shaman spell in stock 3.3.5, while Flourish appears only in the custom Ascension class-11 catalog, so neither is missing from the stock Druid 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. Nature's Reach (talent-764) adds +2/4/6 yards to cast-time/channel damaging spells for players and generated companions; instant damage and healing receive no range bonus. Master Shapeshifter (talent-1915) now applies its generated 2/4% bonus only for the active Bear, Cat, Moonkin, or Tree form, covering player/companion damage and critical chance plus direct/periodic healing paths.
|
||||
- **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, Anger Management, and Damage Shield have focused behavior tests. Damage Shield reflects the generated 10/20% of the Warrior's resolved block value after a landed direct melee hit, for both players and generated companions. 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 (`talent-159`) grants the generated 2/4 Rage on landed, damaging main-hand melee autoattacks at the upstream [WotLK Simulator](https://github.com/wowsims/wotlk/blob/master/sim/warrior/talents.go)'s 3 procs-per-minute-per-rank rate, using each Warrior's own un-hasted weapon speed. Player and companion procs are covered; Warrior off-hand autos are not modeled. The generated tooltip takes precedence over the conflicting child payloads (12964/812964). Improved Victory Rush now uses the generated 33/66/100% parry chances, 10-second window, 12-second internal cooldown, +20/40/60 percentage points of critical chance, and +33/66/100% threat while using the parry window; base Victory Rush also opens for 20 seconds after an XP-yielding kill. Player and generated companion casts require and consume readiness. Critical Block (`talent-1893`) applies its generated 8/16/25% additional reduction to successful blocks for players and generated companions, except against player attackers and with Touched by the Light (`talent-2195`); the conflicting stock double-block chance is not used. Improved Defensive Stance applies its generated stance mitigation and avoidance-triggered Enrage proc for both paths.
|
||||
|
||||
### 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. Warrior/Scout Calm now adds +20 physical hit for 15 seconds after failed Skull Breaker, following the populated RuneWaker passive/component values. Druid/Warrior Briar Technique now gains up to five +3% Physical Attack/+3% Wisdom stacks for 15 seconds on landed Natural Attack or Heart Piercing hits while its passive is learned; the local child payload is duplicated on those two skills, so the implementation scopes the proc to those source-backed triggers. The [current CoA Druid/Warrior page](https://chroniclesofarcadia.com/en/wiki/skills/list/8-1) names Cross Thorn Blow (494018) as a third trigger, but the matching RuneWaker ability row is absent; its locally referenced child 499956 is also missing, so no third trigger has been inferred. The current CoA [Warrior/Knight page](https://chroniclesofarcadia.com/ja/wiki/skills/list/1-6) identifies local Heroic Surprise Attack (492661) as Heroic Charge Attack; Resist (493251) now references Blocking Stance 493287, while RuneWaker has active Blocking Stance 491480, and the Wiki omits Resist's Rage chance/amount. Recovery of Life (494377) is now Rage of Calmness with an unquantified rider on the [Warrior/Druid page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-8). The [Warrior/Rogue page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-3) gives Exhaustion (493248) a 30% chance to return 25 Energy and 50 Rage on current Probing Attack 493283, but RuneWaker maps the trigger to primary Warrior Probing Attack 491133 and describes player-only target-resource drain. The [Warrior/Warlock page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-9) now describes Speed Slash (499827) as increasing Slash damage and reducing its Rage cost by 10, and Excellent Combat Training (499806) as +20% Physical Damage; these conflict with RuneWaker's cooldown-reset references to missing rows 499828, 499825, and 499826. No conflicting Wiki payloads were imported pending identity/version confirmation.
|
||||
- **Scout:** Ranged Weapon Mastery's bow/crossbow accuracy now affects ranged hit chance only; Poisonous Spit requires Poison Powder; Accurate Targeting grants its source-backed buff and cooldown; and Scout/Priest Sniper reduces Snipe's cast time by one second. Scout/Warrior Enhanced Throat Attack and Rogue/Scout Slit Throat now silence a target for four seconds after a landed Throat Attack; Enhanced Throat Attack's extra range is unquantified in local data. The [current CoA Scout skills page](https://chroniclesofarcadia.com/wiki/skills/list/2-6?locale=en) confirms ranged-weapon requirements for Shot, Joint Blow, Vampire Arrows, Wind Arrows, and Autoshot; the combat validator now enforces those requirements, with player and companion rejection covered for Scout secondary skills without a ranged slot. Drums of War (493010) now triggers for the learned Scout/Priest build after a landed ranged Shot (490423), applying RuneWaker's +20 physical/magical critical-strike modifiers for 8 seconds to living party members within 50 yards. Each caster has a separate 15-second cooldown. RuneWaker describes the recipients as nearby party members but omits the radius; the [CoA Scout/Priest page](https://chroniclesofarcadia.com/en/wiki/skills/list/2-5) supplies range 50 and was used only for this missing detail. Player, party-caster, cooldown, and range behavior have focused regressions. The newer CoA critical-damage rework was not imported. Natural Poison Powder acquisition for Scout/Rogue remains unavailable in the loadout model. Pet crit/dodge/damage passives remain unsupported because summons expose no pet-specific stats or event path; other paired passives still need review.
|
||||
- **Rogue:** Dagger Mastery, Treasure Hunter, Weakened Soul Stab, rear-position gates, Sneak Attack's out-of-combat gate, resource gates, Rogue/Priest Shadow Walker, and Rogue/Mage Day of Rain's Throw cooldown and Enchanted Throw hit-rate interaction have targeted behavior. Rogue/Priest Sinister Methods now grants +20 physical critical strike for 10 seconds on landed Low Blow or Blind Spot hits, including critical hits, with a 20-second cooldown; the passive is only a marker until one of those hits lands. Instant Shadow (497206) now arms after a successful dodge and can be activated for the local child 502597's three-second stealth. Enchanted Throw (491178) now adds the referenced generated Throw (490309) damage on landed Rogue/Mage melee auto-attacks; the hidden 491185 child payload is absent, so the runtime reuses the local Throw damage type/calculation. Projectile ammunition is not modeled. Off Hand Mastery (490143) remains unsupported because RoM off-hand swings are not modeled; local component 620335 carries its damage/accuracy modifiers, while the [current CoA Rogue page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1) reports later level-55 values that were not copied into the RuneWaker model.
|
||||
- **Warrior:** Survival thresholds, weapon requirements, three-hit Moon Cleave, several Slash/state riders, and Warrior/Priest Defender's Roar party max-health effects have focused fixes. Warrior/Scout Calm now adds +20 physical hit for 15 seconds after failed Skull Breaker, following the populated RuneWaker passive/component values. Druid/Warrior Briar Technique now gains up to five +3% Physical Attack/+3% Wisdom stacks for 15 seconds on landed Natural Attack or Heart Piercing hits while its passive is learned; the local child payload is duplicated on those two skills, so the implementation scopes the proc to those source-backed triggers. The [current CoA Druid/Warrior page](https://chroniclesofarcadia.com/en/wiki/skills/list/8-1) names Cross Thorn Blow (494018) as a third trigger, but the matching RuneWaker ability row is absent; its locally referenced child 499956 is also missing, so no third trigger has been inferred. The current CoA [Warrior/Knight page](https://chroniclesofarcadia.com/wiki/skills/list/1-6?locale=en) identifies local Heroic Surprise Attack (492661) as Heroic Charge Attack. It keeps Resist at 493251 and lists the distinct level-50 Blocking Stance upgrade at 493287; current CoA says Resist grants +80% Aggro and +20% received physical-damage reduction while that stance is active. Patch 11.1.1 introduced the aggro effect (current value supersedes the older +75.6% patch text), and patch 12.1.3 says the missing damage-reduction effect was fixed. Blocking Stance and Resist are now cataloged and integrated separately for player and generated-companion incoming hits and threat, gated by each learned passive plus active local Blocking Stance 491480. Blocking supplies +8% Parry, −18.9% damage taken, and +75.4% Aggro; Resist supplies +80% Aggro and −20% physical damage taken. The two Aggro factors remain individually modeled but their overlap is neutral/unmodeled until a stacking rule is sourced. The 3-Rage proc and 10-second lockout are retained as tooltip information only because the proc chance is unspecified. Recovery of Life (494377) has a pair/learned-passive-gated runtime helper for current CoA Rage of Calmness: local RuneWaker values provide −10 Rage and −10 seconds to modeled Whirlwind 490133, applied for both player casts and generated companions. Component 506368 supplies the 20-second post-hit window. CoA confirms the effect directions but points to Whirlwind 494939; the ID mismatch is documented. Its general post-hit Rage reduction stays unwired because neither source quantifies it. Source: [CoA Warrior/Druid page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-8). Current CoA [Warrior/Rogue](https://chroniclesofarcadia.com/en/wiki/skills/list/1-3) confirms Exhaustion's (493248) identity and passive Whirlwind reductions. The runtime now restores 25 Energy and 50 Rage with a deterministic 30% chance after landed pair Probing Attack 493283, only when Exhaustion is learned; player cast-path coverage and a capped generated-companion hook regression are included. Its separate catalog row uses 664.3% main-hand weapon DPS, or 1116.9% with caster-owned Bleed or the locally represented two-handed mace/hammer. The unspecified hammer cooldown rider is not modeled. Local primary-Warrior Probing Attack 491133 remains distinct. The earlier audit claim that 493248 was Morale Slash and 493283 Exhaustion was incorrect: the current page lists 493248 as Exhaustion and Morale Slash as 1491760. The [Warrior/Warlock page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-9) describes Speed Slash (499827) as increasing Slash damage and reducing its Rage cost by 10, and Excellent Combat Training (499806) as +20% Physical Damage. Runtime already models the known −10 Rage cost and +20% Physical Damage; it leaves Speed Slash's unquantified damage increase and RuneWaker's unsupported cooldown-reset references alone. Generated tooltips now state the supported −10 Rage and +20% Physical Damage effects without restoring the unsupported cooldown resets.
|
||||
- **Scout:** Ranged Weapon Mastery's bow/crossbow accuracy now affects ranged hit chance only; Poisonous Spit uses its landed-hit triggers and RuneWaker item 200563 Poison Powder as an unlimited carried reagent, without the separate Rogue Poison Bottle aura; Accurate Targeting grants its source-backed buff and cooldown; and Scout/Priest Sniper reduces Snipe's cast time by one second. Scout/Warrior Enhanced Throat Attack and Rogue/Scout Slit Throat now silence a target for four seconds after a landed Throat Attack. Hash-matched local RuneWaker rows do not quantify Enhanced Throat Attack's extra range or link a range modifier to Throat Attack; the passive's own range field 5 is cast-distance metadata, not an additive range bonus. The [current CoA Scout skills page](https://chroniclesofarcadia.com/wiki/skills/list/2-6?locale=en) confirms ranged-weapon requirements for Shot, Joint Blow, Vampire Arrows, Wind Arrows, and Autoshot; the combat validator now enforces those requirements, with player and companion rejection covered for Scout secondary skills without a ranged slot. Current CoA Drums of War (493010) replaces RuneWaker's +20 physical/magical critical-strike rating payload: after a landed local ranged Shot 490423 (the CoA same-name/role Shot is 493011), it grants +20% critical damage for 15 seconds to the living party within modeled range 50. Another 15-second post-expiry lockout makes the next proc ready 30 seconds after activation. The shared player/companion critical resolver adds this to the critical bonus portion, preserving crit chance and ordinary hits; it adds with the Warlock/Priest +25% critical bonus for party actors that have both auras. Current CoA Screen Attack (499555) replaces the local passive marker with an active 17.4% damage buff for 20 seconds and a 60-second cooldown. The current skill page's +55 is skill training, so the simulator applies the max-row value at all character levels. Its radius 150 is modeled as 15 project units; recipients follow the existing living-party, 3D-radius, and line-of-sight checks. Patch 11.1.1 states 30 seconds, but the current CoA skill row states 20; runtime follows the current row and uses the patch only for periodic-damage coverage. Player and generated-companion tests cover activation, recipients, direct/periodic damage, expiry, and cooldown. Priest/Scout Curing Shot (492636) now uses the current CoA instant cast, 6-second cooldown, +105 heal values, uncapped modeled-party fan-out, and caster-only +10% subclass-defined Elemental Damage. The Priest/Scout kit is water-themed, so the simulator maps this buff to its `frost` Water analogue only; fire/nature/arcane/holy/shadow/physical remain unchanged. Its 120 MP local cost is preserved because no lower-rank CoA cost is published. See [the source/runtime note](ROM_PRIEST_SCOUT_CURING_SHOT.md). The generic damage resolver now filters expired dealt-damage auras at the timestamp boundary, which makes timed party buffs stop affecting the exact expiry-time hit. Pet crit/dodge/damage passives remain unsupported because summons expose no pet-specific stats or event path; other paired passives still need review.
|
||||
- Scout/Knight Enhanced Armor Perfection (492346) sets Enhanced Armor's duration to the 600 seconds stated by local component 501823. Scout/Priest Sniper (492344) now reduces Snipe's cast by one second for both players and generated party Scouts.
|
||||
- **Rogue:** Dagger Mastery, Treasure Hunter, Weakened Soul Stab, rear-position gates, Sneak Attack's out-of-combat gate, resource gates, Rogue/Priest Shadow Walker, and Rogue/Mage Day of Rain's Throw cooldown and Enchanted Throw hit-rate interaction have targeted behavior. Rogue/Priest Sinister Methods now grants +20 physical critical strike for 10 seconds on landed Low Blow or Blind Spot hits, including critical hits, with a 20-second cooldown; the passive is only a marker until one of those hits lands. Instant Shadow (497206) now arms after a successful dodge and can be activated for the local child 502597's three-second stealth. Enchanted Throw (491178) links its buff component 501279 to hidden physical child 491185, also described by passive 491184. Child component 502595 combines skill-scaled main-hand DPS and `Throw_DMG`; component 502596 grants 10% physical and magic damage reduction for four seconds. RuneWaker C++ source defines these terms, but Healer Man does not have the native `Main_DMG`/`Throw_DMG` derivation or `CalPhyDamage` mitigation inputs. Player and companion incoming-hit branches are present in `combatStore.ts`; no child counterattack hook has been added. The mechanic remains unresolved, and the existing player outgoing Throw (490309) fallback stays in place. Projectile ammunition is not modeled. Off Hand Mastery (490143) now applies RuneWaker component 620335's +4% off-hand damage and +4 percentage-point accuracy modifiers to player and companion off-hand swings. RoM Rogue autoattacks use separate hand timing; companion main-hand hits also use the dual-wield hit table when an off-hand weapon is equipped. Focused regressions cover player and party swing behavior. The [current CoA Rogue page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1) reports later level-55 values that were not copied into the RuneWaker model.
|
||||
- RuneWaker Numbing Dagger (490347) supplies a 10-second creature sleep (500737) and a separate 3-second player sleep (503046). `applyStatus` now selects the local duration by defender kind for player casts, companion casts, and the lower-level control action; focused regressions cover NPC and character-defender targets.
|
||||
- Rogue/Scout Coerce (493019) now follows the current [CoA skill entry](https://chroniclesofarcadia.com/en/wiki/skills/list/3-2): Low Blow deals 20.2% more damage and costs 5 fewer Energy. The Wiki says “lesser energy cost” without a number, so the implementation keeps only the compatible −5 amount in the RuneWaker tooltip; it removes RuneWaker's below-30%-health temporary +2% Physical Damage proc because CoA defines a different direct Low Blow modifier. Both player and generated-companion casts are covered. Focused Rogue mechanic, pair validation, and party ability tests passed (98 total); the TypeScript no-emit check passed.
|
||||
- **Rogue/Champion Nerve Gear:** RuneWaker passive 498839 (child 621652) now remains a learned marker; each successful non-passive Rogue/Champion elite-skill cast grants +5% attack speed and +2 dodge for 5 seconds, stacking to five. Player and party hooks apply once per cast, including area casts.
|
||||
- **Rogue Poison:** RuneWaker Poison (490329) component 500715 specifies a 50% normal-attack trigger to child 490357. Its local tooltip supplies a four-stack cap and a 2% reduction to target damage per stack. Player and party landed normal attacks now apply source-owned stacks when that Rogue has learned Poison and its self aura is active; the common enemy-hit consumer applies the debuff to melee, ranged, and spell damage. The hidden child has no local duration row, so only its missing 900-second duration is taken from the [current CoA Rogue skills page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1); its conflicting 8% reduction and attack-speed rider are excluded.
|
||||
- **Priest:** Knight/Priest Holy Smite now heals and consumes Holy Seals correctly; Holy Protection applies its alternate threat payload as intended; Priest/Knight Holy Power applies RuneWaker's +60 Wisdom and +1% Wisdom for 20 seconds, ending after three successful casts of Heal; Priest/Mage Enlightenment now triggers when the Priest/Mage caster completes Heal (490261), grants +1% Wisdom for 8 seconds, and uses a per-caster 20-second internal cooldown in player and party paths; its generated passive aura is suppressed at startup. Lightning Rod requires the sourced Bone Chill target state; Priest/Scout Saint's Blessing uses its friendly-target health threshold; Priest/Warrior Battle Monk Stance gates five stance-dependent abilities for player and companion paths; Priest/Warlock Prayer of Tribulation has focused runtime coverage; and Soul Cleansing Ceremony conditionally extends Cleanse to curses. Blessed Spring Water (490298), Embrace of the Water Spirit (491323), and Shadow Fury (492932) now apply their source-described modifier auras to the party; Embrace's separate periodic proc remains caster-only. Infused Light Chain (498610) locally says it increases Chain of Light damage, removes its cooldown, and can add Rage on hits, but the generated passive has no source components: the trigger chance, Rage amount, and damage increase are all missing. Chain of Light (491164) itself has a local 6-second cooldown, and the existing override removes that cooldown when the passive is learned. The [current CoA Priest/Champion record](https://chroniclesofarcadia.com/en/wiki/skills/list/5-10) reuses ID 498610 for a differently named Rage Control passive that grants 20 Rage per second of spell channeling; because that is a conflicting later payload rather than a matching source for the local on-hit effect, no chance, Rage amount, or damage bonus was inferred from it. Protection Rune Seal (498613) now attaches a hidden monitor to Regenerate and uses its local 8% chance to reset Heart Rune Energy on a successful periodic tick, including the final tick before expiry; the healing-strength rider remains unquantified. Current CoA records also conflict with local RuneWaker IDs/names or payloads for Priest/Champion 498612 and Priest 490274–490276; those records were not used to remap the local catalog. Other conditional/passive mechanics still need review.
|
||||
- **Knight:** Divine Punishment's 1% damage modifier is now limited to Punishment instead of applying globally; Knight/Priest Holy Light's Fury applies the local 3-second Holy Seal cooldown reduction. Disregard Danger (493273) now redirects 40% of one ally hit for 10 seconds, consumes the redirect after the first hit, and gives the caster +20 Parry for 10 seconds; its integration test verifies the 40/60 split and that the next hit is unredirected. Master of Parry (492935) applies the generated +5,000 Parry modifier for 15 seconds and consumes it after two successful blocks; the hidden periodic trigger now shares the source 15-second lifetime. Its runtime test forces real block events and verifies the charge countdown/removal. Shield of Valor (490468) now reduces a party Knight's Holy Seal cooldown by 1 second on each parry while its local 15-second aura is active; the deterministic regression covers active-parry, expired/inactive-parry, and active non-parry controls. War Prayer (490078) schedules six personal Holy Seal increments at 3-second intervals over 18 seconds, and its regression test verifies Punishment consumes the earned stacks. Local child 490079 is absent; the current CoA [skill entry](https://chroniclesofarcadia.com/en/wiki/skills/list/6-1) and [fix patch](https://chroniclesofarcadia.com/patch/10-4-1-1000) corroborate the personal Seal mechanic. Deadly Whirlwind (491639) now applies its local 10-Rage/20-second reductions to Warrior Whirlwind (490133) in player and party casts; paired passive lookup is scoped to a learned Knight/Warrior build. Focused regressions verify both paths, including the party cast at 40 Rage against Whirlwind's 50-Rage base cost. Its 10-second healing-reduction rider is not inferred because its local trigger/child attribution is absent. God's Sword (491621) has an isolated helper and regression that scopes the local 40% critical-hit-rate value to Holy Strike (490148) and the Knight/Rogue pairing, but landed-damage integration remains unresolved: the local tooltip says only “increased by 40%” and the RuneWaker passive row has no extracted components or numeric modifier. The current combat resolver interprets added critical chance as percentage points; the local data does not establish whether this is +40 points or a 40% relative increase to Holy Strike's existing critical chance. The current [CoA 10.6.1 patch](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) describes a later active/offhand-DPS version of God's Sword that conflicts with the local passive tooltip and cannot resolve the older value's semantics, so that later behavior was not imported. Some other periodic/triggered spell references do not resolve. Holy Light's Fury's extra damage value is absent, and the current CoA page describes a later, conflicting healing version. Throw Shield and Shield Bash require an equipped shield, and Arrow of Vengeance enforces its ranged-weapon requirement. Light-vulnerability remains ambiguous between tooltip and extracted resistance component.
|
||||
- **Warden:** Nature Crystal immunity, Heart of Oak/Crystalline absorbs, Awakened immunity/next-crit behavior, Nature Power scaling for Healing Ripple/Gift Pulse, Spirit of Life party HoT, Mysterious Grace party scope, Will Attack's Frantic Briar debuff, Damage Transfer's 30% split to a live nearby pet, Tough Oak's one-second Punch/Sepal Stab cooldown reductions, and Briar Shield's sourced 900-second +5% armor aura plus level-scaled physical reflection have focused fixes. Briar Shield uses RuneWaker's local formula for main-hand DPS reflection on landed physical melee/ranged attacks within 50 range; newer Wiki trigger/stat behavior was not imported. Tough Oak's pet-defense rider has no pet-stat model. Oak Blessing (494011) now activates RuneWaker component 620679's +5% physical attack aura and 1%-maximum-HP healing every three seconds only during Heart of the Oak's 60-second duration. Three gaps remain source-limited: Damage Transfer (493399) component 503942 references missing child 493416 via `onHitMagicId` and has 10-second duration/30% modifiers, while the tooltip references local component 503643 with 15-second duration; no child payload exists in the generated catalog, and the [current CoA Warden page](https://chroniclesofarcadia.com/en/wiki/skills/list/7-2) has no matching child details and describes a conflicting version. Revival (499542) references missing state 499544; its only extracted component, 623078, contains no numeric payload. The [current Rogue/Warden page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-7) reuses ID 499542 for Wood Spirit's Will and ID 494043 for Blood Hound, so those later entries do not match local RuneWaker Revival or Weak Point Strike. Weak Point Strike's two local damage components (620294/620295) preserve base and bonus hit formulas but contain no extra-hit proc chance; do not invent one. Other reactive passives remain incomplete.
|
||||
- **Mage:** rank-1 Catalysis mana ticks, rank-2 Catalysis cast haste, Phoenix's 12-target cap, Control Flame, Flame Follower's Fireball cooldown rider, and rank-1 Elemental Weakness's all-resistance debuff are covered. Elemental Extraction's critical triggers now match its RuneWaker tooltip. Meditative Current now suppresses the generic always-on passive payload and applies its source-complete rule (landed Flame 491150 or Plasma Arrow 491169, +10 spell haste for 10 seconds, 30-second internal cooldown) to player and party Mage casters, with an independent cooldown marker per caster and focused runtime coverage. Mage/Knight Light of Day (493034) now suppresses its generic passive aura and grants +8% Holy damage for 15 seconds only after a landed direct Holy Light Strike (491594) or Light Charge (492634) hit, with an independent 20-second cooldown per caster; player and party regressions cover both triggers, startup suppression, and cooldown behavior. Fireball 490204 is intentionally excluded by the local tooltip. Eruption's local passive row (490221) has no proc chance or effect payload. Party Mage casts now receive the same deterministic proc roll and existing 10% damage-doubling behavior as player casts, but this only establishes runtime parity. The current CoA entry for the same spell ID confirms the identity and Fireball/Flame triggers but has no numeric payload; CoA patch 10.2.1 reports a later 30% proc chance (up from 10%) and Eruption damage increased to 200%, without confirming that patch values match this RuneWaker snapshot or defining whether 200% is a separate proc hit or multiplier. Those later values and the damage interpretation remain unimported and unverified. Rank 2's source payload has no mana tick; Flame Follower's crit amount is absent from source components.
|
||||
- **Druid:** Spirit of Life party HoT, Unity's guaranteed spell crit/healing and 15-second movement lock, Spirit Guidance's Nature Power gain, Savage Blessing's friendly target, Healing Arrows, source-backed Nature Power selection/spending for Fountain, Seeds, Wrath, Sandstorm, Earth Chain, Healing Wind, Poisonous Widow, Heart Piercing, and Cross of Thorns, plus Brilliance of Nature's 10-to-15 dynamic cap for the player and companion, have focused fixes. Weakening Seed and Withering Seed select exactly one local RuneWaker modifier branch per cast; each component maps to its own 20-second −2% target debuff. Caster-directed Nature Power drains are now distinct from hostile-target resource steals, preserving the source ordering for spends and refunds. Mage/Druid Green Guardian retains both local HoT components and, on either Mother Earth's Protection rank expiring, heals the protected recipient for five ticks at two-second intervals. Player and companion casters are covered. Charm faction conversion and Rebirth's debt reductions have no corresponding simulator state; hostile player targets are unsupported, so Necrotic Wound's player-only 10-second healing reduction cannot be applied or tested. Local passive proc probabilities remain unavailable on Revival Slash, Quick Light Protection, Boiling Rock, Inspiration, Inspiration Display, Avalanche, Extended Recovery, and Spring Flower Eulogy; the current CoA Inspiration/Recovery descriptions conflict with the local trigger behavior, so no later proc rates were imported.
|
||||
- **Warlock:** Mind Transference's Rage-to-psi conversion, Heart Fence's full Rage consumption and duration scaling, Heart Strike gate, Crime and Punishment refresh/scaling, Liquidation Suffering target scopes, named cooldown resets, Defense Net's magic mitigation/control immunity, and Mind Shield's pair-scoped duration reduction have focused behavior. Helpless currently maps to fear in the runtime. Breathing Control and several Priest-pair passives lack proc probabilities or complete event details; hidden child 499869 and End of Thought's tick interval are absent. Current [CoA Warlock/Warrior](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1) and [Warlock/Priest](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) records conflict with RuneWaker values and IDs for Mind Transference, Deep Breathing Control, Divine Assistance, and related triggers; those were preserved.
|
||||
- **Champion:** Fearless Blow's health/Rage gate, Rune Draw pull, Indomitable Spirit threshold, cooldown-reset scopes, rank-1 Forge trigger, Rune Pulse's 5 Rage on landed hit, Champion/Warrior Determination Rune's 30%-max-HP shield, and Rune Overload's local 15-Rage-per-second drain and zero-Rage termination have focused fixes. Soul Forge Mystery activates its sourced +10% Shadow damage bonus for 10 seconds after a critical hit, while its five-minute physical-critical aura remains active. Way of Frenzy now grants +2% Stamina per landed Divine Vengeance hit, stacking to five for 60 seconds; its local tooltip and component supply the trigger, stack cap, modifier, and duration. Shield Form has no player-learnable source skill; Forge rank-2, Chain Drive, Dark Energy Strike, and other proc chances are absent or incomplete. The current [CoA Champion/Rogue skills page](https://chroniclesofarcadia.com/en/wiki/skills/list/10-3) reuses Rune Overload's identity with newer values/behavior, so local RuneWaker values were retained.
|
||||
- **Priest:** Knight/Priest Holy Smite now heals and consumes Holy Seals correctly; Holy Protection applies its alternate threat payload as intended; Priest/Knight Holy Power applies RuneWaker's +60 Wisdom and +1% Wisdom for 20 seconds, ending after three successful casts of Heal; Priest/Mage Enlightenment (492933) follows current CoA as a permanent +12% Wisdom passive for the same Priest/Mage pair, replacing RuneWaker’s completed-Heal (490261) +1% Wisdom for 8 seconds and its 20-second internal cooldown; the catalog tooltip matches CoA, and the player passive synchronizer applies the permanent aura on build initialization/update while generated companions apply the learned self-passive before their first RuneWaker cast. Lightning Rod requires the sourced Bone Chill target state; Priest/Scout Saint's Blessing applies current CoA's +28% damage below 30% maximum target HP, while its separate +597/+21% Heal rider at skill level 105 remains unresolved because the local Heal row and lower-rank curve are absent; Priest/Warrior Battle Monk Stance gates five stance-dependent abilities for player and companion paths; Priest/Warlock Prayer of Tribulation has focused runtime coverage; and Soul Cleansing Ceremony conditionally extends Cleanse to curses. Blessed Spring Water (490298), Embrace of the Water Spirit (491323), and Shadow Fury (492932) now apply their source-described modifier auras to the party; Embrace's separate periodic proc remains caster-only. Current CoA reworks Infused Light Chain (498610) into Rage Control: +20 Rage per completed second of channeling. Player and generated-companion completion and interruption paths now grant only whole elapsed channel seconds; ordinary casts do not count, replacing the unsupported local Chain of Light damage, cooldown, and on-hit riders. Protection Rune Seal (498613) attaches a hidden monitor to Regenerate and uses the local 8% chance to reset Heart Rune Energy on a successful periodic tick, including the final tick before expiry. Its local generated passive row has no components or heal scalar, and current CoA describes only a “slight chance” to reset Heart Rune Energy; neither source establishes the local tooltip's increased-healing clause or quantifies it, so it remains unwired rather than inferred from the reset chance. Beacon of Regeneration (498620) now applies the current CoA 30-second effect to one friendly marked member, once per direct healing cast after recipients are aggregated. The CoA +105 row publishes 770 HP; because skill progression is unavailable, every unlocked rank uses that documented value. Player and companion casts both apply generic source healing-done and recipient healing-taken modifiers (raw 770, Faith +5% = 809 on either path). The local range 20 is decimeters and matches CoA range 200; its level-50 29 flat MP cost differs from the CoA +105 88 flat MP, with no supported cost curve. Child 621227 does not map to the current trigger or amount. HoT ticks and channel pulses remain outside the event model because structured healing events lack a periodic marker and CoA does not settle their eligibility. The 10.2.1 patch explicitly renames local Ice Fog ranks 1/2/3 (490274/490275/490276) to Holy Blessing, Vow of Silence, and Holy Void, respectively; the [current Priest skill list](https://chroniclesofarcadia.com/wiki/skills/list/5-1) confirms each ID and level. This is a patch-backed identity mapping, not an ID-only inference; Bone Chill is separate ID 491165. Holy Blessing (490274) follows the current [CoA Priest-primary entry](https://chroniclesofarcadia.com/en/wiki/skills/list/5-7): its permanent `(current Wisdom + 115) × 1.105` Wisdom formula and +100.9% Light damage are represented by a tagged caster aura. Existing Wisdom modifiers resolve first; only the incremental Wisdom-to-spell-power conversion is injected into coefficient-based spell amounts, because cast scaling bypasses `applyAuraStats`, and generic spell-power aura fields are not double-counted. Light maps to Holy; the player source aura is school-scoped, and generated-companion direct spell damage applies the same tagged modifier on the deterministic party path. CoA does not specify stacking with other Wisdom buffs, so existing stat-resolver behavior is retained before Holy Blessing. Two player/companion combat regressions verify actual spell amounts and Holy-versus-Nature damage; the four-file focused Priest suite passes 14 tests. The player Frost control includes RuneWaker's existing “Prayer: water-damage” +5% Frost aura when predicting the actual event amount. Vow of Silence (490275) applies 8.4 seconds to NPC targets and 4.2 seconds to character targets in player and companion casts. Holy Void (490276) now deals the CoA 1655 Light hit and applies Voidal Infection for 20 seconds, reducing physical and magical defense by 18.8%. The Priest-primary cast path maps physical-defense to armor, and the presentation school explicitly resolves this “Holy Void” identity aLine truncated
|
||||
- **Priest/Champion Heart Rune Energy (498612):** current CoA keeps the same name, ID, and pair but deliberately replaces the local RuneWaker heal/Mind buff with 975.1% CPS Light damage (runtime coefficient 9.751, Holy school), 15 Rage cost, 150 range, an 80-radius caster-centered AoE, instant cast, and 12-second cooldown. Patch [11.1.5.3000](https://chroniclesofarcadia.com/tr/patch/11-1-5-3000) confirms the damage rework and a 15-second Blessing of Humility cooldown reduction on use; patch [11.1.5.4002](https://chroniclesofarcadia.com/patch/11-1-5-4000) confirms the AoE is centered on the caster with 80 range. Player and generated-companion execution has focused coverage for the Rage cost, Holy-school damage to all six in-radius test targets, exclusion of a target just outside the radius, 12-second cooldown, and Blessing cooldown reduction. CoA and the catalog provide no target-count cap, so runtime leaves it uncapped rather than guessing; party target resolution covers modeled visible/engaged mobs. The separate Protection Rune Seal Regenerate-tick path still resets this exact ability ID; its existing player regression passes. Companion Protection Rune Seal procs are not separately verified.
|
||||
- **Knight/Warrior CoA batch:** current CoA removes the periodic-damage component from Holy Seal and converts Knight-primary Holy Power Explosion (490172) from its local active mana/healing effect to a passive +198% Aggro modifier. Patch [10.6.1](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) supplies the Holy Seal change; patch [12.1.3](https://chroniclesofarcadia.com/patch/12-1-3-1000) confirms Holy Power Explosion's current value. The generated local Holy Power Explosion row is `rom-knight`, so its threat effect belongs on Knight actions, not Warrior-primary actions. The current CoA pair page has a level-50 Warrior/Knight Blocking Stance passive (493287), now represented as a separate catalog marker from Resist (493251) and local active Blocking Stance (491480). Its +8% parry, −18.9% received damage, and +75.4% Aggro are modeled as separate helper modifiers from Resist's +80% Aggro/20% physical reduction; each requires its passive plus the separate active stance, and they are not combined until the source/runtime stacking rule is established. Player and companion runtime integration now removes only the four legacy Holy Seal DoTs, applies Knight-only Holy Power Explosion +198% Aggro, and gates Blocking Stance/Resist mitigation on the learned passives plus active stance. Separate helper, catalog, combat, and runtime regressions cover the effects; the two Rage proc chances remain unresolved. Leopard Instinct (495402) now updates hit stacks and Dodge-stack loss for player and generated-companion incoming-hit paths. Each stack grants +3 percentage points Dodge; its separate Magic Resistance Rate value is retained in helper state but remains unapplied because the stat model has no consumer.
|
||||
- **Knight/Rogue Leopard Instinct (495402):** current CoA describes a passive: each received hit adds a stack of +3% Dodge and +3% Magic Resistance Rate, up to 10; a dodge removes one stack. Patch [11.1.5.3000](https://chroniclesofarcadia.com/tr/patch/11-1-5-3000) replaced the three-hit absorb with this stack behavior, and patch [12.1.2](https://chroniclesofarcadia.com/fr/patch/12-1-2-1000) made the skill passive. The current source says each hit, not specifically a normal attack; player and generated-companion hit paths increment its persistent stack aura, and each dodge removes one stack. There is no duration, cooldown, or absorb in the current version; the 20-second/90-second values are historical. Each stack grants +3 percentage points Dodge. The separate Magic Resistance Rate component is intentionally unapplied because the simulator has no stat consumer; it is not folded into spell resistance or another unrelated stat.
|
||||
- **Priest/Warrior Condensed Rage (493038):** the current [CoA Priest/Warrior entry](https://chroniclesofarcadia.com/en/wiki/skills/list/5-1) confirms the same name, ID, and class pair, with a +22% Physical Damage passive. Patch [11.1.1](https://chroniclesofarcadia.com/patch/11-1-1-3000) changed the rider from Water/Light Damage to Physical Damage; patch [11.1.1.5000](https://chroniclesofarcadia.com/patch/11-1-1-5000) removed the buff requirement. This supersedes RuneWaker's incoming-hit healing description and missing-child references 493056/495293. The generated catalog/tooltip and player/companion physical-damage paths now use the learned passive without a trigger or stance gate; focused regressions cover both actor paths.
|
||||
- **Priest/Champion Rage Control (498610):** current CoA keeps the same ID and pair but reworks RuneWaker's Infused Light Chain into a passive that grants 20 Rage for each completed second of spell channeling, per the [current CoA Priest/Champion list](https://chroniclesofarcadia.com/en/wiki/skills/list/5-10). This replaces the local unsupported Chain of Light damage, cooldown, and on-hit Rage riders. Player/party channel completion and interruption paths grant only whole elapsed channel seconds; ordinary casts and partial seconds do not count. The generated tooltip reflects the new passive. The source does not specify additional channel exceptions.
|
||||
- **Knight:** Divine Punishment now follows current CoA's +28.5% Punishment damage and applies a 10-second general damage-taken increase of 2% per pre-spend Holy Seal stack, replacing the RuneWaker +1% Light-only modifier. Deadly Whirlwind retains the local RuneWaker −10 Rage/−20-second Warrior Whirlwind adjustments because current CoA still says the passive reduces its Rage cost and cooldown but does not publish amounts. On successful Whirlwind hits it adds CoA Deadly Wound: −30% healing received for 10 seconds on NPCs or 5 seconds on player targets. Compassion of Holy Light (491635) is a documented cross-pair bridge: the selected English CoA [skill list](https://chroniclesofarcadia.com/wiki/skills/list/6-5?locale=en) reports Knight/Shaman, while localized/index results disagree on the displayed pair. This simulator has no playable Shaman class/pair, so the local Knight/Priest row stays and uses the explicit CoA payload; it does not assert that the pair identities match. The [11.1.5 patch](https://chroniclesofarcadia.com/patch/11-1-5-3000) supplies the 120 + 0.2 × STA landed-hit heal, Holy Illumination trigger, and 5-to-4-second cooldown reduction; player and generated companion casts are covered. Holy Illumination supplies 15% damage reduction for 10 seconds. The simulator has no shield-defense stat, so the patch's 5× shield-defense damage rider is not represented. Focused validation passed 22 tests across the Knight override, periodic runtime, and Whirlwind integration files; the narrow TypeScript check passed. Disregard Danger (493273) still redirects 40% of one ally hit for 10 seconds, consumes the redirect after the first hit, and gives the caster +20 Parry for 10 seconds; its integration test verifies the 40/60 split and that the next hit is unredirected. Master of Parry (492935) applies the generated +5,000 Parry modifier for 15 seconds and consumes it after two successful blocks; its hidden periodic trigger shares the source 15-second lifetime. Shield of Valor (490468) reduces a party Knight's Holy Seal cooldown by 1 second on each parry while its local 15-second aura is active. War Prayer (490078) schedules six personal Holy Seal increments at 3-second intervals over 18 seconds; local child 490079 is absent, but the current CoA [skill entry](https://chroniclesofarcadia.com/en/wiki/skills/list/6-1) and [fix patch](https://chroniclesofarcadia.com/patch/10-4-1-1000) corroborate the personal Seal mechanic. God's Sword (491621) is a continuous same-ID/name/pair rework: CoA 10.6.1 changed it to offhand-DPS Light damage, 11.1.5 set the coefficient to 350%, and 12.1.0 made it passive. The current [CoA Knight/Rogue entry](https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru) says offensive physical abilities add 350% offhand weapon DPS as Light damage with high Aggro, superseding RuneWaker's +40% Holy Strike crit rider. Player and generated Knight/Rogue companion runtime integration now records a separate Light/Holy damage packet at 3.5× offhand DPS after a qualifying landed direct physical hit, gated by the learned passive, exact pairing, and positive offhand DPS. Four helper tests and three integration tests cover those gates and actor paths. CoA does not publish an Aggro multiplier or multi-hit, channel, or AoE cadence, so none is inferred. Holy Light's Fury's extra damage value and remaining periodic/triggered spell references are unresolved. Throw Shield and Shield Bash require an equipped shield; Arrow of Vengeance enforces its ranged-weapon requirement.
|
||||
- **Warden:** Nature Crystal immunity, Heart of Oak/Crystalline absorbs, Awakened immunity/next-crit behavior, Nature Power scaling for Healing Ripple/Gift Pulse, Spirit of Life party HoT, Mysterious Grace party scope, Will Attack's Frantic Briar debuff, Damage Transfer's 30% split to a live nearby pet, Tough Oak's one-second Punch/Sepal Stab cooldown reductions, and Briar Shield's sourced 900-second +5% armor aura plus level-scaled physical reflection have focused fixes. Briar Shield uses RuneWaker's local formula for main-hand DPS reflection on landed physical melee/ranged attacks within 50 range; newer Wiki trigger/stat behavior was not imported. Tough Oak's pet-defense rider has no pet-stat model; local RuneWaker encodes +5% armor while current CoA specifies +60% pet Physical Defense, so no proxy value was added. Oak Blessing (494011) now activates RuneWaker component 620679's +5% physical attack aura and 1%-maximum-HP healing every three seconds only during Heart of the Oak's 60-second duration. The hash-matched RuneWaker source contains Damage Transfer child 493416 and Lua `01460.lua`; executable component 503942 transfers 30% for 10 seconds when the owner's supported pet (102297/102324/102325) is alive within inclusive 3D range 300. Player and generated-party incoming-hit paths apply the split once and sync pet health/absorbs; party coverage checks the 30/70 split, range and expiry, and a single pet event. Tooltip component 503643's 15-second value is not executable. The [current CoA Warden page](https://chroniclesofarcadia.com/en/wiki/skills/list/7-2) does not resolve the RuneWaker conflict. Revival (499542) references missing state 499544; its only extracted component, 623078, contains no numeric payload. The [current Rogue/Warden page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-7) uses ID 499542 for Rogue/Warden Low Blow and ID 494043 for Blood Hound; neither current skill matches the local Warden Revival/Weak Point Strike identity, so no cross-skill mapping was made. Weak Point Strike's two local damage components (620294/620295) preserve base and bonus hit formulas but contain no extra-hit proc chance; do not invent one. Other reactive passives remain incomplete.
|
||||
- **Mage:** rank-1 Catalysis mana ticks, rank-2 Catalysis cast haste, Phoenix's 12-target cap, Control Flame, Flame Follower's Fireball cooldown rider, and rank-1 Elemental Weakness's all-resistance debuff are covered. Elemental Extraction's critical triggers now match its RuneWaker tooltip. Meditative Current now suppresses the generic always-on passive payload and applies its source-complete rule (landed Flame 491150 or Plasma Arrow 491169, +10 spell haste for 10 seconds, 30-second internal cooldown) to player and party Mage casters, with an independent cooldown marker per caster and focused runtime coverage. Mage/Knight Light of Day (493034) now suppresses its generic passive aura and grants +8% Holy damage for 15 seconds only after a landed direct Holy Light Strike (491594) or Light Charge (492634) hit, with an independent 20-second cooldown per caster; player and party regressions cover both triggers, startup suppression, and cooldown behavior. Fireball 490204 is intentionally excluded by the local tooltip. Eruption's local passive row (490221) has no proc chance or effect payload. Hash-matched RuneWaker MagicObject trigger fields contain no edge to Eruption; Fireball (490204) and Flame (491150) likewise do not link the passive. Party Mage casts now receive the same deterministic 30% base proc roll as player casts. The existing damage-doubling formula remains unchanged and unverified: no source defines the baseline for the “200%” damage statement. The current CoA entry has the same name, ID, and Mage-primary pairing and confirms Fireball/Flame triggers, so CoA patch 10.2.1 supplies the selected 30% base proc chance (up from 10%). Patch 12.1.0.5000 implies Elemental Catalysis also raises Eruption's chance but gives no value, so that bonus remains unapplied. Patch 10.2.1.1250 raised Eruption damage to 200% without defining its baseline; do not interpret that as either an extra 200% hit or a 2x total multiplier. The sourced base chance is being applied while the damage payload remains unresolved. Rank 2's source payload has no mana tick; Flame Follower's crit amount is absent from source components. Fire Ward (490248) follows the current CoA Mage page: it applies 11.5% less received damage across the six modeled magical schools to the caster and party within radius 100 (10 runtime units), leaving physical damage unchanged. The local RuneWaker component (500366) supplies +10 Fire resistance and a 600-second duration; CoA lists +230 at skill level 105 but gives no scaling curve, and the simulator has no skill-level state for Fire Ward, so +10 remains active rather than guessing intermediate values. Local cost/range/timing (120 MP, 150/15 runtime units, instant) are retained. The isolated catalog/aura test passed; two player/companion scope/radius runtime tests pass: player and generated-companion casts apply to self/nearby party members and exclude a distant member.
|
||||
- **Druid:** Spirit of Life party HoT, Unity's guaranteed spell crit/healing and 15-second movement lock, Spirit Guidance's Nature Power gain, Savage Blessing's friendly target, Healing Arrows, source-backed Nature Power selection/spending for Fountain, Seeds, Wrath, Sandstorm, Earth Chain, Healing Wind, Poisonous Widow, Heart Piercing, and Cross of Thorns, plus Brilliance of Nature's 10-to-15 dynamic cap for the player and companion, have focused fixes. Weakening Seed and Withering Seed select exactly one local RuneWaker modifier branch per cast; each component maps to its own 20-second −2% target debuff. Caster-directed Nature Power drains are now distinct from hostile-target resource steals, preserving the source ordering for spends and refunds. Mage/Druid Green Guardian (494331) now follows the exact-pair current CoA active stack skill: player and companion actors gain one stack per full observed stationary second up to 20, for +1% movement speed per stack. Companion auto-use when its aura is absent is simulator policy. The old expiry HoTs are suppressed only for Mage-primary/Druid-secondary; the post-movement one-second gate is a timing approximation, and stack loss/MP refund remain unwired because CoA leaves the trigger and basis unspecified. See ROM_DRUID_MAGE_GREEN_GUARDIAN.md. An isolated CoA helper and 7 tests record Spirit Guidance 493536's no-player-target rule and reputation-based NPC conversion, plus Rebirth 493537's 30%, Advanced Rebirth 493559's 45% with Amulet of Light, and Supreme Rebirth 493560's 75% with Golden Amulet. These rules are not runtime-integrated: combat lacks NPC faction/reputation and player death-debt/item-consumption state. The CoA duration is listed only for one Spirit Guidance skill value, and basic Rebirth's optional amulet bonus is unquantified. Current CoA Druid/Rogue Necrotic Wound supersedes RuneWaker's old heal-reduction payload; see the source/rationale note. Local passive proc probabilities remain unavailable on Revival Slash, Quick Light Protection, Boiling Rock, Inspiration Display, Avalanche, Extended Recovery, and Spring Flower Eulogy. Current CoA Druid/Mage Inspiration (494322) now grants one deterministic stack per positive Nature's Power spend for the exact pair, capped at ten for ten seconds. Its modeled five-unit caster-centered radius and shared-stack refresh are explicit approximations; player/companion recipient and cast-haste integrations are tested. Fireball speed/resource riders remain unresolved per ROM_DRUID_MAGE_INSPIRATION.md. Current CoA Recovery still conflicts with the local trigger behavior, so no later proc rates were imported.
|
||||
- **Warlock:** Aftermath remains covered for player and generated companions. Current CoA same-pair or explicit-rework behavior is now wired for Mind Transference 498642 (75 Rage → 6 Psi, 1-second cooldown); Divine Assistance 498668 (+16% magical damage and healing); Healing Fortune 498633 (landed Psychic Arrows grants +12% healing and +12% Wisdom for 20 seconds, and Warp Charge heals a friendly target); Psychic Rising Tide 498643 (Rising Tide becomes instant with a 5-second cooldown, grants 1 Psi, and a critical hit triggers Healing Fortune); and Mind Shield 498648 (+1 Psi on Urgent Heal; Defense Net duration reduction retained). Soul Enlightenment 498649 now uses the current CoA +5.7% damage dealt/+17.1% received healing aura for 30 seconds, 2 Psi, and a modeled 7.5-unit radius; player and companion casts are covered, while the Willpower Construct Caster restriction is tooltip-only because that state is not represented by cast validation. The local Mind Transference +40% physical-attack child rider is omitted because current CoA does not confirm it. Current CoA Rising Tide can route an ally-targeted cast to that ally's hostile target, but the simulator has no hostile-target selection state on party members, so only that redirect is unsupported and is called out in its tooltip. Psychic Warrior 499868 is the same Warlock/Warrior pair and ID as the local obsolete passive, now shown with the current name and tooltip. Its 187.5% area / 1,050% single-target main-hand DPS coefficients are recorded in an isolated helper; combat integration remains open: the isolated helper preserves both Physical and Dark tags, but CoA does not document hybrid defense order or added-hit cadence/roll inheritance; see the [source/runtime note](ROM_WARLOCK_WARRIOR_PSYCHIC_WARRIOR.md). The [12.1.4.1500 patch](https://chroniclesofarcadia.com/patch/12-1-4-1500) raised the area coefficient to 250%, and [12.1.5.1000](https://chroniclesofarcadia.com/patch/12-1-5-1000) reduced it to the current 187.5%. The additional attribute-gated main-hand damage for Slash and Consistent Heart Strike remains unmodeled because CoA gives no amount. Current CoA lists Enraged 499871 on the [Warlock/Warrior page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1) as Warrior General level 60: instant, 40-second cooldown, restoring 100 Rage, 35 Focus, and 1 Psi. It is a distinct learned row and reuses the generic capped resource effects for players and companions. RuneWaker Warrior General Enraged 490492 remains a separate same-name ability, while hidden local child 499869 remains unidentified; the player/companion regressions cover resource gains and caps, cooldown, level unlock, and pair gating. Current CoA Warlock/Mage Soul Brand 498705 now handles any landed Fire damage (including periodic hits): it applies the capped, refreshing +2%-per-stack elemental damage-taken aura and restores 1 Psi to the triggering player or companion. Soul Brand Sting 498710 costs 2 Psi, gates on the tagged local Willpower Blade aura, adds 1× derived Intelligence to each Dark/Fire packet, and resets Flaming Heart Strike on cast. Warlock/Priest Spreading Pain 499856 consumes its +25% Magical Critical Bonus aura in the central player/companion spell-critical resolver, scaling the bonus portion of a 2.0 multiplier to 2.25; ordinary hits and crit chance do not change. See the feature notes for source conflicts and simulator mapping limits. Remaining gaps include Spreading Pain's CoA 10% MP cost, which the RoM Warlock resource model cannot pay, and CoA range 150, which has no verified conversion to local 22.5; Soul Brand Sting preserves local range 22.5 for the same conversion reason. Other gaps include Breathing Control's unspecified proc probability/timing; Deep Breathing Control 499870's 15% HP amount is known, but neither current CoA nor patch 11.1.2.3000 identifies whether it triggers on manual Enraged 499871 or only an automatic Breathing Control proc, and its extra Focus amount is unknown; Mind Shield's Ring of Comfort reset probability; hidden local child 499869's identity; Psychic Warrior's composite damage type and unknown attribute-gated amounts; and End of Thought's tick interval.
|
||||
- **Champion:** Fearless Blow's health/Rage gate, Rune Draw pull, Indomitable Spirit threshold, cooldown-reset scopes, rank-1 Forge trigger, Rune Pulse's 5 Rage on landed hit, Champion/Warrior Determination Rune's 30%-max-HP shield, and Rune Overload's local 15-Rage-per-second drain and zero-Rage termination have focused fixes. The Rune Overload child catalog entry now resolves hidden MagicCollect 498574 from component 621243 as a 0.6 physical-damage coefficient; catalog work also removed an unsupported 10-yard radius heuristic. Component 621241 links it as an on-time one-second tick, but runtime has no autonomous timer consumer for that link. The local description says the child hits nearby targets without a numeric radius or target cap, so scheduling and target scope remain unresolved rather than guessed. The current [CoA Champion/Rogue list](https://chroniclesofarcadia.com/en/wiki/skills/list/10-3) has a conflicting later ID mapping: CoA calls 498571 Rune Overload and 498572 Runecraft - Haste, while RuneWaker calls 498572 Rune Overload and 498571 Remodeled Body. CoA patch history confirms later revisions (Shield Form gate and Disassembly termination; Rage drain and damage changes), so the current CoA values were not imported into the RuneWaker model. Soul Forge Mystery activates its sourced +10% Shadow damage bonus for 10 seconds after a critical hit, while its five-minute physical-critical aura remains active. Way of Frenzy now grants +2% Stamina per landed Divine Vengeance hit, stacking to five for 60 seconds; its local tooltip and component supply the trigger, stack cap, modifier, and duration. Shield Form has no player-learnable source skill; Forge rank-2, Chain Drive, Dark Energy Strike, and other proc chances are absent or incomplete.
|
||||
|
||||
### Source-complete CoA mechanics with remaining runtime gaps
|
||||
|
||||
- **Warlock/Priest Spreading Pain (499856):** The current [CoA Warlock/Priest page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) preserves the exact local skill name, ID, and pair and specifies the three linked debuffs, +18% Magical Attack Power, +25% Magical Critical Bonus, and Healing Fortune activation. Patch [10.6.1](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) narrows that activation to Healing Fortune's healing-increase portion: +12% healing for 20 seconds, not its separate Wisdom or Warp Charge effects. The catalog/tooltip and player/generated-companion cast effects preserve the local linked children and apply the sourced +18% magical attack, +12% healing, and +25% Magical Critical Bonus. The central critical resolver consumes the tagged bonus aura only for magical spell crits: a 2.0 critical multiplier becomes 2.25 by increasing its bonus portion, with no change to crit chance or ordinary hits. The wiki also lists 10% MP plus 15 Focus and range 150, versus the local row's 15 Focus and range 22.5. RoM Warlock has no Mana pool and no general wiki-range conversion was verified, so those cost/range differences remain documented without an invented cost or range override.
|
||||
|
||||
- **Warlock/Rogue Shadow Slice (CoA rework of local Soul Raid 498678):** The official [CoA special-skills list](https://chroniclesofarcadia.com/en/wiki/basic-information/special-skills) confirms the skill is Warlock/Rogue level 30. Patch [10.4.0.4001](https://chroniclesofarcadia.com/patch/10-4-0-4000) explicitly maps Soul Raid to Shadow Slice and says it modifies Shadow Step (490344): a 1-second stun, 4,400 Dark hit, and +30% Dark damage/+10% movement speed for 30 seconds. Patch [10.4.1](https://chroniclesofarcadia.com/en/patch/10-4-1-1000) corrects its Psi recovery from 2 to 3. `romWarlockRogueShadowSlice.ts` encodes the source-gated hit, status, buffs, and resource rider; focused tests cover exact values and identity gating. The rework replaces Shadow Step's local teleport; the simulator can model the strike and effects but has no position state for the shadow-behind-target movement. CoA does not provide a lower-rank damage curve, so 4,400 is represented as the quoted flat amount with no invented level scaling. The generated catalog/tooltip and player/companion cast mechanics are integrated, replacing the local six-second +10% Dark/+10% movement rider when the passive is learned.
|
||||
- **Warlock/Mage Soul Brand (498705):** Current CoA's same-name, same-ID, same-pair rework supersedes RuneWaker's three-skill trigger, 9% Dark/Flame-only debuff, and no-Psi payload. Player and generated-companion hooks now apply the current effect after positive landed Fire damage, including periodic events: +2% damage taken per stack for 20 seconds, up to four stacks, and +1 Psi to the source. The five available elemental-school mappings are Fire, Frost, Nature, Holy, and Shadow; Arcane and Physical are excluded. Focused helper/aura and runtime tests cover direct/periodic player and companion hits.
|
||||
- **Warlock/Mage Soul Brand Sting (498710):** The current [CoA Warlock/Mage page](https://chroniclesofarcadia.com/wiki/skills/list/9-4) and patches [11.1.1.4000](https://chroniclesofarcadia.com/patch/11-1-1-4000) and [12.1.0.5000](https://chroniclesofarcadia.com/patch/12-1-0-5000) support the 2-Psi cost, 3737.5 + 1×Intelligence Dark and Fire packets, Willpower Blade requirement, and Flaming Heart Strike reset on cast. The exact-pair catalog/runtime now models those packets using each actor's derived Intelligence, gates use on an active locally tagged “Willpower Blade” aura, and resets on cast. This state-name gate does not equate local RuneWaker 497955 with CoA Willpower Sharpening 1494096. CoA range 150 remains unsupported because its unit conversion to the local 22.5 is unverified; the local range is preserved. Focused tests cover player and companion gates/amounts and the cooldown reset.
|
||||
|
||||
## Mechanics implemented in this follow-up
|
||||
|
||||
- **Rogue/Mage Chaos Dagger (491178):** The same-ID/name/pair [current CoA row](https://chroniclesofarcadia.com/en/wiki/skills/list/3-4) gives the landed normal-attack Fire packet as 169.4% of the attacking hand's weapon DPS; [patch 12.1.0.4000](https://chroniclesofarcadia.com/patch/12-1-0-4000) changes it to passive. Player and companion main/off-hand packets now go through the existing Fire resistance, aura, absorb, and death pipeline. This deliberately replaces RuneWaker's Enchanted Throw projectile/physical-child behavior for Rogue/Mage only. CoA's listed 25 MP has no published spend timing after the passive conversion; the simulator does not guess a per-auto debit. The separate temporary Physical Attack Power rider says only “slightly” and supplies no amount or duration, so it remains open. See [the source and rationale note](ROM_ROGUE_MAGE_CHAOS_DAGGER.md); 43 helper/player checks and one party integration case pass.
|
||||
- **WoW Death Knight Merciless Combat (`talent-1993`):** Generated local rank rows supply the eligible abilities (Icy Touch, Howling Blast, Obliterate, Frost Strike), 6%/12% bonus, and strict target-health threshold below 35%, so no WotLKDB fallback was needed. Player and generated-companion direct-hit paths check each target's current health immediately before resolving damage and apply the rank bonus before mitigation. It does not modify Icy Touch's periodic Frost Fever tick; exact-threshold and unrelated-spell controls are covered. The parser cannot safely model this conditional as a global scalar, so the per-target hook is intentional. See [the source and integration note](WOW_DEATH_KNIGHT_MERCILESS_COMBAT.md); focused validation passed six tests.
|
||||
- **WoW Mage Netherwind Presence (`talent-1846`):** Generated local ranks 44400/44402/44403 set 1/2/3% spell haste per stack, up to three stacks for 10 seconds. Because the generated 100%-chance Proc Trigger Spell rows lack proc-phase flags, the runtime awards one stack at accepted cast commitment; this is documented as an event-phase inference, and channels do not grant one stack per pulse. The generated tooltip's reachable Mage triggers are Arcane Missiles, Arcane Barrage, and Arcane Blast; Arcane Orb is missing from the catalog, while Moonfire/Starfire/Starsurge are non-Mage rows. At max rank, casting Slow with three stacks adds 30% movement speed for three seconds without consuming the haste stacks. The player movement controller and companion follow controller consume that aura; actual in-scene movement feel remains a manual playtest item. No WotLKDB fallback was used because stock 44400 is a different permanent +2% effect. Focused validation passed seven tests across two files; see [the feature note](WOW_MAGE_NETHERWIND_PRESENCE.md).
|
||||
- **WoW Death Knight Spell Deflection (`talent-2018`):** Generated ranks supply the 15/30/45% School Absorb and school mask 126 (Holy, Fire, Nature, Frost, Shadow, Arcane); the tooltip supplies the dynamic proc chance equal to current Parry. The player and generated-companion incoming-hit paths apply one rank-scaled absorb on landed discrete enemy spell hits after existing aura/resistance reductions and before subsequent Ward/transfer/shield stages, and record it in absorbed combat damage. The current enemy model has no periodic incoming damage-tick event; future periodic spells must not use this direct-hit adapter. Integer flooring is labeled as a simulator choice, and an AzerothCore handler is used only as corroboration because it is a fork-specific reference. Focused validation passed seven tests across helper, player/companion integration, and Frost Warding regression files; see [the source/rationale note](WOW_DEATH_KNIGHT_SPELL_DEFLECTION.md).
|
||||
- **RoM Mage/Druid Green Guardian (494331):** Current CoA behavior replaces the exact Mage-primary/Druid-secondary local passive: a zero-stack aura gains one stack per full observed stationary second, capped at 20, and each grants 1% movement speed. The full stationary second after a sampled position change is an explicit timing approximation; CoA says moving briefly pauses generation but gives no duration. The old Protection-expiry HoTs are suppressed only for this pair. Companion AI casts the skill through the normal ability path when learned and its aura is absent, a simulator policy. Stack loss and the 10% MP restore remain unwired because CoA does not define the loss event or percentage basis. Focused validation passed 29 tests and TypeScript emitted no diagnostics; see [the source/runtime note](ROM_DRUID_MAGE_GREEN_GUARDIAN.md).
|
||||
|
||||
- **Druid/Rogue Necrotic Wound and Sinister Request:** Current CoA behavior replaces conflicting RuneWaker payloads for this exact pair. Wound requires Corrosive Poison plus one Nature's Power, spends 4% Mana once at channel start, deals three 329.7% Dark hits over 1.5 seconds, spreads Corrosive Poison, and has no cooldown or old healing-reduction/root riders. Its three evenly spaced pulse timestamps are a documented approximation; CoA specifies hit count and duration, not timestamps. The channel completion check now tests only unspent costs, which fixes rejection after prepaid Nature's Power reaches zero. Sinister Request fills existing supported party resource pools to their caps in source radius 150, converted to 15 project units; patch 12.1.6.2004 supplies the current 15-second cooldown, and successful Wound/Poisonous Widow Embrace casts reduce it by one second. Player and companion paths respect the existing position/LOS selection and companion AI reacts to nearby depleted allies. CoA does not define the poison-spread duration-copy rule, so the simulator refreshes the full duration. The global 50% AoE multiplier is not represented. See [the source and implementation note](ROM_DRUID_ROGUE_NECROTIC_WOUND.md).
|
||||
- **Rogue Poisonous Hands and Energized Hands:** The current [CoA Rogue page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1) and patches [12.1.0.4000](https://chroniclesofarcadia.com/patch/12-1-0-4000), [12.1.0.5000](https://chroniclesofarcadia.com/patch/12-1-0-5000), and [12.1.5.1000](https://chroniclesofarcadia.com/patch/12-1-5-1000) take precedence over conflicting RuneWaker rows for Rogue-primary IDs 490329 and 490347. Poisonous Hands builds up to ten target stacks while the target's damage-dealt reduction remains fixed at 8% total; each successful application also builds one 1% current-target attack-speed stack, and switching targets resets that self-ramp. Energized Hands supplies +100% Energy regeneration, +50% maximum Energy, +20% Energy costs, and −0.1 seconds to committed skill cooldowns. The local generated 50% proc chance is retained only as a fallback because CoA does not quantify “a chance.” The project has no Poison Bottle inventory; CoA gives no faster-main-hand cast-time formula, so the 1-second tooltip cast is the current fallback; and the cooldown subtraction timing is an implementation choice documented in [the feature note](ROM_ROGUE_POISONOUS_HANDS.md). Player/companion focused validation passed 49 tests.
|
||||
- **Priest-primary Holy Candle (490304):** Current CoA values and patch 12.1.6.2000 set +27% healing, 5% Mana activation cost, range 1, a 30-second cooldown, and 12% self-damage; the CoA developer reply sets its per-owner limit to once per second. The local 60-second duration is retained because CoA omits duration; the old +5% modifier and free-running HP drain are replaced. Accepted healing casts charge once before target fan-out, with per-owner throttling across spells; full-health targets count, invalid or interrupted casts do not, HoTs charge at application rather than each tick, and channels use a persistent per-cast guard. The max-health basis is an explicit interpretation because CoA does not specify current versus maximum HP. Tests cover player/companion, AoE fan-out, overheal, failure/interruption, HoT ticks, and a channel scheduler boundary; the Divine Hymn channel is a test fixture only. See [the rationale](ROM_PRIEST_HOLY_CANDLE.md); focused validation passed 16 tests across three files.
|
||||
|
||||
## Visual review and evidence limits
|
||||
|
||||
@@ -173,4 +217,7 @@ 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 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.
|
||||
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**. Companion Maelstrom Weapon regressions passed **89 tests across four files**, covering party and player runtimes, the party support-cast path, and Stormstrike catalog mapping. The latest focused Death Knight, Hunter, and party run passed **80 tests across four files**, covering Blood-Caked Blade player/companion procs and companion pet-aura targeting. Blink forward movement, player relocation, source-distance, obstruction, and root/stun regressions passed **29 tests across three files**. The 8 GB TypeScript check and Vite production bundle passed after the Blink changes; `npm run release:check` also passed. The Hunter pet model remains Wolf-only; family selection/utilities and pet critical inheritance remain open. The Hunter pet formula, Hunter combat, and party-runtime regressions passed 62 tests across three focused files. 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.
|
||||
|
||||
|
||||
This RoM current-CoA follow-up passed 114 focused tests across eight files and sc --noEmit; git diff --check and whitespace checks on touched/new files passed. Companion Exhaustion coverage directly exercises the landed-hit helper and capped resource events; party AI did not select this pair-specific ability in rotation, so the new test does not claim an end-to-end AI cast.
|
||||
@@ -1,117 +1,203 @@
|
||||
# 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.
|
||||
Updated 2026-10-02. 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. For RoM conflicts, prefer clearly matching current Chronicles of Arcadia behavior over the RuneWaker snapshot; verify skill identity and class pairing, and document the source and any simulator approximation. 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.
|
||||
- [ ] Complete the remaining WoW 3.3.5a Hunter pet mechanics for player Hunters and generated Hunter companions. The current Wolf baseline has level-ranked Bite, a regenerating Focus pool, separate Bite and melee-swing events, Call/Revive handling, Furious Howl, Kill Command, Mend Pet targeting, owner stamina/ranged-AP and spell-damage inheritance, and Bite's local AP/spell-damage coefficients. Still needed: selectable/preserved families; the remaining family, stable, feed, and training mechanics; and pet critical-stat inheritance. No stock owner-crit inheritance rule was established.
|
||||
- [x] Audit pet critical inheritance sources: local 19591 “Tamed Pet Passive 06” has zero-valued melee/spell crit aura amounts, and stock 3.3.5 core uses pet base crit plus the pet's own auras without an owner-crit formula. The proposed owner-melee-crit formula is only from an unmerged server issue; do not import it as stock behavior. Pet critical-stat inheritance itself remains unresolved.
|
||||
- [x] Hunter pets dynamically inherit 45% of owner stamina (+10 pet health per inherited point) and 22% of owner ranged AP as pet AP for player and companion summons. The pet profile recalculates when owner stats or auras change.
|
||||
- [x] Wolf Bite adds 7% of effective pet AP to the local rank's base damage. Local AzerothCore `spell_bonus_data.sql` rows 17253–17261, 27050, 52473, and 52474 supply that coefficient; temporary pet AP effects such as Furious Howl are included.
|
||||
- [x] Kill Command uses local 3.3.5a Spell.dbc rows 34026/34027 and triggered child 58914: +60% pet special-attack damage for 30 seconds, reduced by 20 percentage points when a pet special is executed, with a 60-second cooldown. Three Bite executions therefore use +60%, +40%, then +20%; ordinary melee swings do not consume the rider.
|
||||
- [x] The Wolf baseline selects the highest local Bite rank available at the pet's level (1–80), charges the DBC-listed 25 Focus, and keeps its 1.5-second pet global cooldown independent of ordinary swings. Mend Pet's periodic heal is routed to the living active pet for player and generated companion Hunters; Revive Pet restores a dead pet at the modeled 15% health.
|
||||
- [x] Wolf Furious Howl selects the local level-appropriate rank, costs 20 Focus, buffs the Wolf and master with the DBC-listed AP for 20 seconds, and recovers in 40 seconds without starting the pet GCD. The pet-side AP adds `rank AP × 2 / 14` damage to each 2-second auto swing using stock [TrinityCore `Pet.cpp`](https://github.com/TrinityCore/TrinityCore/blob/3.3.5/src/server/game/Entities/Pet/Pet.cpp) and [`StatSystem.cpp`](https://github.com/TrinityCore/TrinityCore/blob/3.3.5/src/server/game/Entities/Unit/StatSystem.cpp); Bite separately receives its 7% pet-AP coefficient.
|
||||
- [x] Hunter pets inherit spell damage at 12.87% of owner ranged AP. Bite applies local AzerothCore coefficients of 0.119658 spell damage and 0.07 pet AP for player and generated companion Hunters.
|
||||
- [x] Player Hunter pets expose Attack/Follow/Stay, Defensive/Aggressive/Passive, and Growl autocast controls (Growl starts off). Target decisions consume the owner's executed hostile-action event, retain a living victim until it becomes invalid, and use the local rank-specific Growl Focus/cooldown/flat-threat rows without a hard taunt; generated companion Wolves start Follow/Defensive and receive only their own owner's action events.
|
||||
- [x] Player and generated-companion Hunter pets apply stock-compatible inherited defenses on incoming hits: the pinned TrinityCore 3.3.5/TDB profile supplies pet level-based armor/Agility, 35% of the owner's effective armor, and 40% of the owner's five modeled magical resistances. Holy remains zero because character stats have no Holy-resistance field. Other summons keep their prior mitigation path. This uses a pinned TrinityCore implementation baseline, not direct Blizzard-server evidence; see [the source/runtime note](WOW_HUNTER_PET_DEFENSE_INHERITANCE.md).
|
||||
- [x] Go for the Throat (`talent-1818`) grants the generated 15/30 Focus after a qualifying Hunter melee/ranged critical strike, with a two-second Hunter-owner cooldown and the active living pet's Focus cap. Player and generated companion paths are wired. Companion roll setup covers the baseline ranged swing and the exact generated direct attack map (Aimed Shot, Arcane Shot, Auto Shot, Chimera Shot, Counterattack, Kill Shot, Mongoose Bite, Multi-Shot, Raptor Strike, Scatter Shot, Silencing Shot, Steady Shot), requiring its mapped attack kind. Party integration covers the baseline ranged swing and Arcane Shot; the current Marksmanship AI does not select Raptor Strike. Traps, Stings, Volley, and unclassified spells remain excluded, even if a generic event labels them ranged; a ranged-labeled Explosive Trap regression verifies this gate. The owner-scoped cooldown surviving pet swaps is an architecture inference.
|
||||
|
||||
- [x] Bestial Discipline (`talent-1390`) increases the active tamed Hunter pet's Focus regeneration by the generated 50%/100% ranks (5 baseline → 7.5/10 Focus per second) for player and generated-companion Hunters. Focus accrues continuously through the existing elapsed-time pet pool; the source gives no discrete tick cadence, so this is a runtime integration inference. The separate Intimidation Mana-cost rider remains scoped to Intimidation by the existing talent modifier parser.
|
||||
|
||||
### 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.
|
||||
- [x] Lightwell rank-6 Renew child (48085) uses 0.626 spell power per tick. Local Spell.dbc supplies the 1,540 base heal and two-second cadence but has zero coefficient fields. The exact [WotLKDB 3.3.5a entry](https://wotlkdb.com/index.php?spell=48085) identifies child 48085 as Rank 6 and lists 62.60% spell power per tick; this is direct evidence for rank 6, not an inference from sibling 48084 or 28276. The runtime and focused profile test now use 0.626.
|
||||
- [x] Current CoA Priest-primary Holy Candle (490304) uses +27% healing, a 5% Mana activation cost, range 1, 30-second cooldown, and the local 60-second aura duration because CoA publishes no duration. Patch 12.1.6.2000 supplies the current 12% self-damage; a developer reply confirms a one-second owner throttle. Player and generated-companion casts pay once per accepted healing cast, regardless of target fan-out; HoTs charge on application rather than ticks, and channels keep a per-cast guard. The maximum-health basis is an explicit local interpretation because CoA does not say current versus maximum HP. The old +5% aura and free-running -6% HP dot are replaced. The focused CoA Priest suite passed 16 tests across three files; see [the source and runtime note](ROM_PRIEST_HOLY_CANDLE.md).
|
||||
|
||||
### 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%).
|
||||
- [ ] Continue the remaining conditional, pet, and disease talent pass. Blood-Caked Blade is wired for player and companion autoattacks, and Epidemic extends both Death Knight diseases for player and companion casts.
|
||||
- [x] Reaping (`talent-2001`) uses generated rank chances 33/66/100%, trigger spells Blood Strike (45902) and Pestilence (50842), and the local aura type 226 30-second period. WotLKDB 56835 confirms a 30-second Periodic Dummy with duration `n/a`; the timer is a refreshable removal tick, not a rune duration. The pinned [AzerothCore-derived handler](https://github.com/mod-playerbots/azerothcore-wotlk/blob/e822d718322289dd036fb2daf8867ba41283bf9a/src/server/scripts/Spells/spell_dk.cpp#L2640-L2705) converts one base Blood rune just spent, restores a Reaping-owned rune immediately on a successful landed spend, and retains it after a miss until expiry. Player and companion paths use independent conversion owners so Reaping expiry does not erase Blood Tap; Blood Strike's second damage packet cannot re-restore the rune. Companion Blood adds Blood Strike as a final trigger fallback only with a learned rank and ready base Blood rune; Pestilence is available only when it can spread that companion's active disease to another engaged target within the local 10-yard circle. Pestilence utility casts treat an actual disease transfer as the landed event because they have no damage packet. Focused tests cover helper ownership/expiry, player and companion Blood Strike, companion selection, and player/companion Pestilence spread. The core link is an AzerothCore-derived fork reference, not pristine TrinityCore behavior.
|
||||
- [x] Bloody Vengeance (`talent-1944`) uses generated ranks for +1/2/3% physical damage per stack, a three-stack cap, and rank-specific 15/30/30-second duration. Any positive landed critical damage from a generated Death Knight player or companion event adds one source-owned stack after the hit; companion autos and direct damaging abilities receive combat-result rolls while talented. No WotLKDB fallback was needed. Party channel pulses only proc when their combat path produces a critical result; deterministic periodic damage without a crit result cannot trigger it.
|
||||
- [x] Desolation (`talent-2285`) reads each rank's generated +1/2/3/4/5% all-attack damage bonus and 20-second duration, then applies the linked 100%-chance child aura after a landed Blood Strike for players and generated companions. The landed/main-hand gate is corroborated by the WotLK simulator's [`blood_strike.go`](https://github.com/wowsims/wotlk/blob/master/sim/deathknight/blood_strike.go); local generated talent rows do not specify the proc phase. This simulator has one primary Blood Strike action and no separate off-hand strike event. No WotLKDB fallback was needed because the local generated payload contains rank amounts, duration, and child IDs.
|
||||
- [x] Blade Barrier (`talent-2017`) uses the generated 1/2/3/4/5% all-damage reduction for 10 seconds and the rank child links 51789/64855/64856/64858/64859. The aura triggers only on a real base-Blood-rune transition: a base Blood rune that was ready enters cooldown, and both base Blood runes are cooling after the committed action. This tracks original Blood runes, not Blood-cost runes: a Death Rune paying Blood cost while both base Blood runes were already cooling cannot refresh the aura. Player and generated-companion casts use the same transition gate and their own ranks. No WotLKDB fallback was needed. Four helper tests and two integration tests cover ranks, duration, reduction, player activation, companion activation, and Death Rune exclusion.
|
||||
- [x] Merciless Combat (`talent-1993`) applies the generated 6%/12% bonus to Icy Touch, Howling Blast, Obliterate, or Frost Strike only while the current target is below 35% health. The generated rank rows supply the exact named spells, strict threshold, rank values, and child spell IDs, so no WotLKDB fallback was needed. Player and generated-companion direct damage paths apply the bonus before target mitigation; helper and integration coverage verify rank behavior and the exact 35% boundary. See [the source/rationale note](WOW_DEATH_KNIGHT_MERCILESS_COMBAT.md).
|
||||
- [x] Spell Deflection (`talent-2018`) reads generated 15/30/45% school-absorb ranks and the 126 nonphysical school mask, then rolls once per landed direct incoming spell hit against the defender's live Parry chance. Player and generated-companion paths apply it after existing resistance/aura reductions and before later Ward, transfer, shield, and health-loss stages; absorbed damage is recorded. The discrete enemy attack model has no periodic incoming spell-tick event, so its current spell-hit event is the direct-hit boundary. Whole-damage floor rounding is a simulator choice; AzerothCore is corroboration, not stock-source authority. Focused validation passed 7 tests across three files; see [the source/implementation note](WOW_DEATH_KNIGHT_SPELL_DEFLECTION.md).
|
||||
- [x] Epidemic applies its local generated 3/6-second duration increase as one/two extra three-second ticks to Blood Plague and Frost Fever.
|
||||
- [x] Hysteria (49016) follows current Area-52 talent node 1954: +15% physical damage with 1% max-health loss per second for 30 seconds; the conflicting stock class row's +20% is not used.
|
||||
- [x] Resolve Dancing Rune Weapon (49028)'s local payload: generated Spell.dbc gives 60 Runic Power, instant cast, 30-yard range, a 12-second summon, and a 90-second cooldown; its tooltip references child 51906, whose third effect reduces the Rune Weapon's damage by 50%. The parent's 10-point effect is Dummy, not mitigation. [WotLKDB 3.3.5a](https://wotlkdb.com/index.php?spell=49028) confirms the cost, duration, range, cooldown, and parent effect structure. A half-damage helper and source-gated mirror-event predicate are implemented and tested.
|
||||
- [x] Integrate Dancing Rune Weapon (49028) for player and generated-companion Death Knights. Casting applies its sourced 12-second aura; the simulator does not fabricate a separate summon actor or stats. During the window, player and companion runtimes mirror eligible landed direct hostile primary-target spells and melee autos at half of the owner's raw damage. The [AzerothCore 3.3.5-derived handler](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp#L3260-L3419) is the executable server reference, not a TrinityCore implementation: spell gates require a learned spell (Death Coil damage child 47632 is the exception), a living hostile target, and exclude listed utility spells (Raise Dead/Ally, Death Grip, Dark Command, Death Pact, Death and Decay, Anti-Magic Shell), positive/summoning actions, and secondary AoE targets. Companion mirroring is a simulator extension over modeled companion actions. The source Rune Weapon's independent hit/crit/stat calculations are approximated by reusing the owner's pre-target-mitigation raw damage at 50% through the existing target-damage path; periodic spell ticks and a separate Rune Weapon actor are not modeled. Coverage: 10 focused helper tests plus player Death Coil/window-expiry and companion summon-aura/Death Coil/melee integration regressions pass.
|
||||
|
||||
### 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.
|
||||
- [x] Improved Kick follows the requested stock effect: Kick has a 50%/100% chance to silence the target for 2 seconds. Its max-rank capstone restores up to 40 Energy and grants 35% attack speed for 6 seconds after a successful interrupt. The generated tooltip and player/party runtime use these effects; the generated movement-speed cap is ignored.
|
||||
- [x] Combat Potency's local custom child 415010 confirms its rank-3 cooldown rider targets Blade Flurry (13877), Killing Spree (51690), and Combat Readiness (86378) by one second. The existing runtime applies this to those abilities when present; Combat Readiness is absent from the stock 3.3.5 generated catalog, so the custom recipient remains unavailable in this catalog scope.
|
||||
|
||||
### Shaman
|
||||
|
||||
- [x] Elemental Devastation follows generated talent node 1645: direct Fire/Frost/Nature spell criticals grant the rank's 4/8/12% melee and ranged critical chance for 10 seconds. Player and generated companion Shamans use the event path; periodic damage is excluded.
|
||||
- [x] Extend Elemental Focus (`talent-574`) to generated companion Shamans: landed nonperiodic Fire/Frost/Nature spell criticals use the generated rank's 50%/100% proc chance and grant two Clearcasting charges for 15 seconds. Each committed eligible spell spends one charge and reduces mana cost by 30%. The player path already used these generated rank values; the local SpellDuration row 8 supplies the missing 15-second duration. Focused validation: 2 profile tests and 5 party Shaman tests.
|
||||
- [x] Improved Shields applies the generated 5/10/15% increase to Water Shield orb mana for player and generated companion Shamans.
|
||||
- [x] Implement Lightning Overload's generated base effect (`talent-1686`, rank spells 30675/30678/30679) from [the local generated 3.3.5 rank row](../src/game/wow335Talents.generated.ts#L72628): Lightning Bolt and Chain Lightning landed direct spell hits use the local 7/14/20% rank chance, tripled after a critical hit. A proc resolves a same-target echo from half the initiating spell's pre-defense amount, with no extra mana cost or threat and no recursive Overload roll. Player and generated-companion paths are covered; companion Lightning Bolt/Chain Lightning damage uses Nature school. The local source does not give a separate event rule for Chain Lightning jumps, so the simulator checks each direct target hit in its existing damage loop. Focused validation: 2 helper tests and 2 player/companion integration tests.
|
||||
- [x] Implement Elemental Warding (`talent-1640`, generated spells 28996/28997/28998) from the [local generated talent node](../src/game/wow335Talents.generated.ts#L70552): ranks 1/2/3 reduce the owner’s incoming Fire, Frost, and Nature damage by 2/4/6%. The rank helper reads only that owner clause and prevents generic parsing of the separate Tethered Elemental-only riders. Both player and generated-companion incoming-hit paths apply the school-scoped modifier after existing race and Druid multipliers; Fire/Frost/Nature reductions and unchanged physical/Arcane/Holy/Shadow damage are covered. Focused validation: 6 helper tests and 7 player/companion integration tests.
|
||||
- [ ] Model Elemental Warding’s separate Tethered Elemental damage reduction (5/10/15%) and threat bonus (50/100/150%) only if the simulator gains an actual Tethered Elemental actor and pet-specific incoming damage/threat. The current simulator has no such actor; do not apply these clauses to the Shaman or generic companions.
|
||||
- [ ] 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.
|
||||
- [x] Close Convection's Earthquake catalog question. Generated Shaman Convection (`talent-564`) mentions Earthquake, but local 3.3.5a `Spell.dbc` has no matching `SkillLineAbility.dbc` assignment for Earthquake and the generated class catalog has no learnable Earthquake row. The talent tooltip reference alone does not establish a Shaman ability; do not synthesize a catalog entry or mana-cost mapping.
|
||||
- [x] Close the Fireball/Frostbolt attribution question. Local 3.3.5a `SkillLineAbility.dbc` assigns Fireball ranks to Mage Fire (SkillLine 8, class mask 128) and Frostbolt ranks to Mage Frost (SkillLine 6, class mask 128); neither has a Shaman assignment on SkillLines 373/374/375 (class mask 64). Their appearance in generated Maelstrom Weapon (`talent-2057`) rank text is not class-attribution evidence, so no Shaman rows or gameplay mappings are added. The class-skill-line mapping is defined in [the ability generator](../scripts/generate-wow335-abilities.mjs#L16-L23); the generated Convection node is [here](../src/game/wow335Talents.generated.ts#L70074).
|
||||
- [x] Add the generated Maelstrom Weapon PvP damage rider: 2% less damage per consumed stack (10% at five stacks) for eligible damage spells against `characterDefender`; exclude Lesser Healing Wave healing and NPCs. Player and companion casts preserve the consumed stack count through cast completion and channel pulses.
|
||||
- [x] Implement WoW Shaman Flurry (`talent-602`) for players and generated companions. Local generated rank descriptions supply 4/6/10% melee haste and three charges; their child aura rows resolve locally to a 15-second duration, so WotLKDB is only corroboration. The rank-two expanded tooltip says 6% while its raw child `basePoints` says 7%; runtime follows the generated tooltip. A landed direct melee critical applies the actor's rank, and each next melee autoattack attempt consumes one shared charge across both hands. Charge refresh on re-proc, equal-time hand order, and proc-phase flags are documented implementation boundaries. See [the source/runtime note](WOW_SHAMAN_FLURRY.md).
|
||||
|
||||
### Paladin
|
||||
- [x] Illumination (`talent-1461`) uses local generated 33/66/100% critical Flash of Light/Holy Light/Holy Shock proc chances and a 50% refund of the unmodified DBC base Mana cost. Player and companion paths snapshot that rank's base cost at cast start and restore through the normal Mana cap. WotLKDB's stock 20%/30% row conflicts; generated local data remains project authority. A separate Light's Hammer cooldown rider is unsupported because no matching local catalog row exists. Payment rank selection semantics were not changed; a level change mid-cast could still alter charged rank independently of the refund snapshot.
|
||||
- [x] Vengeance (`talent-1402`) applies one stack after positive landed Paladin critical damage from a weapon swing, spell, or ability. Generated ranks define 1/2/3% per stack, five stacks, and 15 seconds; damage bonus applies to Holy damage or exact Crusader Strike/Divine Storm rows. Player and companion share the resolved-hit runtime path. Generated tooltip values intentionally supersede conflicting child Spell.dbc values (3 stacks/30 seconds, Physical+Holy).
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] Resolve Vindication's capstone AP gain. Rechecked generated rank `talent-1633`/209452 and current Area-52 patch-D and client-current patch-T `Spell.dbc` rows: effect 0 is a proc-trigger aura (42) linked to AP-reduction spell 67; effects 1/2 are self-targeted aura 4 (`DUMMY`), with base 1 and +1.55/+0.90 per level, but neither specifies an AP operation or child spell. The rank text says only that AP increases after qualifying Holy damage, without a quantity. SLS row 209451 separately names flat-AP child 200067 and a Holy-damage increase; 200067's AP aura is not referenced by rank 3. Stock 3.3.5a Spell.dbc copies contain 67/9452/26016 but omit all three custom IDs. WotLKDB direct URLs for 209452, 209451, and 200067 were inaccessible on recheck, and indexed searches found no matching WotLKDB rows. Keep the gain unwired: the DUMMY coefficients and separate SLS child do not establish 209452's AP formula or operation.
|
||||
- [x] Aura Mastery follows the current talent tooltip and [WotLKDB 3.3.5a spell 31821](https://wotlkdb.com/?spell=31821): 6 seconds, not the conflicting older generated class row's 10 seconds. Its tooltip/behavior includes 50% self-only duration reduction for represented confuse/fear/sleep controls, silence/interrupt immunity for affected allies, and doubled modeled modifiers from the same Paladin's aura; Retribution Aura's generated rank damage is Holy and doubled while Aura Mastery is active. Party incoming-hit retaliation is wired, including the DBC's +0.033 spell-power coefficient for player and companion Paladins. Concentration Aura pushback reduction is now integrated on player and generated-companion incoming cast/channel paths: its exact aura plus Paladin source supplies 35% base, and that Paladin's Improved Concentration Aura ranks add +7/14/20 percentage points. This scales the existing 500ms cast and 25% channel penalties after the cast's pushback-resist check; two-hit limits and spell-specific resistance remain intact. Aura Mastery does not amplify this separate modifier. Stock WotLKDB ranks (+5/10/15) are not used.
|
||||
|
||||
### Mage
|
||||
- [x] Frost Warding Ward proc (`talent-70`) supports the local Mage Fire Ward and Frost Ward rows for player and companion incoming matching-school spell damage: generated ranks set 25/50% proc chance, 50% damage reduction, and Mana refund equal to damage reduced. The simulator applies reduction before ordinary Ward absorption as an explicit ordering inference because generated tooltip text does not specify it; stock full-absorb behavior is not imported. Arcane Ward has no local Mage row; Shadow Ward is Warlock, so neither is synthesized.
|
||||
- [x] Frost Warding armor rider (`talent-70`) scales only the positive armor/resistance modifiers on the exact local Mage Frost Armor or Ice Armor cast-generated aura by the generated 25/50% talent rank. Player and companion auras carry the modifier for their own duration and exclusive armor replacement; no aura means no bonus, and generic talent parsing remains guarded.
|
||||
- [x] Netherwind Presence (`talent-1846`) uses the generated local 1/2/3% spell-haste-per-stack ranks, three-stack cap, and 10-second refresh. Accepted casts of the generated Mage trigger spells grant one stack per cast; cast-commit timing is an explicit inference because generated proc-phase flags are absent. At max rank, Slow at three stacks grants 30% movement speed for three seconds and preserves the haste stacks. Player and companion cast timing plus player/companion movement paths are wired; Arcane Orb is absent from the Mage catalog, and Moonfire/Starfire/Starsurge are not Mage entries, so those tooltip triggers remain unavailable. No WotLKDB fallback was used because stock 44400 describes a different permanent 2% talent. Focused validation passed 7 tests across two files; see [the feature note](WOW_MAGE_NETHERWIND_PRESENCE.md).
|
||||
|
||||
- [ ] 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.
|
||||
- [x] Focus Magic follows generated talent node 2211: its recipient aura and tooltip use +1% spell critical chance, while the separate Mage proc remains +3% for 10 seconds. The older `patch-enUS.MPQ` class spell row 54646's +3% recipient value is superseded by the current generated Area-52 talent data.
|
||||
- [x] Improved Fire Blast (talent-27) reduces Fire Blast's cooldown by 1/2 seconds and increases its damage against monsters by 5/10% for players and generated companions; explicit rank modifiers prevent mixed-unit tooltip parsing from inventing values.
|
||||
- [x] Shatter (talent-67) adds the generated +17/34/50 percentage points of critical chance to nonperiodic Mage spell hits against frozen targets, including Fingers of Frost, for players and generated companions.
|
||||
- [x] Verified generated Mage talent-tooltip references against local `patch-enUS.MPQ` 3.3.5a Spell.dbc and SkillLineAbility.dbc: Moonfire and Starfire ranks map to Druid (skill line 574, class mask 1024); Arcane Orb rows 34172/34190 have no player skill-line or talent mapping; Frozen Orb and Starsurge have no named rows in this local Spell.dbc. WotLKDB's Frozen Orb row 72082 has no Mage class/trainer attribution, so tooltip mentions alone do not justify adding/remapping these entries.
|
||||
|
||||
### 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.
|
||||
- [x] Wire player Aftermath ranks 18119/18120 to proc on landed Conflagrate for 50%/100%, applying the generated talent tooltip's 50% slow for 4 seconds. Per the project source preference, the generated custom tooltip takes precedence over stock child 18118's WotLKDB 70%/5-second payload.
|
||||
- [x] Add Aftermath to generated companion Warlocks: landed direct primary-target Conflagrate uses generated talent-982 ranks, a deterministic companion-seeded 50%/100% proc, and the same generated-tooltip 50% slow for four seconds as the player path. Companion Conflagrate now derives its burst from that Warlock's active Immolate/Shadowflame and consumes the DoT only after the hit lands. Party regression covers ranks 0 and 2, exact burst amount, and DoT consumption.
|
||||
- [ ] Resolve Soul Leech (talent-1678)'s +30% critical-chance rider after a physical hit while casting. Unlinked custom patch-D rows 830297–830299 hint at a one-charge current-cast buff and cast-finish cleanup, but no rank spell links to them; neither the trigger, rank gate, nor consumption behavior is established. Keep the rider unwired until a matching source connects those rows.
|
||||
- [x] Adopt current CoA Mind Transference (Warlock/Warrior 498642): spend 75 Rage, grant 6 Psi, and use a 1-second cooldown. The [current pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1), [10.2.1 patch](https://chroniclesofarcadia.com/patch/10-2-1-1000) (fixed 50 Rage → 6 Psi), and [11.1.2 patch](https://chroniclesofarcadia.com/patch/11-1-2-1000) (75 Rage, 1-second cooldown) establish the current conversion. This replaces the local RuneWaker tooltip's consume-all-Rage, 35-Rage-per-Psi rule. Local component 621320 also grants +40% physical attack for 30 seconds; current CoA does not confirm that rider, so it is intentionally omitted. Player and companion conversions have focused regression coverage.
|
||||
- [ ] Integrate current CoA Psychic Warrior (Warlock/Warrior 499868): area-damage spells summon a 187.5% main-hand DPS Ethereal Hammer; single-target damage spells summon a 1,050% main-hand DPS Ethereal Sword, both dealing Physical Dark damage. Patch [12.1.4.1500](https://chroniclesofarcadia.com/patch/12-1-4-1500) raised the area coefficient to 250%, then [12.1.5.1000](https://chroniclesofarcadia.com/patch/12-1-5-1000) reduced it to the current 187.5%; the [current pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1) confirms the current values. The catalog now uses the current name/tooltip and an isolated helper resolves the coefficients, but combat integration remains open until CoA's Physical Dark defense behavior and the added-hit event/roll semantics are sourced or explicitly decided; see the [source/runtime note](ROM_WARLOCK_WARRIOR_PSYCHIC_WARRIOR.md). The attribute-gated extra main-hand weapon damage for Slash/Consistent Heart Strike has no stated amount and remains open. Do not map local hidden child 499869 to current Enraged 499871.
|
||||
- [x] Adopt current CoA Enraged 499871 from the [Warlock/Warrior page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1): Warrior General level 60, instant, 40-second cooldown, restores 100 Rage, 35 Focus, and 1 Psi. Add it as a separate Warlock/Warrior learned row, preserving RuneWaker Warrior General Enraged 490492 and leaving hidden local child 499869 unresolved. The shared resource-effect path handles all three pools for player and companion; regressions cover level-60 learnability, exact pair gating, resource caps, and cooldown.
|
||||
- [ ] Update Breathing Control (Warlock/Warrior 498634) to the current CoA behavior: casts of Psychic Arrows, Warp Charge, Weakening Weave Curse, Slash, Soul Trauma, Puzzlement, Heart Collection Strike, or Surge of Malice can trigger Enraged automatically, replacing RuneWaker's cooldown-reset behavior. The [10.7.1 patch](https://chroniclesofarcadia.com/en/patch/10-7-1-2000) documents the behavior change, and [11.1.2](https://chroniclesofarcadia.com/patch/11-1-2-1000) documents the expanded trigger list. The proc probability and precise event timing are not published; leave the proc unwired until those are sourced.
|
||||
- [ ] Keep Deep Breathing Control (Warlock/Warrior 499870)'s 15% HP rider unwired until its trigger is explicit. The current CoA row says “Your Breathing Control recovers more focus and an extra 15.0% HP,” and [11.1.2.3000](https://chroniclesofarcadia.com/patch/11-1-2-3000) raises the rider from 10% to 15%, but neither source says whether HP recovery occurs on manually cast Enraged 499871 or only when Breathing Control 498634 automatically triggers Enraged. The current CoA Breathing Control row leaves proc probability/timing unspecified, and local 498634/499870 rows have no components. Do not attach the heal to manual 499871 without a source-defined event link; the extra Focus amount also remains unknown.
|
||||
- [x] Replace local Divine Assistance (Warlock/Priest 498668)'s Inspiration-stack/Defense Net reset behavior with the current passive +16% Magical Damage and Healing. The [10.2.1 patch](https://chroniclesofarcadia.com/patch/10-2-1-1000) establishes the stat effect and [12.1.0](https://chroniclesofarcadia.com/patch/12-1-0-5000) makes it passive; the [current pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) confirms the current payload. Player and companion effects are covered.
|
||||
- [x] Update Warlock/Priest Healing Fortune (498633): a landed Psychic Arrows hit grants +12% heal power and +12% Wisdom for 20 seconds, and Warp Charge heals a friendly target instead of dealing damage. Psychic Rising Tide (498643) makes Rising Tide instant with a 5-second cooldown, restores 1 Psi, and triggers Healing Fortune on a critical hit. The [current pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) selects these current effects over RuneWaker's instant/free Urgent Heal proc descriptions. The simulator cannot route an ally-targeted Rising Tide cast to that ally's hostile target because party members have no hostile-target selection state; the current passive's other sourced effects are wired.
|
||||
- [x] Replace RuneWaker's stale Warlock/Priest Soul Enlightenment (498649) payload with current CoA's +5.7% damage dealt and +17.1% received healing for 30 seconds, costing 2 Psi, in a 7.5-unit party aura radius (CoA range 100/radius 75 interpreted through the established 10:1 conversion). Exact-pair catalog and player/companion cast integrations are covered. The simulator has only a party roster, and its existing area targeting requires line of sight. CoA's Willpower Construct Caster-only restriction is retained in the tooltip but not enforced because the cast validator has no corresponding state gate. See [the source/rationale note](ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT.md).
|
||||
- [x] Add Mind Shield's (Warlock/Priest 498648) explicit +1 Psi on Urgent Heal from the [current pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5). Its Defense Net duration reduction is retained. Keep Ring of Comfort's cooldown-reset rider open because the proc probability is unspecified, and leave the enhanced-defense amount unmodeled because CoA does not quantify it.
|
||||
- [x] Integrate current CoA Warlock/Priest Spreading Pain (499856) in the generated catalog/tooltip and player/companion cast effects. The [current CoA pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) specifies the three linked debuffs, +18% Magical Attack Power, +25% Magical Critical Bonus, and activation of Healing Fortune. Patch [10.6.1](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) narrows that activation to Healing Fortune's +12% healing for 20 seconds, excluding its Wisdom and Warp Charge clauses. The three RuneWaker children 497959/497960/497964 remain intact. The tagged +25% Magical Critical Bonus aura is consumed by the central critical resolver for player and companion magical spell crits, scaling only the bonus portion (2.0 becomes 2.25); crit chance and ordinary hits stay unchanged. CoA lists 10% MP plus 15 Focus and range 150, while the local row supplies only 15 Focus and range 22.5. RoM Warlock has no Mana pool and no general CoA range conversion is established; keep the unrepresentable base-MP difference and local range documented rather than inventing a cost/range override.
|
||||
- [x] Complete Warlock/Mage Soul Brand (498705) player and party event hooks. Current CoA replaces RuneWaker's three-skill trigger and 9% Dark/Flame-only debuff with a +2% per stack (four-stack cap, 20-second refresh) damage-taken aura from any landed Fire damage, including periodic hits, plus 1 Psi per trigger. Player and generated-companion direct/periodic event paths apply the target aura and grant Psi to the triggering source. The simulator maps the five RoM elemental categories to Fire, Frost, Nature, Holy, and Shadow; Physical and Arcane are unaffected.
|
||||
- [x] Implement current CoA Warlock/Mage Soul Brand Sting (498710): 2 Psi, 1× derived Intelligence added to each 3737.5 Dark/Fire packet, cast only under an active locally tagged Willpower Blade aura, and reset Flaming Heart Strike on cast. The CoA state entry is ID 1494096 and the local RuneWaker aura source is ID 497955; the gate uses the observed local state name and does not equate those IDs. Preserve local range 22.5 because CoA's 150 has no verified unit conversion. See [the source/rationale note](ROM_WARLOCK_MAGE_SOUL_BRAND_STING.md).
|
||||
- [ ] Find source-backed proc probabilities and event details for Breathing Control, Mind Shield's Ring of Comfort reset, and remaining Priest-pair passives. The old hidden child 499869 and End of Thought's tick interval remain unresolved; do not infer current Enraged 499871 is the same child.
|
||||
|
||||
### 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.
|
||||
- [x] Companion Rejuvenation snapshots the owner's Improved Rejuvenation rank bonus (+7/14/20%) when the HoT is applied, matching the player healing path.
|
||||
- [x] Nature's Reach (talent-764) adds the generated +2/4/6 yards to cast-time/channel damaging spells for players and generated companions; instant damage and healing spells receive no range bonus.
|
||||
- [x] Master Shapeshifter (talent-1915) applies the generated rank 2/4% form-specific bonus to Bear physical damage, Cat melee critical chance, Moonkin Arcane/Nature damage, and Tree healing across player and companion direct/periodic paths.
|
||||
- [x] Predatory Instincts (talent-1795; rank spells 33859/33866/33867) applies Aura 163's 5/10/15% increase to the critical bonus portion and Aura 229's 4/7/10% reduction to damage from authored area attacks, with both clauses gated by Cat Form. Generated rank text and local DBC effects give the values; [WotLKDB rank 2](https://wotlkdb.com/?spell=33866) independently confirms Cat Form plus Aura 163/229. Player and generated-companion outgoing/incoming paths are covered. The general `damagePlayer(amount, school)` adapter has no attack-delivery metadata, so this AoE-specific rider is applied only when the enemy attack definition identifies `delivery: "area"`; no competing AoE-specific reduction is currently modeled, and the source's non-stacking clause is therefore not independently exercised.
|
||||
- [x] Catalog attribution check: Lesser Healing Wave is present only as a Shaman spell in the stock 3.3.5 data; Flourish is in the separate custom Ascension class-11 catalog, not the stock Druid catalog. Neither should be added to the stock Druid catalog from a tooltip reference alone.
|
||||
|
||||
### 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.
|
||||
- [x] Damage Shield reflects 10/20% of resolved block value after a landed direct melee hit for players and generated companions.
|
||||
- [x] Resolve Unbridled Wrath's project data choice: use generated node `talent-159`'s custom 2/4-Rage tooltip values, with the upstream [WotLK Simulator](https://github.com/wowsims/wotlk/blob/master/sim/warrior/talents.go)'s 3 procs-per-minute-per-rank melee-white-hit model to fill the missing effective proc rate. The generated child links still conflict (12964/812964), but the project's generated tooltip takes precedence; an isolated calculation helper is in place.
|
||||
- [x] Unbridled Wrath grants the generated 2/4 Rage on eligible landed, damaging Warrior main-hand melee autoattacks, using the source-backed 3 procs-per-minute-per-rank rate and each Warrior's un-hasted weapon speed. Player and generated-companion hits are covered; Warrior off-hand autoattacks are not modeled.
|
||||
- [x] Improved Victory Rush uses generated 33/66/100% parry proc ranks, a 10-second ready window, 12-second internal cooldown, +20/40/60 percentage points of Victory Rush critical chance, and +33/66/100% threat during the parry window. The base Victory Rush ability is also enabled for 20 seconds after an XP-yielding kill. Player and generated companion casts are gated on readiness and consume the landed-hit window.
|
||||
- [x] Critical Block (`talent-1893`) applies its generated 8/16/25% extra reduction to successful blocked hits for player and generated-companion Warriors. The reduction is excluded against player attackers and when Touched by the Light (`talent-2195`) is learned; the conflicting stock double-block chance is not applied.
|
||||
- [x] Improved Defensive Stance applies its generated stance mitigation and avoidance-triggered Enrage proc for player and generated-companion Warriors.
|
||||
|
||||
## 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.
|
||||
- [x] Add current CoA's missing level-50 Warrior/Knight Blocking Stance passive (493287) as a distinct catalog marker. The [current CoA pair page](https://chroniclesofarcadia.com/wiki/skills/list/1-6?locale=en) lists it separately from Resist (493251) and local active Blocking Stance (491480). Its tooltip records the sourced stance-gated static modifiers; the marker carries no generic stat aura so the bonuses do not activate when 491480 is off. Its physical-hit proc grants 3 Rage with a 10-second retrigger lockout, but the chance is not stated.
|
||||
- [x] Integrate current CoA's Warrior/Knight stance modifiers: learned Blocking Stance 493287 plus active local stance 491480 gives +8% parry, −18.9% received damage, and +75.4% Aggro; learned Resist 493251 gives +80% Aggro and 20% reduced received Physical Damage. Player and generated-companion incoming hits and threat use the sourced factors. The two Aggro bonuses remain separate and their combined result is neutral/unmodeled until CoA stacking is established; the Rage proc chances are unspecified, so they remain unmodeled. CoA lists a 10-second retrigger lockout for each proc.
|
||||
- [x] Finish current CoA Warrior/Rogue Exhaustion (493248). The [current CoA Warrior/Rogue page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-3) confirms the same name, ID, and pair as the local passive; it reduces Whirlwind's Rage cost by 20 and cooldown by 10 seconds, and a landed Probing Attack has a 30% chance to restore 25 Energy and 50 Rage. Player and generated-companion landed-hit hooks use that proc; focused coverage checks player casts and the capped companion resource helper. The separate CoA Probing Attack 493283 uses a 664.3% main-hand DPS coefficient, or 1116.9% with a caster-owned Bleed or locally represented two-handed hammer (`mace` in weapon metadata). The unquantified hammer cooldown rider remains unapplied. The local primary-Warrior Probing Attack 491133 remains distinct and does not trigger Exhaustion. The earlier ID-move claim was incorrect: the current CoA page still identifies Exhaustion as 493248 and Morale Slash as 1491760.
|
||||
- [x] Apply the supported Warrior/Druid Recovery of Life (494377) → current CoA Rage of Calmness changes. The [CoA Warrior/Druid page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-8) confirms the same passive ID/name lineage and says Whirlwind costs less Rage and has a shorter cooldown. Local RuneWaker 494377 supplies −10 Rage and −10 seconds for Whirlwind, while component 506368 gives the post-hit general Rage rider a 20-second window. CoA's current page references Whirlwind 494939, while the RuneWaker catalog exposes 490133; `romWarriorDruidRageOfCalmness.ts` gates the local values to learned 494377 on Warrior/Druid and local Whirlwind 490133. Player and companion cost/cooldown paths and the catalog label are updated. Focused helper and player/party integration coverage passes 8 tests across two files.
|
||||
- [ ] Implement Rage of Calmness's post-hit general Rage-cost reduction only when its numeric amount is sourced. The 20-second duration is known from local component 506368, but neither that component nor current CoA specifies the reduction amount; the rider remains explicitly unwired.
|
||||
- [x] Warrior/Warlock Speed Slash (499827): runtime applies the current CoA −10 Rage cost to Slash, and its generated tooltip now says Slash damage increases without inventing the unspecified amount. The [current CoA pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-9) does not support RuneWaker's missing-child cooldown reset.
|
||||
- [x] Warrior/Warlock Excellent Combat Training (499806): runtime applies current CoA's +20% Physical Damage from the [current CoA pair page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-9), and its generated tooltip now states that effect without unsupported cooldown resets.
|
||||
|
||||
### Scout
|
||||
|
||||
- [ ] Quantify Enhanced Throat Attack's extra range from a matching source.
|
||||
- [ ] Add Natural Poison Powder acquisition to the Scout/Rogue loadout model.
|
||||
- [x] Scout/Knight Enhanced Armor Perfection (492346) uses local component 501823 to extend Enhanced Armor (490152) from its 300-second base to 600 seconds for player and party casts.
|
||||
- [x] Scout/Priest Sniper (492344) reduces Snipe (490450)'s cast time by one second for player and generated companion Scouts.
|
||||
- [x] Scout/Priest Screen Attack (499555) now follows current CoA's active +17.4% party damage buff for 20 seconds and 60-second cooldown, replacing the local passive marker. CoA lists +55 skill training (not character level) and radius 150; the simulator has no training rank and interprets radius as 15 project units. Current skill-page duration wins over patch 11.1.1's earlier 30 seconds; DoT coverage follows the patch. Player and generated-companion casts scale direct and periodic damage, use existing party radius/LOS/living-recipient rules, and expire at the modeled 20-second boundary. See [the source/runtime note](ROM_SCOUT_PRIEST_SCREEN_ATTACK.md).
|
||||
- [x] Scout/Priest Drums of War (493010) follows CoA's +20% critical damage after landed Shot (CoA Shot 493011 is mapped by name/role to local RuneWaker Shot 490423). It lasts 15 seconds and then waits another 15 seconds before it can proc again, interpreted as 30 seconds from proc to ready. The buff applies to party members within the modeled range 50; it raises the critical bonus portion (2.0→2.2), leaves chance/noncrit damage unchanged, and stacks additively with critical-damage bonuses such as Spreading Pain. Player/companion proc and hit-resolution tests are documented in [the source/runtime note](ROM_SCOUT_PRIEST_DRUMS_OF_WAR.md).
|
||||
- [x] Priest/Scout Curing Shot (492636) now follows current CoA: instant, 6-second cooldown, 125.6 immediate healing, then 167.5 every 3 seconds for 18 seconds, radius 100 (10 project units), no target cap, and caster-only +10% Elemental Damage for 18 seconds. CoA says all raid members; the simulator reaches all eligible modeled party members. CoA defines Elemental Damage by subclass; Water is inferred from the Priest/Scout kit and maps to the project's `frost` school, so other schools are unchanged. The local 120 MP cost is preserved because CoA publishes 242 MP only at +105 without a cost curve. Player/companion coverage and source assumptions are in [the feature note](ROM_PRIEST_SCOUT_CURING_SHOT.md).
|
||||
- [ ] Quantify Scout/Warrior Enhanced Throat Attack (491489)'s extra range. Hash-matched RuneWaker data has no components or modifier linking it to Throat Attack (490428); the passive's own range 5 is its cast-distance field, not a range bonus. The current [CoA patch notes](https://chroniclesofarcadia.com/patch/11-1-1-4000) rework the same-named skill into Soldier's Determination, so that later behavior does not fill the RuneWaker amount. Do not apply the cast-distance field as an additive modifier.
|
||||
- [x] Model RuneWaker item 200563, Poison Powder, as an unlimited carried reagent for Scout/Rogue. Poisonous Spit does not require the separate Rogue Poison Bottle aura.
|
||||
- [ ] 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.
|
||||
- [x] Align Rogue/Scout Coerce (493019) to current CoA's [+20.2% Low Blow damage](https://chroniclesofarcadia.com/en/wiki/skills/list/3-2). CoA says the ability also lowers Energy cost but omits the amount, so retain only the compatible local −5 Energy reduction; remove RuneWaker's conflicting below-30%-health temporary +2% Physical Damage proc. Player and generated-companion Low Blow paths are covered.
|
||||
- [x] Follow current CoA Rogue-primary behavior for Poisonous Hands (490329) and Energized Hands (490347): mutually exclusive 900-second buffs; a target debuff that stacks to ten but applies a fixed 8% damage-dealt reduction total; one 1% self attack-speed stack per successful target application; and Energized Hands' +100% Energy recovery, +50% maximum Energy, +20% Energy costs, and −0.1-second skill cooldowns. CoA leaves Poisonous Hands' chance qualitative, so the populated RuneWaker 50% remains a documented fallback. Poison Bottle consumption and the faster-main-hand cast-time formula are not modeled because CoA provides no runtime inventory model or formula. The cooldown rider is applied once as a flat 100 ms at cooldown commit. Player and companion mechanics pass 49 focused tests; see [the behavior rationale](ROM_ROGUE_POISONOUS_HANDS.md).
|
||||
- [x] Replace Rogue/Mage RuneWaker Enchanted Throw with current CoA Chaos Dagger (491178): the learned passive adds a Fire packet equal to 169.4% of the attacking hand's weapon DPS on each landed main-hand or off-hand normal attack for players and generated companions. The matching CoA skill page and [12.1.0.4000 patch](https://chroniclesofarcadia.com/patch/12-1-0-4000) establish the current passive identity and damage; RuneWaker's 25-Mana activatable projectile buff, Throw substitution, and hidden physical child 491185 do not describe the current skill and are removed for this pair. The current page still lists 25 MP but does not say whether it is paid per normal attack after the passive conversion; runtime does not invent that recurring debit. The slight temporary Physical Attack Power rider has no sourced amount or duration and remains unwired. See [the source and runtime note](ROM_ROGUE_MAGE_CHAOS_DAGGER.md).
|
||||
- [ ] 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.
|
||||
- [x] Priest/Warrior Condensed Rage (493038) follows current CoA as a passive +22% Physical Damage effect. The [CoA skill entry](https://chroniclesofarcadia.com/en/wiki/skills/list/5-1) confirms the same name, ID, and pair; patch 11.1.1 changed its damage type from Water/Light to Physical, and patch 11.1.1.5000 removed the buff requirement. RuneWaker's incoming-hit heal state and missing children 493056/495293 are superseded. The player and companion physical-damage paths use the learned passive without an obsolete trigger/stance gate.
|
||||
- [x] Priest/Champion 498610 follows current CoA's Rage Control passive: the [current CoA Priest/Champion list](https://chroniclesofarcadia.com/en/wiki/skills/list/5-10) keeps the same ID and pair but renames/reworks RuneWaker's Infused Light Chain into a passive that grants 20 Rage for each completed second of spell channeling. Player and companion channels grant whole seconds only; ordinary casts and partial seconds do not count. Interrupted channels preserve only their completed seconds. The local Chain of Light damage, cooldown, and on-hit riders are removed.
|
||||
- [ ] Keep Protection Rune Seal's local Regenerate healing-strength rider unresolved unless a matching source quantifies it. The local generated 498613 passive has no components or numeric heal modifier; current CoA 498613 says only that Regenerate has a “slight chance” to reset Heart Rune Energy and does not document an increased-healing rider or proc percentage. Do not infer a heal bonus from “empowers,” and do not reinterpret the separately modeled local 8% cooldown-reset chance as a healing modifier.
|
||||
- [x] Integrate current CoA Priest/Champion Beacon of Regeneration (498620): a 30-second mark on one friendly party member, once per direct healing cast after recipients are aggregated. The [CoA row](https://chroniclesofarcadia.com/en/wiki/skills/list/5-10) publishes 770 HP at skill +105, which the simulator deliberately applies to every unlocked rank because no skill progression model exists. Player and companion casts use generic source healing-done and target healing-taken modifiers consistently; unbuffed transfer is 770 and the +5% Faith aura resolves it to 809 on either path. The local level-50 row costs 29 MP plus 1% and lists range 20 (decimeters), while CoA +105 lists 88 MP plus 1% and range 200; there is no source-backed cost curve to reconcile the flat-MP difference. Local child 621227 (`dotBase=35`, `fixedValue=0.2`, `skillLevelArg=30`) does not map to the current trigger/amount. HoT ticks and channel pulses remain excluded because CoA does not settle their eligibility and the structured event lacks a periodic marker.
|
||||
- [x] Finish the CoA-backed Priest reworks mapped from local Ice Fog ranks: patch 10.2.1 explicitly maps Ice Fog 1/2/3 to Holy Blessing, Vow of Silence, and Holy Void, and the [current Priest skill list](https://chroniclesofarcadia.com/wiki/skills/list/5-1) confirms the same IDs and levels. Bone Chill is a separate skill, 491165.
|
||||
- [x] Holy Blessing (490274) follows the [current CoA Priest-primary entry](https://chroniclesofarcadia.com/en/wiki/skills/list/5-7): permanent Wisdom `(current Wisdom + 115) × 1.105` and +100.9% Light damage. The tagged caster aura maps Light to Holy; player spell damage resolves its school-scoped aura, and generated-companion direct damage uses the same scope when it takes the deterministic party path. The dedicated stat stage resolves existing Wisdom modifiers first and adds only the incremental Wisdom-to-spell-power conversion to coefficient-based spell amounts; generic spell-power aura modifiers stay on their usual path. Current CoA does not specify how this formula stacks with other Wisdom buffs, so existing Wisdom modifiers use the simulator's current stat resolver before this passive is applied. `applyAuraStats` can expose the changed Wisdom and derived spell power, but cast coefficients read equipment scaling directly; the per-skill delta bridges that boundary without double-counting generic spell-power fields. Player and generated-companion combat tests cover actual spell amounts and Holy versus Nature damage. The player Frost control includes RuneWaker's existing “Prayer: water-damage” +5% Frost aura in its expected value; the Holy and Nature controls isolate Holy Blessing's school scope.
|
||||
- [x] Vow of Silence (490275) interrupts and silences for 8.4 seconds on NPC targets and 4.2 seconds on player targets. Player and generated-companion casts use the shorter player-target duration.
|
||||
- [x] Holy Void (490276) follows the current [CoA Priest-primary entry](https://chroniclesofarcadia.com/wiki/skills/list/5-1): instant 1655 Light damage plus Voidal Infection for 20 seconds, reducing physical and magical defense by 18.8%. The cast override maps the catalog's physical-defense alias to armor and keeps the magical-defense debuff; player and generated-companion casts against NPCs both deal Holy/Light damage and apply the target aura. NPC armor and modeled magical resistances are reduced by the debuff. The generic aura path supports character targets, but this focused test covers NPC targets only. NPCs have no Light/Holy resistance field, so magical-defense does not mitigate Holy Void's Light hit; this remains an explicit simulator limit. Three focused runtime regressions pass.
|
||||
- [x] Finish Priest/Champion Heart Rune Energy (498612)'s current CoA behavior. The [current CoA Priest/Champion entry](https://chroniclesofarcadia.com/en/wiki/skills/list/5-10) and patch [11.1.5.3000](https://chroniclesofarcadia.com/tr/patch/11-1-5-3000) confirm the same name, ID, and pair, but a deliberate payload rework: replace local healing and +2% Mind with 975.1% CPS Light damage (runtime coefficient 9.751, Light/Holy school), 15 Rage cost, 150 range, 80 radius, instant cast, 12-second cooldown, and −15 seconds to Blessing of Humility's cooldown on use. Patch [11.1.5.4002](https://chroniclesofarcadia.com/patch/11-1-5-4000) confirms the AoE is centered around the caster with 80 range. Player and generated-companion regressions confirm damage to targets at or inside 80, none just outside, six eligible targets without a catalogued cap, Rage cost, cooldown, and Blessing cooldown reduction. CoA publishes no target-count cap, so none is added; the simulator resolves targets among its modeled visible/engaged mobs. The existing Protection Rune Seal test still verifies its separate Regenerate tick reset against ID 498612.
|
||||
- [x] Rework Priest/Mage Enlightenment (492933) to current CoA. The [current CoA Priest/Mage page](https://chroniclesofarcadia.com/fr/wiki/skills/list/5-4) confirms the same name, ID, and pair, and says it passively increases Wisdom by 12%. This replaces RuneWaker's completed-Heal (490261) trigger, +1% Wisdom for 8 seconds, and 20-second internal cooldown. The catalog tooltip now reads “Passive: Increases Wisdom by 12%,” and the effect is a permanent caster aura with a standard Wisdom percent modifier of 0.12. Player passive synchronization activates it when the build is initialized or updated; generated companions apply learned self-target passives before their first RuneWaker cast. Two helper tests and two player/companion integration tests pass. No special formula or runtime limitation is known for this modeled stat modifier.
|
||||
- [x] Integrate current CoA Priest/Scout Saint's Blessing (493039): the current skill page gives +28% damage only against live targets below 30% max HP, separately from its low-health Heal rider. Player and generated-companion direct and periodic damage packets check the live target threshold at each packet and apply +28% once. The older 11.1.5.2000 patch says +21%; the current +28% row takes precedence. The separate Heal rider is documented but still unresolved: CoA lists Heal 493048 with +597/+21% at skill level 105, while the local generated catalog has no 493048 row or current lower-rank curve. See [the source/runtime note](ROM_PRIEST_SCOUT_SAINTS_BLESSING.md).
|
||||
- [ ] 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.
|
||||
- [x] Remove legacy Holy Seal periodic damage from 490148, 490149, 490150, and 490151 while preserving their direct attacks and seal build/spend behavior. Current CoA Knight/Warrior entries and patch 10.6.1 establish that Holy Seal no longer deals periodic damage; the runtime filters only those legacy DoT outputs.
|
||||
- [x] Align Knight-primary Holy Power Explosion (490172) to current CoA's passive +198% Aggro. The [current CoA Knight/Warrior page](https://chroniclesofarcadia.com/wiki/skills/list/6-1?locale=en) identifies the passive; patch [12.1.3](https://chroniclesofarcadia.com/patch/12-1-3-1000) confirms the value. The old active mana/healing payload is removed. Knight, Blocking Stance, and Resist threat factors are applied individually when their source gates are met; overlapping Aggro factors remain uncombined pending a source-defined stacking rule.
|
||||
- [x] Replace the stale Knight/Rogue Leopard Instinct (495402) absorb with CoA's current passive stacks: each received hit adds one stack, up to 10, granting +3 percentage points Dodge and a separately recorded +3% Magic Resistance Rate per stack; a dodge removes one stack. Player and companion incoming-hit/dodge paths update the stack aura. Patch [11.1.5.3000](https://chroniclesofarcadia.com/tr/patch/11-1-5-3000) replaced the absorb, and patch [12.1.2](https://chroniclesofarcadia.com/fr/patch/12-1-2-1000) made it passive. Magic Resistance Rate remains unapplied because the simulator has no consumer; no absorb or timer is substituted.
|
||||
- [x] Fearless (490088) preserves its local +3% Strength and +3% Stamina effects and grants 20-second Fear immunity; the misleading Fear-control tooltip marker is filtered out.
|
||||
- [x] Integrate current CoA Knight/Rogue God's Sword (491621): while the level-15 passive is learned, offensive physical abilities add 350% of offhand weapon DPS as Light damage and generate high Aggro. The current [CoA skill entry](https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru) confirms the same ID/name/pair; patch [10.6.1](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) begins the offhand-DPS rework, [11.1.5](https://chroniclesofarcadia.com/tr/patch/11-1-5-3000) changes it to 350% Light, and [12.1.0](https://chroniclesofarcadia.com/patch/12-1-0-5000) makes it passive. This supersedes RuneWaker's +40% Holy Strike crit rider. Player and generated-companion landed direct-hit resolvers now check the Knight/Rogue pairing, learned passive, a qualifying physical ability (including source descriptions marked “Physical Light Damage”), and positive offhand DPS, then record a separate Light/Holy damage packet at 3.5× offhand DPS. Validation: 4 helper tests and 3 player/companion runtime integration tests passed. CoA does not publish an Aggro multiplier or specify how multi-hit, channel, and AoE casts generate extra damage; no Aggro multiplier or additional cadence is inferred, and coverage follows only the direct damage event granularity the runtime exposes.
|
||||
- [x] Align Deadly Whirlwind (491639) to current CoA: successful Whirlwind hits apply Deadly Wound, with current Whirlwind text specifying 30% reduced received healing for 10 seconds (5 seconds on player targets). CoA still says the passive reduces Whirlwind Rage cost and cooldown but gives no amounts, so retain the matching local RuneWaker −10 Rage/−20-second values; do not import a damage-type or visual rider.
|
||||
- [x] Bridge the local Knight/Priest 491635 row to current CoA's Compassion of Holy Light behavior. The selected English [CoA skill list](https://chroniclesofarcadia.com/wiki/skills/list/6-5?locale=en) reports Knight/Shaman, while localized/index results disagree on the displayed pair; this simulator has no playable Shaman class/pair. The code keeps the local pairing and labels the exact-ID cross-pair bridge in the tooltip; it does not claim the pair identities match. The [11.1.5 patch](https://chroniclesofarcadia.com/patch/11-1-5-3000) supplies the 120 + 0.2 × STA landed-hit heal, Holy Illumination trigger, and 5-to-4-second cooldown reduction; player and companion Whirlwind Shield hits are covered. Holy Illumination uses the current 15% damage-reduction/10-second payload. The simulator has no shield-defense stat, so the patch's 5× shield-defense damage rider is not modeled.
|
||||
- [x] Align Divine Punishment (491608) to current CoA: Punishment gains 28.5% damage and applies +2% damage taken per Holy Seal stack for 10 seconds. This replaces RuneWaker's +1% Light-only vulnerability. The current modifier is general damage taken, not Light-only, and is based on the pre-spend Holy Seal count.
|
||||
- [ ] 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.
|
||||
- [x] Damage Transfer (493399) now handles player and generated-party Warden incoming hits. The hash-matched RuneWaker executable modifier 503942 applies a 30% transfer for 10 seconds when the owner's supported summon (102297/102324/102325) is alive and within inclusive 3D range 300; the party path applies the split once, consumes pet absorbs, syncs pet health/aura state, and records one pet damage event without chaining. Tooltip component 503643's 15-second value is not executable; do not copy later CoA behavior.
|
||||
- [ ] Keep local Warden Revival (499542) unresolved: trigger state 499544 and component 623078 lack a numeric healing payload. Current CoA uses ID 499542 for Rogue/Warden Low Blow, a different name and class role, so do not map that behavior onto Warden Revival. [CoA Rogue/Warden list](https://chroniclesofarcadia.com/en/wiki/skills/list/3-7)
|
||||
- [ ] Keep the local Weak Point Strike (494043) bonus hit disabled until its proc chance is sourced; components 620294/620295 contain the two damage formulas but no chance. Current CoA uses ID 494043 for Rogue/Warden Blood Hound, a different conditional hit. [CoA Rogue/Warden list](https://chroniclesofarcadia.com/en/wiki/skills/list/3-7)
|
||||
- [ ] Add pet defense stats before implementing Tough Oak's defense rider. Current CoA specifies +60% pet Physical Defense for the same Warden/Mage passive, while the RuneWaker component describes +5% physical-defense-percent; no pet Physical Defense baseline or mitigation formula exists in the simulator. Keep the rider unresolved rather than substituting armor or a WoW pet-defense formula. Current CoA pet skill IDs also differ from the local cooldown IDs; see [the source and runtime plan](ROM_WARDEN_MAGE_TOUGH_OAK.md).
|
||||
|
||||
### 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.
|
||||
- [x] Apply current CoA's 30% base proc chance to Eruption 490221. The current [CoA Mage page](https://chroniclesofarcadia.com/wiki/skills/list/4?locale=en) identifies the passive and Fireball/Flame triggers; patch [10.2.1](https://chroniclesofarcadia.com/patch/10-2-1-1000) raises the base chance from 10% to 30%. No later base-chance change was found. Patch 12.1.0.5000 implies Elemental Catalysis also increases the chance but gives no value, and 10.2.1.1250 raises damage to “200%” without defining the baseline. The base chance is updated and tested; the EC bonus and damage interpretation remain unresolved. Existing damage formula is unchanged and is not source-verified.
|
||||
- [x] Align Fire Ward (490248) with the current [CoA Mage page](https://chroniclesofarcadia.com/wiki/skills/list/4?locale=en): apply −11.5% received damage to every modeled magical school for the caster and party within radius 100 (10 runtime units); preserve local 120 MP, range 150 (15 runtime units), instant cast, and 10-minute duration because CoA omits duration. The hash-matched RuneWaker component 500366 grants +10 Fire resistance, which remains the runtime value; CoA lists +230 only at skill level 105, but there is no source-backed scaling curve or skill-level state to derive other ranks. The isolated catalog/aura regression passed (1 test), checking all six magical schools and excluding physical mitigation. Player and generated-companion party-scope/radius integration tests pass (2/2): each applies Fire Ward to the caster and eligible nearby party members, while excluding the distant party member.
|
||||
- [x] Rank 2 Elemental Catalysis (497971) has no Mana tick: its RuneWaker tooltip omits Mana regeneration and component 620176 has `dotTime=0` and `dotBase=0`; rank 1's Mana tick stays rank-specific.
|
||||
- [x] Current CoA Mage/Druid Green Guardian (494331) replaces the conflicting local passive/Protection-expiry HoTs for that exact pair. Players and generated companions gain one stack per full observed stationary second, up to 20, for +1% movement speed per stack. Companions auto-cast the learned zero-cost self skill on their first available combat action when the aura is absent; that is a simulator policy. A full stationary second after a sampled move is an explicit timing approximation. Stack loss and the MP refund remain unwired because the current CoA page gives no loss trigger or percentage basis. See [the source/runtime note](ROM_DRUID_MAGE_GREEN_GUARDIAN.md).
|
||||
- [ ] Keep the local Mage/Warden Flame Follower (494051) passive unresolved: its critical-rate amount and Fireball trigger phase are unspecified, while current CoA has reworked Flame Follower under 490244 and reused 494051 for Purgatory Fire. Do not map the current active summon onto the old local passive by ID or name alone; see [the source review](ROM_MAGE_WARDEN_FLAME_FOLLOWER_SOURCE_REVIEW.md).
|
||||
|
||||
### 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.
|
||||
- [x] Druid/Warrior Cross of Thorns Attacks (499956) is emitted as a separate active elite skill from its local level-60 learn row (key item 546833, prerequisite 499955). It selects the source-backed two-hit Nature Power branch, and Briar Technique listens to 499956; 494018 remains a separate ability.
|
||||
- [x] Record current CoA Spirit Guidance (local Charm 493536) rules in an isolated helper with 7 focused tests: players cannot be targeted; NPCs become friendly at nonnegative caster reputation and remain hostile below zero. CoA lists 16.2 seconds at +55 skill but gives no rank-duration formula, so do not reuse RuneWaker's local 3-second component as a universal CoA duration. Runtime faction/reputation state is still missing.
|
||||
- [x] Record current CoA Rebirth rules in the same helper: Rebirth 493537 reduces death debt by 30% and optionally accepts an Amulet of Light or Golden Amulet for an unspecified extra reduction; Advanced Rebirth 493559 reduces it by 45% with required Amulet of Light (203606); Supreme Rebirth 493560 reduces it by 75% with required Golden Amulet (203605). Runtime death-debt units, rounding, inventory, and item consumption are not modeled, so this is source documentation/helper coverage rather than a claim of runtime implementation.
|
||||
- [x] Current CoA Druid/Rogue Necrotic Wound (494321) and Corrosive Poison (494024) replace the conflicting RuneWaker effects for that exact pair: 25 Energy/12-second DoT poison; Wound requires poison plus one Nature's Power, spends 4% Mana once at channel start, hits three times for 329.7% Dark damage across 1.5 seconds, and spreads a fresh poison to targets within radius 8, with no Wound cooldown or old healing-reduction/root riders. The 150/80 CoA range/radius values map to 15/8 project units. The three 500ms hit timestamps and full poison refresh are documented simulator choices; CoA does not specify pulse timestamps or whether spread copies remaining duration. Player and companion spread regressions pass. The separate global 50% AoE multiplier remains unsupported. See [the feature note](ROM_DRUID_ROGUE_NECROTIC_WOUND.md).
|
||||
- [x] Current CoA Sinister Request (494023) instantly refills each supported existing party resource pool to its maximum within radius 150 (15 project units), with the 15-second cooldown from patch 12.1.6.2004 and one-second successful-cast reductions from Poisonous Widow Embrace/Necrotic Wound. Player and companion paths respect positions/line of sight, living recipients, caps, and absent pools; companion AI selects it for a depleted nearby ally even when its caster is full. Focused catalog/runtime/AI validation passed 29 tests across four files. Source: [current Druid/Rogue skill page](https://chroniclesofarcadia.com/wiki/skills/list/8-3?locale=en) and [patch history](https://chroniclesofarcadia.com/patch/12-1-6-2000).
|
||||
- [ ] Add simulator support for Spirit Guidance's faction conversion and Rebirth's debt reduction after the combat model can represent caster reputation/NPC faction and death-debt/item-consumption state. Current CoA Druid/Rogue Necrotic Wound replaces the stale RuneWaker healing-reduction payload; see [the CoA behavior note](ROM_DRUID_ROGUE_NECROTIC_WOUND.md).
|
||||
- [x] Current CoA Druid/Mage Inspiration (494322): a learned exact-pair passive now grants one stack per positive Nature's Power spend event (independent of points spent), capped at ten stacks for ten seconds. Current CoA says +2% Physical Attack Speed and Casting Speed per stack; runtime applies these through the shared melee/ranged/spell-haste consumers to the player and generated companions, including their modeled attack intervals, cast durations, and GCDs. The CoA page's Range 50 maps by the established 10:1 conversion to an interpreted five-unit caster-centered party radius; the living caster and party allies within inclusive 3D distance are recipients. One shared aura refreshes all stacks on each new spend because CoA does not specify per-stack expiry. Helper, catalog, and player/companion integration tests cover trigger, cap/refresh, radius filtering, and subsequent cast haste. Fireball's qualitative travel-speed rider and Nature's Power gain remain unwired: current CoA ID 499943 is likely local Fireball 490204's counterpart, but no source formally maps the IDs, gives a travel formula, or disambiguates the separate one-point grant timing. See [the source/runtime note](ROM_DRUID_MAGE_INSPIRATION.md).
|
||||
- [ ] Find matching-version proc probabilities for Revival Slash, Quick Light Protection, Boiling Rock, Inspiration Display, Avalanche, Extended Recovery, and Spring Flower Eulogy. Current CoA Recovery still conflicts with the RuneWaker trigger; do not infer unknown proc chances.
|
||||
|
||||
### Warlock
|
||||
|
||||
- [x] Replace Warlock/Rogue Soul Raid 498678 with current CoA Shadow Slice when the passive is learned. Patch [10.4.0.4001](https://chroniclesofarcadia.com/patch/10-4-0-4000) reworks Soul Raid to modify Shadow Step 490344 into a 1-second stun, 4,400 Dark hit, +30% Dark damage, and +10% movement speed for 30 seconds; [10.4.1](https://chroniclesofarcadia.com/en/patch/10-4-1-1000) sets Psi recovery to 3. The catalog/tooltip and player/companion cast mechanics are integrated. The simulator cannot model the shadow's behind-target positioning, and CoA gives no lower-rank damage curve, so 4,400 remains a flat amount. The local +10%/+10% six-second component is not layered onto the learned CoA rework.
|
||||
- [ ] 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.
|
||||
|
||||
@@ -119,4 +205,5 @@ Updated 2026-09-30. This document lists unresolved ability and talent mechanics
|
||||
|
||||
- [ ] 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.
|
||||
- [x] Confirm Rune Overload's RuneWaker identity before comparing it with current CoA: the local generated catalog maps 498572 to Rune Overload and 498571 to Remodeled Body, while the [current CoA Champion/Rogue list](https://chroniclesofarcadia.com/en/wiki/skills/list/10-3) maps 498571 to Rune Overload and 498572 to Runecraft - Haste. The current page and patch history describe a later skill revision; preserve the local RuneWaker names, costs, and values rather than crossing the ID collision.
|
||||
- [ ] Wire local Rune Overload (498572)'s one-second on-time child tick into player and companion runtime. Catalog work now resolves hidden MagicCollect 498574 from component 621243 as a 0.6 physical-damage coefficient and removes the unsupported 10-yard radius heuristic; the isolated catalog/resolver tests pass (16 tests across the hidden-child and Champion runtime files). RuneWaker component 621241 supplies the one-second `onTimeMagicId` link, but runtime has no autonomous consumer for it; the periodic proc path only receives existing DoT/HoT ticks. The local tooltip says the child hits nearby targets, but the source has no numeric radius or target cap, so target selection remains unresolved and must not be guessed.
|
||||
@@ -0,0 +1,34 @@
|
||||
# RoM Mage/Druid: Current CoA Green Guardian
|
||||
|
||||
## Source choice and identity
|
||||
|
||||
This note covers only Mage-primary/Druid-secondary (`rom-mage` / `rom-druid`) skill `494331`. The current [CoA Mage/Druid skills page](https://chroniclesofarcadia.com/en/wiki/skills/list/4-8) identifies Green Guardian as a level-25 instant self skill. The exact pair and skill ID match, so this current CoA entry replaces the conflicting generated RuneWaker interpretation for that pair.
|
||||
|
||||
The generated `rom-mage-rom-druid-elite-30` row uses the same ID and name as a level-30 passive. Its RuneWaker components describe healing after Mother Earth's Protection ends. The local old hook in `combatStore.ts` only schedules those HoTs when it finds the passive Green Guardian row and its tagged Protection aura. The current Mage/Druid catalog row is now active, its translated effect is replaced with the stack aura, and the passive lookup therefore does not fire. This removes the old expiry HoT only for this exact pair; the RuneWaker passive and HoTs are retained for any other row using that source ID.
|
||||
|
||||
## Implemented behavior
|
||||
|
||||
CoA states that each second standing still grants one stack, capped at 20, and each stack adds one percentage point of movement speed. Movement pauses stack generation but does not say that it removes stacks. The runtime implements those explicit values and leaves MP restoration disabled: the page mentions a restore on stack loss, but does not identify the loss trigger or whether its 10% basis is maximum or missing MP.
|
||||
|
||||
The small timing rule needed by the simulator is explicit: after a sampled position change, the actor must accumulate one full stationary second before another stack can be gained. This is a one-full-stationary-second gate, not a claim about the source's unspecified “brief” pause duration. It does not decay or remove stacks.
|
||||
|
||||
## Runtime integration
|
||||
|
||||
- `src/game/romAbilityCatalog.ts` updates the existing exact-pair row in place: level 25, active instant self skill, no cost or cooldown, current CoA tooltip, and the existing generated ID. The reverse Druid/Mage pair gate remains separate.
|
||||
- `src/game/romDruidOverrides.ts` replaces only the Mage/Druid 494331 effect with the current self aura. Other Druid rows retain their existing RuneWaker effects.
|
||||
- `src/game/combatStore.ts` starts the caster aura at zero stacks, samples player and companion positions each combat tick, advances standing time, adds stacks to 20, and drops timing state after the aura is gone. The existing Mother Earth's Protection expiry branch is bypassed for this pair because 494331 is no longer a passive and has no legacy Green Guardian tag.
|
||||
- `src/game/partyRuntime.ts` selects learned 494331 for an exact-pair companion on its first available combat action when the aura is absent, then uses the ordinary ability commit and effect execution path. CoA describes the skill as active but does not specify companion AI; auto-use in combat is a simulator policy, not a sourced mechanic.
|
||||
- `src/scene/PlayerRig.tsx` applies the stack movement multiplier to the player. `src/scene/PartyPopulation.tsx` applies it to a Mage/Druid companion. Both scene hooks check the exact pair.
|
||||
- `src/game/romMageDruidGreenGuardianRuntime.ts` contains the pair gate, stack cap, one-second transition, speed multiplier, aura definition, and timing helper.
|
||||
|
||||
The aura is indefinite and starts with zero stacks. The timer begins on the first movement sample after application; the simulator does not guess elapsed standing time before it can observe a position. Player accrual/movement coverage is in `src/game/romMageDruidGreenGuardian.integration.test.ts`; companion AI cast selection and aura application are covered in `src/game/romMageDruidGreenGuardian.party.integration.test.ts`. Helper and catalog regressions are in `src/game/romMageDruidGreenGuardianRuntime.test.ts` and `src/game/romDruidMageCoaCatalog.test.ts`.
|
||||
|
||||
## Still unknown
|
||||
|
||||
- The exact duration and refresh behavior of the movement pause.
|
||||
- Whether any event removes stacks, and if so how many and at what interval.
|
||||
- The MP percentage basis and whether each lost stack has a separate restore.
|
||||
- Whether standing time counts during casts or other actions. The runtime observes position only.
|
||||
- Any level-scaling formula beyond the stated per-stack values and 20-stack cap.
|
||||
|
||||
Historical [11.1.1.2000-2009](https://chroniclesofarcadia.com/patch/11-1-1-2000) and [11.1.6.1000](https://chroniclesofarcadia.com/patch/11-1-6-1000) patch notes describe Subterraneous Blade thresholds, not Green Guardian's pause or stack-loss rules. They do not fill these gaps.
|
||||
@@ -0,0 +1,32 @@
|
||||
# RoM Druid/Mage: Current CoA Inspiration
|
||||
|
||||
## Source choice and identity
|
||||
|
||||
This profile is limited to the Druid-primary/Mage-secondary pair and passive ID 494322. The current [CoA Druid/Mage page](https://chroniclesofarcadia.com/en/wiki/skills/list/8-4) lists `Inspiration` under Druid/Mage at level 30 with the same ID and name as the local RuneWaker passive, so this is a matching skill identity rather than an ID-only cross-pair guess. The current CoA mechanic replaces the conflicting local RuneWaker trigger.
|
||||
|
||||
The generated RuneWaker row has no components and says a Fireball hit has a chance to make the next spell cast in under one second. Current CoA instead says spending Nature's Power inspires nearby raid members. [Patch 11.1.5.3000](https://chroniclesofarcadia.com/patch/11-1-5-3000) specifies the reworked event and payload: each Nature's Power spend grants an Inspiration stack that gives 2% physical attack speed and 2% cast speed, stacks up to ten, and lasts ten seconds. At ten stacks the modeled bonus is +20% to each stat.
|
||||
|
||||
## Runtime behavior and simulator choices
|
||||
|
||||
- The pure helper in `src/game/romDruidMageInspirationRuntime.ts` represents one stack for each positive Nature's Power spend event, regardless of amount, with a 0–10 cap and +2 percentage points per stack. The player and generated-companion combat paths now invoke it after a successful positive Nature's Power cost or an actual caster-target Nature's Power drain. A partial/empty drain grants no stack; spending multiple points in one event still grants one stack.
|
||||
- The passive is deterministic on a qualifying spend under the current CoA text. The older RuneWaker Fireball proc chance is not carried forward in the current tooltip or helper.
|
||||
- The current CoA page lists `Range: 50` and says “raid members around.” The simulator cannot represent a raid, so runtime covers the player and modeled party members only. Existing CoA range conversion maps source units 10:1 to simulator units (for example, 150 to 15); applying that same convention makes this a 5-unit party radius. The page does not say whether the listed range is centered on the passive owner, so a caster-centered radius is an explicit simulator interpretation. The modeled check uses inclusive 3D distance, includes the living caster, and excludes dead or unplaced allies.
|
||||
- CoA states a 10-second duration but not whether each stack has an independent timer or the entire stack aura refreshes together. Runtime uses the existing refreshable stack-aura model: each qualifying spend adds one stack and refreshes the shared aura's 10-second duration. This preserves the sourced cap and duration without inventing per-stack expiry timestamps.
|
||||
- Each stack contributes +2% physical attack speed and +2% spell haste, up to +20%. The simulator separates melee and ranged haste, so the physical clause writes to both haste stats; this lets Hunter ranged swings and other physical autoattacks use the same CoA bonus. Spell haste reaches cast durations and GCDs. The Mage player's older generic aura reader skips this tagged Inspiration aura because the new shared haste path already includes it; this avoids applying spell haste twice to a Mage recipient. Existing unrelated haste sources continue to combine through their current haste paths.
|
||||
- CoA also says this passive speeds up Fireball and restores one Nature's Power. The official-source audit for those separate clauses is below; neither clause is wired in runtime.
|
||||
|
||||
### Inspiration's separate Fireball clauses: official-source audit
|
||||
|
||||
- The [current CoA Druid/Mage skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/8-4) lists Fireball under `Mage General`, level 30, ID `499943`, as an instant 6-second-cooldown spell. Its tooltip describes `3010 + 0.3 x Wisdom` Fire damage and one Nature's Power restored. The same page describes Inspiration as making Fireball travel “a lot quicker” and recovering one Nature's Power.
|
||||
- The local [generated RuneWaker catalog](../src/game/romPlayerCatalog.generated.json) has `rom-mage-general-0`, general Mage Fireball ID `490204`, unlocked at level 4; its generated description references fixed damage scaled from INT. Both rows are Mage General Fireballs with the same name. The [11.1.5.4000 patch notes](https://chroniclesofarcadia.com/patch/11-1-5-4000) say Druid/Mage Fireball's fixed damage changed from Intelligence to Wisdom, which strongly supports `499943` being the current CoA counterpart of the local Fireball. However, the official sources reviewed do not explicitly state that IDs `499943` and `490204` are aliases or that one replaces the other. Conclusion: likely same intended action, but not formally established by an ID mapping; preserve the ID discrepancy in any implementation note.
|
||||
- The official [10.4.1.1003 patch notes](https://chroniclesofarcadia.com/patch/10-4-1-1000) say Inspiration was changed to make Fireball travel faster “additionally.” This confirms a qualitative travel-speed rider existed, but gives no percentage, formula, or exact Fireball row ID. The current page still gives only the qualitative phrase “a lot quicker.” The official [11.1.5.4000 patch notes](https://chroniclesofarcadia.com/patch/11-1-5-4000) later changed Fireball's fixed-damage scaling from Intelligence to Wisdom in the Druid/Mage section, but likewise do not identify its row ID or quantify projectile speed.
|
||||
- The official [11.1.5.3000 patch notes](https://chroniclesofarcadia.com/patch/11-1-5-3000) say Druid/Mage Fireball was changed to grant one Nature's Power additionally. The Fireball row's own tooltip also states that it restores one point, while Inspiration's tooltip separately says it recovers one point. The sources reviewed do not clarify whether Inspiration adds a second point or describes the Fireball grant, nor whether the point is granted on use, projectile launch, hit, or successful damage. The separate Inspiration change in the 10.4.1.1003 notes says it had a one-second delay; that does not establish the resource grant timing.
|
||||
- Conclusion: CoA supports the qualitative travel-speed rider and a one-point Nature's Power grant associated with Druid/Mage Fireball. The current row, local row, and patch-history stat change make `499943` a likely counterpart to local `490204`, but do not formally map those IDs. Sources give no numeric speed effect, no event timing for the point, and no resolution of whether Inspiration's wording is an additional point. Do not infer these details.
|
||||
|
||||
## Catalog and runtime integration
|
||||
|
||||
The catalog overrides the existing generated passive row only when it is `rom-druid` / `rom-mage` ID 494322. It preserves that row's ID and learn level, clears the conflicting translated legacy effect payload, and uses the current CoA description on both the passive and its rank. No new ability row was added. Catalog regressions verify this exact-pair gating, existing-row identity, empty legacy effects/costs, and current tooltip text.
|
||||
|
||||
Player casts in `src/game/combatStore.ts` and generated-companion casts in `src/game/partyRuntime.ts` check for the learned exact-pair passive and apply one stack to living party actors in the interpreted radius. They listen to both committed direct Nature's Power costs and successful caster-target resource drains, since RuneWaker Druid skills commonly spend the resource through an effect instead of a cast-cost field. `src/game/romDruidMageInspirationRuntime.ts` owns the shared ten-stack aura and recipient geometry. The existing player and party haste consumers now read its melee- and spell-haste modifiers.
|
||||
|
||||
Focused validation: six helper tests cover exact-pair gating, one stack per spend event, cap, refresh, melee/ranged/spell stat payloads, and radius; three catalog tests cover current CoA identity and tooltip; three player/companion integration tests cover actual Nature's Power spends, nearby-recipient filtering, and cast-speed application without applying the bonus twice to a Mage recipient. Autoattack timing uses the existing shared melee/ranged haste stat paths; no separate weapon model was introduced for this passive.
|
||||
@@ -0,0 +1,27 @@
|
||||
# RoM Druid/Rogue: Current CoA Mechanics
|
||||
|
||||
## Scope and source choice
|
||||
|
||||
The profile applies only to the `rom-druid` / `rom-rogue` pair and CoA skill IDs 494321 (Necrotic Wound), 494024 (Corrosive Poison), and 494023 (Sinister Request). RuneWaker assigns different effects to these local IDs, so this profile replaces the conflicting RuneWaker effects instead of combining them. The [current English CoA Druid/Rogue skills page](https://chroniclesofarcadia.com/wiki/skills/list/8-3?locale=en) supplies current names and tooltip values; patch notes resolve values the current page leaves stale or vague. This is a RoM feature, so no WotLKDB data is used.
|
||||
|
||||
## Necrotic Wound and Corrosive Poison
|
||||
|
||||
- CoA requires Corrosive Poison on the selected target and one Nature's Power. Wound costs 4% MP, channels for 1.5 seconds, hits three times for 329.7% Dark damage per hit, spreads Corrosive Poison, and has no cooldown.
|
||||
- [Patch 10.4.0.4000](https://chroniclesofarcadia.com/patch/10-4-0-4000) removed Wound's cooldown. [Patch 11.1.2.1000](https://chroniclesofarcadia.com/patch/11-1-2-1000) set three hits; [11.1.5.2000](https://chroniclesofarcadia.com/patch/11-1-5-2000) set the 1.5-second channel, 4% MP cost, and cooldown rider; [11.1.5.3000](https://chroniclesofarcadia.com/patch/11-1-5-3000) and [11.1.6.2000](https://chroniclesofarcadia.com/patch/11-1-6-2000) reduced its damage to the current 329.7% and made Nature's Power consumption once per cast.
|
||||
- CoA's published range/radius values are 150/80. The project maps these source distances by dividing by ten, yielding range 15 and target-centered radius 8. The channel uses three evenly spaced pulses at 500 ms intervals; CoA specifies hit count and total duration but not individual pulse timestamps. The scheduler spaces pulses across the actual cast duration and retains Penance's immediate first pulse. The focused channel suite passes all six tests, including accelerated Penance.
|
||||
- A successful cast hits enemies within radius 8 of the selected target and applies a fresh 12-second Corrosive Poison to each hit target. CoA specifies that poison spreads but not whether it copies remaining duration; refreshing the full duration is the simulator's explicit approximation. No target cap or per-extra-target damage falloff is added because [patch 12.1.0.5000](https://chroniclesofarcadia.com/patch/12-1-0-5000) removed Wound's prior 8% falloff. The simulator does not apply CoA's separate global 50% area-damage multiplier, so this profile does not invent a Wound-specific substitute.
|
||||
- Corrosive Poison costs 25 Energy, has a one-second cooldown, and deals 11,342 Dark damage each second for 12 seconds at +105. The [English page](https://chroniclesofarcadia.com/wiki/skills/list/8-3?locale=en) and English patch history say 25 Energy; the [Japanese-localized page](https://chroniclesofarcadia.com/wiki/skills/list/8-3?locale=jp) says 25 MP. The English value is used and the locale discrepancy is kept visible. [Patch 12.1.0.5500](https://chroniclesofarcadia.com/patch/12-1-0-5000) supplies the 11,342 damage value.
|
||||
- Each successful Wound cast reduces Sinister Request's remaining cooldown by one second. This rider comes from [patch 11.1.5.2000](https://chroniclesofarcadia.com/patch/11-1-5-2000); the helper cannot move readiness earlier than the current time. The channel's three synthetic hit events defer this rider, so it applies once when the channel completes instead of once per pulse.
|
||||
- The Wound channel pays its 4% Mana and Nature's Power costs at channel start. Completion now checks only `costsToSpend`, which is empty for an already-paid channel; this prevents it from rechecking and rejecting the prepaid Nature's Power after it reaches zero. The player regression verifies payment once, three hits, spread to the primary and nearby targets, and no old root/healing-reduction effect.
|
||||
|
||||
## Sinister Request
|
||||
|
||||
- The current CoA tooltip restores MP, Rage, Focus, Energy, Psi, and Nature's Power for party members within radius 150. [Patch 12.1.6.2004](https://chroniclesofarcadia.com/patch/12-1-6-2000) lowers its stale page cooldown from 20 to 15 seconds. The CoA page and [patch 11.1.5.2000](https://chroniclesofarcadia.com/patch/11-1-5-2000) also establish the one-second cooldown reductions from successful Poisonous Widow Embrace and Necrotic Wound casts.
|
||||
- Runtime uses a party circle with radius 15 after the project's source-distance conversion. Its existing selector also requires a known position and line of sight; line of sight is a simulator constraint, not an extra CoA tooltip rule.
|
||||
- Runtime fills only resource pools the recipient already has, caps each value at that pool's maximum, and records the refill against the caster and recipient. It does not synthesize missing pools. The player and companion paths are covered; companion AI selects the skill for a nearby living ally with a depleted supported pool even when the caster is full. A fully restored party or absent resource pool does not trigger that AI choice.
|
||||
|
||||
## Files and verification
|
||||
|
||||
- `romDruidRogueCurrentCoa.ts` holds the pair gate, values, tooltips, resource effects, and cooldown rider. `romAbilityCatalog.ts`, `romDruidOverrides.ts`, and `romCombatMechanics.ts` replace stale effects and enforce the poison prerequisite.
|
||||
- `combatChannelAmounts.ts`, `combatStore.ts`, and `partyRuntime.ts` handle channel timing, player/companion poison spread, cooldown reduction, and resource restoration. `partyAbilityAi.ts` and `combatRotation.ts` provide the depleted-ally selector.
|
||||
- Focused coverage: `romDruidRuntime.test.ts` (15 tests), `romDruidRogueCurrentCoa.test.ts`, `romDruidRogueSinisterRequest.test.ts`, `romDruidRogueSinisterRequest.integration.test.ts`, `partyAbilityAi.test.ts`, and `combatChannelAmounts.test.ts` (6 tests). The Sinister Request/catalog/AI run passed 29 tests across four files.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Flame Follower (local RuneWaker 494051) source review
|
||||
|
||||
Status: unresolved; no runtime mechanic was added from this review.
|
||||
|
||||
## What the local catalog contains
|
||||
|
||||
The generated RuneWaker row is the Mage-primary / Warden-secondary elite passive at level 25, source skill ID `494051`, named **Flame Follower**. Its tooltip says it reduces Fireball's cooldown by 1 second and increases Fireball critical rate. Its source component list is empty, so the generated data supplies no critical-rate value, proc or application details, rank curve, or targeting rules. The local general Fireball row is ID `490204`.
|
||||
|
||||
## Current Chronicles of Arcadia identity
|
||||
|
||||
The current [CoA Mage/Warden skills page](https://chroniclesofarcadia.com/en/wiki/skills/list/4-7) assigns **Flame Follower** ID `490244`, Mage/Warden level 25. It is an active spell: 10% MP, range 1, instant, 60-second cooldown, and summons a Flame Incarnation for 30 seconds. The summon cannot attack or be attacked. While active, Fireball gains Earth damage, increased critical-hit chance, and reduced cooldown; using Fireball also reduces Flame Follower's cooldown.
|
||||
|
||||
The same current page uses ID `494051` for **Purgatory Fire**, a Mage-primary level-26 active spell, not Flame Follower. It also lists the Mage/Warden-modified Fireball as ID `490248`; the local RuneWaker catalog uses `490248` for the general Fire Ward row and `490204` for Fireball. These identity conflicts mean current CoA IDs cannot be copied onto the old RuneWaker rows by numeric ID alone.
|
||||
|
||||
## Revision history relevant to the conflict
|
||||
|
||||
- [CoA patch 11.1.1.5000](https://chroniclesofarcadia.com/patch/11-1-1-5000) says Flame Follower was changed so Fireball additionally inflicts Earth damage. It provides no critical-chance amount.
|
||||
- [CoA patch 12.1.0.4000](https://chroniclesofarcadia.com/patch/12-1-0-4000) reworked Flame Follower into the 30-second Flame Incarnation summon and describes Fireball reducing its active cooldown by 4 seconds. That patch gives the initial 3-second cast, 60-second cooldown, and 10% MP cost.
|
||||
- [CoA patch 12.1.0.5000](https://chroniclesofarcadia.com/patch/12-1-0-5000) made it usable while moving, shortened cast time from 3 to 2 seconds, and raised Fireball's cooldown reduction from 4 to 5 seconds.
|
||||
- [CoA patch 12.1.0.6000](https://chroniclesofarcadia.com/patch/12-1-0-6000) changed the spell to trigger global cooldown instead of having a cast time. The current skill page now reports it as instant.
|
||||
|
||||
This is a later active-summon revision, not evidence for the missing number or trigger semantics of the older local passive. In particular, the current page's phrase “increased critical hit chance” still has no numeric amount.
|
||||
|
||||
## Source-backed fields and unresolved fields
|
||||
|
||||
| Question | Evidence | Result |
|
||||
|---|---|---|
|
||||
| Current name, ID, and pair | Current CoA skill list | Flame Follower is `490244`, Mage/Warden active level 25. Local `494051` is a different, old passive row. |
|
||||
| Old passive's critical amount | Local RuneWaker tooltip only says “increasing ... critical rate”; current CoA page says “increased critical hit chance” without a value | Unknown. Do not substitute critical damage/effectiveness or a guessed critical chance. |
|
||||
| Eligible Fireball triggers | Old local tooltip names Fireball; current reworked page and patch history say casting/using Fireball reduces the summon skill's cooldown | Old passive's trigger phase (cast, landed hit, or another event) is unknown. The current Fireball row is ID `490248`, while local general Fireball is `490204`; do not bridge by name alone. |
|
||||
| Rank behavior | Local row is a single passive elite level-25 row with no components. Current CoA active entry is level 25; its separate Fireball is rankable to 105 | No old passive rank curve or amount is source-backed. Current active skill rank data does not establish old passive ranks. |
|
||||
| Duration/cooldown | Current CoA active skill and patch history | The **new active summon** lasts 30 seconds with a 60-second cooldown; the old passive's duration and cooldown are not applicable/known. |
|
||||
| Target conditions | Current active description: summoned pet cannot attack or be attacked; skill summons it on self | No old passive target condition beyond naming Fireball. No crit condition (for example, target type, hit requirement, or crit roll timing) is specified. |
|
||||
|
||||
## Implementation decision
|
||||
|
||||
Keep the local old passive unresolved and do not add a helper or runtime effect. A correct CoA-era replacement would require a deliberate migration of the Mage/Warden class-pair catalog to the active `490244` identity, plus reconciling the current CoA Fireball `490248` against local Fireball `490204` and deciding how to represent an untargetable summon. This review does not silently make those cross-version identity changes. If the older passive must be reproduced instead, an authoritative historical CoA tooltip or implementation detail giving its critical chance and trigger timing is still needed.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Current CoA Priest Holy Candle (490304)
|
||||
|
||||
## Source and precedence
|
||||
|
||||
- The [current CoA Priest skill list](https://chroniclesofarcadia.com/wiki/skills/list/5-1?locale=en) identifies Holy Candle as Priest-primary skill 490304 and gives a +27% healing-effectiveness bonus. Its displayed 5% HP trigger is stale.
|
||||
- The same CoA row lists a 5% MP cost, range 1, and 30-second cooldown. The local row has no cost override, range 0.1, and a 180-second cooldown; the exact same-ID/name/class entry supports using CoA's metadata.
|
||||
- The newer [CoA patch 12.1.6.2000 notes](https://chroniclesofarcadia.com/patch/12-1-6-2000) explicitly increase Holy Candle's self-damage from 5% to 12%, so the patch value takes precedence over the older page value.
|
||||
- A [CoA developer reply on the Priest feedback thread](https://forum.chroniclesofarcadia.com/thread/4572-priest-class-feedback/?postID=30657) clarifies that Holy Candle can hit the caster only once per second.
|
||||
- The skill page lists a 5% MP activation cost. This is distinct from the 12% HP self-damage trigger. The generated RuneWaker row has no cost, so the catalog supplies an ordinary 5% Mana cost, which resolves against maximum Mana; CoA does not specify a base-Mana cost.
|
||||
|
||||
## RuneWaker conflict and model
|
||||
|
||||
Generated local skill 490304 uses component 500549. It currently has range 0.1, a 180-second cooldown, no Mana cost, a 60-second duration, a type-149 +5% healing-done modifier, and a -6% HP periodic effect every second. The CoA behavior uses range 1, a 30-second cooldown, and a 5% Mana activation cost; replaces the +5% modifier with +27%; retains the local 60-second duration because CoA does not publish one; and removes the free-running periodic drain. The 5% Mana activation cost is separate from the HP trigger.
|
||||
|
||||
The patch says 12% self-damage but does not state whether the percentage uses current or maximum HP. The simulator interprets it as 12% of maximum HP, matching the local percentage-based component's existing maximum-health calculation pattern. This basis remains an implementation interpretation rather than an explicit CoA value.
|
||||
|
||||
The chosen trigger boundary is one charge for each accepted healing-spell cast that resolves a heal, independent of the number of recipients. Direct and AoE heal casts charge once before recipient fan-out, even when the recipients are already full. A HoT charges once when its valid application cast resolves; periodic healing ticks do not charge again. A channel charges on its first qualifying healing pulse, and a per-cast flag prevents later pulses from charging again even when they are more than one second apart. The official one-second throttle is exact and stored per owner: multiple heal spells from the same owner share a window, while the player and each companion have independent windows. Failed, invalid, or interrupted casts do not charge.
|
||||
|
||||
For the channel regression, the test uses WoW Divine Hymn only as a fixture for the shared channel scheduler while the simulated owner is a RoM Priest-primary character. This verifies the persistent per-cast guard; it does not claim Holy Candle or Divine Hymn has a CoA Priest-primary channel mapping. A test-only RoM Holy Candle HoT fixture covers application-versus-tick behavior because the generated Priest-primary catalog currently has no healing HoT row. The source leaves these timing boundaries unspecified, so they remain explicit simulator choices.
|
||||
|
||||
## Current implementation and limits
|
||||
|
||||
- `src/game/romPriestHolyCandleRuntime.ts` owns the current values, Priest-primary effect transform, aura marker, and pure throttle/self-damage helpers.
|
||||
- `src/game/romAbilityCatalog.ts` gives only Priest-primary 490304 the 5% Mana cost, range 1, and 30-second cooldown plus a tagged 60-second +27% aura, replacing the generated periodic dot.
|
||||
- `src/game/romPriestTooltips.ts` exposes the current values and separates cast cost from HP trigger cost.
|
||||
- `src/game/romPriestHolyCandle.test.ts` covers catalog values, exact class scope, modifier replacement, periodic-dot removal, active-aura detection, and throttle math.
|
||||
- `src/game/combatStore.ts` applies the player self-damage in the cast-resolution transaction and handles channel-pulse charging.
|
||||
- `src/game/partyRuntime.ts` applies the same trigger for generated Priest-primary companions on direct/HoT heals and channel pulses; `src/game/partyStore.ts` retains the channel's charged flag across party-runtime advances.
|
||||
- `src/game/romPriestHolyCandle.integration.test.ts` covers direct over-heal, one charge for multi-recipient Group Heal, inactive/invalid/interrupted casts, per-owner one-second throttling across spell IDs, companion heals, HoT application versus later ticks, and channel pulses more than one second apart.
|
||||
|
||||
The runtime hook applies raw self-damage up to the owner's remaining health and records it as a Holy damage event. The 12% basis remains maximum HP by simulator interpretation. The one-second timestamp map is per combat owner; the channel guard is per cast and persists on companion casts across scheduler updates. Integration coverage verifies the player and generated-companion paths described above.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Priest/Scout Curing Shot (492636)
|
||||
|
||||
## Current CoA behavior
|
||||
|
||||
The current [Chronicles of Arcadia Priest/Scout skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/5-2) identifies Curing Shot as ID **492636**, Priest primary / Scout secondary, level 35. Its current +105 entry is instant, has a 6-second cooldown, range 150, and radius 100. It heals all raid members in reach for 125.6, then heals 167.5 every 3 seconds for 18 seconds. The caster receives 10% Elemental Damage for 18 seconds.
|
||||
|
||||
The official patch history establishes the current version of the effect:
|
||||
|
||||
- [Patch 10.6.1](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) changed the HoT interval from 2 to 3 seconds and duration from 20 to 18 seconds.
|
||||
- [Patch 11.1.5](https://chroniclesofarcadia.com/tr/patch/11-1-5-3000) made Curing Shot instant and added Good Will, then described as +10% magical damage for 18 seconds.
|
||||
- [Patch 12.1.6](https://chroniclesofarcadia.com/patch/12-1-6-1000) changed the bonus from magical to Elemental Damage. That patch and the current English class page are the authority for the present wording; the Japanese page still shows the older magical-damage text.
|
||||
|
||||
## Implementation
|
||||
|
||||
`src/game/romPriestScoutCuringShot.ts` overrides only the generated RuneWaker row with the exact Priest-primary / Scout-secondary identity and skill ID. `src/game/romAbilityCatalog.ts` applies the override to the generated catalog.
|
||||
|
||||
- The CoA range 150 and radius 100 are converted at 10:1 to 15 and 10 project units, respectively.
|
||||
- The instant heal and six-tick HoT use the published +105 values. The HoT ticks every 3 seconds for 18 seconds.
|
||||
- CoA says all raid members in reach and publishes no target cap. The simulator applies it to every eligible modeled party member; the player and companion healing paths are both covered. It cannot currently represent a full raid roster.
|
||||
- Good Will is a caster-only 18-second aura with a 10% damage-dealt modifier.
|
||||
- The local level-35 row's 120 MP cost is preserved. CoA's page gives 242 MP at skill level +105, but no cost progression is available to derive an honest level-35 replacement.
|
||||
|
||||
## Elemental-school mapping
|
||||
|
||||
CoA says Elemental Damage is defined by subclass (for example, the [Mage skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/4-1)). It does not name Good Will's exact school on the Priest/Scout page. That class's page is water-themed: it includes One with the Water and the Water-damage skills Rising Tide and Ice Blades, while Frost Halo increases Elemental Damage. The implementation therefore makes the explicit, class-specific inference that Priest/Scout Elemental Damage means Water damage and maps Water to the simulator's `frost` school. The project already uses `frost` as its Water analogue.
|
||||
|
||||
This mapping is intentionally limited to `frost`; it does not boost `fire`, `nature`, or `arcane`. It also leaves `holy`, `shadow`, and `physical` unchanged. The current source identifies the category and subclass theme, but does not explicitly state “Good Will increases Water damage”; that remains an inference rather than a quoted CoA rule.
|
||||
|
||||
## Differences from the RuneWaker snapshot
|
||||
|
||||
| Field | Local RuneWaker row | Current CoA behavior in the simulator |
|
||||
|---|---:|---:|
|
||||
| Cast time | 2 seconds | Instant |
|
||||
| Cooldown | 12 seconds | 6 seconds |
|
||||
| Healing radius / target count | Radius 20; maximum 5 | Radius 100, converted to 10 units; no added cap |
|
||||
| HoT cadence / duration | Every 2 seconds for 20 seconds | Every 3 seconds for 18 seconds |
|
||||
| Published heal values | Local effect from component 502905; child 502914 carries the older periodic payload | 125.6 upfront, then 167.5 per tick at +105 |
|
||||
| Good Will | No matching current payload in the local components | Caster gets +10% Elemental Damage for 18 seconds; simulator applies this to the inferred Water/frost school |
|
||||
| Mana cost | 120 MP on the local level-35 row | Preserved at 120 MP because no cost curve is sourced |
|
||||
|
||||
The local direct component is 502905 (`damagePower=20`). Child 502914 has a 20-second effect, a 2-second periodic interval, and `dotBase=10`. Those local values describe the older snapshot and are superseded by the current CoA behavior above.
|
||||
|
||||
## Runtime and verification
|
||||
|
||||
Player casts heal all eligible modeled party recipients and schedule six periodic effects. Companion casts use the same party healing and periodic paths. The aura stays on the caster and the damage resolver consumes its typed `kind: "damage"` modifier. The player's self-HoT applies through `healPlayerInWork` and is not recorded as a `combatEvents` healing entry in this runtime path; the integration test verifies the player's health rises on each tick. Companion-party ticks are recorded as healing events, so their coverage is asserted by recipient ID. Runtime healing amounts can be changed by existing simulator healing bonuses and critical effects, so catalog tests assert the exact source base values while integration tests assert recipients and scheduling.
|
||||
|
||||
Focused coverage is in:
|
||||
|
||||
- `src/game/romPriestScoutCuringShot.test.ts` for source identity, helper values, the frost-only aura modifier, and local cost preservation.
|
||||
- `src/game/romPriestScoutCuringShot.catalog.test.ts` for the actual generated RuneWaker row and catalog override.
|
||||
- `src/game/romPriestScoutCuringShot.integration.test.ts` for player and companion direct/periodic heals, uncapped modeled party coverage, caster-only aura ownership, the player's six self-HoT health increases, frost damage increase, and unchanged fire/nature/arcane/holy/shadow/physical damage.
|
||||
- `src/game/romCombatRuntime.test.ts` for the general cast and HoT path with the CoA radius and no five-target cap.
|
||||
|
||||
The CoA page provides values at skill level +105, but Healer Man has no Priest/Scout skill-training progression. The simulator uses those published values for this unlocked row and does not invent an intermediate training curve. CoA says “every 3 seconds for 18 seconds”; six ticks are implemented from that interval and duration interpretation.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Priest/Scout Saint's Blessing (493039)
|
||||
|
||||
## Source choice and identity
|
||||
|
||||
The current [Chronicles of Arcadia Priest/Scout skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/5-2) identifies Saint's Blessing as skill **493039**, Priest primary / Scout secondary, and says it increases damage by **28%** against targets below 30% of maximum HP. The same entry separately says a Heal cast on a friendly target below 30% HP receives an additional healing effect, listed at +105 as 597 flat healing plus 21%.
|
||||
|
||||
The local generated RuneWaker row matches the exact class pair and skill ID, so it identifies the skill being reviewed. Its two components (503283 and 620406) describe the older Heal-triggered rider: +30 flat healing and +1% healing power for 10 seconds at the local component values. The current CoA page takes precedence for the new damage rider and the current maximum-rank healing values; the local data remains evidence for the legacy payload and this repository's available rank representation. The current CoA page does not publish a lower-rank healing curve, so this note does not invent one.
|
||||
|
||||
There is a version conflict for the damage rider: [patch 11.1.5.2000](https://chroniclesofarcadia.com/patch/11-1-5-2000) originally says +21% below 30% HP, while the current skill page says +28%. The runtime uses the current page's +28%. It does not carry over an old buff duration or cooldown: the current page describes the damage bonus as a target-health condition and lists no timed aura for it.
|
||||
|
||||
## Runtime mechanic
|
||||
|
||||
`src/game/romPriestScoutSaintsBlessingDamage.ts` returns a 1.28 damage multiplier only when all of these are true:
|
||||
|
||||
- The build is exactly Priest primary / Scout secondary.
|
||||
- Passive 493039 is learned.
|
||||
- The living target's current HP is strictly below 30% of maximum HP when the damage packet resolves.
|
||||
|
||||
The runtime returns 1 otherwise, including at exactly 30%, for dead targets, and for invalid health ratios. There is no proc chance, duration, or cooldown in the current source. It checks the target's live health for each damage packet, so later hits and periodic ticks respond to health lost since the previous packet. The source says “damage” without limiting it to a school or direct-hit type, so the runtime does not restrict the rider by school or direct-versus-periodic delivery.
|
||||
|
||||
## Runtime paths and assumptions
|
||||
|
||||
`src/game/romPriestScoutSaintsBlessingDamage.ts` contains the pure 1.28 multiplier check. `src/game/combatStore.ts` applies it to the rolled damage path in `resolveAndDamageMobInWork` before hit resolution and mitigation. That path covers player and party direct damage, auto-attacks, and timed damage effects: the `tick()` loop routes each DoT packet through the same resolver. The resolver then calls `damageMobInWork` with a marker that prevents scaling the already-modified primary packet twice. Extra damage packets attached to a hit are evaluated once in `damageMobInWork`.
|
||||
|
||||
Unrolled damage events also pass through `damageMobInWork`; this includes generated-companion direct damage and timed area explosions. For a party source, the helper uses that member's primary/secondary classes, level, and talent ranks, then confirms passive 493039 is learned for that exact build. The check uses target HP immediately before the packet is applied. A multi-target ability evaluates each target separately, and successive packets can cross the threshold during combat.
|
||||
|
||||
This follows the simulator's available event model: percentage health is `mob.health / mob.maxHealth`, and the bonus is applied before target mitigation for ordinary damage. A secondary packet belonging to the same hit gets the same pre-hit target-health snapshot and is scaled once as its own damage packet. There is no separate generated-companion passive toggle; learned status comes from the generated class-pair catalog and that member's level/talent ranks.
|
||||
|
||||
Focused coverage is in `src/game/romPriestScoutSaintsBlessingDamage.test.ts` and `src/game/romPriestScoutSaintsBlessingDamage.integration.test.ts`. It covers player and generated-companion rolled damage just below, exactly at, and just above 30%; unrolled companion damage; exclusion for another secondary class; and player/companion Bone Chill ticks where the first tick takes the target from above 30% to below it. The class's existing damage modifiers are retained in the player tests, which compare the below-threshold packet to that same source's exact-30% baseline.
|
||||
|
||||
## Separate Heal rider source review
|
||||
|
||||
The [current CoA Priest/Scout skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/5-2) lists the pair's Heal as **493048**, available at level 45, with a 2-second cast and 2925 base healing at +105. Saint's Blessing 493039 says that casting Heal on a friendly target below 30% HP additionally enhances the healing effect by **597 + 21%** at +105. This wording identifies the skill and friendly low-health condition, but it does not name a separate trigger chance, cooldown, buff duration, or stacking rule.
|
||||
|
||||
The [official 11.1.5.2000 patch notes](https://chroniclesofarcadia.com/patch/11-1-5-2000) say Saint's Blessing was changed to *additionally* increase damage by 21% below 30% HP; their later post-patch notes increase that damage rider to 28%. Those entries document the damage addition and later damage increase, not a revision to the Heal rider. The current skill page is the strongest available source for today's Heal values. I found no official patch entry in the reviewed history that supplies a lower-rank Heal-rider curve or adds a duration/stacking rule.
|
||||
|
||||
The local generated catalog has **no Priest/Scout Heal 493048 row**. It has only Priest-primary Heal **490261** (`rom-priest-primary-2`, unlock level 6), with a generic “Restores (DMG0) points of target's HP” tooltip and a translated heal coefficient of 0.9. Saint's Blessing's local source components **503283** and **620406** instead encode +30 flat healing and +1% healing power, each for 10 seconds. `src/game/romPriestKnightOverrides.ts` turns those local components into a 10-second caster aura before the cast effects of Heal 490261, gated on learned passive 493039 and friendly target health below 30%. That is an implementation of the local snapshot, not evidence that the current 493048 row still uses a 10-second aura or that its current +597/+21% values should be stretched over that duration.
|
||||
|
||||
A [2021 CoA forum exchange](https://forum.chroniclesofarcadia.com/user-post-list/2156-xeae/?pageNo=2) provides secondary corroboration of the spell identity: it reports that Urgent Heal 491147 did not trigger Saint's Blessing, while Heal 499977 did. The same thread quotes an older +104 tooltip of 591.6 + 20.8%. This is useful historical evidence that the passive was intended to react to the named Heal rather than any healing spell, but it is not an official current mechanic specification. The historical 499977 ID also differs from today's 493048.
|
||||
|
||||
### What the sources establish
|
||||
|
||||
- **Heal identity:** current CoA specifies the Priest/Scout Heal row 493048; the historical forum report distinguishes Heal from Urgent Heal 491147. Do not treat every heal, including HoTs, as this trigger.
|
||||
- **Target condition:** a friendly target must be below 30% HP. Current text does not explicitly say “of maximum HP” in the Heal clause (it does say that explicitly in the damage clause), and it does not settle whether the check is at cast start or cast completion for the 2-second cast. The local runtime passes the selected target's HP ratio through the cast-effect resolution path; because a cast first returns from its cast-start branch, this samples health when the effect resolves. That is the simulator's current timing assumption, not a timing rule stated by CoA.
|
||||
- **Cast success:** “When you cast a Heal” and the community report that Heal “works” support a Heal-cast trigger. The sources do not define whether interrupted casts, invalidated targets, zero-effective overheal, or another nonstandard cast outcome count. They do not support substituting a generic successful-healing event.
|
||||
- **Rank values:** +105 is documented as +597 and +21%; the old +104 forum quote reports +591.6 and +20.8%. These historical/current rank samples do not establish the current lower-rank progression curve, so do not extrapolate one.
|
||||
- **Duration and stacking:** the current skill page gives no duration, cooldown, stack limit, refresh, or carryover text for the Heal rider. Treat the 10-second local aura as a version-specific local payload until a current source or current 493048 component data confirms it. Whether the bonus modifies only that Heal's heal packet or creates a temporary effect is not fully specified by the current tooltip.
|
||||
|
||||
This source review leaves the Heal rider unresolved. The next implementation needs current 493048 generated/source data or a more explicit current CoA mechanic source before choosing between the local 10-second aura and an immediate per-cast bonus. Do not copy +597/+21% to lower levels or attach local +30/+1% values to 493048 without resolving that source conflict.
|
||||
@@ -0,0 +1,38 @@
|
||||
# RoM Rogue/Mage Chaos Dagger (491178)
|
||||
|
||||
## Current CoA behavior
|
||||
|
||||
The current [Chronicles of Arcadia Rogue/Mage skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/3-4) lists Chaos Dagger (491178) at Rogue/Mage level 15 as a passive. Its +105 tooltip says every normal attack adds Fire damage equal to 169.4% of the attacking hand's weapon DPS. It also says each auto attack slightly increases Physical Attack Power for a short time, without giving that increase's amount or duration.
|
||||
|
||||
The [CoA 12.1.0.4000 patch notes](https://chroniclesofarcadia.com/patch/12-1-0-4000) explicitly changed Chaos Dagger to a passive. The [CoA 10.4.1.1000 patch notes](https://chroniclesofarcadia.com/patch/10-4-1-1000) separately fixed a wrong off-hand Chaos Dagger coefficient and a projectile requirement. Together with the current tooltip's “attacking hand” wording, this supports applying the coefficient to whichever hand made the normal attack.
|
||||
|
||||
## Why this replaces the local RuneWaker payload
|
||||
|
||||
The local generated row with the same ID is the older active Enchanted Throw. It costs 25 Mana to activate a self aura, describes projectile consumption, and links an on-hit trigger to hidden child 491185. That row is incompatible with the current CoA skill: the current source calls 491178 a passive and specifies per-hand Fire damage. We therefore do not cast the old aura, consume projectiles, call Throw (490309), or invoke child 491185 for this mechanic. The current CoA page and passive conversion patch are the behavior source for this exact ID; the local row is retained only as evidence of the superseded RuneWaker version.
|
||||
|
||||
## Mana cost ambiguity
|
||||
|
||||
The current CoA page still displays “Uses: 25 MP” above the word “Passive,” but the current effect text does not say when Mana is spent. The passive conversion patch does not explain the cost. In the older generated RuneWaker row, 25 MP is clearly the activation cost for Enchanted Throw; that does not establish that current Chaos Dagger drains 25 MP on each attack. Other current passive rows also retain “Uses” metadata, so the page field alone does not settle whether this is a per-trigger cost or leftover skill metadata.
|
||||
|
||||
The runtime currently applies no Mana debit to Chaos Dagger autos and does not expose a castable activation. This is an unresolved cost boundary, not a claim that CoA Chaos Dagger is confirmed to have zero cost. The in-game tooltip calls out the conflicting 25 MP field and says the runtime does not charge it pending clarification. The catalog test has an explicit 491178 cost exception for this same reason.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `src/game/romRogueChaosDagger.ts` contains the current spell ID, 169.4% coefficient, passive marker, tooltip text, and an isolated calculator that requires the Rogue/Mage pair, a learned passive, a landed auto attack, and positive weapon DPS.
|
||||
- `src/game/romAbilityCatalog.ts` replaces the local name and description for the exact Rogue/Mage 491178 row and makes it passive. The generated passive marker is used for learned-state behavior such as Day of Rain.
|
||||
- `src/game/romScoutRogueOverrides.ts` keeps the learned Chaos Dagger marker for Rogue/Mage. The old Enchanted Throw tag export remains as a deprecated alias for existing consumers.
|
||||
- `src/game/combatStore.ts` adds a secondary Fire packet during the shared combat-result resolution for a landed Rogue/Mage melee auto attack. It reads main-hand or off-hand DPS from the same actor's equipped weapon. Player and generated-companion autos use this resolver, so no separate companion damage path is needed.
|
||||
- The Fire packet shares the normal attack's landed result and is passed through Fire resistance, Fire damage-taken modifiers, absorbs, health, and death handling. It does not roll a second hit or a separate critical strike. The base weapon attack remains its own physical packet.
|
||||
|
||||
## Limits and assumptions
|
||||
|
||||
- The CoA page reports the 169.4% value at skill level +105. Healer Man does not currently model Rogue skill ranks from 1 to 105, so the implementation applies this published coefficient at every character level after the passive unlocks at level 15.
|
||||
- The slight temporary Physical Attack Power increase is intentionally omitted because CoA gives neither its amount nor duration.
|
||||
- Only the landed melee auto attacks represented by the current player and companion weapon loops are covered. The helper rejects misses, non-auto attacks, other class pairings, unlearned Chaos Dagger, and a missing/invalid hand weapon DPS.
|
||||
|
||||
## Focused coverage
|
||||
|
||||
- `src/game/romRogueChaosDagger.test.ts`: catalog override, unlock level, class pairing, both hand coefficients, and helper gating.
|
||||
- `src/game/romRogueMechanics.test.ts`: player main-hand and off-hand packet values, Fire immunity, no passive Mana debit, below-unlock behavior, wrong secondary class, misses, and the learned marker used by Day of Rain.
|
||||
- `src/game/partyRuntime.test.ts`: generated Rogue/Mage companion main-hand and off-hand Fire packets through the same mitigation path.
|
||||
- `src/game/romPlayerCatalog.test.ts`: expected-cost exception for the still-ambiguous 25 MP field.
|
||||
@@ -0,0 +1,34 @@
|
||||
# RoM Rogue Hands: Poisonous Hands and Energized Hands
|
||||
|
||||
## Source choice and scope
|
||||
|
||||
This feature replaces the stale RuneWaker behavior only for the generated `rom-rogue` primary rows 490329 and 490347. Other class-pair rows and generated source data are unchanged. The current [CoA Rogue skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1) is the behavior source; CoA patch notes resolve changes that the page does not explain. The `rom-rogue` local resource profile is used for simulator base values. No WoW/WotLK data is relevant to these RoM skills.
|
||||
|
||||
The current CoA page lists 490329 Poisonous Hands and 490347 Energized Hands as Rogue-primary-only skills, each lasting 900 seconds and mutually exclusive. The page gives each a 1-second cast, but the later [12.1.5.1004 post-patch note](https://chroniclesofarcadia.com/patch/12-1-5-1000) says execution time decreases with faster main-hand attack speed. The simulator currently uses the page's 1-second value as a base/local fallback.
|
||||
|
||||
## Poisonous Hands (490329; child 490357)
|
||||
|
||||
- CoA says regular attacks have a chance to apply a 900-second target debuff that reduces the target's damage by 8%. The [12.1.0.4000 patch](https://chroniclesofarcadia.com/patch/12-1-0-4000) says it stacks up to ten times, the damage reduction is fixed regardless of stack count, and applying a target stack also grants attack speed for the current target. The [12.1.0.5000 patch](https://chroniclesofarcadia.com/patch/12-1-0-5000) confirms that switching targets resets the effect.
|
||||
- The local generated component 500715 supplies the 50% normal-attack proc chance and triggers child 490357. The current CoA page says only “a chance” and the patch notes do not quantify it, so 50% is preserved as an explicit local fallback rather than represented as a CoA-confirmed value.
|
||||
- Each successful application adds one target stack, up to ten. The target receives one fixed 8% damage-dealt reduction from the aura; it does not gain another 8% per stack. Each newly applied target stack grants one 1% self attack-speed stack, also capped at ten. Reaching the target cap does not advance the self ramp. A landed normal attack against a different target removes the prior target's self-ramp aura before tracking the new target. The target debuff remains on its original target until its own expiry.
|
||||
- The skill uses one Poison Bottle per CoA's tooltip. The simulator has no Poison Bottle inventory or consumable-cost model, so it currently displays that requirement but does not consume or validate an item.
|
||||
|
||||
## Energized Hands (490347)
|
||||
|
||||
- The current page gives a 900-second self effect: Energy recovery +100%, skill cooldowns reduced by 0.1 seconds, maximum Energy +50%, and Energy costs +20%. The [12.1.0.4000 patch](https://chroniclesofarcadia.com/patch/12-1-0-4000) confirms the recovery and cost values; [12.1.0.5000](https://chroniclesofarcadia.com/patch/12-1-0-5000) adds the cooldown and maximum-Energy riders.
|
||||
- Runtime applies the aura to players and generated Rogue companions. With the generated Rogue baseline of 100 maximum Energy and 10 Energy per second, an active aura yields 150 maximum Energy and 20 Energy per second before other modifiers; Energy costs gain 20% through the shared resource-cost path. Maximum Energy returns to the prior value on removal while current Energy is preserved/clamped by the resource model.
|
||||
- The simulator implements the cooldown rider as subtracting 100 ms from each committed skill cooldown while the aura is active. CoA states “reduces skill cooldowns by 0.1s” but does not specify timing or interaction with other cooldown modifiers; the implementation uses a flat reduction once per committed cast.
|
||||
- Switching between the two hands effects is exclusive for the Rogue rows. Companion automation holds its selected effect through its duration because generated companions do not have a hands-effect configuration control; player-selected casts replace the other effect.
|
||||
|
||||
## Limits still open
|
||||
|
||||
- CoA gives no numerical Poisonous Hands proc probability. The runtime uses the generated local 50% value pending an authoritative CoA rate.
|
||||
- Poison Bottle possession and consumption are not modeled.
|
||||
- CoA confirms faster main-hand speed lowers both casts' execution times but does not publish a formula. Runtime uses the current page's 1-second base cast; dynamic attack-speed scaling remains unsupported.
|
||||
- CoA names the 0.1-second cooldown rider but does not explain aggregation or timing. Runtime applies one flat 100 ms subtraction when each skill cooldown is committed.
|
||||
|
||||
## Files and verification
|
||||
|
||||
`romRoguePoisonRuntime.ts` handles the proc, separate target debuff, ten-stack cap, attack-speed ramp, and target-switch reset. `romRogueEnergizedHandsRuntime.ts` describes the synthetic current-CoA Energized aura and tracks its Energy maximum. `romAbilityCatalog.ts` overrides the exact Rogue rows and tooltips; `combatStore.ts` and `partyRuntime.ts` integrate player/companion attacks, resources, aura exclusivity, and cooldowns. Generated RuneWaker catalog data remains unchanged.
|
||||
|
||||
Focused verification passed: `romCombatMechanics.test.ts` (21), `romRoguePoisonRuntime.test.ts` (17), `romCreatureFamilies.test.ts` (3), `romRogueCurrentCoaHands.integration.test.ts` (3), and `romRoguePoison.integration.test.ts` (5), for 49 tests total.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Scout/Priest: Drums of War
|
||||
|
||||
## Source hierarchy
|
||||
|
||||
- [Current CoA Scout/Priest skills page](https://chroniclesofarcadia.com/en/wiki/skills/list/2-5) identifies Drums of War as Scout/Priest skill `493010`, level 45, passive, range 50, triggered by dealing damage with Shot, and affecting nearby raid members. It lists the current CoA Shot as `493011`.
|
||||
- CoA [patch 11.1.1](https://chroniclesofarcadia.com/patch/11-1-1-5000) is the most specific effect-value source found: Drums now grants 20% critical damage only, replacing the former 5% critical damage plus raw critical-hit rating.
|
||||
- The earlier CoA [patch 10.2.1](https://chroniclesofarcadia.com/es/patch-10-2-1-1000-masked-festival-class-balance/) raised effect duration to 15 seconds and added the previous 5% critical-damage effect. Patch 11.1.1 changes the effect value and does not state a new duration.
|
||||
- The current skill-list Shot tooltip still exposes +10% Physical Critical Bonus and +10% Magical Critical Bonus, which conflicts with the more specific 11.1.1 patch note. The patch note governs the modeled effect value; the skill list supplies the passive identity, trigger, and range.
|
||||
- RuneWaker's generated local row remains the simulator's ability source. It matches passive ID `493010` and the Scout/Priest pair but retains its older payload: component `503258` applies flat +20 physical and magical critical-strike ratings for 8 seconds; component `503310` carries 15 seconds of re-trigger timing. The local Shot row is ID `490423`, not CoA's `493011`.
|
||||
|
||||
## Modeled CoA behavior and explicit assumptions
|
||||
|
||||
- Keep the exact Scout-primary/Priest-secondary gate and passive ID `493010`.
|
||||
- Map CoA Shot `493011` by name and role to the local RuneWaker Scout General Shot `490423`. The catalog has no separate CoA `493011` cast row.
|
||||
- Trigger on a landed ranged Shot. CoA says the Shot must inflict damage; the local combat event gives us the landed result.
|
||||
- Grant +20% critical damage for 15 seconds. CoA patch 10.2.1 supplies the latest located duration; patch 11.1.1 changes only the value.
|
||||
- Interpret the current Shot tooltip's post-effect restriction literally: wait 15 seconds after the aura expires before another proc. Since the existing ready-at runtime stores a timestamp from proc time, its equivalent total delay is 30 seconds (15-second aura plus 15-second lockout).
|
||||
- Represent “critical damage” as a 20% increase to the damage bonus portion of a critical multiplier. At the default 2.0 multiplier, Drums gives 2.2. Multiple critical-damage bonuses are summed before scaling that bonus portion; e.g. a separate +25% bonus plus Drums gives 2.45 at a 2.0 base.
|
||||
- Keep the sourced range 50. CoA says raid-wide; the simulator currently models the player and configured party, not a full raid, so recipient scope remains party.
|
||||
|
||||
## Simulator mapping
|
||||
|
||||
`romScoutPriestOverrides.ts` owns the current constants, tagged aura, exact-pair proc selector, critical-multiplier helper, and tooltip override. `romAbilityCatalog.ts` applies the tooltip override to only the local RuneWaker Scout/Priest passive row.
|
||||
|
||||
The aura's `critical-damage` stat is descriptive metadata; its utility tag is consumed directly in `combatStore.ts` by the shared outgoing-damage resolver used for players and companions. On a critical hit, the resolver adds Drums' 20% to other critical-damage bonuses on the critical bonus portion of the multiplier. It does not change critical chance or ordinary hits. The existing +25% Spreading Pain bonus is preserved and stacks additively: at a 2.0 base multiplier, both effects produce 2.45. Aura application is controlled by the exact Scout/Priest proc gate; any living configured party recipient carrying the aura benefits, matching the simulator's party scope.
|
||||
|
||||
## Validation and remaining scope
|
||||
|
||||
`romScoutPriestOverrides.test.ts` covers source values, duration/lockout timing, exact pair and local Shot mapping, learned-passive and landed-ranged gates, multiplier behavior, additive stacking, and catalog override scope. `romScoutPriestDrumsOfWar.integration.test.ts` covers player and generated-companion critical damage, unchanged critical chance and ordinary hits, configured party coverage/range, aura expiry, and the 15-second post-expiry lockout (30 seconds from proc to next readiness). The focused validation command is:
|
||||
|
||||
```sh
|
||||
npx vitest run src/game/romScoutPriestOverrides.test.ts src/game/romScoutPriestDrumsOfWar.integration.test.ts src/game/romWarlockPriestSpreadingPain.test.ts src/game/romWarlockMageCoaRuntime.integration.test.ts
|
||||
```
|
||||
|
||||
At implementation handoff, this focused run passed all 17 tests across four files. The CoA patch says “critical damage” without spelling out every attack category. The shared hook applies the buff to critical hits across represented physical and magical attacks, while leaving critical chance untouched. No full raid actor model exists to cover non-party raid members.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Scout/Priest Screen Attack (499555)
|
||||
|
||||
## Selected CoA behavior
|
||||
|
||||
The current Chronicles of Arcadia Scout/Priest skill list keeps Screen Attack under ID 499555 and lists it as an instant active skill with a 60-second cooldown and a 17.4% raid damage increase for 20 seconds. The row is Screen Attack **+55** for Scout/Priest level 70: +55 is the skill training level, separate from the character level requirement. Its radius is 150. The 11.1.1 patch notes identify the rework from passive to active, say the effect also applies to damage-over-time effects, and give a 30-second duration, 60-second cooldown, and 17.4% damage increase.
|
||||
|
||||
The current skill entry is selected for the runtime values: +17.4% damage, 20 seconds, and 60 seconds. This resolves the direct duration conflict in favor of the current CoA skill entry over the earlier rework patch note. The patch note remains the evidence for periodic-damage coverage because the current entry does not repeat that clause. The simulator does not track per-skill training levels, so the current +55 value is represented as a fixed +17.4% at every character level rather than being scaled by skill training.
|
||||
|
||||
## RuneWaker conflict and implementation boundary
|
||||
|
||||
The local generated RuneWaker row for 499555 is a passive marker that says Aggro Lead (499578) increases damage. CoA reworked Screen Attack into a standalone active buff. The override is gated on all of: RuneWaker source, primary Scout, secondary Priest, and spell ID 499555. It does not change other pairings or Scout-primary skills.
|
||||
|
||||
The simulator represents CoA's raid recipients with the current party roster; it does not model a separate raid roster here. The listed radius 150 is interpreted as 15 project units using the 10:1 range conversion already used for other CoA mechanics. Generic party aura targeting includes the caster, then maps eligible living party-roster actors to runtime actors and filters allies by an inclusive 3D radius and line of sight. Allies without usable positions cannot pass the radius check. The current CoA entry lists no resource cost, so the catalog uses no cost. It lists an instant cast; the catalog preserves the general 1.5-second simulator GCD.
|
||||
|
||||
The aura is a generic +17.4% damage-dealt modifier. The player and companion cast-completion paths apply generic party auras, and the shared damage resolver consumes damage-dealt auras for both direct damage and periodic ticks while retaining the originating actor. This means the ability uses those generic routes without a Screen Attack-specific damage hook.
|
||||
|
||||
Player and generated-companion integration tests cover activation, eligible recipients, fixed direct damage and periodic tick scaling, the exact 20-second expiration boundary, and the 60-second cooldown boundary. Recipient coverage verifies caster inclusion, living party-roster membership, an inclusive 3D radius edge, line of sight, and exclusion of dead, out-of-range, occluded, or unpositioned allies. The damage fixtures isolate the shared modifier resolver from Scout-specific shot effects. Those tests exposed that the shared dealt-damage resolver included expired source auras; it now uses the already-filtered active aura list, so Screen Attack stops increasing damage exactly at expiration.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Current CoA Scout/Priest skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/2-5)
|
||||
- [CoA patch 11.1.1.5000–5005](https://chroniclesofarcadia.com/patch/11-1-1-5000)
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx vitest run src/game/romScoutPriestScreenAttack.integration.test.ts src/game/romScoutPriestScreenAttack.test.ts` — 2 files, 5 tests passed.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Warden/Mage Tough Oak: pet defense source and runtime plan
|
||||
|
||||
## Status
|
||||
|
||||
The pet-defense rider remains unwired. The existing local cooldown rider is implemented for RuneWaker's Warden abilities. This review adds no combat, catalog, or scene code.
|
||||
|
||||
## Current CoA source
|
||||
|
||||
The current [CoA Warden/Mage skill list](https://chroniclesofarcadia.com/wiki/skills/list/7-4) identifies Tough Oak as skill ID `494207`, a Warden/Mage level-20 passive, maximum rank 55. Its current text says: “Increases your pet's Physical Defense by 60% and reduces Cooldown from Punch and Sepal Stab by 1 second.” The page lists Warden/Mage pet Punch as `494209` and pet Sepal Stab as `498199`, both level 20 with four-second cooldowns.
|
||||
|
||||
I searched the official patch-note results for Tough Oak's defense value and did not find a patch note that supplies a different amount or a mitigation formula. The current skill page is therefore the source for the current `+60%` claim. Patch history does not establish what stat value this percentage multiplies or how that value reduces damage.
|
||||
|
||||
## RuneWaker row and source-precedence decision
|
||||
|
||||
The local generated row is the same named `494207` Warden-primary/Mage-secondary level-20 passive. Its RuneWaker tooltip describes a pet `DEF` increase using component `504600`; that component is permanent (`effectTime: -1`) and has ability modifier type `135`, value `5`.
|
||||
|
||||
The project records type `135` as `physical-defense-percent` in [`romAbilitySemantics.ts`](../src/game/romAbilitySemantics.ts) and translates it to the generic `armor` stat in [`romAbilityCatalog.ts`](../src/game/romAbilityCatalog.ts). That is the local RuneWaker decoding choice; it does not establish that CoA's current +60% pet Physical Defense is 60% armor, that RuneWaker's +5% equals the CoA value, or that the two versions share a damage-mitigation formula. For the current class-pair behavior, prefer current CoA's +60% statement, but keep the numerical defense rider inactive until pet defense has a real stat and mitigation rule. Do not replace it with +5% armor.
|
||||
|
||||
The local tooltip names Punch `493382` and Sepal Stab `493385`. Those local generated rows are Warden primary rows. The current CoA page instead exposes pet-skill IDs `494209` and `498199`. The existing Tough Oak cooldown code in [`romWardenOverrides.ts`](../src/game/romWardenOverrides.ts) reduces cooldown by one second when local IDs `493382`/`493385` are cast by a Warden with the passive marker. It does not establish that CoA's different pet-skill IDs execute in the simulator; verify their actor and cooldown ownership before expanding that rider.
|
||||
|
||||
## Current pet combat model
|
||||
|
||||
- [`CombatSummon`](../src/game/combatStore.ts) carries summon identity, owner, health, position, attack damage, abilities/resources, and optional Hunter-specific state. It has no armor, physical-defense value, or general defensive-stat profile.
|
||||
- [`CombatActorState`](../src/game/combatActors.ts) has health and absorbs but no defense statistics. `combatActorFromSummon` copies those common values and does not derive a pet defense profile.
|
||||
- [`damageAfterEquipmentMitigation`](../src/game/wotlkStats.ts) accepts character `ItemStats` and applies the simulator's WotLK armor/resistance model. That is not a sourced CoA pet Physical Defense formula and is not currently applied to summons.
|
||||
- Warden pets are generic `CombatSummon` instances. In the code paths inspected, pet health loss from an enemy hit is represented by Warden Damage Transfer: the owner hit is mitigated using the owner's defenses, the transfer share is computed, then pet absorbs are consumed and remaining damage is subtracted from pet health. The transfer path does not run a second pet defense calculation. Ordinary enemy hit transactions address player/party recipients; there is no general mob-to-Warden-pet incoming-hit transaction in this model.
|
||||
- The Hunter pet defense helper and source note are prep only; they do not yet give the simulator a generic live pet defense or incoming-hit model. Even after Hunter integration, WoW owner-armor inheritance is a separate rule and must not be reused as CoA Warden defense.
|
||||
|
||||
## Concrete follow-up plan
|
||||
|
||||
1. In the Hunter pet defense integration, establish a reusable per-summon defense profile and a shared incoming-pet-damage boundary. Keep Hunter armor/resistance inheritance explicitly WoW-specific.
|
||||
2. Before applying Tough Oak, find or deliberately define a CoA pet baseline Physical Defense value and a CoA Physical Defense-to-damage-reduction formula. Current sources specify a percentage increase only; they do not give either value. Do not substitute owner armor, `ItemStats.armor`, the local +5% component, or a WoW armor formula.
|
||||
3. Add a distinct CoA pet `physicalDefense` stat/profile and apply the learned Warden/Mage Tough Oak `1.6` multiplier to eligible active Warden pets. Verify which summon types the word “pet” includes; the current page does not limit it to Oak Walker, Spirit of the Oak, or Nature Crystal.
|
||||
4. Route known pet-directed incoming damage through that profile in an explicit pet-recipient resolver. At minimum review player and generated-companion Warden Damage Transfer paths. Confirm whether a transferred share receives the pet's Physical Defense, and where it sits relative to owner mitigation and pet absorbs; the current skill text does not specify this ordering. Add a direct mob-to-pet hit path only if the combat design introduces one.
|
||||
5. Keep cooldown mapping separate. Preserve local `493382`/`493385` until current CoA's `494209`/`498199` are represented as executable pet actors/skills; then test the one-second reduction against the correct pet-owned cooldowns instead of extending a name-only match.
|
||||
6. Add focused tests for passive scope (Warden/Mage only), active-pet filtering, +60% defense application, physical versus magical incoming damage, transfer/absorb ordering, and unchanged cooldowns for other class pairs. Update this note with any remaining mapping/formula assumptions before marking the backlog item complete.
|
||||
|
||||
## Open source gaps
|
||||
|
||||
- The current CoA page does not define the underlying pet Physical Defense stat, its baseline/scaling, or the formula mapping it to mitigated damage.
|
||||
- It does not state whether the multiplier affects all Warden pets or only a named pet family/summon.
|
||||
- It does not explain how pet Physical Defense interacts with Damage Transfer or pet absorbs.
|
||||
- The current CoA pet skill IDs differ from the local RuneWaker cooldown child IDs. Name equality alone is not enough to bridge them.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Warlock/Mage Soul Brand (498705)
|
||||
|
||||
## Source and decision
|
||||
|
||||
- [Current Chronicles of Arcadia special skills](https://chroniclesofarcadia.com/wiki/skills/list/9-4) identifies Soul Brand as the Warlock/Mage elite skill and describes its current behavior: any Fire damage inflicted applies Soul Brand, increasing damage taken from elemental magic by 2% per stack, up to four stacks, for 20 seconds, and restoring Psi.
|
||||
- [CoA patch 10.7.1.6000](https://chroniclesofarcadia.com/patch/10-7-1-6000) confirms the broader Fire-damage trigger. Its historical 3% value is not used because the current skill entry says 2%.
|
||||
- [CoA patch 11.1.1.3000](https://chroniclesofarcadia.com/patch/11-1-1-3000) specifies that each trigger restores 1 Psi.
|
||||
|
||||
RuneWaker's generated entry for the same 498705 name and Warlock/Mage pairing triggers only from skills 497964, 497960, or 498545, buffs only Dark/Flame damage taken, and uses a 9% value. The current CoA entry matches the exact skill identity and pairing, so its newer explicit behavior is the chosen target. The generated RuneWaker payload is retained as source evidence; these differences are intentional.
|
||||
|
||||
## Modeled behavior
|
||||
|
||||
`src/game/romWarlockMageSoulBrand.ts` describes a landed Fire damage event as eligible, including periodic Fire damage. It returns a target debuff with a 20-second duration, additive stacks capped at four, and +2% damage taken per stack for the simulator's five modeled elemental schools: Fire, Frost, Nature, Holy, and Shadow. These stand in for RoM Fire, Water, Earth/Wind, Light, and Dark. At four stacks, the aura engine therefore resolves +8% damage taken from each of those five schools. Physical and Arcane damage remain unaffected. Each eligible helper result also includes +1 Psi.
|
||||
|
||||
`romWarlockMageSoulBrandAuraSemantics.test.ts` verifies the aura payload's stack cap, refresh time, expiry boundary, affected schools, and unaffected Physical/Arcane schools. `romWarlockMageCoaRuntime.integration.test.ts` covers player direct and periodic Fire damage, generated-companion deterministic direct Fire damage, and companion periodic Fire damage.
|
||||
|
||||
## Runtime integration boundary
|
||||
|
||||
`combatStore.ts` now invokes the helper after a positive landed Fire result in the shared damage resolver. That covers player hits and centralized player/companion periodic ticks, refreshes the target's capped aura through the aura engine, and grants 1 Psi to the exact Warlock/Mage source. `partyRuntime.ts` adds the equivalent post-landed hook for generated companions whose direct damage uses the deterministic no-roll path; hits already sent through the shared resolver are not processed twice. Regression coverage verifies player and companion direct/periodic events, excludes a non-Fire hit, and confirms that Psi goes to the triggering owner.
|
||||
|
||||
The simulator has no Arcane elemental school distinct from the five mapped RoM elemental types; CoA's listed elemental categories are represented by the five available mappings above. This note does not infer a range, cost, chance, or alternate stack rule.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Warlock/Mage Soul Brand Sting (498710)
|
||||
|
||||
## Source priority
|
||||
|
||||
- [Current Chronicles of Arcadia Warlock/Mage skill list](https://chroniclesofarcadia.com/wiki/skills/list/9-4) identifies Soul Brand Sting as Warlock/Mage skill 498710. It specifies a 2 Psi cost, range 150, 1-second cast, use only in Willpower Blade, 3737.5 + 1 × Intelligence Dark damage and Fire damage, and a Flaming Heart Strike cooldown reset.
|
||||
- The same CoA list describes Willpower Sharpening 1494096 as entering Willpower Blade for 30 seconds upon reaching full Psi. RuneWaker's generated 497955 is named Willpower Blade and describes a related state, but the IDs and names differ; no explicit patch bridge linking them was found.
|
||||
- [CoA patch 11.1.1.4000](https://chroniclesofarcadia.com/patch/11-1-1-4000) records the damage update to 3737 + 1 × Intelligence and Psi cost increase from 1 to 2. The current skill-list amount 3737.5 is used as the current value; the patch is historical confirmation of the mechanic and cost.
|
||||
- [CoA patch 12.1.0.5000](https://chroniclesofarcadia.com/patch/12-1-0-5000) explicitly changes the Flaming Heart Strike reset to occur on cast instead of on hit. This patch clarifies current trigger timing.
|
||||
|
||||
The project backlog and audit do not currently list 498710. RuneWaker's generated row matches the exact skill ID, name, and Warlock/Mage pairing, but its local payload has no resource cost, a 22.5 range, and component damage derived from local DBC fields. Its tooltip describes Dark and Flame damage and resetting Flaming Heart Strike but omits the Willpower Blade requirement and CoA's quantified amount. The current CoA skill row is preferred for this exact identity/pair; the generated RuneWaker values remain source evidence and are not silently treated as equivalent.
|
||||
|
||||
## Implemented behavior
|
||||
|
||||
`src/game/romWarlockMageSoulBrandSting.ts` makes the CoA pair identity and values explicit. `romAbilityCatalog.ts` applies the source's 2-Psi cost to both the parent ability and rank only for Warlock/Mage 498710, and the tooltip states the full sourced damage and state requirement. The local RuneWaker range 22.5 is preserved because no verified conversion relates it to CoA's 150.
|
||||
|
||||
Each sourced damage packet uses base 3737.5 and `intellectCoefficient: 1`; the shared damage scaler adds the attacking player's or generated companion's derived Intelligence. Dark maps to the simulator's Shadow school and Fire remains Fire. The class-specific cast gate requires an active aura tagged for the locally observable “Willpower Blade” state. It does not equate the different skill IDs: current CoA's Willpower Sharpening is 1494096, while the local RuneWaker aura source is 497955. On cast, the runtime resets Flaming Heart Strike, matching patch 12.1.0.5000. Focused tests cover exact pair/skill gating, player and companion state rejection/readiness, the derived Intelligence contribution, 2-Psi cost, tooltip, packets, cooldown reset, and nonmatching-class controls.
|
||||
|
||||
## Remaining simulator and catalog limits
|
||||
|
||||
- CoA's range 150 has no verified unit conversion to RuneWaker's local 22.5, so the local range is retained. The CoA base-MP component is not on this skill; its 2-Psi cost is supported and applied.
|
||||
- The state-name gate uses the active local generated aura because it exposes the “Willpower Blade” state; this is a simulator bridge to the sourced condition, not an assertion that local 497955 and current CoA 1494096 share an ID or implementation.
|
||||
- Damage resolves one combined 3737.5 + 1×Int amount in each of the two available damage-school packets. The simulator has no composite Dark-plus-Fire packet type, so both packets remain distinct as the current CoA entry describes.
|
||||
- Passive Soul Brand 498705 is documented separately in [its feature note](ROM_WARLOCK_MAGE_SOUL_BRAND.md); this skill's cast does not trigger that passive unless its actual damage packet is Fire and the passive is learned.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Warlock/Priest Soul Enlightenment (498649)
|
||||
|
||||
## Recommendation
|
||||
|
||||
Implemented as a source-backed CoA correction. CoA publishes the skill under the same ID, name, class pair, and elite level as the RuneWaker row, with explicit cost, range, area, duration, and buff values. The existing simulator already translates this row into a party aura, so the stale payload was replaced through the RoM catalog/Warlock override layer without adding another combat-event mechanic.
|
||||
|
||||
Full CoA parity has one architecture gap: the source restricts use to the `Willpower Construct Caster` state, while the simulator currently tags Willpower Blade but has no corresponding Construct-state eligibility check. The catalog tooltip preserves the source requirement, but casts are not gated on that state. Do not treat the restriction as enforced until that state is represented in the cast-validation path.
|
||||
|
||||
## Current Chronicles of Arcadia behavior
|
||||
|
||||
The [current Warlock/Priest skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) gives Soul Enlightenment as skill 498649, Warlock/Priest level 40, with a maximum skill level of 105. At +105 it costs 2 Psi, has range 100 and radius 75, is instant, and grants the caster and friendly raid members Soul Enlightenment for 30 seconds. The aura increases damage dealt by 5.7% and received healing by 17.1%. The page says the skill can only be used while the caster is in the Willpower Construct Caster state.
|
||||
|
||||
The [10.2.1.1000–1261 patch notes](https://chroniclesofarcadia.com/patch/10-2-1-1000) record the received-healing value as 17% at skill level 104. That is consistent with the current page's 17.1% at +105; use the current +105 row for the simulator's published value.
|
||||
|
||||
## Local RuneWaker row and mismatch
|
||||
|
||||
The generated local row [rom-warlock-rom-priest-elite-40](../src/game/romPlayerCatalog.generated.json) has the same source ID 498649, name, pair, and level. Its source component 621328 instead supplies +10% maximum HP and +6% healing received for 10 seconds. Those local component values do not match current CoA and are replaced only for this exact Warlock-primary/Priest-secondary skill.
|
||||
|
||||
The local range is 10 project units, consistent with CoA range 100 under the simulator's established 10:1 range conversion. The local row has no cost and no radius. The current CoA cost is explicit (2 Psi); current CoA radius 75 converts to 7.5 project units. The generic damage-dealt and healing-taken modifiers, plus the existing party-aura radius filters, support the CoA payload without a new combat hook. Player casts use the ability's circle targeting radius; companion casts use the aura effect's radius. Both paths include the caster and only apply to living party members inside the radius.
|
||||
|
||||
## Implemented behavior and explicit simulator choices
|
||||
|
||||
- For source ID 498649 on the exact Warlock/Priest pair, replace the local +10% maximum-HP/+6% received-healing/10-second aura with +5.7% damage dealt, +17.1% received healing, and a 30-second duration. Maximum HP is no longer modified.
|
||||
- Include the caster and living modeled party members. CoA says “friendly raid members”; the simulator only represents its current party roster, not a separate raid roster.
|
||||
- Apply the +105 values to the one local elite unlock row. The project has no per-skill training-level state with which to derive lower values.
|
||||
- Use a 7.5-unit caster-centered radius from CoA's 75 range and the established 10:1 conversion. The simulator's existing area-aura path also requires line of sight; CoA does not state a line-of-sight rule, so this remains the simulator's normal targeting policy.
|
||||
- Preserve the ordinary aura recast behavior. CoA gives no stacking or refresh rule beyond the 30-second effect duration.
|
||||
- Keep the 2-Psi cost because CoA states it explicitly and the simulator supports Psi. Do not reuse the empty local cost list.
|
||||
- Keep the `Willpower Construct Caster` restriction documented but unenforced until the Construct state can be checked in the ability cast-validation path. A future state-gating change must also verify that entering/leaving Construct updates the tag consistently for player and companion Warlocks.
|
||||
|
||||
## Validation
|
||||
|
||||
`npx vitest run src/game/romWarlockPriestSoulEnlightenment.catalog.test.ts src/game/romWarlockPriestSoulEnlightenment.integration.test.ts` passes 4 tests. The catalog suite checks exact pair/ID scoping, +105 description and cost, aura values, obsolete maximum-HP effect removal, and radius metadata. Player integration pays 2 Psi, confirms the caster and an ally exactly 7.5 units away receive the 30-second aura, excludes an ally at 7.51 units, and resolves 1,000 damage/healing to 1,057/1,171. Companion integration verifies the existing party-cast path applies that same area aura and modifiers without shared combat-runtime edits. These tests do not assert or imply enforcement of the Willpower Construct state requirement.
|
||||
|
||||
Regression run: `romWarlockRuntime.test.ts` passes 16 tests. `romAbilityCatalogSemantics.test.ts` has one unrelated fixed-size assertion failure in the current shared worktree: it expects 1,864 catalog rows and sees 1,869, matching five additional entries being added elsewhere in the active catalog work. The Soul Enlightenment correction does not add or remove a catalog row.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Warlock/Priest Spreading Pain (499856)
|
||||
|
||||
## Source and local conflict
|
||||
|
||||
- The [current CoA Warlock/Priest skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/9-5) lists Spreading Pain as the same ID, name, and class pairing as the local RuneWaker skill. It specifies three linked debuffs, +18% Magical Attack Power, +25% Magical Critical Bonus, and activation of Healing Fortune.
|
||||
- [CoA patch 10.6.1](https://chroniclesofarcadia.com/en/patch/10-6-1-1000) narrows the Healing Fortune rider to +12% healing for 20 seconds. Its Wisdom gain and Warp Charge behavior are separate Healing Fortune clauses and are not granted by Spreading Pain.
|
||||
|
||||
RuneWaker's linked child casts 497959/497960/497964 remain. The current CoA additions are applied to this exact Warlock/Priest skill; other class pairs and skills are untouched. CoA also lists 10% MP plus 15 Focus and range 150, while the local row has 15 Focus and range 22.5. The simulator has no Mana resource for RoM Warlock and has no verified CoA range conversion, so the local Focus cost/range remain and the unsupported 10% base-MP cost is called out rather than approximated.
|
||||
|
||||
## Implemented behavior
|
||||
|
||||
The class-specific helper retains the generated child effects and adds three caster buffs: +18% magical attack and +25% Magical Critical Bonus for 30 seconds, plus +12% healing for 20 seconds. The central critical resolver consumes the tagged bonus only for magical spell criticals from the exact Warlock/Priest build. It scales the critical-bonus portion `(multiplier − 1)` by 1.25; for a baseline 2.0 critical multiplier, the result is 2.25. It does not add critical chance or modify ordinary hits. The same rule applies to player and generated-companion magical spell damage.
|
||||
|
||||
Focused coverage verifies both actor paths: buff application, baseline-to-enhanced critical multiplier, physical/non-magical exclusions, and no bonus outside the active exact-pair aura. The companion hit path is resolved through the shared combat critical calculation.
|
||||
|
||||
## Runtime/source boundary
|
||||
|
||||
`romWarlockPriestSpreadingPain.ts` defines the source-tagged effects and multiplier rule. `romAbilityCatalog.ts` keeps the CoA behavior exact-pair scoped; `combatStore.ts` consumes the active aura at hit resolution; `partyRuntime.ts` supplies generated-companion spell crits with the same central calculation while preserving party runtime's normal hit-avoidance behavior.
|
||||
|
||||
The catalog/runtime does not invent a mapping for CoA range 150 or the 10% base-MP cost. These remain source/catalog discrepancies, not implemented values. RuneWaker's child mappings stay intact because the current CoA source retains the named debuff effects alongside its additional caster buffs.
|
||||
@@ -0,0 +1,52 @@
|
||||
# RoM Warlock/Warrior Psychic Warrior (499868)
|
||||
|
||||
Source review verified 2026-10-02 against the current Chronicles of Arcadia skill list and the relevant 12.1.4/12.1.5 patch history.
|
||||
|
||||
## Source hierarchy and current behavior
|
||||
|
||||
The current [CoA Warlock/Warrior skill list](https://chroniclesofarcadia.com/en/wiki/skills/list/9-1) is the authority for the present identity and tooltip: Psychic Warrior is passive `499868`, Warlock-primary/Warrior-secondary, learned at level 70, levelable to 105. Its text says magical spells manifest ethereal weapons that deal **Physical Dark** damage:
|
||||
|
||||
- Area Damage skills add an Ethereal Hammer for **187.5% main-hand weapon DPS**.
|
||||
- Single Target Damage skills add an Ethereal Sword for **1,050% main-hand weapon DPS**.
|
||||
- Slash and Consistent Heart Strike have a separate conditional main-hand-DPS rider when Strength or Dexterity exceeds both Intelligence and Wisdom. CoA publishes no coefficient for this rider, so it is excluded.
|
||||
|
||||
Patch history resolves the Hammer value. [Patch 12.1.4.1500](https://chroniclesofarcadia.com/patch/12-1-4-1500) introduced the Warlock/Warrior physical-dark hybrid rework and initially gave the Hammer 250% main-hand DPS and Sword 1,050%. [Patch 12.1.5.1000](https://chroniclesofarcadia.com/patch/12-1-5-1000) explicitly reduced the Hammer to 187.5% and made Psychic Warrior levelable to 105. The current skill page and later patch value supersede the initial 250%.
|
||||
|
||||
## Exact trigger gate
|
||||
|
||||
The modeled passive is enabled only when the caster is **Warlock primary / Warrior secondary** and has learned passive ID `499868`. The triggering ability must be a damaging RuneWaker Warlock ability representing a magical spell; a passive marker, non-damaging action, or a different primary/secondary pair does not qualify. This gate is applied in the isolated resolver, separate from the spell's own `AbilityDefinition` pair fields because many qualifying Warlock spells are Warlock-general actions.
|
||||
|
||||
`romWarlockPsychicWarriorRuntime.ts` now accepts explicit caster build context and checks both class slots, the learned passive, and a caller-supplied `isMagicalSpell` gate before selecting a coefficient. The future caller must derive that flag from the resolved action/event classification; this helper does not guess from ability names. It preserves the local ability-source gate and rejects passive or non-damaging definitions. Its area-vs-single classification uses `targetingRulesForAbility`; that is a local classification input, not a CoA definition of event cadence.
|
||||
|
||||
## Physical Dark representation and unresolved mitigation
|
||||
|
||||
The simulator's ordinary combat result accepts one `DamageSchool`. CoA's Physical Dark label has two source tags; the isolated `CombatDamageProfile` retains them together as `schools: ["physical", "shadow"]`, using the project's `shadow` school for Dark. `combatDamageProfileMatchesSchool` is only a tag-membership helper. It does not apply armor, resistance, penetration, damage modifiers, absorbs, or ordering.
|
||||
|
||||
**Mitigation remains unresolved and unwired.** CoA describes the damage as Physical Dark but does not say whether both Physical armor/block and Dark resistance/penetration apply, how modifiers of the two tags stack, or their order. Do not collapse the packet to physical or shadow, apply both defenses in sequence, or otherwise pick a defense formula without a source or explicit product decision. The current catalog/helper layer does not calculate this packet, and no shared player/companion combat consumer is implemented here.
|
||||
|
||||
## Local mapping, exclusions, and source gaps
|
||||
|
||||
The generated RuneWaker catalog contains the pair's local ID `499868`. The catalog override already presents current CoA's Psychic Warrior name and tooltip, with no generic damage effect attached; the isolated resolver returns the current coefficients and composite profile. It does not yet alter combat results. The hidden local child `499869` is not mapped to current CoA Enraged `499871`; those are kept distinct.
|
||||
|
||||
The sources provide the class pair, passive gate, general magical-spell trigger, area/single-target mode, weapon hand, and two coefficients. They do **not** settle:
|
||||
|
||||
- Whether an ethereal weapon is one event per cast, per target, per damage packet, or per channel tick, especially for AoE, channels, multi-hit casts, or periodic damage.
|
||||
- Whether the rider has its own hit, critical, block, dodge, and resistance rolls, or inherits any result from its triggering spell.
|
||||
- How the Physical and Dark tags interact with defenses and damage modifiers.
|
||||
- The conditional Slash/Consistent Heart Strike bonus amount.
|
||||
|
||||
Do not infer the missing Slash/Consistent Heart Strike amount, substitute a single damage school, or generate two full-value packets to represent the two tags.
|
||||
|
||||
## Integration proposal
|
||||
|
||||
When the shared player and companion damage paths are available, integrate from the originating Warlock damage event after identifying the exact caster pair, learned passive, eligible magical spell, target mode, and main-hand weapon DPS. Resolve one additional packet at 1.875× DPS for area spells or 10.5× DPS for single-target spells, retaining both profile tags. Reuse the existing target and source actor so player and generated-companion paths behave consistently.
|
||||
|
||||
Before enabling damage, the owner must decide or source the packet cadence/result inheritance and the hybrid mitigation formula. The proposed helper deliberately returns a descriptor, not a combat amount; this prevents the current ratios from accidentally acquiring an unsupported mitigation, crit, or multi-hit interpretation. Focused future integration tests should cover both exact-pair gates, missing passive, area/single-target ratios, player/companion source stats, and whichever explicit hybrid mitigation and trigger-event policy is approved. Keep direct tests of this helper separate from central combat integration coverage.
|
||||
|
||||
## Focused helper validation
|
||||
|
||||
`romWarlockPsychicWarriorRuntime.test.ts` verifies catalog identity and current tooltip values, 187.5% vs 1,050% selection, both retained damage tags without a mitigation formula, and exact Warlock/Warrior plus learned-passive gates. Run:
|
||||
|
||||
```sh
|
||||
npx vitest run src/game/romWarlockPsychicWarriorRuntime.test.ts
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# WoW Death Knight Blood Gorged
|
||||
|
||||
## Source precedence and confirmed values
|
||||
|
||||
The local generated talent node `talent-2034` is the primary source. It has five ranks, using spell IDs `61154`–`61158`, and each rank links two child spells. The generated rank tooltip says: “When you are above 75% health, you deal N% more damage. In addition, your attacks ignore up to N% of your opponent's armor at all times.” The values for N are 2%, 4%, 6%, 8%, and 10% at ranks 1–5.
|
||||
|
||||
The child links are `50096`, `50108`–`50111` for the damage bonus and `61274`–`61278` for armor penetration. Both local `Spell.dbc` copies checked (the patch-D DBC used by the talent-generation command and the original patch-A DBC) agree on these payloads:
|
||||
|
||||
- Damage children use aura type `79`, school mask `127`, and base values 2/4/6/8/10. Their descriptions retain the “above 75% health” gate.
|
||||
- Armor children use aura type `280` and base values 2/4/6/8/10. The generated tooltip says this part applies “at all times.”
|
||||
- Both child auras use Death Knight spell family `15` and the effect family mask `[0x01400001, 0, 0]`.
|
||||
|
||||
The numeric payloads come from local generated rows and client DBC data; WotLKDB was not needed. The local historical AzerothCore reference labels aura type `79` as percent damage done and aura type `280` as percent armor penetration. Those names corroborate the local numeric effects; they do not replace the project DBC values.
|
||||
|
||||
## Trigger and family-mask behavior
|
||||
|
||||
Blood Gorged is a passive rank effect, not a random proc. The generated ranks have no proc cooldown, charges, or duration. The simulator helper reads the rank values and child IDs from the generated talent row. It treats “above 75%” as a strict comparison at outgoing-hit resolution: exactly 75% health receives no damage bonus. The armor penetration component is independent of current health.
|
||||
|
||||
The client DBC class mask is narrower than the prose tooltip. In local Death Knight spell family 15 data, its first word intersects Plague Strike (`0x1`), Blood Strike (`0x400000`), and Heart Strike (`0x1000000`). A spell must match family 15 and have at least one bit intersect the mask. The helper preserves this gate for both child effects; it does not turn the tooltip into an unconditional bonus for every Death Knight ability. Aura type 280 only affects the physical armor calculation, while the aura’s class mask remains the same.
|
||||
|
||||
The historical AzerothCore reference has a fork-specific `SpellInfoCorrections.cpp` override for armor child spells `61274`–`61278`. Its comment says it adds Death Strike and Rune Strike to the armor-penetration class mask. The local patch-A and patch-D DBCs do not contain those added bits, so this preparation follows the local client DBC mask and does not import the fork override. The discrepancy is recorded here because it changes which attacks receive armor penetration.
|
||||
|
||||
## Runtime integration boundary and limits
|
||||
|
||||
Player and generated-companion hits now share Blood Gorged handling in `combatStore.ts`. `resolveAndDamageMobInWork` looks up the selected raw `dbcSpellId`, obtains the source actor's talent rank and current health, then applies the damage bonus once to the outgoing amount only when health is strictly above 75%. `resolvePlayerDamageResult` adds the unconditional armor-penetration percentage to the attacker's existing armor-penetration stat; the normal physical armor calculation then consumes it. The damage rider never changes the armor input or applies itself again after the roll.
|
||||
|
||||
Player casts already use the rolled-hit resolver. `partyRuntime.ts` now selects it for a non-periodic direct damage effect only when a companion Death Knight has Blood Gorged ranks and the actual selected spell ID matches the local family mask. It passes that rank's `dbcSpellId` in the roll context; this matters because a party action ID alone does not preserve the chosen spell rank. The three eligible actions are physical strikes, so this routed event uses the melee attack table. Other companion actions and auto-attacks remain on their existing path. Periodic events do not receive Blood Gorged, and unlisted IDs or known nonmatching IDs such as Death Strike remain ineligible. This keeps the raw `damageMobInWork` route from receiving a second application.
|
||||
|
||||
The generated tooltip and DBC establish the percentage values and mask, not fractional-damage rounding; the simulator keeps the existing damage rounding in its normal hit resolver. The DBC mask itself does not enumerate names; the exact eligible local rows are listed above. No Death Strike or Rune Strike correction from a non-stock fork is added.
|
||||
|
||||
## Local DBC family mapping and catalog cross-check
|
||||
|
||||
The generated ability JSON contains 77 Death Knight raw spell-rank rows, but omits `SpellFamilyName` and `SpellFamilyFlags`. The ID-keyed map in `src/game/wow335DeathKnightBloodGorgedSpellFamily.ts` supplies those fields from the same local patch-D `Spell.dbc` used by the talent generator. It keeps all 77 generated raw rows for source auditing; the selectable generated ability catalog filters out the two rank-0 Death Coil variants `47632` and `47633`, leaving 75 rank IDs that can reach the runtime cast/action definitions. No name-based inference is used. Spell IDs outside this known generated set (for example Rune Strike `56815`) return `null` and do not match.
|
||||
|
||||
The local mask `[0x01400001, 0, 0]` matches eight generated rows. All are actual selectable direct-hit actions:
|
||||
|
||||
| Modeled action | Raw DBC spell IDs matching the mask | ID selected at level 80 |
|
||||
| --- | --- | --- |
|
||||
| Plague Strike | `45462`, `49917`, `49918`, `49919`, `49920`, `49921` | `49921` (rank 6) |
|
||||
| Blood Strike | `45902` | `45902` (rank 1) |
|
||||
| Heart Strike | `55050` | `55050` (rank 1; requires learned `talent-1957`) |
|
||||
|
||||
The mapping tests cross-check each generated source row against the explicit ID table, confirm the 75 selectable catalog ranks, and pass the level-80 player selection and the party AI's `learnedAbilityForBuild` selection for these attacks through the same mask lookup. In particular, Plague Strike resolves to rank-6 spell ID `49921` in both selections; the companion Heart Strike selection requires `talent-1957` rank 1. Among the generated raw rows, the other 69 IDs do not intersect the mask: `42650`, `43265`, `45463`, `45477`, `45524`, `45529`, `46584`, `47476`, `47528`, `47541`, `47568`, `47632`, `47633`, `48263`, `48265`, `48266`, `48707`, `48721`, `48743`, `48778`, `48792`, `48982`, `49005`, `49016`, `49020`, `49028`, `49039`, `49143`, `49146`, `49158`, `49184`, `49189`, `49203`, `49206`, `49222`, `49576`, `49796`, `49892`, `49893`, `49894`, `49895`, `49896`, `49903`, `49904`, `49909`, `49923`, `49924`, `49936`, `49937`, `49938`, `49939`, `49940`, `49941`, `49998`, `49999`, `50149`, `50150`, `50842`, `51052`, `51267`, `51271`, `55090`, `55233`, `61154`, `61155`, `61156`, `61157`, `61158`, `63560`. The two Death Coil IDs in this source-level exclusion list are the rank-0 variants noted above, not selectable runtime ranks.
|
||||
|
||||
## Files and validation
|
||||
|
||||
- Local talent node and ranks: `src/game/wow335Talents.generated.ts` (`talent-2034`).
|
||||
- Isolated profile and eligibility helper: `src/game/wow335DeathKnightBloodGorgedRuntime.ts`.
|
||||
- Focused helper test: `src/game/wow335DeathKnightBloodGorgedRuntime.test.ts`.
|
||||
- Local Spell.dbc family map and rank-selection test: `src/game/wow335DeathKnightBloodGorgedSpellFamily.ts`, `src/game/wow335DeathKnightBloodGorgedSpellFamily.test.ts`.
|
||||
- Player/companion integration tests: `src/game/wow335DeathKnightBloodGorged.integration.test.ts`.
|
||||
- Focused tests: `npx vitest run src/game/wow335DeathKnightBloodGorgedRuntime.test.ts src/game/wow335DeathKnightBloodGorgedSpellFamily.test.ts src/game/wow335DeathKnightBloodGorged.integration.test.ts`.
|
||||
@@ -0,0 +1,19 @@
|
||||
# WoW Death Knight Merciless Combat
|
||||
|
||||
## Source and value choices
|
||||
|
||||
The local generated talent catalog identifies Merciless Combat as `talent-1993` and provides the exact eligible spells and target-health condition: Icy Touch, Howling Blast, Obliterate, and Frost Strike deal 6% more damage at rank 1 or 12% more at rank 2 when the target is below 35% health. Its generated rank spells are 49024 and 49538. Their local generated effects share aura type 112 and `miscValue: 7277`, with `basePoints` 6 and 12 respectively.
|
||||
|
||||
The helper reads those rank amounts from `src/game/wow335Talents.generated.ts`, produced by `scripts/generate-wow335-talents.mjs`. The generated rank tooltip supplies the spell list and strict “less than 35%” boundary. No WotLKDB fallback was needed because the local generated rank data includes every modeled value.
|
||||
|
||||
The generic talent-modifier parser does not recognize this conditional “do an additional X% damage when striking targets” form, and a global scalar would apply outside the health gate. The isolated helper therefore takes rank, ability name, and the target's health fraction for each hit; it returns no bonus at exactly 35%, for other abilities, or at invalid health input. The returned bonus is intended to modify the resolved hit before target mitigation.
|
||||
|
||||
## Runtime integration and limits
|
||||
|
||||
The player direct-damage path in `src/game/combatStore.ts` and generated-companion direct-damage path in `src/game/partyRuntime.ts` now apply the helper to each target hit. Both paths require the Death Knight class and a Death Knight ability, read the owner's `talent-1993` rank, and sample that target's current health fraction immediately before damage resolution. Multi-target strikes evaluate each target independently.
|
||||
|
||||
The helper only recognizes the four abilities named by the local rank tooltip. Integration coverage verifies rank 1 and rank 2 below 35%, no bonus at exactly 35%, and no bonus on Plague Strike. Icy Touch applies Frost Fever as well as direct damage; its periodic tick remains unchanged by this direct-hit talent, covered by a player runtime regression. Generated-companion direct hits are tested through companion cast completion for both ranks and the exact threshold.
|
||||
|
||||
The generic talent-modifier parser does not recognize this conditional “do an additional X% damage when striking targets” form, and a global scalar would apply outside the health gate. Runtime handling therefore stays in the per-target direct-damage paths; no player or companion periodic path is modified. The simulator uses the current health fraction before each hit because the tooltip gives no separate sampling phase. All modeled values come from generated local talent data; no WotLKDB fallback or inferred values were used.
|
||||
|
||||
Focused validation: `node_modules/.bin/vitest run src/game/wow335DeathKnightMercilessCombatRuntime.test.ts src/game/wow335DeathKnightMercilessCombat.integration.test.ts`.
|
||||
@@ -0,0 +1,31 @@
|
||||
# WoW Death Knight Spell Deflection
|
||||
|
||||
## Source and chosen behavior
|
||||
|
||||
Spell Deflection is the next Death Knight mechanic selected for implementation. The generated local talent node is `talent-2018`, with ranks 1–3 at spell IDs `49145`, `49495`, and `49497`. The generated rank descriptions state that the proc chance equals the Death Knight's Parry chance and that it applies to direct-damage spells. Their DBC-backed effect rows contain `effectType: 6`, `auraType: 69`, and `miscValue: 126`; the rank values are 15%, 30%, and 45%.
|
||||
|
||||
Aura type 69 is `SPELL_AURA_SCHOOL_ABSORB` in the 3.3.5 aura definitions, so the generated effect is a school absorb, not a generic damage-taken modifier. School mask 126 covers the non-physical schools. The helper therefore treats the effect as a one-hit partial absorb of the rank percentage after the proc roll succeeds. The generated rank rows have no recovery time or charges. The tooltip and DBC rows do not specify how fractional damage is rounded; this simulator floors the absorbed amount to whole damage points. That rounding rule is a simulator choice, not a claim about stock 3.3.5a server rounding.
|
||||
|
||||
The proc chance is read from the defender's current derived Parry chance at hit time. It is not a saved or fixed talent profile chance: the local description says “equal to your Parry chance,” and the 3.3.5 AzerothCore handler uses the target's current `GetUnitParryChance()` as corroborating implementation evidence. The handler also checks direct spell damage and absorbs the rank percentage of that hit. No WotLKDB fallback was needed for the implementation values or eligibility description.
|
||||
|
||||
## Trigger, timing, and exclusions
|
||||
|
||||
`combatStore.ts` applies the effect on both player and generated-companion incoming enemy spell hits. The hook runs after normal hit resolution, resistance and aura-based incoming reductions, and before Mage Frost Warding, Soul Link transfer, named shields, and health loss. It rolls once for each resolved hit, using the defender's current derived Parry chance. On success, it absorbs 15%, 30%, or 45% of the remaining hit at the learned rank. The absorbed amount is included in the incoming combat event's `absorbed` field, and the reduced damage is passed to later mitigation and absorb stages.
|
||||
|
||||
Only a landed enemy attack represented as a direct spell hit with a non-physical school is eligible. Physical attacks and periodic damage ticks are excluded. A zero-damage result, miss, or invalid roll cannot proc. The DBC school mask is retained in the profile and checked by the helper, so a physical-only school cannot accidentally use the absorb. School masks are derived from the incoming combat school's 3.3.5 bit (`physical` 1; `holy` 2; `fire` 4; `nature` 8; `frost` 16; `shadow` 32; `arcane` 64).
|
||||
|
||||
The generated local enemy attack model represents spell attacks as discrete hit events and currently has no periodic incoming damage-tick event for this hook to consume. If periodic enemy spells are added later, their event type must remain distinct from direct hits. The generic `damagePlayer(amount, school)` adapter also lacks delivery/direct-hit context, so it does not invoke this talent. The AzerothCore handler suppresses the proc while the defender is casting a non-melee spell or controlled; that extra condition is not included because it is a fork-specific script gate and has not been verified against stock TrinityCore 3.3.5 behavior. It remains a source difference to revisit if stock-server behavior is later established.
|
||||
|
||||
## Simulator limits and implementation status
|
||||
|
||||
Generated spell rows provide the school absorb amount and school mask but do not encode the dynamic “chance equals Parry” calculation as a numeric proc chance; the helper uses the tooltip's dynamic rule and live combat stats. Incoming enemy hits do not currently carry a dedicated spell direct-vs-periodic subtype, so the runtime adapter treats the model's discrete non-physical spell hit event as direct. A future periodic damage event must be marked separately before integration.
|
||||
|
||||
Focused helper coverage verifies the generated rank amounts, school mask, live-Parry proc chance at 0%, threshold, and 100% edges, direct-hit and physical-school exclusions, and the simulator's whole-point partial-absorb rounding. Player and generated-companion integration coverage compares baseline damage with rank 3 and verifies the logged absorb amount.
|
||||
|
||||
## Local references
|
||||
|
||||
- Generated talent node and rank rows: `src/game/wow335Talents.generated.ts` (`talent-2018`).
|
||||
- Isolated profile, proc, and absorb helper: `src/game/wow335DeathKnightSpellDeflectionRuntime.ts`.
|
||||
- Focused helper and combat regressions: `src/game/wow335DeathKnightSpellDeflectionRuntime.test.ts`, `src/game/wow335DeathKnightSpellDeflection.integration.test.ts`.
|
||||
- 3.3.5 aura type 69 definition in the local AzerothCore reference checkout: `wow-pvt-server-backups-2/azerothcore-wotlk/src/server/game/Spells/Auras/SpellAuraDefines.h`.
|
||||
- AzerothCore's corroborating Spell Deflection absorb handler: `wow-pvt-server-backups-2/azerothcore-wotlk/src/server/scripts/Spells/spell_dk.cpp`.
|
||||
@@ -0,0 +1,36 @@
|
||||
# WoW Hunter Bestial Discipline
|
||||
|
||||
## Source contract
|
||||
|
||||
The local generated 3.3.5a Hunter talent node `talent-1390` is generated from client DBC data by `scripts/generate-wow335-talents.mjs`; `src/game/wow335Talents.generated.ts` is marked generated and must not be hand-edited. The node contains rank spells 19590 and 19592:
|
||||
|
||||
| Rank | Spell row | Pet Focus regeneration | Intimidation Mana cost |
|
||||
|---|---:|---:|---:|
|
||||
| 1 | 19590 | +50% | -25% |
|
||||
| 2 | 19592 | +100% | -50% |
|
||||
|
||||
Each Focus value is cross-checked against the rank's aura effect (`effectType: 6`, `auraType: 107`, `miscValue: 8`, `basePoints: 50` or `100`) and its generated rank description. The generated rank description also names the separate Intimidation cost rider. No external fallback was needed because local generated rows contain the mechanic and exact values.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
`combatStore.ts` initializes a Hunter pet's Focus pool at 100/100 with a project baseline regeneration rate of 5 Focus per second. Each combat update already accrues Focus from elapsed milliseconds. `wow335HunterBestialDisciplineRuntime.ts` multiplies that existing rate by `1 + rankBonus / 100`, yielding 7.5 Focus/sec at rank 1 and 10 Focus/sec at rank 2. It does not add a discrete Focus tick. The pool's `regenerationPerSecond` field is updated with the effective rate, and Focus remains capped by the existing maximum.
|
||||
|
||||
The tick resolves the pet owner before applying the talent: player pets use the Hunter player's `talentRanks`; generated party Hunter pets use their owner's `talentRanks`. An owner who is not a Hunter receives no Bestial Discipline bonus. The same `summon.hunterPet` path covers the existing player and generated-companion Hunter pet actors.
|
||||
|
||||
The source describes a regeneration increase but does not define simulator update cadence. Applying the bonus continuously over the elapsed time already used by the pet Focus pool is therefore an integration inference, not a sourced discrete tick interval.
|
||||
|
||||
## Separate Intimidation rider and limits
|
||||
|
||||
The generated rows also say Bestial Discipline reduces Intimidation's Mana cost by 25%/50%. That rider is not implemented by the pet Focus helper. The existing generic talent modifier parser recognizes it as a resource-cost modifier scoped to Intimidation; a regression confirms that it does not reduce Arcane Shot cost and that the Focus clause does not become a global player resource-regeneration scalar. This feature does not claim separate cast-path parity for companion-controlled Intimidation.
|
||||
|
||||
The simulator currently has no saved Hunter pet-family selection; this change neither chooses a family nor changes pet stats or attacks. No family-specific behavior is inferred.
|
||||
|
||||
## Files and verification
|
||||
|
||||
- `src/game/wow335HunterBestialDisciplineRuntime.ts` reads and validates the generated rank values and computes the rate.
|
||||
- `src/game/combatStore.ts` applies the owner-rank multiplier during existing elapsed-time pet Focus regeneration and stores the effective rate in the pool.
|
||||
- `src/game/wow335HunterBestialDisciplineRuntime.test.ts` covers source rank rows, base rate, scaled rates, invalid ranks, and invalid input.
|
||||
- `src/game/wow335HunterBestialDiscipline.integration.test.ts` checks that one second of elapsed time yields 7.5 and 10 Focus/sec for player and companion Hunter owners with different ranks, while a non-Hunter control stays at 5.
|
||||
- `src/game/talentModifiers.test.ts` checks the Intimidation-only Mana rider scope and absence of a generic Focus-regeneration modifier.
|
||||
|
||||
Focused validation: `npx vitest run src/game/talentModifiers.test.ts src/game/wow335HunterBestialDisciplineRuntime.test.ts src/game/wow335HunterBestialDiscipline.integration.test.ts`.
|
||||
@@ -0,0 +1,83 @@
|
||||
# WoW Hunter Pet Defensive Inheritance
|
||||
|
||||
## Status
|
||||
|
||||
Source-audited against the pinned TrinityCore TDB 335.26091 release and matching TrinityCore source commit. The generic Hunter-pet armor/Agility baseline and source-order armor helper are implemented, and both player Hunter pets and generated-companion Hunter pets now use the profile in their shared incoming-hit path. Physical armor and the five locally modeled magical resistances are applied before pet damage-taken auras and absorbs. Holy resistance remains a documented model limit.
|
||||
|
||||
## Selected behavior
|
||||
|
||||
For WoW 3.3.5a Hunter pets, the selected stock-compatible core behavior is:
|
||||
|
||||
- Add 35% of the Hunter's effective armor to the pet's own armor calculation.
|
||||
- The upstream core adds 40% of the Hunter's effective resistance for every non-physical school: Holy, Fire, Nature, Frost, Shadow, and Arcane. This project currently models only Arcane, Fire, Frost, Nature, and Shadow resistance; the pet's Holy resistance is kept at zero because `ItemStats` and `CharacterStats` have no Holy-resistance field.
|
||||
- Do not inherit physical resistance; armor handles physical mitigation separately.
|
||||
- Recompute the pet values when the owner's armor or school resistance changes.
|
||||
|
||||
The source implementation uses the owner's calculated armor/resistance, not only item stats. Thus owner-side buffs and other modifiers already reflected in those values participate in the inherited portion. The pet also has its own baseline armor, Agility-derived armor, and pet aura modifiers. `wow335HunterPetInheritedDefenses` returns only the owner-derived contributions; `wow335HunterPetArmorForProfile` and `wow335HunterPetArmorForLevel` compose the complete armor value from the pet profile and those source-ordered modifiers.
|
||||
|
||||
## Source and precedence
|
||||
|
||||
The primary local data did not specify owner armor or resistance inheritance. The implementation reference is the TrinityCore 3.3.5a server source at the TDB release commit [`fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a`](https://github.com/TrinityCore/TrinityCore/commit/fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a). This is a server-core implementation, not Blizzard server source:
|
||||
|
||||
- [`Guardian::UpdateArmor` and `Guardian::UpdateResistances`](https://github.com/TrinityCore/TrinityCore/blob/fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a/src/server/game/Entities/Unit/StatSystem.cpp#L1344-L1376) state the 35% armor and 40% magical-resistance contributions and combine them with the pet's own values.
|
||||
- [`Unit::UpdateAllResistances`](https://github.com/TrinityCore/TrinityCore/blob/fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a/src/server/game/Entities/Unit/StatSystem.cpp) iterates school IDs from `SPELL_SCHOOL_NORMAL` up to, but not including, `MAX_SPELL_SCHOOL`. `Guardian::UpdateResistances` skips only the physical school, so the core path includes Holy as well as the five resistance stats this project represents. The Holy formula is source-backed; supplying a nonzero owner Holy value is not possible in the current local stats contract.
|
||||
- [`Player::UpdateArmor` and `Player::UpdateResistances`](https://github.com/TrinityCore/TrinityCore/blob/fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a/src/server/game/Entities/Unit/StatSystem.cpp#L248-L286) recalculate the active pet after the owner's effective armor or school resistance is updated.
|
||||
- [`Guardian::InitStatsForLevel`](https://github.com/TrinityCore/TrinityCore/blob/fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a/src/server/game/Entities/Pet/Pet.cpp#L890-L924) selects `pet_levelstats` entry 1 for Hunter pets, initially seeds base armor as pet level × 50, and then uses that row's armor (when positive) and level stats. It also says Hunter pet resistance does not come from `creature_template`; pet auras and inherited resistance are the relevant inputs.
|
||||
- [`CalculatePct`](https://github.com/TrinityCore/TrinityCore/blob/fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a/src/common/Utilities/Util.h#L62-L67) returns the percentage result in the input base type after calculating `float(base) * float(percent) / 100.0f`. Since owner armor and resistance getters return integers, the float32 result is converted back to that integer type (truncating toward zero) before being promoted into the pet's float formula. `Guardian::UpdateArmor` then passes the completed float value to `SetArmor(int32(value))`, also truncating toward zero. The isolated helper reproduces both conversions and preserves this operation order; it does not claim to model the entire core modifier system.
|
||||
- `Guardian::UpdateArmor` composes pet base armor × BASE_PCT, 2 × effective pet Agility, total flat pet armor, and the integer 35% owner-armor contribution, then applies TOTAL_PCT and passes the result to `SetArmor(int32(value))`. This final cast truncates toward zero. The pet's BASE_VALUE is loaded from its `pet_levelstats` armor row when positive rather than the level × 50 initializer.
|
||||
|
||||
No WotLKDB fallback was needed for these formulas. The available WotLKDB pages are spell entries and do not define these derived pet stats.
|
||||
|
||||
## Local baseline-data audit
|
||||
|
||||
The repository's existing [`wow335HunterPetRuntime.ts`](../src/game/wow335HunterPetRuntime.ts#L129-L169) profile carries level-indexed health and Strength. The verified level-indexed armor and Agility values are stored separately in [`wow335HunterPetDefenseData.ts`](../src/game/wow335HunterPetDefenseData.ts). The defense helper is consumed at the shared player/companion summon incoming-hit resolver; this avoids adding derived armor or resistance to persisted pet state.
|
||||
|
||||
TrinityCore's source documents the fallback used only when a `pet_levelstats` row is absent: it leaves the level × 50 armor seed in place and assigns fixed Strength 22, Agility 22, Stamina 25, Intellect 28, and Spirit 27. That fallback is not evidence for the normal entry-1 row values. Using it as if it were the actual Hunter pet table would risk misrepresenting the stock baseline.
|
||||
|
||||
## Verified pet-level-stat data and revision
|
||||
|
||||
A complete, pinned dataset is present in the TrinityCore [TDB 335.26091 release](https://github.com/TrinityCore/TrinityCore/releases/tag/TDB335.26091), release commit [`fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a`](https://github.com/TrinityCore/TrinityCore/commit/fbd20a3cd829617a64c27cdf65ed6cfb5f304a8a). Its world database artifact is [`TDB_full_world_335.26091_2026_09_09.7z`](https://sourceforge.net/projects/trinitycore.mirror/files/TDB335.26091/TDB_full_world_335.26091_2026_09_09.7z/download), with published SHA-256 `ed0c50177da912b50c9f386ba8a26254cecfb997ca7fd2e992c25b344e11274f`. The downloaded archive hash matched; its inner SQL file is `TDB_full_world_335.26091_2026_09_09.sql`.
|
||||
|
||||
The pinned TDB schema stores `creature_entry` as `int unsigned`, `level` as `tinyint unsigned`, `hp` and `mana` as `smallint unsigned`, `armor` as `int unsigned`, and the five stats plus min/max damage as `smallint unsigned`. The entry-1 rows for levels 1–80 are contiguous. Their example values are level 1: armor 20 / Agility 20; level 60: armor 3,814 / Agility 100; level 80: armor 9,784 / Agility 158.
|
||||
|
||||
For direct integration, these arrays contain the verified armor and Agility values in level order (index 0 is level 1). They were extracted from the pinned TDB SQL after the artifact hash check:
|
||||
|
||||
```text
|
||||
# Levels 1–20
|
||||
armor = [20, 21, 46, 82, 126, 180, 245, 322, 412, 518, 545, 580, 615, 650, 685, 721, 756, 791, 826, 861]
|
||||
agi = [20, 21, 21, 22, 23, 24, 24, 25, 26, 26, 27, 28, 30, 31, 32, 33, 34, 36, 37, 39]
|
||||
|
||||
# Levels 21–40
|
||||
armor = [897, 932, 967, 1002, 1037, 1072, 1108, 1142, 1177, 1212, 1247, 1283, 1317, 1353, 1387, 1494, 1607, 1724, 1849, 1980]
|
||||
agi = [39, 40, 42, 43, 45, 46, 47, 49, 50, 52, 53, 54, 56, 57, 59, 60, 61, 63, 64, 66]
|
||||
|
||||
# Levels 41–60
|
||||
armor = [2117, 2262, 2414, 2574, 2742, 2798, 2853, 2907, 2963, 3018, 3072, 3128, 3183, 3237, 3292, 3348, 3402, 3457, 3512, 3814]
|
||||
agi = [68, 69, 71, 72, 74, 75, 77, 79, 80, 82, 84, 85, 87, 89, 91, 92, 94, 96, 98, 100]
|
||||
|
||||
# Levels 61–80
|
||||
armor = [4113, 4410, 4708, 5006, 5303, 5601, 5900, 6197, 6495, 6794, 7093, 7392, 7691, 7990, 8289, 8588, 8887, 9186, 9485, 9784]
|
||||
agi = [102, 105, 107, 110, 113, 116, 119, 122, 125, 128, 131, 134, 137, 140, 143, 146, 149, 152, 155, 158]
|
||||
```
|
||||
|
||||
The AzerothCore `master` [`pet_levelstats.sql`](https://github.com/azerothcore/azerothcore-wotlk/blob/master/data/sql/base/db_world/pet_levelstats.sql) was compared with the pinned TDB SQL. Both contain exactly 80 entry-1 rows, and every one of the 12 stored values matches for each level 1–80. AzerothCore's dump schema uses `int unsigned` for the stat fields where this TDB schema uses `smallint unsigned`; the values themselves match exactly. The AzerothCore URL remains a moving branch, but it is no longer the data authority for this profile.
|
||||
|
||||
The pinned TDB is a TrinityCore world-database release paired with the 3.3.5a server source, not a Blizzard database dump. The source/data revision is therefore exact for the selected TrinityCore baseline, while direct Blizzard-server provenance is not claimed.
|
||||
|
||||
**Integration status: implemented for the modeled defense fields.** The pinned TDB entry-1 Agility and armor values for levels 1–80 are in [`wow335HunterPetDefenseData.ts`](../src/game/wow335HunterPetDefenseData.ts); the moving ACore export was verified as an exact 12-column match but is not treated as the revision source. `Guardian::InitStatsForLevel` assigns `creature_ID = 1` for any Hunter pet, regardless of family, so a Wolf uses these generic Hunter-pet rows rather than Wolf-specific stats. It loads row armor and Agility when present; the fake fallback branch is not used for these rows. At each incoming pet hit, the integration derives the current owner's effective armor and five native resistances, combines those values with the pet's baseline and pet-owned aura modifiers, and passes them through the shared combat resolver. The helper mirrors `CalculatePct<T>`'s float32 calculation and integer conversion, then composes BASE_PCT, 2 × effective Agility, TOTAL_VALUE, owner armor, and TOTAL_PCT in source order before truncating the final armor like `SetArmor(int32(value))`. The source behavior remains a TrinityCore-compatible baseline, not direct proof of Blizzard server internals.
|
||||
|
||||
## Runtime preparation
|
||||
|
||||
[`wow335HunterPetDefenseData.ts`](../src/game/wow335HunterPetDefenseData.ts) contains entry-1 armor and Agility arrays for levels 1–80, and [`wow335HunterPetInheritedDefenseRuntime.ts`](../src/game/wow335HunterPetInheritedDefenseRuntime.ts) provides the base-profile lookup, integer owner-inheritance calculation, and pure armor composition helper. The composition accepts current effective pet Agility and optional pet armor multipliers/flat modifiers so callers can preserve aura-derived values. Non-finite inputs are sanitized; the helper does not calculate incoming-damage mitigation.
|
||||
|
||||
The focused tests check both coefficients and their integer conversion, the helper's six-school arithmetic, no physical-school inheritance, the entry-1 array bounds and representative rows, baseline-level clamping, source-order armor composition, negative synthetic resistance input, and invalid numeric input. Combat-store integration regressions cover physical armor and Fire resistance for both player and generated-companion Hunter pets, a player-owner armor aura, and an unchanged non-Hunter summon path. Owner defenses are re-derived at incoming-hit time, so current owner auras participate; pet Agility, armor, and resistance aura aliases are also applied before mitigation.
|
||||
|
||||
## Boundaries and open questions
|
||||
|
||||
- The integration runs only for summons with a Hunter-pet profile. Other summons retain the existing incoming-damage behavior.
|
||||
- Holy resistance is not inherited in combat because there is no owner Holy-resistance value to read. Upstream TrinityCore's school loop would inherit 40% if such a value were present; the current simulator supplies zero and does not invent a synthetic stat.
|
||||
- The pet defender disables new miss, crit, dodge, parry, and block outcomes in the mitigation resolver so this change preserves the summon path's existing critical-charge, avoidance, and absorption behavior. Physical armor and modeled-school partial resistance are added to that path.
|
||||
- Negative owner resistance is covered as defensive input handling; TrinityCore's owner getter returns an integer resistance value and the source formula converts `CalculatePct<T>` back to that integer type.
|
||||
- This profile models inherited defenses, not Stable slots, Feed Pet happiness, loyalty, pet training points, or family-specific abilities.
|
||||
- Keep pet critical inheritance unresolved. This armor/resistance source does not supply an owner-crit formula.
|
||||
- The percentages are supported by the upstream 3.3.5 server implementation. If later direct evidence from Blizzard 3.3.5a or the project's target server conflicts, revisit this source choice before claiming exact stock parity.
|
||||
@@ -0,0 +1,36 @@
|
||||
# WoW Hunter Pet Happiness
|
||||
|
||||
## Status
|
||||
|
||||
The tier lookup and base auto-attack damage multiplier are source-audited and prepared as isolated helpers. They are not yet connected to the player's or generated companion's pet state or attack resolver. The game does not yet model pet happiness, so this note does not claim that happiness affects live combat.
|
||||
|
||||
## Selected 3.3.5 behavior
|
||||
|
||||
The upstream TrinityCore `3.3.5` branch uses `POWER_HAPPINESS` in bands of 333,000:
|
||||
|
||||
| Pet happiness power | State | Base pet main-hand damage |
|
||||
| --- | --- | ---: |
|
||||
| Below 333,000 | Unhappy | 75% |
|
||||
| 333,000 to below 666,000 | Content | 100% |
|
||||
| 666,000 or higher | Happy | 125% |
|
||||
|
||||
`Pet::GetHappinessState` defines the band boundaries, and `Guardian::UpdateDamagePhysical` applies the matching multiplier to the Hunter pet's minimum and maximum base melee damage. This source path is why the isolated helper scopes its effect to base auto-attack damage. It does not assume that every special-attack formula receives the same multiplier.
|
||||
|
||||
## Source and precedence
|
||||
|
||||
No local generated pet-happiness row or runtime state was found. These missing details use the upstream TrinityCore source for the 3.3.5 branch, a server-core reference for the target 3.3.5a behavior rather than Blizzard server source:
|
||||
|
||||
- [`HAPPINESS_LEVEL_SIZE`](https://github.com/TrinityCore/TrinityCore/blob/3.3.5/src/server/game/Entities/Pet/Pet.h#L19-L24) is 333,000.
|
||||
- [`Pet::GetHappinessState`](https://github.com/TrinityCore/TrinityCore/blob/3.3.5/src/server/game/Entities/Pet/Pet.cpp#L653-L671) sets the Unhappy, Content, and Happy thresholds.
|
||||
- [`Guardian::UpdateDamagePhysical`](https://github.com/TrinityCore/TrinityCore/blob/3.3.5/src/server/game/Entities/Unit/StatSystem.cpp#L1427-L1476) applies 1.25 / 1.00 / 0.75 multipliers to a Hunter pet's minimum and maximum base melee damage.
|
||||
|
||||
## Deliberate exclusions
|
||||
|
||||
- Happiness decay is not implemented here. TrinityCore updates the happiness timer every 7,500 ms and loses 670 points per update, but its own `Pet::LoseHappiness` comment labels the 1.5× in-combat loss as a guess. This is insufficient evidence for claiming exact stock decay behavior.
|
||||
- Feed Pet is not implemented here. Food-family compatibility, food item levels, inventory consumption, and the happiness amount need a separate full source audit.
|
||||
- The helper does not define the initial value or persistence policy. TrinityCore initializes a newly tamed pet to 166,500, but modeling this requires persistent happiness state and the tame/summon lifecycle.
|
||||
- This is separate from the WoW Hunter pet armor/resistance preparation in [the defense inheritance note](WOW_HUNTER_PET_DEFENSE_INHERITANCE.md).
|
||||
|
||||
## Runtime integration contract
|
||||
|
||||
[`wow335HunterPetHappinessRuntime.ts`](../src/game/wow335HunterPetHappinessRuntime.ts) maps a happiness power value to a state and base auto-attack multiplier. Integration needs persistent per-pet happiness, must apply the multiplier to the pet's ordinary melee auto-attack damage after its existing base damage is calculated, and must preserve the pet-specific state across player and companion pet attack paths. Add damage taken from Bite or other pet special abilities only if the target 3.3.5 source path proves those formulas consume the adjusted weapon damage; this note does not infer that coupling.
|
||||
@@ -0,0 +1,50 @@
|
||||
# WoW Mage Netherwind Presence
|
||||
|
||||
## Source and selected behavior
|
||||
|
||||
The local generated 3.3.5a talent node `talent-1846` is the authority for this
|
||||
custom Netherwind Presence mechanic. Its three rank rows are spells 44400,
|
||||
44402, and 44403. The rank tooltips explicitly grant 1%, 2%, or 3% spell haste
|
||||
per stack for 10 seconds, up to three stacks. Thus the maximum rank-1/2/3 haste
|
||||
is 3%/6%/9%. Each rank's DBC row reports a 100% Proc Trigger Spell effect and
|
||||
the class mask (`[536872960, 0, 0]`) is the same across ranks. The rank rows link
|
||||
to child spells 966120, 966121, and 966122, respectively, and each has
|
||||
`procChance` 100 with no generated `procFlags` field. No WotLKDB fallback was
|
||||
used: its stock spell 44400 is a different, permanent 2% Netherwind Presence
|
||||
effect and does not describe this local custom talent.
|
||||
|
||||
The generated tooltip names seven trigger spells: Arcane Missiles, Arcane
|
||||
Barrage, Arcane Blast, Arcane Orb, Moonfire, Starfire, and Starsurge. The
|
||||
current generated Mage ability catalog contains Arcane Missiles, Arcane
|
||||
Barrage, and Arcane Blast. Arcane Orb is absent; Moonfire, Starfire, and
|
||||
Starsurge are Druid abilities in this project, not Mage catalog entries. They
|
||||
are kept in the source inventory but cannot trigger a Mage talent until the
|
||||
corresponding Mage actions exist. Slow is not a stacking trigger; it is the
|
||||
separate capstone action.
|
||||
|
||||
## Runtime mapping and limits
|
||||
|
||||
- One eligible spell cast commitment adds one stack, refreshes the 10-second
|
||||
duration, and stops at three stacks. The local talent row does not include
|
||||
proc phase flags, so awarding at accepted cast commitment is an explicit
|
||||
event-phase inference. The runtime awards at most one stack per cast,
|
||||
including one stack for a channel cast rather than one stack per missile
|
||||
pulse. Failed or invalid casts do not reach this hook; a cast interrupted
|
||||
after commitment keeps its stack, matching the chosen commitment-time
|
||||
interpretation.
|
||||
- Each stack contributes the rank's 1%/2%/3% to the existing spell-haste aura
|
||||
stat. The general talent parser skips this talent so its conditional haste
|
||||
does not become an always-on bonus.
|
||||
- At max talent rank and three stacks, a Slow cast applies a separate 30%
|
||||
movement-speed aura for three seconds. The tooltip does not say this consumes
|
||||
Netherwind Presence, so the three haste stacks remain and keep their original
|
||||
expiry.
|
||||
- The capstone aura is consumed by the player movement controller and generated
|
||||
party companion follow controller, multiplying displacement by 1.3 while
|
||||
active. Expired or differently targeted auras do not change movement. Runtime
|
||||
tests verify the modifier and expiry; an in-scene movement playtest remains
|
||||
useful to confirm feel and pathing.
|
||||
|
||||
The implementation intentionally does not add substitute Mage spell rows for
|
||||
Arcane Orb, Moonfire, Starfire, or Starsurge. Adding them requires local
|
||||
class-attribution/ability data beyond Netherwind Presence itself.
|
||||
@@ -0,0 +1,30 @@
|
||||
# WoW Shaman Flurry
|
||||
|
||||
## Source precedence and selected values
|
||||
|
||||
The project-generated DBC talent row `talent-602` is authoritative for this mechanic's rank values and trigger. Its Enhancement ranks are spell IDs `16256`, `16281`, and `16282`; their expanded local tooltips say a melee critical strike grants three following swings with `4%`, `6%`, and `10%` melee haste. The profile in [`src/game/wow335ShamanFlurryRuntime.ts`](../src/game/wow335ShamanFlurryRuntime.ts) reads those descriptions rather than duplicating the rank values.
|
||||
|
||||
The generated row also contains a rank-two payload mismatch: its expanded tooltip says `6%`, while the raw `rankEffects` child effect has `basePoints: 7`. WotLKDB child aura rows linked from the rank spells report values `12` for child `16277` and `18` for child `16278`; those payload values do not replace the generated project tooltip values. This implementation follows the generated `4/6/10%` tooltips, as directed.
|
||||
|
||||
The local rank-spell `durationIndex: 21` resolves to `-1` in local `SpellDuration.dbc` row 21, correctly describing each passive talent rank as permanent. The proc child spells—not the passive ranks—carry the temporary aura duration: local `Spell.dbc` rows `16257`, `16277`, and `16278` each reference duration index `8`, which resolves to `15_000ms` in local `SpellDuration.dbc` row 8. Runtime uses this local duration mapping. The local values were read from the extracted client DBC pair at `F:\Projects\LadiksMPQEditor\client-current\patch-T\DBFilesClient\Spell.dbc` and `F:\Projects\LadiksMPQEditor\client-current\patch-S\DBFilesClient\SpellDuration.dbc`. The [WotLKDB rank-two child aura](https://wotlkdb.com/?spell=16277) and [rank-three child aura](https://wotlkdb.com/?spell=16278) corroborate 15 seconds; the rank-one child duration is resolved locally even though its WotLKDB page was not independently readable.
|
||||
|
||||
The [WotLKDB rank-one parent](https://wotlkdb.com/?spell=16256), [rank-two parent](https://wotlkdb.com/?spell=16281), and [rank-three parent](https://wotlkdb.com/?spell=16282) each show a proc-trigger effect linked to a Flurry child aura. Those pages do not expose the proc flags, child charge count, or the rule for refreshing charges while the aura is active. No rank values or trigger details are taken from those pages when the local generated row already supplies them.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
`combatStore.ts` uses the source actor's exact `talent-602` rank and only recognizes Shaman talent owners. A direct, non-periodic combat result procs on a landed melee critical strike. No random roll is added: each generated rank row lists a 100% proc chance. The effect applies to the owner, so player and generated-companion Shamans keep separate auras and rank values.
|
||||
|
||||
The temporary aura adds the selected rank's flat melee-haste percentage for 15 seconds. Its three aura stacks represent swing charges; haste is explicitly not multiplied by those charges. Every resolved owner melee autoattack attempt consumes one charge, including a miss, and the triggering swing consumes none because the charge is applied after consuming any prior charge. Main-hand and off-hand attempts consume from the same owner aura. The player and companion Shaman autoattack schedulers recompute that hand's next interval from the post-hit aura state, so a proc speeds following intervals and the third consumed charge stops affecting the interval after that swing.
|
||||
|
||||
When a new eligible critical occurs with Flurry already active, runtime policy replaces the remaining charges with three and resets the 15-second timer. This is a practical interpretation of “for your next 3 swings”; WotLKDB does not document the stock refresh rule, so this exact refresh policy remains unverified. If two hands are due at the same simulation timestamp, the existing player/party loop order resolves main-hand first and then off-hand; the source pages do not establish a stock order for that tied case.
|
||||
|
||||
## Coverage limits and assumptions
|
||||
|
||||
- The passive ranks' generated data omits proc-phase flags. The runtime follows the local tooltip's broad direct melee-critical trigger, including modeled direct melee abilities; it excludes periodic packets. It does not synthesize attacks or spell events absent from the current combat resolver.
|
||||
- A critical special ability can apply Flurry, but an already-pending autoattack deadline is not retimed at the moment of that ability hit. Haste is read when the next autoattack swing resolves and its following interval is scheduled. The simulation does not track fractional swing progress, so exact mid-swing haste retiming remains outside this integration.
|
||||
- The 15-second value is resolved locally for all three child rank auras. WotLKDB child rows for ranks two and three corroborate it.
|
||||
- Charge refresh behavior, proc flags for proc-generated direct melee hits, and ordering for equal-time dual-wield swings remain source boundaries rather than confirmed stock details.
|
||||
|
||||
## Focused validation
|
||||
|
||||
[`src/game/wow335ShamanFlurryRuntime.test.ts`](../src/game/wow335ShamanFlurryRuntime.test.ts) checks generated rank identity/IDs, tooltip values, proc gates, rank normalization, the local raw-payload mismatch, aura duration/haste, refresh policy, charge ownership, and expiry. [`src/game/wow335ShamanFlurry.integration.test.ts`](../src/game/wow335ShamanFlurry.integration.test.ts) checks a player Shaman's landed critical and resulting hastened interval plus a generated companion Shaman's own rank and three subsequent autoattack charge consumption.
|
||||
@@ -0,0 +1,41 @@
|
||||
# WoW Shaman Remaining Talent Audit
|
||||
|
||||
**Audit date:** 2026-10-01
|
||||
|
||||
**Scope:** generated WoW Shaman talent rows and player/companion combat paths. The initial audit was read-only; its recommended Flurry follow-up is now implemented in isolated runtime hooks and covered by focused tests.
|
||||
|
||||
The source model and combat integration are in [`src/game/wow335ShamanFlurryRuntime.ts`](../src/game/wow335ShamanFlurryRuntime.ts), [`src/game/combatStore.ts`](../src/game/combatStore.ts), and [`src/game/partyRuntime.ts`](../src/game/partyRuntime.ts). Focused source and player/companion integration regressions are in [`src/game/wow335ShamanFlurryRuntime.test.ts`](../src/game/wow335ShamanFlurryRuntime.test.ts) and [`src/game/wow335ShamanFlurry.integration.test.ts`](../src/game/wow335ShamanFlurry.integration.test.ts).
|
||||
|
||||
## Implemented candidate: Flurry (`talent-602`)
|
||||
|
||||
The generated Enhancement talent row has three explicit ranks:
|
||||
|
||||
| Rank | Melee attack speed | Charges |
|
||||
| --- | ---: | ---: |
|
||||
| 1 | 4% | 3 swings |
|
||||
| 2 | 6% | 3 swings |
|
||||
| 3 | 10% | 3 swings |
|
||||
|
||||
The proc begins after a melee critical strike. The local generated DBC row and rank text are in [`src/game/wow335Talents.generated.ts`](../src/game/wow335Talents.generated.ts) (`talent-602`, “Flurry”); the generated Shaman talent UI row mirrors those rank descriptions in [`src/game/generated/talentUi/shaman.json`](../src/game/generated/talentUi/shaman.json).
|
||||
|
||||
### Why this was a bounded candidate
|
||||
|
||||
- The generic talent parser does not interpret “attack speed” as haste, so rank-specific timing needed an explicit temporary melee-haste effect.
|
||||
- Player main-hand and off-hand autos resolve through the common melee hit path in [`src/game/combatStore.ts`](../src/game/combatStore.ts). Companion Shaman weapon autos resolve through [`src/game/partyRuntime.ts`](../src/game/partyRuntime.ts). Both paths produce the combat result needed to distinguish a landed melee critical from a miss or non-critical hit.
|
||||
- Player weapon intervals are derived from melee haste, and companion Shaman swing intervals read the actor's melee-haste aura. An actor-local temporary melee-haste aura can therefore affect the already modeled swing timing without inventing a new weapon or attack system.
|
||||
- `Flurry` supplies the trigger, all three rank values, and the number of affected swings locally. Its generated 100% proc chance avoids an unsupported random roll. WotLKDB is only a secondary reference when local child-spell details are unavailable.
|
||||
|
||||
### Implemented behavior and source notes
|
||||
|
||||
The runtime gates by each source actor's generated `talent-602` rank; a landed direct melee critical applies that rank's value and three charges to the actor. Each completed melee autoattack attempt consumes one charge; the triggering hit does not. Existing player and companion Shaman swing schedulers read the aura when scheduling the following interval.
|
||||
|
||||
The generated rank spells are `16256`, `16281`, and `16282`, with displayed values `4%`, `6%`, and `10%`. Local rank two's expanded tooltip says 6% while its raw child effect has `basePoints: 7`; WotLKDB child effects report 12% (`16277`) and 18% (`16278`). Runtime follows the generated expanded tooltip values. Local rank spells' duration index 21 resolves to `-1` (permanent); triggered child spells `16257`, `16277`, and `16278` each use local duration index 8, which resolves to 15,000 ms. Runtime uses this local duration mapping; WotLKDB rank-two and rank-three child aura rows corroborate 15 seconds. The local `Spell.dbc` and `SpellDuration.dbc` rows were parsed from the extracted client files under `F:\Projects\LadiksMPQEditor\client-current\patch-T\DBFilesClient` and `patch-S\DBFilesClient`, respectively.
|
||||
|
||||
Repeated procs currently replace remaining charges with three and restart the timer. The source pages do not state whether stock reapplication restores used charges or expose proc-phase flags, so those are documented implementation choices. The runtime counts resolved melee autoattack attempts, including misses, as swings; special direct melee hits can trigger Flurry, while periodic packets cannot. Both hands consume a shared actor aura. If hands are due at the same simulation timestamp, existing loop order resolves main-hand then off-hand. A special ability's proc does not retime an already-pending swing; exact fractional swing progress is outside the combat model.
|
||||
|
||||
Focused validation passed for the source helper and player/companion integration (8 tests total). The tests cover generated ranks, landed-crit gating, player haste scheduling, companion rank ownership, three subsequent autoattack charges, charge expiry, and the implementation refresh policy. Equal-time dual-wield order and server-confirmed refresh/proc flags remain open.
|
||||
|
||||
## Other candidates reviewed
|
||||
|
||||
- **Static Shock (`talent-2055`, Enhancement, 3 ranks):** the generated rows specify rank-based Lightning Shield damage/charge increases and melee-hit proc chances, but leave the internal cooldown unresolved as `the listed amount[1 second][1.5 seconds]`. Resolve that rank mapping from the local spell data before implementing the full talent; the unresolved text is why this was not selected as the source-complete candidate.
|
||||
- **Nature's Guardian (`talent-1699`, Restoration, capstone rider):** its threshold and cooldown are named, but the self-heal is only “a small amount (increased by healing power).” The generated tooltip does not provide its base amount or scaling formula, so do not guess those values.
|
||||
@@ -254,36 +254,40 @@ function rankNumber(spell) {
|
||||
return Number(spell.rankText.match(/^Rank\s+(\d+)$/i)?.[1] ?? 0);
|
||||
}
|
||||
|
||||
function resolveDescription(spell) {
|
||||
const effect = (oneBasedIndex) => spell.effects.find(
|
||||
(candidate) => candidate.index === Math.max(0, Number(oneBasedIndex || 1) - 1),
|
||||
);
|
||||
function resolveDescription(spell, spells) {
|
||||
return (spell.description || "No client description is available.")
|
||||
.replace(/\$(\d+)?[sSmM](\d+)?/g, (_token, _spellId, effectIndex) => {
|
||||
const value = effect(effectIndex);
|
||||
.replace(/\$(\d+)?[sSmM](\d+)?/g, (_token, referencedSpellId, effectIndex) => {
|
||||
const referencedSpell = referencedSpellId ? spells.get(Number(referencedSpellId)) : undefined;
|
||||
const value = (referencedSpell ?? spell).effects.find(
|
||||
(candidate) => candidate.index === Math.max(0, Number(effectIndex || 1) - 1),
|
||||
);
|
||||
if (!value) return "the listed amount";
|
||||
const maximum = Math.abs(value.basePoints) + Math.max(0, Math.abs(value.dieSides) - 1);
|
||||
return String(Math.round(maximum * 100) / 100);
|
||||
})
|
||||
.replace(/\$(\d+)?t(\d+)?/g, (_token, _spellId, effectIndex) => {
|
||||
const period = effect(effectIndex)?.periodMs ?? 0;
|
||||
.replace(/\$(\d+)?t(\d+)?/g, (_token, referencedSpellId, effectIndex) => {
|
||||
const referencedSpell = referencedSpellId ? spells.get(Number(referencedSpellId)) : undefined;
|
||||
const period = (referencedSpell ?? spell).effects.find(
|
||||
(candidate) => candidate.index === Math.max(0, Number(effectIndex || 1) - 1),
|
||||
)?.periodMs ?? 0;
|
||||
return period > 0 ? String(period / 1000) : "the listed interval";
|
||||
})
|
||||
.replace(/\$(\d+)?d\b/g, () => {
|
||||
const milliseconds = Math.abs(spell.durationMs);
|
||||
.replace(/\$(\d+)?d\b/g, (_token, referencedSpellId) => {
|
||||
const referencedSpell = referencedSpellId ? spells.get(Number(referencedSpellId)) : undefined;
|
||||
const milliseconds = Math.abs((referencedSpell ?? spell).durationMs);
|
||||
return milliseconds > 0 ? `${milliseconds / 1000} sec` : "the listed duration";
|
||||
})
|
||||
.replace(/\$[a-zA-Z0-9:;.*+/{}-]+/g, "the listed amount");
|
||||
}
|
||||
|
||||
function slimRank(spell) {
|
||||
function slimRank(spell, spells) {
|
||||
return {
|
||||
spellId: spell.id,
|
||||
rank: rankNumber(spell),
|
||||
level: spell.spellLevel,
|
||||
sourceLevel: spell.baseLevel || spell.spellLevel,
|
||||
maxScalingLevel: spell.maxLevel,
|
||||
description: resolveDescription(spell),
|
||||
description: resolveDescription(spell, spells),
|
||||
castTimeMs: spell.castTimeMs,
|
||||
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
|
||||
// Zero is the DBC signal for abilities that do not start a global cooldown.
|
||||
@@ -391,7 +395,7 @@ try {
|
||||
sourceUnlockLevel: Math.max(1, Math.min(...ranks.map((spell) => spell.spellLevel))),
|
||||
...(passive ? { passive: true } : {}),
|
||||
...(talentNodeId ? { talentNodeId } : {}),
|
||||
ranks: ranks.map(slimRank),
|
||||
ranks: ranks.map((spell) => slimRank(spell, spells)),
|
||||
};
|
||||
}).sort((left, right) => (
|
||||
left.sourceUnlockLevel - right.sourceUnlockLevel
|
||||
|
||||
@@ -419,7 +419,9 @@ if (talentDbcPath) {
|
||||
const lastSpell = spells.get(rankSpellIds.at(-1));
|
||||
const rankSpells = rankSpellIds.map((spellId) => spells.get(spellId)).filter(Boolean);
|
||||
const rawRankDescriptions = rankSpells.map((spell) => cleanDescription(spell.description || spell.auraDescription));
|
||||
const rankDescriptions = rankSpells.map((spell) => resolveDescription(spell.description || spell.auraDescription, spell));
|
||||
const rankDescriptions = rankSpells.map((spell, rankIndex) => dbcTalentId === 206
|
||||
? `Your Kick has a ${rankIndex === 0 ? 50 : 100}% chance to silence the target for 2 sec. Capstone Bonus: Successful interrupts with Kick restore 40 Energy and increase your attack speed by 35% for 6 sec.`
|
||||
: resolveDescription(spell.description || spell.auraDescription, spell));
|
||||
exactNodes.push({
|
||||
id: `talent-${dbcTalentId}`,
|
||||
dbcTalentId,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const output = path.resolve('artifacts/playtest/graphics-options');
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-graphics-'));
|
||||
const staticDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-graphics-dist-'));
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
fs.cpSync(path.resolve('dist'), staticDir, { recursive: true });
|
||||
|
||||
async function enterDungeon(page, category, name) {
|
||||
await page.getByRole('button', { name: /^Dungeons/ }).click();
|
||||
if (category) await page.locator('.dungeon-category-tab').filter({ hasText: category }).click();
|
||||
await page.locator('.dungeon-select-row').filter({ hasText: name }).first().click();
|
||||
const download = page.getByRole('button', { name: 'Download and continue' });
|
||||
if (await download.isVisible()) await download.click();
|
||||
else await page.getByRole('button', { name: 'Choose role' }).click();
|
||||
await page.locator('.dungeon-role-option--healer').click();
|
||||
await page.locator('.scene-shell').waitFor();
|
||||
await page.locator('.loading-screen').waitFor({ state: 'visible', timeout: 180_000 });
|
||||
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 });
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 180_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async function canvasScale(page) {
|
||||
return page.locator('.scene-shell canvas').first().evaluate((canvas) =>
|
||||
Number((canvas.width / canvas.clientWidth).toFixed(2)));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir, staticDir, enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(4198, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.setDefaultTimeout(180_000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
try {
|
||||
await page.goto('http://127.0.0.1:4198', { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('GraphicsQA');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
|
||||
await enterDungeon(page, 'WoW', 'Wailing Caverns');
|
||||
const defaultScale = await canvasScale(page);
|
||||
await page.screenshot({ path: path.join(output, 'wow-default.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('dialog', { name: 'Paused' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Options', exact: true }).click();
|
||||
await page.getByRole('dialog', { name: 'Options' }).waitFor();
|
||||
const setting = (name) => page.locator('.options-panel .option-row').filter({ has: page.getByText(name, { exact: true }) }).getByRole('button').first();
|
||||
for (let index = 0; index < 3; index++) await setting('Render resolution').click();
|
||||
for (let index = 0; index < 2; index++) await setting('Texture filtering').click();
|
||||
await setting('Shadows').click();
|
||||
await setting('Ambient fill').click();
|
||||
assert.equal(await setting('Render resolution').innerText(), '200%');
|
||||
assert.equal(await setting('Texture filtering').innerText(), '16×');
|
||||
assert.equal(await setting('Shadows').innerText(), 'High');
|
||||
await page.screenshot({ path: path.join(output, 'graphics-options.png') });
|
||||
await page.setViewportSize({ width: 960, height: 540 });
|
||||
await page.screenshot({ path: path.join(output, 'graphics-options-thor.png') });
|
||||
assert.ok(await setting('Ambient fill').isVisible());
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Resume game' }).click();
|
||||
await page.waitForTimeout(1500);
|
||||
const highScale = await canvasScale(page);
|
||||
assert.ok(highScale >= 1.9, `Expected 200% render scale, got ${highScale}`);
|
||||
await page.screenshot({ path: path.join(output, 'wow-high.png') });
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await enterDungeon(page, 'RoM', 'Forsaken Abbey');
|
||||
assert.ok(await canvasScale(page) >= 1.9);
|
||||
await page.screenshot({ path: path.join(output, 'rom-high.png') });
|
||||
assert.deepEqual(errors, []);
|
||||
const results = { defaultScale, highScale, romScale: await canvasScale(page), errors };
|
||||
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify(results, null, 2)}\n`);
|
||||
console.log(JSON.stringify(results));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(output, 'failure.png') });
|
||||
console.error((await page.locator('body').innerText()).slice(-1500));
|
||||
console.error(errors);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
assert.ok(dataDir.startsWith(path.resolve(os.tmpdir()) + path.sep));
|
||||
assert.ok(staticDir.startsWith(path.resolve(os.tmpdir()) + path.sep));
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
fs.rmSync(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -34,6 +34,14 @@ const CLASS_IDS = ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-pri
|
||||
const CLASS_NAMES = ["Warrior", "Scout", "Rogue", "Mage", "Priest", "Knight", "Warden", "Druid", "Warlock", "Champion"];
|
||||
const RESOURCES = ["rage", "focus", "energy", "mana", "mana", "mana", "mana", "mana", "focus", "rage"];
|
||||
const ELITE_LEVELS = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70];
|
||||
const DRUID_WARRIOR_BRIAR_CHILD = {
|
||||
mainClass: 8,
|
||||
secondaryClass: 1,
|
||||
level: 60,
|
||||
keyItemId: 546833,
|
||||
passiveSourceSkillId: 499955,
|
||||
childSourceSkillId: 499956,
|
||||
};
|
||||
const RACE_MATRIX = {
|
||||
"rom-human": [1, 2, 3, 4, 5, 6],
|
||||
"rom-elf": [1, 2, 3, 4, 7, 8],
|
||||
@@ -554,6 +562,41 @@ for (const key of [...eligiblePairs].sort()) {
|
||||
icon: `/assets/ui/rom-spells/${spell.source.imageId}.png`,
|
||||
iconResolution: "native-interface-fdb",
|
||||
});
|
||||
|
||||
// RuneWaker unlocks Cross of Thorns Attacks as an additional class-8
|
||||
// skill behind Briar Technique. It shares the passive's key item, so the
|
||||
// usual one-candidate-per-elite-milestone lookup above would omit it.
|
||||
if (mapping.mainClass === DRUID_WARRIOR_BRIAR_CHILD.mainClass
|
||||
&& mapping.secondaryClass === DRUID_WARRIOR_BRIAR_CHILD.secondaryClass
|
||||
&& milestone.level === DRUID_WARRIOR_BRIAR_CHILD.level
|
||||
&& milestone.keyItemId === DRUID_WARRIOR_BRIAR_CHILD.keyItemId
|
||||
&& candidate.sourceSkillId === DRUID_WARRIOR_BRIAR_CHILD.passiveSourceSkillId) {
|
||||
const child = candidates.find((entry) => entry.classIndex === DRUID_WARRIOR_BRIAR_CHILD.mainClass
|
||||
&& entry.sourceSkillId === DRUID_WARRIOR_BRIAR_CHILD.childSourceSkillId
|
||||
&& entry.learnLevel === milestone.level
|
||||
&& entry.keyItemId === milestone.keyItemId
|
||||
&& entry.prerequisites.includes(DRUID_WARRIOR_BRIAR_CHILD.passiveSourceSkillId));
|
||||
if (!child) throw new Error("Druid/Warrior Briar Technique child 499956 lost its source unlock row.");
|
||||
const childSpell = sourceSpell(child.sourceSkillId, mapping.mainClass);
|
||||
if (!childSpell) throw new Error("Druid/Warrior Briar Technique child 499956 has no MagicCollect row.");
|
||||
skills.push({
|
||||
id: `${CLASS_IDS[mapping.mainClass - 1]}-${CLASS_IDS[mapping.secondaryClass - 1]}-elite-${milestone.level}-${child.sourceSkillId}`,
|
||||
classId: CLASS_IDS[mapping.mainClass - 1],
|
||||
secondaryClassId: CLASS_IDS[mapping.secondaryClass - 1],
|
||||
group: childSpell.passive ? "passive" : "elite",
|
||||
unlockLevel: child.learnLevel,
|
||||
keyItemId: child.keyItemId,
|
||||
...childSpell,
|
||||
ranks: [{
|
||||
rank: 1,
|
||||
level: child.learnLevel,
|
||||
prerequisites: child.prerequisites,
|
||||
...nestedRankSpell(childSpell, childSpell.sourceSkillId),
|
||||
}],
|
||||
icon: `/assets/ui/rom-spells/${childSpell.source.imageId}.png`,
|
||||
iconResolution: "native-interface-fdb",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,6 +604,8 @@ const eliteSkills = skills.filter((skill) => skill.secondaryClassId);
|
||||
const ordinarySkills = skills.filter((skill) => !skill.secondaryClassId);
|
||||
const unresolvedVisible = skills.filter((skill) => !skill.passive && !skill.effect?.kind);
|
||||
const iconCoverage = await extractNativeIcons(skills);
|
||||
const eliteMilestoneCount = [...eliteMappings.values()].reduce((sum, entry) => sum + entry.milestones.length, 0);
|
||||
const supplementalEliteCount = eliteSkills.length - eliteMilestoneCount;
|
||||
const sourceHashes = {
|
||||
...Object.fromEntries(Object.entries(buffers).map(([key, value]) => [key, sha256(value)])),
|
||||
interfaceFdb: await sha256File(interfaceFdb),
|
||||
@@ -583,7 +628,7 @@ const audit = {
|
||||
duelistExcluded: !CLASS_NAMES.includes("Duelist"),
|
||||
races: Object.keys(RACE_MATRIX).length,
|
||||
orderedPairs: eligiblePairs.size,
|
||||
eliteMilestones: eliteSkills.length,
|
||||
eliteMilestones: eliteMilestoneCount,
|
||||
ordinaryFamilies: ordinarySkills.length,
|
||||
sourceRankRows: ordinarySkills.reduce((sum, skill) => sum + skill.ranks.length, 0),
|
||||
iconResolved: skills.filter((skill) => skill.iconResolution === "native-interface-fdb").length,
|
||||
@@ -594,7 +639,12 @@ const audit = {
|
||||
},
|
||||
adaptations,
|
||||
};
|
||||
if (eliteSkills.length !== 660 || unresolvedVisible.length !== 0 || audit.coverage.iconResolved !== skills.length || iconCoverage.requested !== iconCoverage.resolved) throw new Error(`Coverage failed: ${JSON.stringify(audit.coverage)}`);
|
||||
// In addition to the 660 ordinary milestones, this source emits the Druid/Warrior
|
||||
// Cross of Thorns Attacks row behind its explicit learned prerequisite.
|
||||
if (eliteMilestoneCount !== 660 || supplementalEliteCount !== 1 || unresolvedVisible.length !== 0
|
||||
|| audit.coverage.iconResolved !== skills.length || iconCoverage.requested !== iconCoverage.resolved) {
|
||||
throw new Error(`Coverage failed: ${JSON.stringify(audit.coverage)}`);
|
||||
}
|
||||
await mkdir(path.dirname(outputFile), { recursive: true });
|
||||
await mkdir(path.dirname(auditFile), { recursive: true });
|
||||
await Promise.all([
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
"eliteMilestones": 660,
|
||||
"ordinaryFamilies": 269,
|
||||
"sourceRankRows": 274,
|
||||
"iconResolved": 929,
|
||||
"iconResolved": 930,
|
||||
"uniqueNativeIcons": 918,
|
||||
"interfaceFdbEntries": 34954,
|
||||
"executableOrNonCombat": 929,
|
||||
"executableOrNonCombat": 930,
|
||||
"unresolvedVisible": 0
|
||||
},
|
||||
"adaptations": [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||
import { CombatBridge } from "./game/CombatBridge";
|
||||
import { connectDpsMeter } from "./game/dpsMeterStore";
|
||||
import { connectFloatingCombatText } from "./game/floatingCombatText";
|
||||
import { useCombatStore } from "./game/combatStore";
|
||||
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
||||
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
||||
@@ -116,6 +117,7 @@ function InputBridge() {
|
||||
|
||||
export default function GameRuntime() {
|
||||
useEffect(connectDpsMeter, []);
|
||||
useEffect(connectFloatingCombatText, []);
|
||||
useEffect(() => installCombatClock(dungeonSimulationNow), []);
|
||||
useEffect(() => { void useDungeonRouteStore.getState().refresh(); }, []);
|
||||
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
dungeonEncounterSnapshot,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { applySharedWorld } from "./OnlineSessionBridge";
|
||||
import { applySharedWorld, eventForRelay } from "./OnlineSessionBridge";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import type { StructuredCombatEvent } from "./combatEvents";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
@@ -75,3 +78,45 @@ describe("online shared-world hydration", () => {
|
||||
expect(useCombatStore.getState().inventory).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("online damage presentation relay", () => {
|
||||
it("relays authority companion hits so followers can display them", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
||||
useCombatStore.getState().registerMob("relay-mob", { name: "Relay mob", maxHealth: 100 });
|
||||
const oldMembers = usePartyStore.getState().members;
|
||||
usePartyStore.setState({ members: [{ id: "companion" } as (typeof oldMembers)[number]] });
|
||||
try {
|
||||
const event = {
|
||||
id: 1, occurredAt: 1_000, kind: "damage", sourceActorId: "companion", targetActorId: "relay-mob",
|
||||
rawAmount: 55, effectiveAmount: 40, overheal: 0, absorbed: 0, critical: true,
|
||||
} satisfies StructuredCombatEvent;
|
||||
expect(eventForRelay(event, "leader", true)).toMatchObject({
|
||||
kind: "damage", sourceActorId: "companion", targetActorId: "relay-mob", effectiveAmount: 40, critical: true,
|
||||
});
|
||||
expect(eventForRelay(event, "leader", false)).toBeNull();
|
||||
} finally {
|
||||
usePartyStore.setState({ members: oldMembers });
|
||||
}
|
||||
});
|
||||
|
||||
it("relays authority AI hits on the leader and companions with their crit flag", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
||||
const oldMembers = usePartyStore.getState().members;
|
||||
usePartyStore.setState({ members: [{ id: "companion" } as (typeof oldMembers)[number]] });
|
||||
try {
|
||||
const hit = {
|
||||
id: 2, occurredAt: 2_000, kind: "damage", sourceActorId: "mob", targetActorId: PLAYER_AGGRO_ID,
|
||||
rawAmount: 80, effectiveAmount: 50, overheal: 0, absorbed: 30, critical: true,
|
||||
} satisfies StructuredCombatEvent;
|
||||
expect(eventForRelay(hit, "leader", true)).toMatchObject({
|
||||
sourceActorId: "mob", targetActorId: "online-player:leader", effectiveAmount: 50, critical: true,
|
||||
});
|
||||
expect(eventForRelay({ ...hit, targetActorId: "companion" }, "leader", true)).toMatchObject({
|
||||
sourceActorId: "mob", targetActorId: "companion", effectiveAmount: 50, critical: true,
|
||||
});
|
||||
expect(eventForRelay(hit, "leader", false)).toBeNull();
|
||||
} finally {
|
||||
usePartyStore.setState({ members: oldMembers });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { ingestRelayDamageText, withSuppressedReplayDamageText } from "./floatingCombatText";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { advanceManastormSession } from "./manastormSession";
|
||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
@@ -112,7 +113,7 @@ export function applySharedWorld(world: OnlineSharedWorld): void {
|
||||
}
|
||||
}
|
||||
|
||||
function eventForRelay(
|
||||
export function eventForRelay(
|
||||
event: ReturnType<typeof useCombatStore.getState>["combatEvents"][number],
|
||||
localAccountId: string,
|
||||
authority: boolean,
|
||||
@@ -124,7 +125,17 @@ function eventForRelay(
|
||||
));
|
||||
const sourceIsLocal = event.sourceActorId === PLAYER_AGGRO_ID || playerOwnedSummon;
|
||||
const targetsRemotePlayer = isOnlinePlayerActorId(event.targetActorId);
|
||||
if (!sourceIsLocal && !(authority && targetsRemotePlayer)) return null;
|
||||
const partyMembers = usePartyStore.getState().members;
|
||||
const sourceIsCompanion = partyMembers.some((member) => member.id === event.sourceActorId)
|
||||
|| combat.summons.some((summon) => partyMembers.some((member) => member.id === summon.ownerActorId)
|
||||
&& summon.id === event.sourceActorId);
|
||||
const companionDamageToMob = authority && event.kind === "damage"
|
||||
&& Boolean(combat.mobs[event.targetActorId]) && sourceIsCompanion;
|
||||
const authorityDamageToFriendly = authority && event.kind === "damage" && !sourceIsLocal
|
||||
&& (event.targetActorId === PLAYER_AGGRO_ID
|
||||
|| partyMembers.some((member) => member.id === event.targetActorId));
|
||||
if (!sourceIsLocal && !(authority && targetsRemotePlayer)
|
||||
&& !companionDamageToMob && !authorityDamageToFriendly) return null;
|
||||
const localActorId = onlinePlayerActorId(localAccountId);
|
||||
const targetActorId = event.targetActorId === PLAYER_AGGRO_ID
|
||||
? localActorId
|
||||
@@ -342,7 +353,10 @@ export function OnlineSessionBridge() {
|
||||
updatePartyRuntimePosition(actorId, player.position[0], player.position[1], player.position[2]);
|
||||
}
|
||||
if (!snapshot.authority && snapshot.world) applySharedWorld(snapshot.world);
|
||||
for (const event of snapshot.events) applyRelayEvent(event, snapshot, applyingRef);
|
||||
for (const event of snapshot.events) {
|
||||
withSuppressedReplayDamageText(() => applyRelayEvent(event, snapshot, applyingRef));
|
||||
ingestRelayDamageText(event, snapshot);
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
accept(snapshot);
|
||||
} catch (error) {
|
||||
|
||||
@@ -140,13 +140,19 @@ export interface AbilityAmountScaling {
|
||||
readonly bonusPowerCoefficient?: number;
|
||||
readonly attackPowerCoefficient?: number;
|
||||
readonly rangedAttackPowerCoefficient?: number;
|
||||
/** Add a fraction of the source character's Intelligence to the resolved amount. */
|
||||
readonly intellectCoefficient?: number;
|
||||
readonly spiritCoefficient?: number;
|
||||
/** Add a fraction of the source character's Stamina to the resolved amount. */
|
||||
readonly staminaCoefficient?: number;
|
||||
/** Add main-hand weapon DPS multiplied by this value to the resolved amount. */
|
||||
readonly weaponDpsCoefficient?: number;
|
||||
readonly sourceLevel?: number;
|
||||
readonly maxScalingLevel?: number;
|
||||
}
|
||||
|
||||
export interface AbilityHealingRecipients {
|
||||
readonly recipient?: "caster" | "ability-target";
|
||||
readonly recipient?: "caster" | "ability-target" | "pet";
|
||||
readonly scope?: "party";
|
||||
readonly center?: "caster" | "ability-target";
|
||||
readonly radius?: number;
|
||||
@@ -170,7 +176,7 @@ 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; readonly percentOfDamage?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number; readonly percentOfDamage?: number; readonly requiresLandedHit?: boolean } & 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; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
@@ -185,7 +191,14 @@ export type AbilityEffect =
|
||||
/** 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: "resource";
|
||||
readonly amount: number;
|
||||
readonly resource?: ResourceType;
|
||||
readonly percentage?: boolean;
|
||||
/** Party-scoped resource effects use the ability's radius and targeting shape. */
|
||||
readonly scope?: "target" | "party";
|
||||
}
|
||||
| { readonly kind: "combo"; readonly amount: number }
|
||||
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
|
||||
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number; readonly breakOnDamage?: boolean }
|
||||
@@ -196,6 +209,8 @@ export type AbilityEffect =
|
||||
readonly recipient?: "ability-target" | "caster" | "pet";
|
||||
/** Party/raid area auras are applied to the player and every active party member. */
|
||||
readonly scope?: "target" | "party";
|
||||
/** Optional range for a party aura sourced by a nearby-target proc. */
|
||||
readonly radius?: number;
|
||||
/** Some party effects benefit allies but exclude the caster. */
|
||||
readonly excludeCaster?: boolean;
|
||||
readonly control?: AbilityAuraControl;
|
||||
@@ -203,6 +218,10 @@ export type AbilityEffect =
|
||||
readonly sourceAuraType: number;
|
||||
readonly miscValue: number;
|
||||
readonly triggerSpellId: number;
|
||||
/** Resolve this rider only if the cast landed at least one direct hit. */
|
||||
readonly requiresLandedHit?: boolean;
|
||||
/** Resolve this rider only if the cast landed at least one direct critical hit. */
|
||||
readonly requiresCriticalHit?: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "dispel";
|
||||
@@ -227,6 +246,8 @@ export type AbilityEffect =
|
||||
readonly kind: "movement";
|
||||
readonly movement: "teleport" | "leap" | "charge" | "knockback" | "sanctuary";
|
||||
readonly toward: "target" | "destination" | "away";
|
||||
/** Source row movement distance in yards when the spell supplies one. */
|
||||
readonly distance?: number;
|
||||
}
|
||||
| { 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 }
|
||||
@@ -340,6 +361,15 @@ export interface AbilityDefinition {
|
||||
readonly talentNodeId?: string;
|
||||
/** Legacy CoA source identifier; `abilityTalentNodeId` normalizes it. */
|
||||
readonly talentEntryId?: number;
|
||||
/** A pair-only upgrade applied after its source passive unlock level. */
|
||||
readonly buildUpgrade?: AbilityBuildUpgrade;
|
||||
}
|
||||
|
||||
export interface AbilityBuildUpgrade {
|
||||
readonly unlockLevel: number;
|
||||
readonly values: Partial<Pick<AbilityDefinition,
|
||||
"description" | "target" | "castMode" | "castTimeMs" | "cooldownMs" | "cost" | "costs" | "effects"
|
||||
>>;
|
||||
}
|
||||
|
||||
export interface ClassResourceProfile {
|
||||
@@ -1063,7 +1093,37 @@ export function abilitiesForCharacter(
|
||||
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
|
||||
));
|
||||
const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
|
||||
return [...primary, ...secondaryGeneral, ...elites];
|
||||
const characterAbilities = [...primary, ...secondaryGeneral, ...elites];
|
||||
if (classId !== "rom-warlock" || secondaryClassId !== "rom-priest") return characterAbilities;
|
||||
return characterAbilities.map((ability) => {
|
||||
if (ability.classId === "rom-priest" && ability.dbcSpellId === 490256) {
|
||||
return {
|
||||
...ability,
|
||||
buildUpgrade: {
|
||||
unlockLevel: 30,
|
||||
values: {
|
||||
castMode: "instant",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 5_000,
|
||||
description: "Instant cast with a 5-second cooldown. Restores 1 Psi. A critical hit activates Healing Fortune. The simulator cannot redirect an ally-targeted cast to that ally's hostile target.",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ability.classId === "rom-warlock" && ability.dbcSpellId === 497958) {
|
||||
return {
|
||||
...ability,
|
||||
buildUpgrade: {
|
||||
unlockLevel: 15,
|
||||
values: {
|
||||
target: "any",
|
||||
description: "Can target a hostile enemy to deal damage or a friendly ally to heal them when Healing Fortune is learned. Ally-target redirection is not modeled.",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return ability;
|
||||
});
|
||||
}
|
||||
|
||||
/** Return the RuneWaker Nature Power cap granted by the learned Brilliance passive. */
|
||||
@@ -1140,6 +1200,12 @@ export function learnedAbilityForBuild(
|
||||
return abilityAtLevel(ability, playerLevel, ability.talentEntryId === undefined ? undefined : learnedRank);
|
||||
}
|
||||
|
||||
function applyBuildUpgradeAtLevel(ability: AbilityDefinition, playerLevel: number): AbilityDefinition {
|
||||
return ability.buildUpgrade && playerLevel >= ability.buildUpgrade.unlockLevel
|
||||
? { ...ability, ...ability.buildUpgrade.values }
|
||||
: ability;
|
||||
}
|
||||
|
||||
export function abilityAtLevel(
|
||||
ability: AbilityDefinition,
|
||||
playerLevel: number,
|
||||
@@ -1148,8 +1214,8 @@ export function abilityAtLevel(
|
||||
const rank = abilityRankForLevel(ability, playerLevel, learnedTalentRank);
|
||||
const effects = rank?.effects ?? ability.effects;
|
||||
const target = effects.length > 0 && effects.every((effect) => effect.kind === "dispel" && effect.relationship === "target" && effect.mode === "cleanse") ? "any" : ability.target;
|
||||
if (!rank) return target === ability.target ? ability : { ...ability, target };
|
||||
return {
|
||||
if (!rank) return applyBuildUpgradeAtLevel(target === ability.target ? ability : { ...ability, target }, playerLevel);
|
||||
return applyBuildUpgradeAtLevel({
|
||||
...ability,
|
||||
target,
|
||||
dbcSpellId: rank.spellId,
|
||||
@@ -1167,7 +1233,7 @@ export function abilityAtLevel(
|
||||
targeting: rank.targeting ?? ability.targeting,
|
||||
castRules: rank.castRules ?? ability.castRules,
|
||||
runeCost: rank.runeCost ?? ability.runeCost,
|
||||
};
|
||||
}, playerLevel);
|
||||
}
|
||||
|
||||
export function isAbilityUnlocked(ability: AbilityDefinition, playerLevel: number): boolean {
|
||||
|
||||
@@ -6,6 +6,13 @@ export type CombatResourceId = string;
|
||||
export type DeathKnightRuneType = "blood" | "frost" | "unholy" | "death";
|
||||
export type CombatActorPosition = readonly [number, number, number];
|
||||
|
||||
export type DeathKnightRuneConversionSource = "blood-tap" | "reaping";
|
||||
|
||||
export interface DeathKnightRuneConversion {
|
||||
readonly source: DeathKnightRuneConversionSource;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface CombatResourcePoolState {
|
||||
readonly id: CombatResourceId;
|
||||
readonly current: number;
|
||||
@@ -20,7 +27,10 @@ export interface DeathKnightRuneState {
|
||||
readonly baseType: Exclude<DeathKnightRuneType, "death">;
|
||||
readonly type: DeathKnightRuneType;
|
||||
readonly readyAt: number;
|
||||
/** Legacy/latest expiry retained for existing Blood Tap consumers. */
|
||||
readonly deathRuneExpiresAt?: number;
|
||||
/** Independent aura ownership prevents one temporary conversion expiring another. */
|
||||
readonly deathRuneConversions?: readonly DeathKnightRuneConversion[];
|
||||
}
|
||||
|
||||
export interface CombatActorCastState {
|
||||
|
||||
@@ -57,6 +57,24 @@ describe("channel amount expansion", () => {
|
||||
expect(channelPulseAt(accelerated, 0, 1_000, 1)).toBe(500);
|
||||
});
|
||||
|
||||
it("honors an explicit channel cadence through the cast end", () => {
|
||||
const mindFlay = abilitiesForClass("priest").find((candidate) => candidate.name === "Mind Flay")!;
|
||||
const necroticWoundChannel = {
|
||||
...mindFlay,
|
||||
castMode: "channel" as const,
|
||||
castTimeMs: 1_500,
|
||||
channelPulses: 3,
|
||||
channelTickMs: 500,
|
||||
};
|
||||
expect([0, 1, 2].map((pulse) => channelPulseAt(necroticWoundChannel, 1_000, 1_500, pulse)))
|
||||
.toEqual([1_500, 2_000, 2_500]);
|
||||
|
||||
const penance = abilitiesForClass("priest").find((candidate) => candidate.name === "Penance")!;
|
||||
const explicitPenance = { ...penance, channelTickMs: 1_000 };
|
||||
expect([0, 1, 2].map((pulse) => channelPulseAt(explicitPenance, 1_000, explicitPenance.castTimeMs, pulse)))
|
||||
.toEqual([1_000, 2_000, 3_000]);
|
||||
});
|
||||
|
||||
it("gives damaging and healing classic channels a pulse payload", () => {
|
||||
const expected = [
|
||||
["priest", "Penance"], ["priest", "Mind Flay"], ["priest", "Mind Sear"],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
||||
import { isRomDruidRoguePair } from "./romDruidRogueCurrentCoa";
|
||||
|
||||
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
|
||||
|
||||
@@ -12,6 +13,10 @@ export function channelPulseCount(ability: AbilityDefinition): number {
|
||||
}
|
||||
|
||||
export function channelPulseAt(ability: AbilityDefinition, startedAt: number, durationMs: number, pulseIndex: number): number {
|
||||
if (ability.channelTickMs && ability.channelTickMs > 0) {
|
||||
const intervals = Math.max(1, channelPulseCount(ability) - (ability.channelStartsWithPulse ? 1 : 0));
|
||||
return startedAt + (pulseIndex + (ability.channelStartsWithPulse ? 0 : 1)) * durationMs / intervals;
|
||||
}
|
||||
const intervals = Math.max(1, channelPulseCount(ability) - (ability.channelStartsWithPulse ? 1 : 0));
|
||||
return startedAt + (pulseIndex + (ability.channelStartsWithPulse ? 0 : 1)) * durationMs / intervals;
|
||||
}
|
||||
@@ -23,12 +28,17 @@ export function channelAmountEffects(ability: AbilityDefinition, totalTicks: num
|
||||
}[] {
|
||||
const ticks = Math.max(1, totalTicks);
|
||||
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
|
||||
const hasPerHitNecroticWoundDamage = ability.source === "runewaker"
|
||||
&& ability.dbcSpellId === 494321
|
||||
&& isRomDruidRoguePair(ability);
|
||||
for (const effect of ability.effects) {
|
||||
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
|
||||
amounts.push({ effect, multiplier: ability.name === "Penance"
|
||||
? 1
|
||||
: ability.dbcSpellId === 491164 && effect.kind === "damage"
|
||||
? 1
|
||||
: hasPerHitNecroticWoundDamage && effect.kind === "damage"
|
||||
? 1
|
||||
: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
|
||||
} else if (effect.kind === "apply-aura") {
|
||||
for (const proc of effect.aura.procs ?? []) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DamageSchool } from "./combatAuras";
|
||||
|
||||
/**
|
||||
* A damage packet can carry more than one school tag. This is distinct from a
|
||||
* single DamageSchool: for example, CoA's "Physical Dark" must remain both
|
||||
* physical and Dark (represented by the project's shadow school mapping).
|
||||
*/
|
||||
export interface CombatDamageProfile {
|
||||
readonly schools: readonly DamageSchool[];
|
||||
}
|
||||
|
||||
/** CoA Dark is represented by the simulator's existing shadow school. */
|
||||
export const ROM_PHYSICAL_DARK_DAMAGE_PROFILE = Object.freeze({
|
||||
schools: Object.freeze(["physical", "shadow"] as const),
|
||||
}) satisfies CombatDamageProfile;
|
||||
|
||||
/** A school-specific modifier/absorb matches a composite packet by either tag. */
|
||||
export function combatDamageProfileMatchesSchool(
|
||||
profile: CombatDamageProfile,
|
||||
school: DamageSchool | undefined,
|
||||
): boolean {
|
||||
return school === undefined || profile.schools.includes(school);
|
||||
}
|
||||
@@ -325,6 +325,12 @@ export function presentationSourceForAbility(ability: AbilityDefinition): Combat
|
||||
}
|
||||
|
||||
export function presentationSchoolForAbility(ability: AbilityDefinition): CombatPresentationSchool {
|
||||
if (ability.source === "runewaker" && ability.classId === "rom-priest" && ability.dbcSpellId === 490276) {
|
||||
// Holy Void contains "Void" in its current CoA name, but its catalog hit
|
||||
// is Light damage. Resolve the explicit Priest-primary identity before
|
||||
// the generic keyword check classifies it as Shadow.
|
||||
return "holy";
|
||||
}
|
||||
const name = `${ability.name} ${ability.description}`.toLowerCase();
|
||||
if (/fire|flame|burn|inferno|pyro|molten/.test(name)) return "fire";
|
||||
if (/frost|ice|rime|chill|snow/.test(name)) return "frost";
|
||||
|
||||
@@ -55,7 +55,39 @@ export function normalizeDeathKnightRunes(
|
||||
now: number,
|
||||
): readonly DeathKnightRuneState[] {
|
||||
return Object.freeze(runes.map((rune) => {
|
||||
if (rune.deathRuneExpiresAt === undefined || rune.deathRuneExpiresAt > now) return rune;
|
||||
if (rune.deathRuneConversions !== undefined) {
|
||||
const activeConversions = rune.deathRuneConversions.filter((conversion) => (
|
||||
Number.isFinite(conversion.expiresAt) && conversion.expiresAt > now
|
||||
));
|
||||
if (activeConversions.length > 0) {
|
||||
const latestExpiry = Math.max(...activeConversions.map((conversion) => conversion.expiresAt));
|
||||
if (rune.type === "death"
|
||||
&& rune.deathRuneExpiresAt === latestExpiry
|
||||
&& activeConversions.length === rune.deathRuneConversions.length) return rune;
|
||||
return Object.freeze({
|
||||
...rune,
|
||||
type: "death",
|
||||
deathRuneExpiresAt: latestExpiry,
|
||||
deathRuneConversions: Object.freeze(activeConversions),
|
||||
});
|
||||
}
|
||||
const { deathRuneExpiresAt: _legacyExpiry, deathRuneConversions: _conversions, ...baseRune } = rune;
|
||||
void _legacyExpiry;
|
||||
void _conversions;
|
||||
return Object.freeze({ ...baseRune, type: rune.baseType });
|
||||
}
|
||||
if (rune.deathRuneExpiresAt === undefined) return rune;
|
||||
if (rune.deathRuneExpiresAt > now) {
|
||||
// Older snapshots only stored Blood Tap's latest expiration. Treat that
|
||||
// legacy shape as a Blood Tap owner before any newer conversion touches it.
|
||||
return Object.freeze({
|
||||
...rune,
|
||||
deathRuneConversions: Object.freeze([{
|
||||
source: "blood-tap" as const,
|
||||
expiresAt: rune.deathRuneExpiresAt,
|
||||
}]),
|
||||
});
|
||||
}
|
||||
const { deathRuneExpiresAt: _expiredAt, ...baseRune } = rune;
|
||||
void _expiredAt;
|
||||
return Object.freeze({ ...baseRune, type: rune.baseType });
|
||||
@@ -86,6 +118,10 @@ export function activateDeathKnightBloodTapRune(
|
||||
type: "death" as const,
|
||||
readyAt: Math.min(rune.readyAt, now),
|
||||
deathRuneExpiresAt: now + Math.max(1, durationMs),
|
||||
deathRuneConversions: Object.freeze([{
|
||||
source: "blood-tap" as const,
|
||||
expiresAt: now + Math.max(1, durationMs),
|
||||
}]),
|
||||
})
|
||||
: rune));
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface RotationContext {
|
||||
readonly comboPoints: number;
|
||||
readonly comboTargetId: string | null;
|
||||
readonly runes: readonly DeathKnightRuneState[];
|
||||
/** Learned Reaping rank, used only to expose its otherwise-omitted Blood Strike trigger fallback. */
|
||||
readonly deathKnightReapingRank?: number;
|
||||
readonly abilities: readonly AbilityDefinition[];
|
||||
/** Learned actions capable of hostile damage, including unavailable ones. */
|
||||
readonly knownDamagingAbilityNames?: ReadonlySet<string>;
|
||||
@@ -76,6 +78,8 @@ export interface RotationContext {
|
||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||
readonly hasActivePet?: boolean;
|
||||
/** True only when an in-range living ally has a depleted pool restored by this party-scoped effect. */
|
||||
readonly partyResourceDeficit?: (ability: AbilityDefinition) => boolean;
|
||||
/** Proc/state-gated actions that the caller has positively made available. */
|
||||
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
|
||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||
@@ -104,9 +108,9 @@ const profile = (
|
||||
|
||||
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
||||
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:protection", "warrior", ["Defensive Stance", "Shield Block", "Last Stand", "Shield Wall", "Enraged Regeneration", "Taunt", "Shield Bash", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Victory Rush", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Victory Rush", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:protection", "warrior", ["Defensive Stance", "Shield Block", "Last Stand", "Shield Wall", "Enraged Regeneration", "Taunt", "Shield Bash", "Bloodrage", "Shield Slam", "Revenge", "Victory Rush", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
|
||||
profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]),
|
||||
profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]),
|
||||
@@ -143,6 +147,7 @@ const PROFILE_BY_SPECIALIZATION = new Map(
|
||||
|
||||
const CONDITIONAL_ABILITY_NAMES = new Set([
|
||||
"Overpower",
|
||||
"Pestilence",
|
||||
"Revenge",
|
||||
"Victory Rush",
|
||||
]);
|
||||
@@ -163,6 +168,7 @@ const TARGET_HEALTH_GATES = new Map<string, number>([
|
||||
]);
|
||||
|
||||
const MINIMUM_HOSTILE_TARGETS = new Map<string, number>([
|
||||
["Pestilence", 2],
|
||||
["Multi-Shot", 2],
|
||||
["Volley", 3],
|
||||
["Flamestrike", 3],
|
||||
@@ -401,6 +407,15 @@ function scoreAbility(
|
||||
if ((context.hostileCount ?? 1) < minimumHostileTargetsForAbility(ability.name)) return null;
|
||||
if (CONDITIONAL_ABILITY_NAMES.has(ability.name)
|
||||
&& !context.availableConditionalAbilityNames?.has(ability.name)) return null;
|
||||
if (ability.name === "Victory Rush" && context.availableConditionalAbilityNames?.has("Victory Rush")) {
|
||||
return {
|
||||
ability,
|
||||
score: 1_016_500,
|
||||
ruleId: "reactive-improved-victory-rush",
|
||||
reason: "a parry opened the Victory Rush window",
|
||||
targetId: context.hostileTarget?.id ?? null,
|
||||
};
|
||||
}
|
||||
if (context.classId === "druid") {
|
||||
const specializationId = rotationProfile?.specializationId;
|
||||
if (specializationId === "druid:feral-bear" && CAT_FORM_ACTION_NAMES.has(ability.name)) return null;
|
||||
@@ -641,7 +656,18 @@ function scoreAbility(
|
||||
reason = "missing pet or summon";
|
||||
}
|
||||
}
|
||||
if (categories.has("resource")) score += resourceFraction < 0.3 ? 2_600 : -1_000;
|
||||
if (categories.has("resource")) {
|
||||
const partyResourceDeficit = ability.effects.some((effect) => (
|
||||
effect.kind === "resource" && effect.scope === "party"
|
||||
)) && (context.partyResourceDeficit?.(ability) ?? false);
|
||||
if (partyResourceDeficit) {
|
||||
score += 2_600;
|
||||
ruleId = "party-resource-restoration";
|
||||
reason = "an in-range ally has a depleted resource pool";
|
||||
} else {
|
||||
score += resourceFraction < 0.3 ? 2_600 : -1_000;
|
||||
}
|
||||
}
|
||||
if (damaging) score += 1_000 + throughput(ability, context.comboPoints) * 10;
|
||||
if (isComboFinisher(ability)) score += 2_400;
|
||||
if (isComboBuilder(ability)) score += context.comboPoints < 4 ? 1_400 : 0;
|
||||
@@ -655,11 +681,32 @@ function scoreAbility(
|
||||
}
|
||||
|
||||
export function evaluateCombatRotation(context: RotationContext): RotationDecision | null {
|
||||
const rotationProfile = rotationProfileFor(
|
||||
const baseRotationProfile = rotationProfileFor(
|
||||
context.classId,
|
||||
context.specializationId,
|
||||
context.specialization,
|
||||
);
|
||||
const hasSpendableBaseBloodRune = context.runes.some((rune) => (
|
||||
rune.baseType === "blood" && rune.readyAt <= context.now
|
||||
));
|
||||
const reapingRank = Math.max(0, Math.trunc(context.deathKnightReapingRank ?? 0));
|
||||
const reapingTriggerFallbacks: string[] = [];
|
||||
if (baseRotationProfile?.specializationId === "death-knight:blood"
|
||||
&& reapingRank > 0
|
||||
&& hasSpendableBaseBloodRune) {
|
||||
reapingTriggerFallbacks.push("Blood Strike");
|
||||
if (context.availableConditionalAbilityNames?.has("Pestilence")) {
|
||||
reapingTriggerFallbacks.push("Pestilence");
|
||||
}
|
||||
}
|
||||
const rotationProfile = baseRotationProfile && reapingTriggerFallbacks.length > 0
|
||||
? {
|
||||
...baseRotationProfile,
|
||||
// Keep the normal Blood priority intact. Reaping triggers are final
|
||||
// fallbacks, with Blood Strike preferred over Pestilence.
|
||||
abilityPriority: Object.freeze([...baseRotationProfile.abilityPriority, ...reapingTriggerFallbacks]),
|
||||
}
|
||||
: baseRotationProfile;
|
||||
const lowest = [...(context.friendlyTargets ?? [])]
|
||||
.filter((target) => target.health > 0 && !target.fallen)
|
||||
.sort((left, right) => (
|
||||
@@ -668,7 +715,10 @@ export function evaluateCombatRotation(context: RotationContext): RotationDecisi
|
||||
))[0] ?? null;
|
||||
const decision = context.abilities
|
||||
.filter((ability) => !ability.passive)
|
||||
.filter(abilityHasRotationSemantics)
|
||||
.filter((ability) => abilityHasRotationSemantics(ability)
|
||||
|| (ability.name === "Pestilence"
|
||||
&& reapingRank > 0
|
||||
&& context.availableConditionalAbilityNames?.has("Pestilence") === true))
|
||||
.filter((ability) => !context.moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
|
||||
.filter((ability) => {
|
||||
if (context.classId !== "death-knight") return true;
|
||||
|
||||
@@ -19,12 +19,13 @@ import {
|
||||
useCombatStore,
|
||||
} from "./combatStore";
|
||||
import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { armorReductionPercent, EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { defaultActionBindings } from "./actionBindings";
|
||||
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
import { applyAura as applyCombatAura } from "./combatAuras";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { wow335HunterPetArmorForLevel } from "./wow335HunterPetInheritedDefenseRuntime";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
@@ -89,6 +90,43 @@ function setPartyMemberHealth(id: string, health: number): void {
|
||||
}));
|
||||
}
|
||||
|
||||
function registerHunterPetDefenseProbe(id: string, school: "physical" | "fire"): string {
|
||||
const attackId = `${id}-attack`;
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: id,
|
||||
level: 80,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [{
|
||||
...DEFAULT_ENEMY_ATTACK,
|
||||
id: attackId,
|
||||
name: "Defense probe",
|
||||
school,
|
||||
range: 5,
|
||||
}],
|
||||
});
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: { ...state.mobs, [id]: { ...state.mobs[id]!, attackDamage: 1_000 } },
|
||||
}));
|
||||
return attackId;
|
||||
}
|
||||
|
||||
function incomingDamageEventForSummon(mobId: string, attackId: string, summonId: string) {
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_000, {
|
||||
actorId: summonId,
|
||||
role: "damage",
|
||||
abilityId: "hunter-pet-defense-probe",
|
||||
});
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
return [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === mobId
|
||||
&& event.targetActorId === summonId
|
||||
&& event.abilityId === attackId
|
||||
));
|
||||
}
|
||||
|
||||
function preparePartyMember(
|
||||
index: number,
|
||||
position: PartyWorldPosition,
|
||||
@@ -949,6 +987,56 @@ describe("combat store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors a player Death Knight's landed spell during Dancing Rune Weapon's 12-second window", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "death-knight",
|
||||
level: 80,
|
||||
xp: 0,
|
||||
});
|
||||
useCombatStore.setState({
|
||||
talentRanks: { "talent-1961": 1 },
|
||||
resource: 100,
|
||||
resourcePools: { "runic-power": 100 },
|
||||
});
|
||||
registerTarget("dancing-rune-weapon-player-target", [2, 0, 0], 1_000_000, 0);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(
|
||||
"wow335-death-knight-dancing-rune-weapon", undefined, 1_000,
|
||||
).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
definition: expect.objectContaining({ name: "Dancing Rune Weapon", durationMs: 12_000 }),
|
||||
appliedAt: 1_000,
|
||||
expiresAt: 13_000,
|
||||
}));
|
||||
expect(useCombatStore.getState().summons.some((summon) => (
|
||||
summon.sourceAbilityId === "wow335-death-knight-dancing-rune-weapon"
|
||||
))).toBe(false);
|
||||
|
||||
const deathCoil = useCombatStore.getState().abilities.find((ability) => ability.name === "Death Coil");
|
||||
expect(deathCoil).toBeDefined();
|
||||
expect(useCombatStore.getState().castAbility(deathCoil!.id, undefined, 3_000).ok).toBe(true);
|
||||
const activeHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === "dancing-rune-weapon-player-target"
|
||||
&& event.abilityId === deathCoil!.id
|
||||
&& event.occurredAt === 3_000
|
||||
));
|
||||
expect(activeHits).toHaveLength(2);
|
||||
expect(activeHits[1].rawAmount).toBe(Math.floor(activeHits[0].rawAmount * 0.5));
|
||||
|
||||
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
||||
expect(useCombatStore.getState().castAbility(deathCoil!.id, undefined, 14_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === "dancing-rune-weapon-player-target"
|
||||
&& event.abilityId === deathCoil!.id
|
||||
&& event.occurredAt === 14_000
|
||||
))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("swings at the resolved companion without redirecting player damage", () => {
|
||||
const target = usePartyStore.getState().members.find((member) => member.role === "damage")!;
|
||||
usePartyStore.setState((state) => ({
|
||||
@@ -1242,6 +1330,12 @@ describe("combat store", () => {
|
||||
|
||||
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
|
||||
expect(useCombatStore.getState().settings.showMobAggroRanges).toBe(false);
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
showOwnDamageText: true,
|
||||
showAllyDamageText: false,
|
||||
showIncomingDamageText: false,
|
||||
showFloatingCombatText: true,
|
||||
});
|
||||
useCombatStore.getState().updateSettings({
|
||||
masterVolume: 9,
|
||||
uiScale: 0.1,
|
||||
@@ -1273,6 +1367,18 @@ describe("combat store", () => {
|
||||
settings: { masterVolume: 0.6 },
|
||||
});
|
||||
expect(useCombatStore.getState().settings.masterVolume).toBe(0.6);
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
showOwnDamageText: true,
|
||||
showAllyDamageText: false,
|
||||
showIncomingDamageText: false,
|
||||
});
|
||||
useCombatStore.getState().updateSettings({ showOwnDamageText: false, showAllyDamageText: true, showIncomingDamageText: true });
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
showOwnDamageText: false,
|
||||
showAllyDamageText: true,
|
||||
showIncomingDamageText: true,
|
||||
showFloatingCombatText: true,
|
||||
});
|
||||
useCombatStore.getState().resetSettings();
|
||||
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS);
|
||||
});
|
||||
@@ -2141,6 +2247,204 @@ describe("combat store", () => {
|
||||
&& event.abilityId === "wow335-hunter-kill-shot")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses player Hunter pet commands and owner attack events while Growl adds flat pet threat", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
||||
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
||||
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
||||
expect(pet).toMatchObject({
|
||||
hunterPetCommand: "follow",
|
||||
hunterPetReactMode: "defensive",
|
||||
hunterPetGrowlAutocastEnabled: false,
|
||||
});
|
||||
useCombatStore.getState().registerMob("hunter-pet-first-target", {
|
||||
name: "Hunter pet target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("hunter-pet-first-target", [6, 0, 0]);
|
||||
useCombatStore.getState().registerMob("hunter-pet-owner-action-target", {
|
||||
name: "Owner action target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("hunter-pet-owner-action-target", [8, 0, 0]);
|
||||
useCombatStore.getState().selectMob("hunter-pet-first-target");
|
||||
const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.name === "Arcane Shot")!;
|
||||
const firstShot = useCombatStore.getState().castAbility(arcaneShot.id, [0, 0, 0], 2_600);
|
||||
expect(firstShot.ok, firstShot.reason).toBe(true);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)
|
||||
?.hunterPetPendingOwnerAttackTargetId).toBe("hunter-pet-first-target");
|
||||
useCombatStore.getState().tick(0.1, 2_700);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
||||
.toBe("hunter-pet-first-target");
|
||||
expect(useCombatStore.getState().mobs["hunter-pet-first-target"]?.threatByActor[pet.id] ?? 0).toBe(0);
|
||||
expect(useCombatStore.getState().setHunterPetGrowlAutocast(pet.id, true)).toBe(true);
|
||||
useCombatStore.getState().tick(0.1, 2_800);
|
||||
useCombatStore.getState().tick(0.1, 2_900);
|
||||
const afterGrowl = useCombatStore.getState();
|
||||
expect(afterGrowl.mobs["hunter-pet-first-target"]?.threatByActor[pet.id]).toBeGreaterThanOrEqual(1_234);
|
||||
expect(afterGrowl.mobs["hunter-pet-first-target"]?.forcedTarget).toBeNull();
|
||||
expect(afterGrowl.summons.find((summon) => summon.id === pet.id)?.hunterPetGrowlReadyAt).toBe(7_900);
|
||||
|
||||
useCombatStore.getState().selectMob("hunter-pet-owner-action-target");
|
||||
expect(useCombatStore.getState().castAbility(arcaneShot.id, [0, 0, 0], 10_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)
|
||||
?.hunterPetPendingOwnerAttackTargetId).toBe("hunter-pet-owner-action-target");
|
||||
useCombatStore.getState().tick(0.1, 10_100);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
||||
.toBe("hunter-pet-first-target");
|
||||
|
||||
useCombatStore.getState().selectMob("hunter-pet-first-target");
|
||||
useCombatStore.getState().tick(0.1, 10_200);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
||||
.toBe("hunter-pet-first-target");
|
||||
expect(useCombatStore.getState().commandHunterPet(pet.id, "follow")).toBe(true);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)).toMatchObject({
|
||||
targetId: null,
|
||||
hunterPetCommand: "follow",
|
||||
});
|
||||
expect(useCombatStore.getState().commandHunterPet(pet.id, "stay")).toBe(true);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.hunterPetCommand)
|
||||
.toBe("stay");
|
||||
useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id
|
||||
? { ...summon, position: [10, 0, 0] as const }
|
||||
: summon) }));
|
||||
expect(useCombatStore.getState().commandHunterPet(pet.id, "follow")).toBe(true);
|
||||
useCombatStore.getState().tick(0.1, 10_300);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!.position[0]).toBeLessThan(10);
|
||||
expect(useCombatStore.getState().commandHunterPet(pet.id, "stay")).toBe(true);
|
||||
useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id
|
||||
? { ...summon, position: [0, 0, 0] as const }
|
||||
: summon) }));
|
||||
useCombatStore.getState().tick(0.1, 10_400);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!.position[0]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(["physical", "fire"] as const)(
|
||||
"applies inherited WoW 3.3.5 defenses to a player Hunter pet against %s damage",
|
||||
(school) => {
|
||||
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000, fireResistance: 500 };
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
||||
useCombatStore.setState({ gearStats });
|
||||
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
||||
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
||||
const attackId = registerHunterPetDefenseProbe(`player-pet-defense-${school}`, school);
|
||||
|
||||
const event = incomingDamageEventForSummon(`player-pet-defense-${school}`, attackId, pet.id);
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.rawAmount).toBe(1_000);
|
||||
if (school === "physical") {
|
||||
const ownerStats = deriveCharacterStats("hunter", 80, gearStats, "human");
|
||||
const petArmor = wow335HunterPetArmorForLevel(80, ownerStats.armor);
|
||||
const expected = 1_000 - Math.round(1_000 * armorReductionPercent(petArmor, 80) / 100);
|
||||
expect(event?.effectiveAmount).toBe(expected);
|
||||
expect(event?.effectiveAmount).toBeLessThan(1_000);
|
||||
} else {
|
||||
// 40% of the owner's 500 Fire Resistance yields a 200-resistance
|
||||
// pet profile: the level-80 partial-resist table selects 20% or 30%.
|
||||
expect(event?.effectiveAmount).toBeGreaterThanOrEqual(700);
|
||||
expect(event?.effectiveAmount).toBeLessThanOrEqual(800);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["physical", "fire"] as const)(
|
||||
"applies inherited WoW 3.3.5 defenses to a generated companion Hunter pet against %s damage",
|
||||
(school) => {
|
||||
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000, fireResistance: 500 };
|
||||
usePartyStore.getState().initializeParty(
|
||||
`hunter-pet-defense-companion-${school}`,
|
||||
80,
|
||||
useCombatStore.getState().health,
|
||||
);
|
||||
const memberId = usePartyStore.getState().members[0]!.id;
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === memberId
|
||||
? {
|
||||
...member,
|
||||
classId: "hunter",
|
||||
secondaryClassId: null,
|
||||
raceId: "human",
|
||||
level: 80,
|
||||
gearStats,
|
||||
talentRanks: {},
|
||||
health: member.maxHealth,
|
||||
}
|
||||
: member),
|
||||
}));
|
||||
registerPartyRuntimePosition(memberId, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const callPet = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!;
|
||||
const petId = useCombatStore.getState().summonActor(memberId, callPet, {
|
||||
kind: "summon",
|
||||
creatureId: 1,
|
||||
coefficient: 1,
|
||||
durationMs: 60_000,
|
||||
}, [0, 0, 0], null, 1_000);
|
||||
expect(petId).not.toBeNull();
|
||||
const attackId = registerHunterPetDefenseProbe(`companion-pet-defense-${school}`, school);
|
||||
|
||||
const event = incomingDamageEventForSummon(`companion-pet-defense-${school}`, attackId, petId!);
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.rawAmount).toBe(1_000);
|
||||
if (school === "physical") {
|
||||
const ownerStats = deriveCharacterStats("hunter", 80, gearStats, "human");
|
||||
const petArmor = wow335HunterPetArmorForLevel(80, ownerStats.armor);
|
||||
const expected = 1_000 - Math.round(1_000 * armorReductionPercent(petArmor, 80) / 100);
|
||||
expect(event?.effectiveAmount).toBe(expected);
|
||||
expect(event?.effectiveAmount).toBeLessThan(1_000);
|
||||
} else {
|
||||
expect(event?.effectiveAmount).toBeGreaterThanOrEqual(700);
|
||||
expect(event?.effectiveAmount).toBeLessThanOrEqual(800);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("includes the player's active armor aura in inherited Hunter-pet physical mitigation", () => {
|
||||
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000 };
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
||||
useCombatStore.setState({ gearStats });
|
||||
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
||||
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
||||
const ownerArmorWithoutAura = deriveCharacterStats("hunter", 80, gearStats, "human").armor;
|
||||
const aura = applyCombatAura(useCombatStore.getState().auras, {
|
||||
definition: {
|
||||
id: "test:hunter-owner-armor",
|
||||
name: "Test Armor Bonus",
|
||||
disposition: "buff",
|
||||
durationMs: 30_000,
|
||||
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 1_000 }],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
});
|
||||
useCombatStore.setState({ auras: aura.auras });
|
||||
const attackId = registerHunterPetDefenseProbe("player-pet-owner-armor-aura", "physical");
|
||||
|
||||
const event = incomingDamageEventForSummon("player-pet-owner-armor-aura", attackId, pet.id);
|
||||
const petArmorWithAura = wow335HunterPetArmorForLevel(80, ownerArmorWithoutAura + 1_000);
|
||||
const expectedWithAura = 1_000
|
||||
- Math.round(1_000 * armorReductionPercent(petArmorWithAura, 80) / 100);
|
||||
const petArmorWithoutAura = wow335HunterPetArmorForLevel(80, ownerArmorWithoutAura);
|
||||
const expectedWithoutAura = 1_000
|
||||
- Math.round(1_000 * armorReductionPercent(petArmorWithoutAura, 80) / 100);
|
||||
expect(event?.effectiveAmount).toBe(expectedWithAura);
|
||||
expect(event?.effectiveAmount).toBeLessThan(expectedWithoutAura);
|
||||
});
|
||||
|
||||
it("leaves a non-Hunter summon on its existing incoming-damage path", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 });
|
||||
const mirrorImage = useCombatStore.getState().abilities.find((ability) => ability.name === "Mirror Image")!;
|
||||
expect(useCombatStore.getState().castAbility(mirrorImage.id, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const summon = useCombatStore.getState().summons.find((candidate) => !candidate.hunterPet)!;
|
||||
const attackId = registerHunterPetDefenseProbe("non-hunter-summon-defense", "physical");
|
||||
|
||||
const event = incomingDamageEventForSummon("non-hunter-summon-defense", attackId, summon.id);
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.rawAmount).toBe(1_000);
|
||||
expect(event?.effectiveAmount).toBe(1_000);
|
||||
});
|
||||
|
||||
it("summons three Mirror Images and gives Combustion Fire hit stacks", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
unlockReadySpell("wow335-mage-combustion");
|
||||
|
||||
+4691
-321
File diff suppressed because it is too large.
Load diff
@@ -140,6 +140,18 @@ describe("Death Knight source-backed runtime mechanics", () => {
|
||||
expect(attempts.every((result) => result.healing === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("extends Blood Plague and Frost Fever by Epidemic's generated duration", () => {
|
||||
registerTarget();
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1963": 2 },
|
||||
}));
|
||||
const icyTouch = ability("Icy Touch");
|
||||
expect(useCombatStore.getState().castAbility(icyTouch.id, { targetActorId: "dk-target", now }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "dot" && effect.tag === "frost-fever" && effect.targetId === "dk-target"
|
||||
))).toMatchObject({ remainingTicks: 7, intervalMs: 3_000 });
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OnlineRelayEvent, OnlineSessionSnapshot } from "../app/onlineSessionTypes";
|
||||
import { damageTextClassName } from "../ui/FloatingDamageText";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { appendCombatEvent, type CombatEventInput } from "./combatEvents";
|
||||
import {
|
||||
DAMAGE_TEXT_LIFETIME_MS,
|
||||
MAX_DAMAGE_TEXT_PER_TARGET,
|
||||
MAX_DAMAGE_TEXT_TOTAL,
|
||||
clearDamageText,
|
||||
connectFloatingCombatText,
|
||||
damageTextCategory,
|
||||
formatDamageText,
|
||||
ingestRelayDamageText,
|
||||
useDamageTextStore,
|
||||
withSuppressedReplayDamageText,
|
||||
} from "./floatingCombatText";
|
||||
|
||||
let disconnect: (() => void) | null = null;
|
||||
|
||||
function damage(id: number, overrides: Partial<CombatEventInput> = {}): void {
|
||||
appendCombatEvent([], {
|
||||
occurredAt: id * 100,
|
||||
kind: "damage",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: "target",
|
||||
abilityId: "priest-smite",
|
||||
rawAmount: 500,
|
||||
effectiveAmount: 18,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
...overrides,
|
||||
}, id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
||||
useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 1_000 });
|
||||
disconnect = connectFloatingCombatText();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disconnect?.();
|
||||
disconnect = null;
|
||||
clearDamageText();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("floating damage text", () => {
|
||||
it("classifies own, ally, and incoming hits independently", () => {
|
||||
const mobs = new Set(["target"]);
|
||||
const party = new Set(["companion"]);
|
||||
const online = new Set(["online-player:local", "online-player:friend"]);
|
||||
expect(damageTextCategory(PLAYER_AGGRO_ID, "target", mobs, party, online, "online-player:local")).toBe("own");
|
||||
expect(damageTextCategory("companion", "target", mobs, party, online, "online-player:local")).toBe("ally");
|
||||
expect(damageTextCategory("online-player:friend", "target", mobs, party, online, "online-player:local")).toBe("ally");
|
||||
expect(damageTextCategory("target", PLAYER_AGGRO_ID, mobs, party, online, "online-player:local")).toBe("incoming");
|
||||
expect(damageTextCategory("target", "companion", mobs, party, online, "online-player:local")).toBe("incoming");
|
||||
});
|
||||
|
||||
it("shows effective Smite damage, preserves crit, and expires after 1.5 seconds", () => {
|
||||
damage(1, { effectiveAmount: 17.6, critical: true });
|
||||
expect(useDamageTextStore.getState().byTarget.target).toEqual([
|
||||
expect.objectContaining({ amount: 18, critical: true, category: "own" }),
|
||||
]);
|
||||
expect(formatDamageText(1_234.4)).toBe("1,234");
|
||||
expect(damageTextClassName(useDamageTextStore.getState().byTarget.target[0]!)).toContain("is-critical");
|
||||
vi.advanceTimersByTime(DAMAGE_TEXT_LIFETIME_MS);
|
||||
expect(useDamageTextStore.getState().byTarget.target).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors each switch and removes a category immediately when disabled", () => {
|
||||
damage(1);
|
||||
expect(useDamageTextStore.getState().byTarget.target).toHaveLength(1);
|
||||
useCombatStore.getState().updateSettings({ showOwnDamageText: false, showIncomingDamageText: true });
|
||||
expect(useDamageTextStore.getState().byTarget.target).toBeUndefined();
|
||||
damage(2);
|
||||
expect(useDamageTextStore.getState().byTarget.target).toBeUndefined();
|
||||
damage(3, { sourceActorId: "target", targetActorId: PLAYER_AGGRO_ID });
|
||||
expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toEqual([
|
||||
expect.objectContaining({ category: "incoming", amount: 18 }),
|
||||
]);
|
||||
damage(4, { sourceActorId: "target", targetActorId: PLAYER_AGGRO_ID, effectiveAmount: 0 });
|
||||
expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bounds bursts per target and across the scene", () => {
|
||||
for (let id = 1; id <= 7; id += 1) damage(id);
|
||||
expect(useDamageTextStore.getState().byTarget.target).toHaveLength(MAX_DAMAGE_TEXT_PER_TARGET);
|
||||
const target = useCombatStore.getState().mobs.target;
|
||||
useCombatStore.setState({ mobs: Object.fromEntries(Array.from({ length: 90 }, (_, index) => [`mob-${index}`, { ...target, id: `mob-${index}` }])) });
|
||||
for (let id = 0; id < 90; id += 1) damage(100 + id, { targetActorId: `mob-${id}` });
|
||||
expect(Object.values(useDamageTextStore.getState().byTarget).flat()).toHaveLength(MAX_DAMAGE_TEXT_TOTAL);
|
||||
});
|
||||
|
||||
it("shows periodic and area hits on their own targets, including a killing hit", () => {
|
||||
const first = useCombatStore.getState().mobs.target;
|
||||
useCombatStore.setState({ mobs: {
|
||||
target: { ...first, dead: true, health: 0 },
|
||||
second: { ...first, id: "second", health: 50 },
|
||||
} });
|
||||
damage(1, { targetActorId: "target", abilityId: "priest-shadow-word-pain", effectiveAmount: 9 });
|
||||
damage(2, { targetActorId: "second", abilityId: "priest-holy-nova", effectiveAmount: 14 });
|
||||
expect(useDamageTextStore.getState().byTarget.target?.[0]?.amount).toBe(9);
|
||||
expect(useDamageTextStore.getState().byTarget.second?.[0]?.amount).toBe(14);
|
||||
});
|
||||
|
||||
it("uses one relayed online crit and suppresses damage generated during replay", () => {
|
||||
useCombatStore.getState().updateSettings({ showAllyDamageText: true });
|
||||
const snapshot = {
|
||||
localAccountId: "local",
|
||||
players: [{ accountId: "local" }, { accountId: "friend" }],
|
||||
} as unknown as OnlineSessionSnapshot;
|
||||
const relay = {
|
||||
id: 40,
|
||||
kind: "damage",
|
||||
sourceAccountId: "friend",
|
||||
sourceActorId: "online-player:friend",
|
||||
targetActorId: "target",
|
||||
effectiveAmount: 61,
|
||||
critical: true,
|
||||
} as OnlineRelayEvent;
|
||||
withSuppressedReplayDamageText(() => damage(40, { sourceActorId: "online-player:friend", effectiveAmount: 61 }));
|
||||
ingestRelayDamageText(relay, snapshot);
|
||||
ingestRelayDamageText(relay, snapshot);
|
||||
expect(useDamageTextStore.getState().byTarget.target).toEqual([
|
||||
expect.objectContaining({ amount: 61, critical: true, category: "ally", key: "relay:40" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps relayed damage to the local player's party frame with its original crit", () => {
|
||||
useCombatStore.getState().updateSettings({ showIncomingDamageText: true });
|
||||
const snapshot = {
|
||||
localAccountId: "local",
|
||||
players: [{ accountId: "local" }, { accountId: "friend" }],
|
||||
} as unknown as OnlineSessionSnapshot;
|
||||
ingestRelayDamageText({
|
||||
id: 41,
|
||||
kind: "damage",
|
||||
sourceAccountId: "friend",
|
||||
sourceActorId: "target",
|
||||
targetActorId: "online-player:local",
|
||||
effectiveAmount: 36,
|
||||
critical: true,
|
||||
} as OnlineRelayEvent, snapshot);
|
||||
expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toEqual([
|
||||
expect.objectContaining({ amount: 36, critical: true, category: "incoming" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { create } from "zustand";
|
||||
import type { OnlineRelayEvent, OnlineSessionSnapshot } from "../app/onlineSessionTypes";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { isOnlinePlayerActorId, onlinePlayerActorId } from "./onlineSessionRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
export type DamageTextCategory = "own" | "ally" | "incoming";
|
||||
|
||||
export interface DamageTextPopup {
|
||||
readonly key: string;
|
||||
readonly targetActorId: string;
|
||||
readonly amount: number;
|
||||
readonly critical: boolean;
|
||||
readonly category: DamageTextCategory;
|
||||
readonly slot: number;
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
export const DAMAGE_TEXT_LIFETIME_MS = 1_500;
|
||||
export const MAX_DAMAGE_TEXT_PER_TARGET = 4;
|
||||
export const MAX_DAMAGE_TEXT_TOTAL = 80;
|
||||
export const EMPTY_DAMAGE_TEXT_POPUPS: readonly DamageTextPopup[] = Object.freeze([]);
|
||||
|
||||
interface DamageTextState {
|
||||
readonly byTarget: Readonly<Record<string, readonly DamageTextPopup[]>>;
|
||||
}
|
||||
|
||||
export const useDamageTextStore = create<DamageTextState>(() => ({ byTarget: {} }));
|
||||
|
||||
let sequence = 0;
|
||||
let replayDepth = 0;
|
||||
const expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const seenRelayIds = new Set<number>();
|
||||
|
||||
export function formatDamageText(amount: number): string {
|
||||
return Math.max(1, Math.round(amount)).toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function sourceOwner(sourceId: string): string {
|
||||
const combat = useCombatStore.getState();
|
||||
const visited = new Set<string>();
|
||||
let id = sourceId;
|
||||
while (!visited.has(id)) {
|
||||
visited.add(id);
|
||||
if (id === PLAYER_AGGRO_ID || isOnlinePlayerActorId(id)
|
||||
|| usePartyStore.getState().members.some((member) => member.id === id)) break;
|
||||
const owner = combat.actors[id]?.ownerId
|
||||
?? combat.summons.find((summon) => summon.id === id)?.ownerActorId;
|
||||
if (!owner) break;
|
||||
id = owner;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function damageTextCategory(
|
||||
sourceActorId: string,
|
||||
targetActorId: string,
|
||||
mobs: ReadonlySet<string>,
|
||||
party: ReadonlySet<string>,
|
||||
online: ReadonlySet<string>,
|
||||
localOnlineId: string | null,
|
||||
): DamageTextCategory | null {
|
||||
if (mobs.has(targetActorId)) {
|
||||
if (sourceActorId === PLAYER_AGGRO_ID || sourceActorId === localOnlineId) return "own";
|
||||
if (party.has(sourceActorId) || online.has(sourceActorId)) return "ally";
|
||||
return null;
|
||||
}
|
||||
if (targetActorId === PLAYER_AGGRO_ID || targetActorId === localOnlineId
|
||||
|| party.has(targetActorId) || online.has(targetActorId)) return "incoming";
|
||||
return null;
|
||||
}
|
||||
|
||||
function enabled(category: DamageTextCategory): boolean {
|
||||
const settings = useCombatStore.getState().settings;
|
||||
return category === "own" ? settings.showOwnDamageText
|
||||
: category === "ally" ? settings.showAllyDamageText
|
||||
: settings.showIncomingDamageText;
|
||||
}
|
||||
|
||||
function removePopup(key: string, targetActorId: string): void {
|
||||
expiryTimers.delete(key);
|
||||
useDamageTextStore.setState((state) => {
|
||||
const current = state.byTarget[targetActorId];
|
||||
if (!current?.some((popup) => popup.key === key)) return state;
|
||||
const next = current.filter((popup) => popup.key !== key);
|
||||
const byTarget = { ...state.byTarget };
|
||||
if (next.length) byTarget[targetActorId] = next;
|
||||
else delete byTarget[targetActorId];
|
||||
return { byTarget };
|
||||
});
|
||||
}
|
||||
|
||||
function addPopup(popup: Omit<DamageTextPopup, "slot" | "sequence">): void {
|
||||
sequence += 1;
|
||||
const nextPopup = { ...popup, slot: sequence % MAX_DAMAGE_TEXT_PER_TARGET, sequence };
|
||||
useDamageTextStore.setState((state) => {
|
||||
const byTarget = { ...state.byTarget };
|
||||
const old = byTarget[popup.targetActorId] ?? EMPTY_DAMAGE_TEXT_POPUPS;
|
||||
const target = [...old, nextPopup];
|
||||
for (const dropped of target.slice(0, -MAX_DAMAGE_TEXT_PER_TARGET)) {
|
||||
clearTimeout(expiryTimers.get(dropped.key));
|
||||
expiryTimers.delete(dropped.key);
|
||||
}
|
||||
byTarget[popup.targetActorId] = target.slice(-MAX_DAMAGE_TEXT_PER_TARGET);
|
||||
const all = Object.values(byTarget).flat();
|
||||
if (all.length > MAX_DAMAGE_TEXT_TOTAL) {
|
||||
const oldest = all.reduce((left, right) => left.sequence < right.sequence ? left : right);
|
||||
const remaining = byTarget[oldest.targetActorId].filter((item) => item.key !== oldest.key);
|
||||
if (remaining.length) byTarget[oldest.targetActorId] = remaining;
|
||||
else delete byTarget[oldest.targetActorId];
|
||||
clearTimeout(expiryTimers.get(oldest.key));
|
||||
expiryTimers.delete(oldest.key);
|
||||
}
|
||||
return { byTarget };
|
||||
});
|
||||
expiryTimers.set(nextPopup.key, setTimeout(() => removePopup(nextPopup.key, nextPopup.targetActorId), DAMAGE_TEXT_LIFETIME_MS));
|
||||
}
|
||||
|
||||
export function clearDamageText(): void {
|
||||
for (const timer of expiryTimers.values()) clearTimeout(timer);
|
||||
expiryTimers.clear();
|
||||
seenRelayIds.clear();
|
||||
useDamageTextStore.setState({ byTarget: {} });
|
||||
}
|
||||
|
||||
function ingestDamage(input: {
|
||||
readonly key: string;
|
||||
readonly sourceActorId: string;
|
||||
readonly targetActorId: string;
|
||||
readonly effectiveAmount: number;
|
||||
readonly critical: boolean;
|
||||
}, onlineOverride?: OnlineSessionSnapshot): void {
|
||||
if (!Number.isFinite(input.effectiveAmount) || input.effectiveAmount <= 0) return;
|
||||
const combat = useCombatStore.getState();
|
||||
const onlineSnapshot = onlineOverride ?? useOnlineSessionStore.getState().snapshot;
|
||||
const localOnlineId = onlineSnapshot ? onlinePlayerActorId(onlineSnapshot.localAccountId) : null;
|
||||
const party = new Set(usePartyStore.getState().members.map((member) => member.id));
|
||||
const online = new Set((onlineSnapshot?.players ?? []).map((player) => onlinePlayerActorId(player.accountId)));
|
||||
const mobs = new Set(Object.keys(combat.mobs));
|
||||
const category = damageTextCategory(sourceOwner(input.sourceActorId), input.targetActorId, mobs, party, online, localOnlineId);
|
||||
if (!category || !enabled(category)) return;
|
||||
addPopup({
|
||||
key: input.key,
|
||||
targetActorId: input.targetActorId === localOnlineId ? PLAYER_AGGRO_ID : input.targetActorId,
|
||||
amount: Math.max(1, Math.round(input.effectiveAmount)),
|
||||
critical: input.critical,
|
||||
category,
|
||||
});
|
||||
}
|
||||
|
||||
export function withSuppressedReplayDamageText<T>(run: () => T): T {
|
||||
replayDepth += 1;
|
||||
try { return run(); } finally { replayDepth -= 1; }
|
||||
}
|
||||
|
||||
export function ingestRelayDamageText(event: OnlineRelayEvent, snapshot: OnlineSessionSnapshot): void {
|
||||
if (event.kind !== "damage" || event.sourceAccountId === snapshot.localAccountId || seenRelayIds.has(event.id)) return;
|
||||
seenRelayIds.add(event.id);
|
||||
if (seenRelayIds.size > 1_000) seenRelayIds.delete(seenRelayIds.values().next().value!);
|
||||
ingestDamage({
|
||||
key: `relay:${event.id}`,
|
||||
sourceActorId: event.sourceActorId,
|
||||
targetActorId: event.targetActorId,
|
||||
effectiveAmount: event.effectiveAmount ?? 0,
|
||||
critical: event.critical ?? false,
|
||||
}, snapshot);
|
||||
}
|
||||
|
||||
export function connectFloatingCombatText(): () => void {
|
||||
clearDamageText();
|
||||
const onCombatEvent = (event: StructuredCombatEvent) => {
|
||||
if (replayDepth || event.kind !== "damage") return;
|
||||
ingestDamage({
|
||||
key: `local:${event.id}`,
|
||||
sourceActorId: event.sourceActorId,
|
||||
targetActorId: event.targetActorId,
|
||||
effectiveAmount: event.effectiveAmount,
|
||||
critical: event.critical,
|
||||
});
|
||||
};
|
||||
const unsubscribeEvents = subscribeCombatEvents(onCombatEvent);
|
||||
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
|
||||
if (state.combatEvents.length === 0 && previous.combatEvents.length > 0) clearDamageText();
|
||||
if (state.settings === previous.settings) return;
|
||||
useDamageTextStore.setState((current) => {
|
||||
const byTarget: Record<string, readonly DamageTextPopup[]> = {};
|
||||
for (const [id, popups] of Object.entries(current.byTarget)) {
|
||||
const kept = popups.filter((popup) => enabled(popup.category));
|
||||
for (const dropped of popups.filter((popup) => !enabled(popup.category))) {
|
||||
clearTimeout(expiryTimers.get(dropped.key));
|
||||
expiryTimers.delete(dropped.key);
|
||||
}
|
||||
if (kept.length) byTarget[id] = kept.length === popups.length ? popups : kept;
|
||||
}
|
||||
return { byTarget };
|
||||
});
|
||||
});
|
||||
const unsubscribeGame = useGameStore.subscribe((state, previous) => {
|
||||
if (state.sessionRevision !== previous.sessionRevision) clearDamageText();
|
||||
});
|
||||
return () => {
|
||||
unsubscribeEvents();
|
||||
unsubscribeCombat();
|
||||
unsubscribeGame();
|
||||
clearDamageText();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_GRAPHICS_SETTINGS,
|
||||
ambientFillMultiplier,
|
||||
nextGraphicsChoice,
|
||||
normalizeGraphicsSettings,
|
||||
RENDER_SCALES,
|
||||
} from "./graphicsSettings";
|
||||
|
||||
describe("dungeon graphics settings", () => {
|
||||
it("keeps known choices and rejects invalid saved values", () => {
|
||||
expect(normalizeGraphicsSettings(DEFAULT_GRAPHICS_SETTINGS, {
|
||||
renderScale: 2,
|
||||
textureFiltering: 16,
|
||||
shadowQuality: "high",
|
||||
ambientFill: "low",
|
||||
})).toEqual({ renderScale: 2, textureFiltering: 16, shadowQuality: "high", ambientFill: "low" });
|
||||
expect(normalizeGraphicsSettings(DEFAULT_GRAPHICS_SETTINGS, {
|
||||
renderScale: 5 as 2,
|
||||
textureFiltering: 8 as 16,
|
||||
shadowQuality: "ultra" as "high",
|
||||
})).toEqual(DEFAULT_GRAPHICS_SETTINGS);
|
||||
});
|
||||
|
||||
it("cycles all resolution choices and keeps authored ambient fill unchanged", () => {
|
||||
expect(nextGraphicsChoice(2, RENDER_SCALES)).toBe("adaptive");
|
||||
expect(ambientFillMultiplier("authored")).toBe(1);
|
||||
expect(ambientFillMultiplier("low")).toBeLessThan(1);
|
||||
expect(ambientFillMultiplier("high")).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export type RenderScale = "adaptive" | 1 | 1.5 | 2;
|
||||
export type TextureFiltering = 1 | 4 | 16;
|
||||
export type ShadowQuality = "off" | "standard" | "high";
|
||||
export type AmbientFill = "low" | "authored" | "high";
|
||||
|
||||
export interface GraphicsSettings {
|
||||
readonly renderScale: RenderScale;
|
||||
readonly textureFiltering: TextureFiltering;
|
||||
readonly shadowQuality: ShadowQuality;
|
||||
readonly ambientFill: AmbientFill;
|
||||
}
|
||||
|
||||
export const DEFAULT_GRAPHICS_SETTINGS: GraphicsSettings = Object.freeze({
|
||||
renderScale: "adaptive",
|
||||
textureFiltering: 1,
|
||||
shadowQuality: "standard",
|
||||
ambientFill: "authored",
|
||||
});
|
||||
|
||||
export const RENDER_SCALES: readonly RenderScale[] = ["adaptive", 1, 1.5, 2];
|
||||
export const TEXTURE_FILTERING_LEVELS: readonly TextureFiltering[] = [1, 4, 16];
|
||||
export const SHADOW_QUALITIES: readonly ShadowQuality[] = ["off", "standard", "high"];
|
||||
export const AMBIENT_FILL_LEVELS: readonly AmbientFill[] = ["low", "authored", "high"];
|
||||
|
||||
export function normalizeGraphicsSettings(
|
||||
current: GraphicsSettings,
|
||||
patch: Partial<GraphicsSettings>,
|
||||
): GraphicsSettings {
|
||||
return {
|
||||
renderScale: RENDER_SCALES.includes(patch.renderScale as RenderScale)
|
||||
? patch.renderScale! : current.renderScale,
|
||||
textureFiltering: TEXTURE_FILTERING_LEVELS.includes(patch.textureFiltering as TextureFiltering)
|
||||
? patch.textureFiltering! : current.textureFiltering,
|
||||
shadowQuality: SHADOW_QUALITIES.includes(patch.shadowQuality as ShadowQuality)
|
||||
? patch.shadowQuality! : current.shadowQuality,
|
||||
ambientFill: AMBIENT_FILL_LEVELS.includes(patch.ambientFill as AmbientFill)
|
||||
? patch.ambientFill! : current.ambientFill,
|
||||
};
|
||||
}
|
||||
|
||||
export function ambientFillMultiplier(fill: AmbientFill): number {
|
||||
return fill === "low" ? 0.6 : fill === "high" ? 1.4 : 1;
|
||||
}
|
||||
|
||||
export function nextGraphicsChoice<T>(current: T, choices: readonly T[]): T {
|
||||
return choices[(choices.indexOf(current) + 1) % choices.length];
|
||||
}
|
||||
@@ -1,15 +1,35 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import {
|
||||
registerCombatSpatialProvider,
|
||||
resetCombatSpatialProvider,
|
||||
} from "./combatSpatial";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
describe("Mage Blink runtime", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
resetCombatSpatialProvider();
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: 0 });
|
||||
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) => {
|
||||
it("uses the generated DBC radius as its forward travel distance", () => {
|
||||
const blink = abilitiesForClass("mage").find((ability) => ability.id === "wow335-mage-blink");
|
||||
|
||||
expect(blink?.effects).toContainEqual(expect.objectContaining({
|
||||
kind: "movement",
|
||||
movement: "teleport",
|
||||
distance: 20,
|
||||
}));
|
||||
});
|
||||
|
||||
it.each(["root", "stun"] as const)("moves forward and clears active %s control", (mechanic) => {
|
||||
useCombatStore.setState({ controlledUntil: 10_000, controlMechanic: mechanic });
|
||||
|
||||
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
|
||||
@@ -18,6 +38,9 @@ describe("Mage Blink runtime", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, abilityId: "wow335-mage-blink" });
|
||||
expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 20]);
|
||||
expect(useGameStore.getState().playerPosition).toEqual([0, 0, 20]);
|
||||
expect(useGameStore.getState().activeSpawn.footPosition).not.toEqual([0, 0, 20]);
|
||||
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
|
||||
expect(useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID))
|
||||
.toEqual(expect.arrayContaining([
|
||||
@@ -32,13 +55,14 @@ describe("Mage Blink runtime", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("does not move away from a target when forward facing is unavailable", () => {
|
||||
it("uses the player's facing rather than moving relative to the selected target", () => {
|
||||
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: Math.PI / 2 });
|
||||
useCombatStore.getState().registerMob("blink-target", {
|
||||
name: "Blink target",
|
||||
maxHealth: 100,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition("blink-target", [5, 0, 0]);
|
||||
setMobPosition("blink-target", [0, 0, 5]);
|
||||
useCombatStore.getState().selectMob("blink-target");
|
||||
|
||||
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
|
||||
@@ -46,7 +70,60 @@ describe("Mage Blink runtime", () => {
|
||||
now: 1_000,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(useCombatStore.getState().playerPosition[0]).toBeCloseTo(20);
|
||||
expect(useCombatStore.getState().playerPosition[2]).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("projects the destination to ground and stops at the first line-of-sight blocker", () => {
|
||||
const projectedRequests: Array<{ distance: number; origin: readonly number[] }> = [];
|
||||
registerCombatSpatialProvider({
|
||||
actor: (actorId) => actorId === PLAYER_AGGRO_ID
|
||||
? { id: actorId, position: [0, 0, 0], yaw: 0 }
|
||||
: null,
|
||||
hasLineOfSight: (origin, target) => Math.hypot(target[0] - origin[0], target[2] - origin[2]) < 6,
|
||||
projectGroundPoint: (requested, distance, origin = [0, 0, 0]) => {
|
||||
projectedRequests.push({
|
||||
distance,
|
||||
origin: [...origin],
|
||||
});
|
||||
return [requested[0], 2, requested[2]];
|
||||
},
|
||||
});
|
||||
|
||||
const result = useCombatStore.getState().castAbility("wow335-mage-blink", {
|
||||
origin: [0, 0, 0],
|
||||
now: 1_000,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(useCombatStore.getState().playerPosition[1]).toBe(2);
|
||||
expect(useCombatStore.getState().playerPosition[2]).toBeLessThan(6);
|
||||
expect(useCombatStore.getState().playerPosition[2]).toBeGreaterThan(5.9);
|
||||
expect(useGameStore.getState().playerPosition).toEqual(useCombatStore.getState().playerPosition);
|
||||
expect(projectedRequests.length).toBeGreaterThan(1);
|
||||
expect(projectedRequests[0].distance).toBe(20);
|
||||
expect(projectedRequests.every((entry) => entry.distance <= 20 && entry.origin[2] === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not relocate when no reachable ground destination exists", () => {
|
||||
registerCombatSpatialProvider({
|
||||
actor: (actorId) => actorId === PLAYER_AGGRO_ID
|
||||
? { id: actorId, position: [0, 0, 0], yaw: 0 }
|
||||
: null,
|
||||
hasLineOfSight: () => false,
|
||||
projectGroundPoint: () => null,
|
||||
});
|
||||
useCombatStore.setState({ controlledUntil: 10_000, controlMechanic: "root" });
|
||||
|
||||
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]);
|
||||
expect(useGameStore.getState().playerPosition).toEqual([0, 0, 0]);
|
||||
expect(useCombatStore.getState()).toMatchObject({ controlledUntil: 0, controlMechanic: null });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./abilityCatalog";
|
||||
import {
|
||||
abilitiesForCharacter,
|
||||
abilitiesForClass,
|
||||
abilityAtLevel,
|
||||
resourceProfileForClass,
|
||||
} from "./abilityCatalog";
|
||||
import { aiHintsForAbility } from "./abilityRules";
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
|
||||
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityHasDepletedPartyResource, partyAbilityIsReady, partyAbilityAmount, partyAbilityCosts } from "./partyAbilityAi";
|
||||
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
@@ -58,6 +64,20 @@ describe("catalog-driven party rotations", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("applies Coerce's five-Energy Low Blow reduction to generated Rogue/Scout companions only", () => {
|
||||
const rogueScout = {
|
||||
...memberFor("rom-rogue", "damage"),
|
||||
secondaryClassId: "rom-scout" as const,
|
||||
};
|
||||
const roguePriest = { ...rogueScout, secondaryClassId: "rom-priest" as const };
|
||||
const lowBlow = abilitiesForCharacter("rom-rogue", "rom-scout")
|
||||
.find((ability) => ability.dbcSpellId === 490323)!;
|
||||
|
||||
expect(partyAbilityCosts(rogueScout, lowBlow)).toEqual([{ resource: "energy", amount: 25 }]);
|
||||
expect(partyAbilityCosts(roguePriest, lowBlow)).toEqual([{ resource: "energy", amount: 30 }]);
|
||||
expect(commitPartyAbilityUse(rogueScout, lowBlow, 2_000)?.resourcePools.energy).toBe(75);
|
||||
});
|
||||
|
||||
it("prioritizes an interrupt over ordinary damage deterministically", () => {
|
||||
const member = memberFor("rogue", "damage");
|
||||
const context = { now: 2_000, target: useCombatStore.getState().mobs.caster };
|
||||
@@ -67,6 +87,57 @@ describe("catalog-driven party rotations", () => {
|
||||
expect(first && aiHintsForAbility(first).categories).toContain("interrupt");
|
||||
});
|
||||
|
||||
it("adds Blood Strike as a Reaping-only Blood rotation fallback when a base Blood rune is ready", () => {
|
||||
const abilities = abilitiesForCharacter("death-knight", null);
|
||||
const strike = abilities.find((ability) => ability.name === "Blood Strike")!;
|
||||
const cooldowns = Object.fromEntries(abilities
|
||||
.filter((ability) => ability.id !== strike.id)
|
||||
.map((ability) => [ability.id, 60_000]));
|
||||
const base = {
|
||||
...memberFor("death-knight", "damage"),
|
||||
specialization: "Blood",
|
||||
specializationId: "death-knight:blood",
|
||||
runes: createDeathKnightRunes(2_000),
|
||||
cooldowns,
|
||||
};
|
||||
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
|
||||
|
||||
expect(choosePartyAbility({ ...base, talentRanks: {} }, { now: 2_000, target })).toBeNull();
|
||||
expect(choosePartyAbility({
|
||||
...base,
|
||||
talentRanks: { "talent-2001": 1 },
|
||||
}, { now: 2_000, target })?.name).toBe("Blood Strike");
|
||||
|
||||
const bloodRunesCooling = base.runes.map((rune) => rune.baseType === "blood"
|
||||
? { ...rune, readyAt: 60_000 }
|
||||
: rune);
|
||||
expect(choosePartyAbility({
|
||||
...base,
|
||||
talentRanks: { "talent-2001": 1 },
|
||||
runes: bloodRunesCooling,
|
||||
}, { now: 2_000, target })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not select Pestilence without a caster-owned disease available to spread", () => {
|
||||
const abilities = abilitiesForCharacter("death-knight", null);
|
||||
const pestilence = abilities.find((ability) => ability.name === "Pestilence")!;
|
||||
const member = {
|
||||
...memberFor("death-knight", "damage"),
|
||||
specialization: "Blood",
|
||||
specializationId: "death-knight:blood",
|
||||
talentRanks: { "talent-2001": 1 },
|
||||
runes: createDeathKnightRunes(2_000),
|
||||
cooldowns: Object.fromEntries(abilities
|
||||
.filter((ability) => ability.id !== pestilence.id)
|
||||
.map((ability) => [ability.id, 60_000])),
|
||||
};
|
||||
expect(choosePartyAbility(member, {
|
||||
now: 2_000,
|
||||
target: { ...useCombatStore.getState().mobs.caster, activeCast: null },
|
||||
hostileCount: 2,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("selects a real healer catalog spell and spends its resource/GCD", () => {
|
||||
const member = memberFor("priest", "healer");
|
||||
const ability = choosePartyAbility(member, {
|
||||
@@ -81,6 +152,29 @@ describe("catalog-driven party rotations", () => {
|
||||
expect(committed.globalCooldownEndsAt).toBeGreaterThan(2_000);
|
||||
});
|
||||
|
||||
it("keeps Chain of Light cooldown after current CoA Priest/Champion Rage Control rework", () => {
|
||||
const pairAbilities = abilitiesForCharacter("rom-priest", "rom-champion");
|
||||
const chain = pairAbilities.map((ability) => abilityAtLevel(ability, 80))
|
||||
.find((ability) => ability.dbcSpellId === 491164)!;
|
||||
const rageControl = pairAbilities.find((ability) => (
|
||||
ability.source === "runewaker"
|
||||
&& ability.passive
|
||||
&& ability.classId === "rom-priest"
|
||||
&& ability.secondaryClassId === "rom-champion"
|
||||
&& ability.dbcSpellId === 498610
|
||||
))!;
|
||||
const member = {
|
||||
...memberFor("rom-priest", "healer"),
|
||||
secondaryClassId: "rom-champion" as const,
|
||||
cooldowns: { [chain.id]: 60_000 },
|
||||
};
|
||||
|
||||
expect(rageControl.unlockLevel).toBeLessThanOrEqual(member.level);
|
||||
expect(partyAbilityIsReady(member, chain, 2_000)).toBe(false);
|
||||
expect(commitPartyAbilityUse(member, chain, 2_000)).toBeNull();
|
||||
expect(partyAbilityIsReady({ ...member, secondaryClassId: "rom-warlock" }, chain, 2_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a companion use Inner Focus during a GCD while preserving that GCD", () => {
|
||||
const member = {
|
||||
...memberFor("priest", "healer"),
|
||||
@@ -311,6 +405,32 @@ describe("catalog-driven party rotations", () => {
|
||||
expect(partyAbilityAmount(talented, wrath, damage)).toBe(partyAbilityAmount(base, wrath, damage));
|
||||
});
|
||||
|
||||
it("selects Sinister Request for a depleted in-range party pool even when its Druid/Rogue caster is full", () => {
|
||||
const member = {
|
||||
...memberFor("rom-druid", "damage"),
|
||||
secondaryClassId: "rom-rogue" as const,
|
||||
resource: 100,
|
||||
maxResource: 100,
|
||||
resourcePools: { mana: 100 },
|
||||
};
|
||||
const request = abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.find((ability) => ability.dbcSpellId === 494023)!;
|
||||
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
|
||||
const chooseWithPartyPools = (resources: readonly { resources: Record<string, { current: number; maximum: number }> }[]) => (
|
||||
choosePartyAbility(member, {
|
||||
now: 2_000,
|
||||
target,
|
||||
canUseAbility: ability => ability.id === request.id,
|
||||
partyResourceDeficit: ability => partyAbilityHasDepletedPartyResource(ability, resources),
|
||||
})
|
||||
);
|
||||
|
||||
expect(chooseWithPartyPools([{ resources: { energy: { current: 12, maximum: 100 } } }])?.id)
|
||||
.toBe(request.id);
|
||||
expect(chooseWithPartyPools([{ resources: { energy: { current: 100, maximum: 100 } } }])).toBeNull();
|
||||
expect(chooseWithPartyPools([{ resources: {} }])).toBeNull();
|
||||
});
|
||||
|
||||
it("scales Envenom from consumed Deadly Poison doses", () => {
|
||||
const member = {
|
||||
...memberFor("rogue", "damage"),
|
||||
|
||||
+227
-15
@@ -14,9 +14,11 @@ import {
|
||||
} from "./combatRotation";
|
||||
import { spendDeathKnightRunes } from "./combatResources";
|
||||
import { resolveResourceValue } from "./combatAuras";
|
||||
import { combatHasLineOfSight } from "./combatSpatial";
|
||||
import { equipmentAdjustedCombatAmount, useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import { getMobPosition } from "./mobRuntimeRegistry";
|
||||
import type { PartyMember } from "./partyStore";
|
||||
import { baseResourcesFor } from "./wotlkStats";
|
||||
import { baseResourcesFor, deriveCharacterStats } from "./wotlkStats";
|
||||
import { companionResourceMaximum } from "./combatActorAuthority";
|
||||
import { currentCompanionCooldownPolicy, type CompanionCooldownMechanicRule } from "./companionCooldownPolicy";
|
||||
import { WOW335_MAGE_ARCANE_CONCENTRATION_TAG } from "./wow335MageTalentRuntime";
|
||||
@@ -24,13 +26,28 @@ import {
|
||||
WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID,
|
||||
wow335MageImprovedCounterspellCooldownReductionMs,
|
||||
} from "./wow335MageImprovedCounterspellRuntime";
|
||||
import {
|
||||
WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID,
|
||||
wow335MageImprovedFireBlastCooldownReductionMs,
|
||||
wow335MageImprovedFireBlastDamage,
|
||||
} from "./wow335MageImprovedFireBlastRuntime";
|
||||
import { WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG } from "./wow335HunterRuntime";
|
||||
import {
|
||||
romPriestWarriorStanceRequired,
|
||||
romPriestWarriorStanceUseAllowed,
|
||||
} from "./romPriestKnightOverrides";
|
||||
import { romKnightWarriorWhirlwindAdjustment } from "./romKnightOverrides";
|
||||
import {
|
||||
romKnightCompassionWhirlwindShieldCooldownReductionMs,
|
||||
romKnightWarriorWhirlwindAdjustment,
|
||||
} from "./romKnightOverrides";
|
||||
import { romWarriorDruidWhirlwindAdjustment } from "./romWarriorDruidRageOfCalmness";
|
||||
import {
|
||||
ROM_ROGUE_COERCE_PASSIVE_SPELL_ID,
|
||||
romRogueCoerceAbilityAdjustment,
|
||||
} from "./romRogueMechanics";
|
||||
import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides";
|
||||
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
|
||||
import { romWarlockMageSoulBrandStingHasWillpowerBladeState } from "./romWarlockMageSoulBrandSting";
|
||||
|
||||
export interface PartyAbilityContext {
|
||||
readonly now: number;
|
||||
@@ -52,6 +69,7 @@ export interface PartyAbilityContext {
|
||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||
readonly hasActivePet?: boolean;
|
||||
readonly partyResourceDeficit?: (ability: AbilityDefinition) => boolean;
|
||||
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
|
||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||
readonly incomingDefensiveMechanic?: CompanionCooldownMechanicRule | null;
|
||||
@@ -59,10 +77,52 @@ export interface PartyAbilityContext {
|
||||
readonly nextHealingSpellFree?: boolean;
|
||||
}
|
||||
|
||||
export interface PartyResourcePoolSnapshot {
|
||||
readonly resources: Readonly<Partial<Record<ResourceType, { readonly current: number; readonly maximum: number }>>>;
|
||||
}
|
||||
|
||||
export function partyAbilityHasDepletedPartyResource(
|
||||
ability: AbilityDefinition,
|
||||
recipients: readonly PartyResourcePoolSnapshot[],
|
||||
): boolean {
|
||||
return ability.effects.some((effect) => {
|
||||
if (effect.kind !== "resource" || effect.scope !== "party" || !effect.resource) return false;
|
||||
return recipients.some(({ resources }) => {
|
||||
const pool = resources[effect.resource!];
|
||||
return Boolean(pool && pool.current < pool.maximum);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function currentResource(member: PartyMember, type: ResourceType): number {
|
||||
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
|
||||
}
|
||||
|
||||
function partyRomPriestChampionCooldownOverride(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): number | null {
|
||||
if (member.classId !== "rom-priest" || member.secondaryClassId !== "rom-champion") return null;
|
||||
const infusedLightChain = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => (
|
||||
candidate.source === "runewaker"
|
||||
&& candidate.classId === "rom-priest"
|
||||
&& candidate.secondaryClassId === "rom-champion"
|
||||
&& candidate.passive
|
||||
&& candidate.dbcSpellId === 498610
|
||||
));
|
||||
if (!infusedLightChain
|
||||
|| !learnedAbilityForBuild(infusedLightChain, member.level, member.talentRanks)) return null;
|
||||
return romPriestChampionCooldownOverride(ability, [infusedLightChain.dbcSpellId]);
|
||||
}
|
||||
|
||||
function partyAbilityWithRomPriestChampionCooldown(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): AbilityDefinition {
|
||||
const cooldownOverride = partyRomPriestChampionCooldownOverride(member, ability);
|
||||
return cooldownOverride === null ? ability : { ...ability, cooldownMs: cooldownOverride };
|
||||
}
|
||||
|
||||
export function partyHunterLockAndLoadReady(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
@@ -112,27 +172,107 @@ function partyKnightWarriorWhirlwindAdjustment(
|
||||
);
|
||||
}
|
||||
|
||||
function partyWarriorDruidWhirlwindAdjustment(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): ReturnType<typeof romWarriorDruidWhirlwindAdjustment> {
|
||||
if (member.classId !== "rom-warrior" || member.secondaryClassId !== "rom-druid") {
|
||||
return { rageCostReduction: 0, cooldownReductionMs: 0 };
|
||||
}
|
||||
const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.filter((candidate) => candidate.source === "runewaker"
|
||||
&& candidate.classId === "rom-warrior"
|
||||
&& candidate.secondaryClassId === "rom-druid"
|
||||
&& candidate.dbcSpellId === 494377
|
||||
&& candidate.passive
|
||||
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
|
||||
.map((candidate) => candidate.dbcSpellId);
|
||||
return romWarriorDruidWhirlwindAdjustment({
|
||||
classId: member.classId,
|
||||
secondaryClassId: member.secondaryClassId,
|
||||
learnedPassiveSpellIds,
|
||||
ability,
|
||||
});
|
||||
}
|
||||
|
||||
function partyKnightCompassionCooldownReductionMs(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): number {
|
||||
if (member.classId !== "rom-knight" || member.secondaryClassId !== "rom-priest") return 0;
|
||||
const learnedPassiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.filter((candidate) => candidate.source === "runewaker"
|
||||
&& candidate.classId === "rom-knight"
|
||||
&& candidate.secondaryClassId === "rom-priest"
|
||||
&& candidate.dbcSpellId === 491635
|
||||
&& candidate.passive
|
||||
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
|
||||
.map((candidate) => candidate.dbcSpellId);
|
||||
return romKnightCompassionWhirlwindShieldCooldownReductionMs(
|
||||
member.classId,
|
||||
member.secondaryClassId,
|
||||
ability,
|
||||
learnedPassiveSpellIds,
|
||||
);
|
||||
}
|
||||
|
||||
function partyAbilityWithKnightWarriorWhirlwind(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): AbilityDefinition {
|
||||
const adjustment = partyKnightWarriorWhirlwindAdjustment(member, ability);
|
||||
if (adjustment.rageCostReduction <= 0 && adjustment.cooldownReductionMs <= 0) return ability;
|
||||
const knightAdjustment = partyKnightWarriorWhirlwindAdjustment(member, ability);
|
||||
const warriorDruidAdjustment = partyWarriorDruidWhirlwindAdjustment(member, ability);
|
||||
const rageCostReduction = knightAdjustment.rageCostReduction + warriorDruidAdjustment.rageCostReduction;
|
||||
const cooldownReductionMs = knightAdjustment.cooldownReductionMs + warriorDruidAdjustment.cooldownReductionMs;
|
||||
if (rageCostReduction <= 0 && cooldownReductionMs <= 0) return ability;
|
||||
const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => (
|
||||
cost.resource === "rage" && !cost.percentage
|
||||
? { ...cost, amount: Math.max(0, cost.amount - adjustment.rageCostReduction) }
|
||||
? { ...cost, amount: Math.max(0, cost.amount - rageCostReduction) }
|
||||
: cost
|
||||
));
|
||||
return {
|
||||
...ability,
|
||||
cooldownMs: Math.max(0, ability.cooldownMs - adjustment.cooldownReductionMs),
|
||||
cooldownMs: Math.max(0, ability.cooldownMs - cooldownReductionMs),
|
||||
cost: costs[0] ?? ability.cost,
|
||||
costs,
|
||||
};
|
||||
}
|
||||
|
||||
function partyRogueCoerceEnergyCostReduction(member: PartyMember, ability: AbilityDefinition): number {
|
||||
if (member.classId !== "rom-rogue" || member.secondaryClassId !== "rom-scout") return 0;
|
||||
const passiveSpellIds = abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.filter((candidate) => candidate.source === "runewaker"
|
||||
&& candidate.classId === "rom-rogue"
|
||||
&& candidate.secondaryClassId === "rom-scout"
|
||||
&& candidate.passive
|
||||
&& candidate.dbcSpellId === ROM_ROGUE_COERCE_PASSIVE_SPELL_ID
|
||||
&& learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null)
|
||||
.map((candidate) => candidate.dbcSpellId);
|
||||
return romRogueCoerceAbilityAdjustment(
|
||||
member.classId,
|
||||
member.secondaryClassId,
|
||||
ability.source,
|
||||
ability.dbcSpellId,
|
||||
passiveSpellIds,
|
||||
).energyCostReduction;
|
||||
}
|
||||
|
||||
function partyAbilityWithRogueCoerce(member: PartyMember, ability: AbilityDefinition): AbilityDefinition {
|
||||
const reduction = partyRogueCoerceEnergyCostReduction(member, ability);
|
||||
if (reduction <= 0) return ability;
|
||||
const costs = (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => (
|
||||
cost.resource === "energy" && !cost.percentage
|
||||
? { ...cost, amount: Math.max(0, cost.amount - reduction) }
|
||||
: cost
|
||||
));
|
||||
return { ...ability, cost: costs[0] ?? ability.cost, costs };
|
||||
}
|
||||
|
||||
export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinition, now?: number): readonly { resource: ResourceType; amount: number }[] {
|
||||
const adjustedAbility = partyAbilityWithKnightWarriorWhirlwind(member, ability);
|
||||
const adjustedAbility = partyAbilityWithRogueCoerce(
|
||||
member,
|
||||
partyAbilityWithKnightWarriorWhirlwind(member, ability),
|
||||
);
|
||||
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id
|
||||
&& (now === undefined || aura.expiresAt === null || aura.expiresAt > now));
|
||||
const mageClearcasting = member.classId === "mage" && auras.some((aura) => (
|
||||
@@ -165,14 +305,22 @@ export function partyAbilityIsReady(
|
||||
ignoreResourceCosts = false,
|
||||
): boolean {
|
||||
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
|
||||
if (member.classId === "rom-warlock" && member.secondaryClassId === "rom-mage"
|
||||
&& ability.source === "runewaker" && ability.dbcSpellId === 498710
|
||||
&& !romWarlockMageSoulBrandStingHasWillpowerBladeState(useCombatStore.getState().auras.filter((aura) => (
|
||||
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
|
||||
)))) return false;
|
||||
if (romPriestWarriorStanceRequired(ability)) {
|
||||
const activeAuraTags = useCombatStore.getState().auras
|
||||
.filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []);
|
||||
if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false;
|
||||
}
|
||||
const hasRomPriestChampionCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability) !== null;
|
||||
if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now)
|
||||
|| (!partyHunterLockAndLoadReady(member, ability, now) && (member.cooldowns[ability.id] ?? 0) > now)) return false;
|
||||
|| (!partyHunterLockAndLoadReady(member, ability, now)
|
||||
&& !hasRomPriestChampionCooldownOverride
|
||||
&& (member.cooldowns[ability.id] ?? 0) > now)) return false;
|
||||
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||
if (!ignoreResourceCosts && partyAbilityCosts(member, ability, now).some(cost => cost.resource === "health"
|
||||
@@ -184,7 +332,8 @@ export function partyAbilityIsReady(
|
||||
function learnedAbilities(member: PartyMember): readonly AbilityDefinition[] {
|
||||
return abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null);
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||
.map((ability) => partyAbilityWithRomPriestChampionCooldown(member, ability));
|
||||
}
|
||||
|
||||
function isComboFinisher(ability: AbilityDefinition): boolean {
|
||||
@@ -244,6 +393,37 @@ export function choosePartyRotationDecision(
|
||||
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
|
||||
);
|
||||
const learned = learnedAbilities(member);
|
||||
const reapingRank = member.classId === "death-knight"
|
||||
? Math.max(0, Math.trunc(member.talentRanks["talent-2001"] ?? 0))
|
||||
: 0;
|
||||
const activeConditionalAbilityNames = new Set(context.availableConditionalAbilityNames ?? []);
|
||||
const pestilence = learned.find((ability) => ability.name === "Pestilence");
|
||||
const targetPosition = context.target ? getMobPosition(context.target.id) : null;
|
||||
const hasNearbyPestilenceTarget = Boolean(targetPosition && pestilence && reapingRank > 0
|
||||
&& member.specializationId === "death-knight:blood"
|
||||
&& member.runes.some((rune) => rune.baseType === "blood" && rune.readyAt <= context.now)
|
||||
&& Object.values(useCombatStore.getState().mobs).some((mob) => {
|
||||
if (mob.id === context.target?.id || !mob.engaged || mob.dead) return false;
|
||||
const position = getMobPosition(mob.id);
|
||||
if (!position) return false;
|
||||
const dx = position[0] - targetPosition[0];
|
||||
const dy = position[1] - targetPosition[1];
|
||||
const dz = position[2] - targetPosition[2];
|
||||
const radius = pestilence!.radius ?? 10;
|
||||
return dx * dx + dy * dy + dz * dz <= radius * radius
|
||||
&& combatHasLineOfSight(targetPosition, position);
|
||||
}));
|
||||
const hasCasterOwnedDiseaseOnAnchor = Boolean(context.target && useCombatStore.getState().effects.some((effect) => (
|
||||
effect.kind === "dot"
|
||||
&& effect.sourceActorId === member.id
|
||||
&& effect.targetId === context.target?.id
|
||||
&& (effect.tag === "blood-plague" || effect.tag === "frost-fever")
|
||||
&& effect.remainingTicks > 0
|
||||
&& effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs > context.now
|
||||
)));
|
||||
if (hasNearbyPestilenceTarget && hasCasterOwnedDiseaseOnAnchor) {
|
||||
activeConditionalAbilityNames.add("Pestilence");
|
||||
}
|
||||
const decision = evaluateCombatRotation({
|
||||
now: context.now,
|
||||
classId: member.classId,
|
||||
@@ -261,6 +441,7 @@ export function choosePartyRotationDecision(
|
||||
comboPoints: member.comboPoints,
|
||||
comboTargetId: member.comboTargetId,
|
||||
runes: member.runes,
|
||||
deathKnightReapingRank: reapingRank,
|
||||
abilities: learned.filter((ability) => partyAbilityIsReady(
|
||||
member,
|
||||
ability,
|
||||
@@ -285,7 +466,10 @@ export function choosePartyRotationDecision(
|
||||
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
|
||||
activeSummonAbilityIds: context.activeSummonAbilityIds,
|
||||
hasActivePet: context.hasActivePet,
|
||||
availableConditionalAbilityNames: context.availableConditionalAbilityNames,
|
||||
partyResourceDeficit: context.partyResourceDeficit,
|
||||
availableConditionalAbilityNames: activeConditionalAbilityNames.size > 0
|
||||
? activeConditionalAbilityNames
|
||||
: undefined,
|
||||
canUseAbility: context.canUseAbility,
|
||||
cooldownPolicy: currentCompanionCooldownPolicy(),
|
||||
incomingDefensiveMechanic: context.incomingDefensiveMechanic,
|
||||
@@ -324,14 +508,24 @@ export function commitPartyAbilityUse(
|
||||
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
|
||||
const runes = runeCost ? spendDeathKnightRunes(member.runes, runeCost, now) : member.runes;
|
||||
if (!runes) return null;
|
||||
const romCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability);
|
||||
const cooldownMs = member.classId === "mage"
|
||||
? Math.max(0, ability.cooldownMs - wow335MageImprovedCounterspellCooldownReductionMs(
|
||||
member.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0,
|
||||
ability.name,
|
||||
) - wow335MageImprovedFireBlastCooldownReductionMs(
|
||||
member.talentRanks[WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID] ?? 0,
|
||||
ability.name,
|
||||
))
|
||||
: ability.cooldownMs;
|
||||
: romCooldownOverride ?? ability.cooldownMs;
|
||||
const knightWarriorCooldownReductionMs = partyKnightWarriorWhirlwindAdjustment(member, ability).cooldownReductionMs;
|
||||
const adjustedCooldownMs = Math.max(0, cooldownMs - knightWarriorCooldownReductionMs);
|
||||
const warriorDruidCooldownReductionMs = partyWarriorDruidWhirlwindAdjustment(member, ability).cooldownReductionMs;
|
||||
const knightCompassionCooldownReductionMs = partyKnightCompassionCooldownReductionMs(member, ability);
|
||||
const adjustedCooldownMs = Math.max(0,
|
||||
cooldownMs - knightWarriorCooldownReductionMs - warriorDruidCooldownReductionMs - knightCompassionCooldownReductionMs);
|
||||
const cooldowns = { ...member.cooldowns };
|
||||
if (romCooldownOverride !== null) delete cooldowns[ability.id];
|
||||
if (adjustedCooldownMs > 0) cooldowns[ability.id] = now + adjustedCooldownMs;
|
||||
return {
|
||||
...member,
|
||||
health,
|
||||
@@ -339,9 +533,7 @@ export function commitPartyAbilityUse(
|
||||
resourcePools,
|
||||
lastResourceSpentAt,
|
||||
runes,
|
||||
cooldowns: adjustedCooldownMs > 0
|
||||
? { ...member.cooldowns, [ability.id]: now + adjustedCooldownMs }
|
||||
: member.cooldowns,
|
||||
cooldowns,
|
||||
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
|
||||
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
|
||||
? now + Math.max(8_000, ability.cooldownMs)
|
||||
@@ -354,7 +546,16 @@ export function partyAbilityAmount(
|
||||
ability: AbilityDefinition,
|
||||
effect: AbilityEffect,
|
||||
poisonDoses = 0,
|
||||
intellectOverride?: number,
|
||||
): number {
|
||||
const sourceIntellect = intellectOverride ?? deriveCharacterStats(
|
||||
member.classId,
|
||||
member.level,
|
||||
member.gearStats,
|
||||
member.raceId,
|
||||
{},
|
||||
member.secondaryClassId,
|
||||
).intellect;
|
||||
if (effect.kind === "finisher-damage" && effect.poison) {
|
||||
const consumedDoses = Math.max(0, Math.min(
|
||||
effect.poison.maximumDoses,
|
||||
@@ -371,6 +572,8 @@ export function partyAbilityAmount(
|
||||
member.gearStats,
|
||||
member.classId,
|
||||
ability,
|
||||
0,
|
||||
sourceIntellect,
|
||||
);
|
||||
}
|
||||
const coefficient = "coefficient" in effect
|
||||
@@ -384,6 +587,8 @@ export function partyAbilityAmount(
|
||||
member.gearStats,
|
||||
member.classId,
|
||||
ability,
|
||||
0,
|
||||
sourceIntellect,
|
||||
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
|
||||
if (member.classId === "druid" && (effect.kind === "damage" || effect.kind === "dot")) {
|
||||
return applyTalentModifierValue(
|
||||
@@ -393,5 +598,12 @@ export function partyAbilityAmount(
|
||||
{ abilityName: ability.name },
|
||||
);
|
||||
}
|
||||
if (member.classId === "mage" && effect.kind === "damage") {
|
||||
return wow335MageImprovedFireBlastDamage(
|
||||
baseAmount,
|
||||
member.talentRanks[WOW335_MAGE_IMPROVED_FIRE_BLAST_TALENT_ID] ?? 0,
|
||||
ability.name,
|
||||
);
|
||||
}
|
||||
return baseAmount;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore, type EnemyActiveCast } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
@@ -16,18 +16,31 @@ import {
|
||||
partyAttackDamage,
|
||||
partyCombatRangeBand,
|
||||
partyCommandTargetId,
|
||||
partyHunterPetRecipientIds,
|
||||
partyUsesRangedAttacks,
|
||||
partyAbilityTargetIsValid,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { partyAbilityAmount } from "./partyAbilityAi";
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId } from "./abilityCatalog";
|
||||
import { abilitiesForCharacter, abilitiesForClass, abilityAtLevel, abilityTalentNodeId, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { baseClassFor } from "../app/characterCatalog";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
|
||||
import { ROM_ROGUE_OFF_HAND_MASTERY_TAG } from "./romRogueMechanics";
|
||||
import {
|
||||
WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE,
|
||||
wow335HunterPetBaseStatsForLevel,
|
||||
} from "./wow335HunterPetRuntime";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||
} from "./wow335DeathKnightDancingRuneWeaponRuntime";
|
||||
|
||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
@@ -93,6 +106,10 @@ function registerFrostbiteTarget(id: string): void {
|
||||
}
|
||||
|
||||
describe("party combat runtime", () => {
|
||||
beforeAll(async () => {
|
||||
await preloadAbilityCatalogsForClasses(["rom-druid"]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
@@ -109,6 +126,398 @@ describe("party combat runtime", () => {
|
||||
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
|
||||
});
|
||||
|
||||
it("extends a companion Druid's cast-time damage range with Nature's Reach", () => {
|
||||
const seed = "druid-natures-reach-party";
|
||||
const druid = createConfiguredPartyMember({
|
||||
name: "Balance Druid",
|
||||
classId: "druid",
|
||||
specialization: "Balance",
|
||||
specializationId: "druid:balance",
|
||||
talentTreeId: "druid-balance",
|
||||
talentRanks: { "talent-764": 3 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const wrath = abilitiesForCharacter("druid", null).find((ability) => ability.name === "Wrath");
|
||||
if (!wrath) throw new Error("Druid Wrath is missing from the learned ability catalog.");
|
||||
const learnedWrath = abilityAtLevel(wrath, 80);
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [druid] });
|
||||
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("druid-natures-reach-party-target", {
|
||||
name: "Nature's Reach party target",
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition("druid-natures-reach-party-target", [35, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(druid.id, "druid-natures-reach-party-target", true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
expect(partyAbilityTargetIsValid(druid, learnedWrath, "druid-natures-reach-party-target")).toBe(true);
|
||||
expect(partyAbilityTargetIsValid({ ...druid, talentRanks: {} }, learnedWrath,
|
||||
"druid-natures-reach-party-target")).toBe(false);
|
||||
});
|
||||
|
||||
it("spreads a companion Druid/Rogue's current Corrosive Poison with Necrotic Wound", () => {
|
||||
const seed = "druid-rogue-necrotic-wound-party";
|
||||
const druid = createConfiguredPartyMember({
|
||||
name: "Druid/Rogue",
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-elf",
|
||||
specialization: "Druid/Rogue",
|
||||
specializationId: "rom-druid:rom-rogue",
|
||||
talentTreeId: "rom-druid-rom-rogue",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const findPairAbility = (spellId: number) => {
|
||||
const found = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId
|
||||
&& candidate.classId === "rom-druid" && candidate.secondaryClassId === "rom-rogue");
|
||||
if (!found) throw new Error(`Missing Druid/Rogue spell ${spellId}.`);
|
||||
return found;
|
||||
};
|
||||
const wound = learnedAbilityForBuild(findPairAbility(494321), 80, druid.talentRanks)!;
|
||||
const poison = findPairAbility(494024);
|
||||
const poisonDot = poison.effects.find((effect) => effect.kind === "dot");
|
||||
const sinisterRequest = findPairAbility(494023);
|
||||
if (!poisonDot || poisonDot.kind !== "dot") throw new Error("Corrosive Poison has no current DoT.");
|
||||
if (!wound) throw new Error("Companion Druid/Rogue did not learn Necrotic Wound.");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...druid,
|
||||
resourcePools: { ...druid.resourcePools, mana: 960, energy: 75, "nature-power": 0 },
|
||||
cooldowns: { ...druid.cooldowns, [sinisterRequest.id]: 20_000 },
|
||||
activeCast: {
|
||||
abilityId: wound.id,
|
||||
school: "shadow",
|
||||
mode: "channel",
|
||||
startedAt: 1_000,
|
||||
completesAt: 2_500,
|
||||
originalDurationMs: 1_500,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: "druid-rogue-wound-primary",
|
||||
channelTicksCompleted: 0,
|
||||
},
|
||||
activeCastTargetId: "druid-rogue-wound-primary",
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const primary = "druid-rogue-wound-primary";
|
||||
const nearby = "druid-rogue-wound-nearby";
|
||||
const outside = "druid-rogue-wound-outside";
|
||||
for (const [id, position] of [
|
||||
[primary, [4, 0, 0]],
|
||||
[nearby, [11, 0, 0]],
|
||||
[outside, [13, 0, 0]],
|
||||
] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(id, position);
|
||||
useCombatStore.getState().damageMob(id, 1, 100);
|
||||
}
|
||||
updatePartyRuntimeTargetVisibility(druid.id, primary, true);
|
||||
expect(partyAbilityTargetIsValid(druid, wound, primary)).toBe(false);
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(druid.id, primary, poison, poisonDot, 1_000)).toBe(true);
|
||||
expect(partyAbilityTargetIsValid(druid, wound, primary)).toBe(true);
|
||||
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === druid.id
|
||||
? { ...member, cooldowns: { ...member.cooldowns, [sinisterRequest.id]: 20_000 } }
|
||||
: member) }));
|
||||
|
||||
advanceDungeonPartyCombat(2_500);
|
||||
|
||||
const poisonTargets = useCombatStore.getState().effects.filter((effect) => (
|
||||
effect.kind === "dot" && effect.tag === "rom-druid-corrosive-poison" && effect.remainingTicks === 12
|
||||
)).map((effect) => effect.targetId).sort();
|
||||
expect(poisonTargets).toEqual([nearby, primary].sort());
|
||||
expect(useCombatStore.getState().mobs[primary]!.health).toBeLessThan(999_999);
|
||||
expect(useCombatStore.getState().mobs[nearby]!.health).toBeLessThan(999_999);
|
||||
expect(useCombatStore.getState().mobs[outside]!.health).toBe(999_999);
|
||||
expect(usePartyStore.getState().members[0]!.cooldowns[sinisterRequest.id]).toBe(19_000);
|
||||
expect(useCombatStore.getState().mobs[primary]!.statuses.some((status) => status.kind === "root")).toBe(false);
|
||||
});
|
||||
|
||||
it("extends a companion Death Knight's diseases with Epidemic", () => {
|
||||
const seed = "death-knight-epidemic-party";
|
||||
const deathKnight = createConfiguredPartyMember({
|
||||
name: "Epidemic DK",
|
||||
classId: "death-knight",
|
||||
specialization: "Frost",
|
||||
specializationId: "death-knight:frost",
|
||||
talentTreeId: "death-knight-frost",
|
||||
talentRanks: { "talent-1963": 2 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const abilities = abilitiesForCharacter("death-knight", null);
|
||||
const icyTouch = abilities.find((candidate) => candidate.name === "Icy Touch");
|
||||
if (!icyTouch) throw new Error("Missing Death Knight Icy Touch.");
|
||||
const learnedIcyTouch = learnedAbilityForBuild(icyTouch, 80, deathKnight.talentRanks);
|
||||
if (!learnedIcyTouch) throw new Error("Companion Death Knight did not learn Icy Touch.");
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({
|
||||
members: [{
|
||||
...deathKnight,
|
||||
gearStats: { ...EMPTY_ITEM_STATS, hitRating: 10_000, expertiseRating: 10_000 },
|
||||
resource: 100,
|
||||
maxResource: 100,
|
||||
resourcePools: { ...deathKnight.resourcePools, "runic-power": 100 },
|
||||
runes: createDeathKnightRunes(1_000),
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
status: "ready",
|
||||
}],
|
||||
command: "attack",
|
||||
activeMobId: "party-dk-epidemic-target",
|
||||
});
|
||||
registerPartyRuntimePosition(deathKnight.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("party-dk-epidemic-target", 1_000_000);
|
||||
|
||||
for (const now of [1_000, 2_500, 4_000, 5_500]) advanceDungeonPartyCombat(now);
|
||||
|
||||
expect(useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "dot" && effect.sourceActorId === deathKnight.id
|
||||
&& effect.tag === "frost-fever" && effect.targetId === "party-dk-epidemic-target"
|
||||
))).toMatchObject({ remainingTicks: 7, intervalMs: 3_000 });
|
||||
});
|
||||
|
||||
it("activates Dancing Rune Weapon for a companion and mirrors its direct spell and melee hits", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = "death-knight-dancing-rune-weapon-party";
|
||||
const deathKnight = createConfiguredPartyMember({
|
||||
name: "Rune Weapon DK",
|
||||
classId: "death-knight",
|
||||
specialization: "Blood",
|
||||
specializationId: "death-knight:blood",
|
||||
talentTreeId: "death-knight-blood",
|
||||
talentRanks: { "talent-1961": 1 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const abilities = abilitiesForCharacter("death-knight", null);
|
||||
const dancingRuneWeapon = abilities.find((ability) => ability.id === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID);
|
||||
const learnedDancingRuneWeapon = dancingRuneWeapon
|
||||
? learnedAbilityForBuild(dancingRuneWeapon, 80, deathKnight.talentRanks)
|
||||
: null;
|
||||
const deathCoil = abilities.find((ability) => ability.name === "Death Coil");
|
||||
if (!learnedDancingRuneWeapon || !deathCoil) {
|
||||
throw new Error("Companion Death Knight is missing Dancing Rune Weapon or Death Coil.");
|
||||
}
|
||||
const learnedDeathCoil = learnedAbilityForBuild(deathCoil, 80, deathKnight.talentRanks);
|
||||
if (!learnedDeathCoil) throw new Error("Companion Death Knight did not learn Death Coil.");
|
||||
const targetId = "party-dk-dancing-rune-weapon-target";
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...deathKnight,
|
||||
talentRanks: { ...deathKnight.talentRanks, "talent-1961": 1 },
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
resource: 100,
|
||||
maxResource: 100,
|
||||
resourcePools: { ...deathKnight.resourcePools, "runic-power": 100 },
|
||||
runes: createDeathKnightRunes(1_000),
|
||||
activeCast: {
|
||||
abilityId: learnedDancingRuneWeapon.id,
|
||||
school: "shadow",
|
||||
mode: "cast",
|
||||
startedAt: 1_000,
|
||||
completesAt: 1_000,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: deathKnight.id,
|
||||
},
|
||||
activeCastTargetId: deathKnight.id,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(deathKnight.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob(targetId, 1_000_000);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
sourceId: deathKnight.id,
|
||||
targetId: deathKnight.id,
|
||||
definition: expect.objectContaining({ name: "Dancing Rune Weapon", durationMs: 12_000 }),
|
||||
appliedAt: 1_000,
|
||||
expiresAt: 13_000,
|
||||
}));
|
||||
expect(useCombatStore.getState().summons.some((summon) => (
|
||||
summon.ownerActorId === deathKnight.id
|
||||
&& summon.sourceAbilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID
|
||||
))).toBe(false);
|
||||
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === deathKnight.id ? {
|
||||
...member,
|
||||
activeCast: {
|
||||
abilityId: learnedDeathCoil.id,
|
||||
school: "shadow",
|
||||
mode: "cast",
|
||||
startedAt: 3_000,
|
||||
completesAt: 3_000,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking" as const,
|
||||
} : member),
|
||||
}));
|
||||
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
||||
advanceDungeonPartyCombat(3_000);
|
||||
const spellHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === deathKnight.id
|
||||
&& event.targetActorId === targetId
|
||||
&& event.abilityId === learnedDeathCoil.id
|
||||
&& event.occurredAt === 3_000
|
||||
));
|
||||
expect(spellHits).toHaveLength(2);
|
||||
expect(spellHits[1].rawAmount).toBe(Math.floor(spellHits[0].rawAmount * 0.5));
|
||||
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === deathKnight.id ? {
|
||||
...member,
|
||||
activeCast: null,
|
||||
activeCastTargetId: null,
|
||||
nextActionAt: 100_000,
|
||||
status: "attacking" as const,
|
||||
} : member),
|
||||
}));
|
||||
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
||||
advanceDungeonPartyCombat(5_000);
|
||||
const meleeHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === deathKnight.id
|
||||
&& event.targetActorId === targetId
|
||||
&& event.abilityId === "party-auto-attack"
|
||||
&& event.occurredAt === 5_000
|
||||
));
|
||||
expect(meleeHits).toHaveLength(2);
|
||||
expect(meleeHits[1].rawAmount).toBe(Math.floor(meleeHits[0].rawAmount * 0.5));
|
||||
});
|
||||
|
||||
it("gives a RoM Rogue a separate off-hand swing and activates learned Off Hand Mastery", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const rogue = createConfiguredPartyMember({
|
||||
name: "RoM Rogue",
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
specialization: "Rogue/Warrior",
|
||||
role: "damage",
|
||||
}, 80, 0, "rom-rogue-offhand-runtime");
|
||||
const targetId = "rom-rogue-offhand-target";
|
||||
usePartyStore.getState().initializeParty("rom-rogue-offhand-runtime", 80,
|
||||
useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({
|
||||
members: [{ ...rogue, gearStats: EMPTY_ITEM_STATS, nextActionAt: 100_000 }],
|
||||
command: "attack",
|
||||
activeMobId: targetId,
|
||||
});
|
||||
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob(targetId, 1_000_000);
|
||||
const presentations: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event") presentations.push(message.event);
|
||||
});
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
unsubscribe();
|
||||
expect(presentations).toContainEqual(expect.objectContaining({
|
||||
sourceActorId: rogue.id,
|
||||
abilityId: "party-auto-attack-offhand",
|
||||
name: "Off-hand Auto Attack",
|
||||
phase: "impact",
|
||||
}));
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === rogue.id
|
||||
&& aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG)
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("adds hand-specific CoA Chaos Dagger Fire packets to generated Rogue/Mage autos", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const rogue = createConfiguredPartyMember({
|
||||
name: "RoM Rogue/Mage",
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-mage",
|
||||
raceId: "rom-human",
|
||||
specialization: "Rogue/Mage",
|
||||
role: "damage",
|
||||
}, 80, 0, "rom-rogue-mage-chaos-dagger-party");
|
||||
const mainHandWeapon = { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 };
|
||||
const offHandWeapon = { damageMin: 40, damageMax: 40, damageSchool: "physical" as const, speedMs: 2_000 };
|
||||
const offHandId = "rom-chaos-dagger-party-offhand";
|
||||
const offHandItem = {
|
||||
instanceId: offHandId,
|
||||
itemId: 1,
|
||||
itemKey: offHandId,
|
||||
name: "Chaos Dagger Test Off-hand",
|
||||
quality: "uncommon" as const,
|
||||
inventorySlot: "off-hand",
|
||||
armorType: null,
|
||||
weaponType: "dagger",
|
||||
itemLevel: 1,
|
||||
sourceItemLevel: 1,
|
||||
stats: { ...EMPTY_ITEM_STATS, meleeWeapon: offHandWeapon },
|
||||
quantity: 1,
|
||||
sourceBossKey: "test",
|
||||
sourceBossName: "Test",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
usePartyStore.getState().initializeParty("rom-rogue-mage-chaos-dagger-party", 80,
|
||||
useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.setState({ members: [{
|
||||
...rogue,
|
||||
gearStats: { ...EMPTY_ITEM_STATS, hitRating: 1_000_000, meleeWeapon: mainHandWeapon },
|
||||
nextActionAt: 100_000,
|
||||
}] });
|
||||
useCombatStore.setState((state) => ({
|
||||
inventory: [...state.inventory, offHandItem],
|
||||
equipment: { ...state.equipment, [rogue.id]: { "off-hand": offHandId } },
|
||||
}));
|
||||
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const targetId = "rom-rogue-mage-chaos-dagger-target";
|
||||
useCombatStore.getState().registerMob(targetId, {
|
||||
name: "Chaos Dagger party target",
|
||||
maxHealth: 1_000_000,
|
||||
level: 80,
|
||||
schoolImmunities: ["fire"],
|
||||
});
|
||||
setMobPosition(targetId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(targetId, 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(rogue.id, targetId, true);
|
||||
usePartyStore.setState({ command: "attack", activeMobId: targetId });
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const damage = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.targetActorId === targetId
|
||||
));
|
||||
const firePackets = damage.filter((event) => event.school === "fire");
|
||||
expect(firePackets.map((event) => event.rawAmount).sort((a, b) => a - b)).toEqual([34, 85]);
|
||||
expect(firePackets.every((event) => event.effectiveAmount === 0)).toBe(true);
|
||||
expect(damage.some((event) => event.school === "physical" && event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes companion pet-targeted auras to that Hunter's live summoned pet", () => {
|
||||
const callPet = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!;
|
||||
const summons = [
|
||||
{ id: "hunter-one-pet", ownerActorId: "hunter-one", health: 1, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
|
||||
{ id: "hunter-two-pet", ownerActorId: "hunter-two", health: 1, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
|
||||
{ id: "hunter-one-dead-pet", ownerActorId: "hunter-one", health: 0, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
|
||||
{ id: "hunter-one-expired-pet", ownerActorId: "hunter-one", health: 1, expiresAt: 1_000, kind: "summon" as const, sourceAbilityId: callPet.id },
|
||||
{ id: "hunter-one-totem", ownerActorId: "hunter-one", health: 1, expiresAt: 10_000, kind: "totem" as const, sourceAbilityId: callPet.id },
|
||||
];
|
||||
|
||||
expect(partyHunterPetRecipientIds(summons, "hunter-one", 2_000)).toEqual(["hunter-one-pet"]);
|
||||
});
|
||||
|
||||
it("maintains HoTs on separate injured allies without resetting pending ticks", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 8 });
|
||||
usePartyStore.getState().initializeParty("party-hot-coverage", 8, useCombatStore.getState().health, "damage");
|
||||
@@ -117,7 +526,7 @@ describe("party combat runtime", () => {
|
||||
const hot = abilities.find(ability => ability.name === "Rejuvenation")!;
|
||||
usePartyStore.setState(state => ({ members: state.members.map(member => member.id === healer.id
|
||||
? { ...member, classId: "druid", resourceType: "mana", resource: 10_000, maxResource: 10_000,
|
||||
resourcePools: { mana: 10_000 }, talentRanks: {}, cooldowns: Object.fromEntries(abilities.filter(a => a.id !== hot.id).map(a => [a.id, 60_000])) }
|
||||
resourcePools: { mana: 10_000 }, talentRanks: { "talent-830": 3 }, cooldowns: Object.fromEntries(abilities.filter(a => a.id !== hot.id).map(a => [a.id, 60_000])) }
|
||||
: { ...member, health: member.maxHealth * .5 }) }));
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
@@ -125,6 +534,9 @@ describe("party combat runtime", () => {
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const first = useCombatStore.getState().effects.find(effect => effect.kind === "hot" && effect.sourceActorId === healer.id)!;
|
||||
expect(first).toBeDefined();
|
||||
const rejuvenationHot = hot.effects.find((effect) => effect.kind === "hot")!;
|
||||
const baselineAmount = partyAbilityAmount({ ...healer, classId: "druid", talentRanks: {} }, hot, rejuvenationHot);
|
||||
expect(first.fixedAmount).toBe(Math.round(baselineAmount * 1.2));
|
||||
advanceDungeonPartyCombat(3_000);
|
||||
const effects = useCombatStore.getState().effects.filter(effect => effect.kind === "hot" && effect.sourceActorId === healer.id);
|
||||
expect(new Set(effects.map(effect => effect.targetId)).size).toBe(2);
|
||||
@@ -294,6 +706,121 @@ describe("party combat runtime", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ rank: 0, shouldSlow: false },
|
||||
{ rank: 2, shouldSlow: true },
|
||||
])("applies generated Warlock Aftermath to landed companion Conflagrate at rank $rank", ({ rank, shouldSlow }) => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = `party-warlock-aftermath-rank-${rank}`;
|
||||
const warlock = createConfiguredPartyMember({
|
||||
name: "Destruction Warlock",
|
||||
classId: "warlock",
|
||||
specialization: "Destruction",
|
||||
specializationId: "warlock:destruction",
|
||||
talentTreeId: "warlock-destruction",
|
||||
talentRanks: { "talent-968": 1, "talent-982": rank },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const conflagrate = abilityAtLevel(
|
||||
abilitiesForClass("warlock").find((ability) => ability.name === "Conflagrate")!,
|
||||
80,
|
||||
);
|
||||
expect(learnedAbilityForBuild(
|
||||
abilitiesForClass("warlock").find((ability) => ability.name === "Conflagrate")!,
|
||||
80,
|
||||
warlock.talentRanks,
|
||||
)).toMatchObject({ id: conflagrate.id });
|
||||
const targetId = `party-warlock-aftermath-target-${rank}`;
|
||||
const completionAt = 10_000;
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...warlock,
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...warlock.resourcePools, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: conflagrate.id,
|
||||
school: "fire",
|
||||
mode: "cast",
|
||||
startedAt: completionAt - conflagrate.castTimeMs,
|
||||
completesAt: completionAt,
|
||||
originalDurationMs: conflagrate.castTimeMs,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(warlock.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
expect(useCombatStore.getState().actors[warlock.id]?.cast).toMatchObject({
|
||||
abilityId: conflagrate.id,
|
||||
targetId,
|
||||
});
|
||||
registerFrostbiteTarget(targetId);
|
||||
const immolate = abilityAtLevel(
|
||||
abilitiesForClass("warlock").find((ability) => ability.name === "Immolate")!,
|
||||
80,
|
||||
);
|
||||
const immolateDot = immolate.effects.find((effect) => effect.kind === "dot");
|
||||
if (!immolateDot || immolateDot.kind !== "dot") throw new Error("Missing generated Immolate DoT.");
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(
|
||||
warlock.id, targetId, immolate, immolateDot, 1_000,
|
||||
)).toBe(true);
|
||||
expect(partyAbilityTargetIsValid(warlock, conflagrate, targetId, [0, 0, 0], completionAt)).toBe(true);
|
||||
const immolateEffectId = useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "dot" && effect.sourceActorId === warlock.id && effect.targetId === targetId
|
||||
))!.id;
|
||||
expect(useCombatStore.getState().actors[warlock.id]?.cast).toMatchObject({
|
||||
abilityId: conflagrate.id,
|
||||
targetId,
|
||||
});
|
||||
const beforeHealth = useCombatStore.getState().mobs[targetId]!.health;
|
||||
const aftermathEvents: StructuredCombatEvent[] = [];
|
||||
const stopAftermathEvents = subscribeCombatEvents((event) => aftermathEvents.push(event));
|
||||
const aftermathPresentation: CombatPresentationEvent[] = [];
|
||||
const stopAftermathPresentation = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event") aftermathPresentation.push(message.event);
|
||||
});
|
||||
|
||||
advanceDungeonPartyCombat(completionAt);
|
||||
stopAftermathEvents();
|
||||
stopAftermathPresentation();
|
||||
|
||||
const mob = useCombatStore.getState().mobs[targetId]!;
|
||||
expect(aftermathPresentation).toContainEqual(expect.objectContaining({
|
||||
phase: "impact",
|
||||
sourceActorId: warlock.id,
|
||||
abilityId: conflagrate.id,
|
||||
}));
|
||||
const conflagrateDamage = aftermathEvents.find((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === warlock.id && event.targetActorId === targetId
|
||||
&& event.abilityId === conflagrate.id
|
||||
));
|
||||
expect(conflagrateDamage).toMatchObject({
|
||||
rawAmount: 628,
|
||||
effectiveAmount: expect.any(Number),
|
||||
});
|
||||
expect(mob.health).toBeLessThan(beforeHealth);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.id === immolateEffectId)).toBe(false);
|
||||
const aftermath = mob.statuses.find((status) => (
|
||||
status.kind === "slow" && status.sourceAbilityId === conflagrate.id && status.sourceActorId === warlock.id
|
||||
));
|
||||
if (shouldSlow) {
|
||||
expect(aftermath).toMatchObject({
|
||||
kind: "slow",
|
||||
sourceAbilityId: conflagrate.id,
|
||||
sourceActorId: warlock.id,
|
||||
endsAt: completionAt + 4_000,
|
||||
magnitude: 0.5,
|
||||
});
|
||||
} else {
|
||||
expect(aftermath).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a party Mage Frostbite proc on a landed Blizzard channel pulse", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = "party-mage-frostbite-blizzard";
|
||||
@@ -708,9 +1235,21 @@ describe("party combat runtime", () => {
|
||||
registerEngagedMob("pet-runtime-boss", 100_000);
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||
let growlConfigured = false;
|
||||
for (let now = 1_000; now <= 30_000; now += 100) {
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
const hunterPet = useCombatStore.getState().summons.find((summon) => summon.ownerActorId === hunter.id
|
||||
&& summon.hunterPet);
|
||||
if (hunterPet && !growlConfigured) {
|
||||
expect(hunterPet).toMatchObject({
|
||||
hunterPetCommand: "follow",
|
||||
hunterPetReactMode: "defensive",
|
||||
hunterPetGrowlAutocastEnabled: false,
|
||||
});
|
||||
expect(useCombatStore.getState().setHunterPetGrowlAutocast(hunterPet.id, true)).toBe(true);
|
||||
growlConfigured = true;
|
||||
}
|
||||
}
|
||||
stop();
|
||||
|
||||
@@ -718,6 +1257,31 @@ describe("party combat runtime", () => {
|
||||
summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id
|
||||
));
|
||||
expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id]));
|
||||
const baseHunterPetStats = wow335HunterPetBaseStatsForLevel(hunter.level);
|
||||
expect(pets.find((summon) => summon.ownerActorId === hunter.id)).toMatchObject({
|
||||
hunterPet: {
|
||||
baseStats: { strength: 192, attackPower: 364 },
|
||||
effectiveStats: {
|
||||
inheritedStamina: expect.any(Number),
|
||||
inheritedRangedAttackPower: expect.any(Number),
|
||||
inheritedSpellDamage: expect.any(Number),
|
||||
},
|
||||
},
|
||||
});
|
||||
const hunterPet = pets.find((summon) => summon.ownerActorId === hunter.id)!;
|
||||
expect(growlConfigured).toBe(true);
|
||||
expect(hunterPet.hunterPetGrowlReadyAt).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().mobs["pet-runtime-boss"]?.threatByActor[hunterPet.id]).toBeGreaterThanOrEqual(1_234);
|
||||
expect(useCombatStore.getState().mobs["pet-runtime-boss"]?.forcedTarget).toBeNull();
|
||||
expect(hunterPet.hunterPet!.effectiveStats.inheritedSpellDamage).toBeGreaterThan(0);
|
||||
expect(hunterPet.hunterPet!.effectiveStats.inheritedSpellDamage).toBeCloseTo(
|
||||
hunterPet.hunterPet!.effectiveStats.inheritedRangedAttackPower / 0.22
|
||||
* WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE,
|
||||
);
|
||||
expect(hunterPet.maxHealth).toBe(hunterPet.hunterPet!.effectiveStats.maxHealth);
|
||||
expect(hunterPet.attackDamage).toBe(hunterPet.hunterPet!.effectiveStats.averageAutoAttackDamage);
|
||||
expect(hunterPet.maxHealth).toBeGreaterThan(baseHunterPetStats.maxHealth);
|
||||
expect(hunterPet.attackDamage).toBeGreaterThan(baseHunterPetStats.averageAutoAttackDamage);
|
||||
expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true);
|
||||
expect(events.filter((event) => event.kind === "damage"
|
||||
&& [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([
|
||||
|
||||
+2070
-175
File diff suppressed because it is too large.
Load diff
@@ -69,10 +69,17 @@ type PartyActiveCast = CombatActorCastState & {
|
||||
readonly backdraftGcdMs?: number;
|
||||
/** Tidal Waves charges spent at cast start, retained for Lesser Healing Wave's crit roll. */
|
||||
readonly tidalWavesChargesConsumed?: number;
|
||||
/** Maelstrom Weapon rank and stacks spent at cast start, retained for its PvP damage rider. */
|
||||
readonly maelstromWeaponRank?: number;
|
||||
readonly maelstromWeaponStacksConsumed?: number;
|
||||
/** Arcane Potency's next-spell critical chance, snapshotted when a Mage cast starts. */
|
||||
readonly arcanePotencyCriticalChanceBonusPercent?: number;
|
||||
/** Mana committed for this cast, used by Master of Elements critical refunds. */
|
||||
readonly manaSpent?: number;
|
||||
/** DBC-derived pre-discount base Mana cost retained for Paladin Illumination. */
|
||||
readonly illuminationBaseManaCost?: number;
|
||||
/** Prevents later pulses of a channel cast from repeating Holy Candle's cast trigger. */
|
||||
readonly romPriestHolyCandleCharged?: boolean;
|
||||
};
|
||||
|
||||
export interface PartyMember {
|
||||
|
||||
+492
-38
@@ -22,11 +22,63 @@ import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
|
||||
import { overrideRomMageEffects } from "./romMageOverrides";
|
||||
import { overrideRomWarlockEffects } from "./romWarlockOverrides";
|
||||
import { overrideRomDruidEffects } from "./romDruidOverrides";
|
||||
import { romPriestDescription } from "./romPriestTooltips";
|
||||
import { romPriestDescription, romPriestName } from "./romPriestTooltips";
|
||||
import { overrideRomPriestMageEffects } from "./romPriestMageRuntime";
|
||||
import { romPriestHolyBlessingEffects } from "./romPriestHolyBlessingRuntime";
|
||||
import {
|
||||
ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_AURA,
|
||||
} from "./romPriestChampionBeaconRuntime";
|
||||
import { overrideRomPriestWarlockEffects } from "./romPriestWarlockOverrides";
|
||||
import { overrideRomChampionEffects } from "./romChampionOverrides";
|
||||
import { ROM_WARDEN_BRIAR_SHIELD_TAG } from "./romWardenOverrides";
|
||||
import { romWarriorCoaAdditionalAbilities } from "./romWarriorCoaCatalog";
|
||||
import { romWarriorDruidRageOfCalmnessCatalogOverride } from "./romWarriorDruidRageOfCalmness";
|
||||
import { romScoutPriestScreenAttackCatalogOverride } from "./romScoutPriestScreenAttack";
|
||||
import { romScoutPriestDrumsOfWarCatalogOverride } from "./romScoutPriestOverrides";
|
||||
import { romPriestScoutCuringShotCatalogOverride } from "./romPriestScoutCuringShot";
|
||||
import { romWarlockWarriorEnragedCoaAdditionalAbilities } from "./romWarlockWarriorEnragedCoaMechanics";
|
||||
import {
|
||||
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST,
|
||||
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION,
|
||||
} from "./romWarlockMageSoulBrandSting";
|
||||
import {
|
||||
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST,
|
||||
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION,
|
||||
ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS,
|
||||
isRomWarlockPriestSoulEnlightenment,
|
||||
} from "./romWarlockPriestSoulEnlightenment";
|
||||
import {
|
||||
ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION,
|
||||
ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION,
|
||||
ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION,
|
||||
romDruidRogueSinisterRequestEffects,
|
||||
isRomDruidRoguePair,
|
||||
} from "./romDruidRogueCurrentCoa";
|
||||
import {
|
||||
isRomDruidMagePair,
|
||||
ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION,
|
||||
} from "./romDruidMageInspirationRuntime";
|
||||
import {
|
||||
isRomMageDruidPair,
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
|
||||
} from "./romMageDruidGreenGuardianRuntime";
|
||||
import {
|
||||
ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION,
|
||||
ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID,
|
||||
ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION,
|
||||
ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP,
|
||||
romRogueEnergizedHandsApplyEffect,
|
||||
} from "./romRogueEnergizedHandsRuntime";
|
||||
import {
|
||||
ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION,
|
||||
ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID,
|
||||
} from "./romRogueChaosDagger";
|
||||
import {
|
||||
ROM_PRIEST_HOLY_CANDLE_COST,
|
||||
ROM_PRIEST_HOLY_CANDLE_DESCRIPTION,
|
||||
romPriestHolyCandleEffects,
|
||||
} from "./romPriestHolyCandleRuntime";
|
||||
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
@@ -99,6 +151,13 @@ function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
|
||||
}
|
||||
|
||||
function costsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "costs">): readonly AbilityCost[] {
|
||||
if (row.sourceSkillId === 490274) return [];
|
||||
if (row.sourceSkillId === 490275) return [{ resource: "mana", amount: 120 }];
|
||||
if (row.sourceSkillId === 490276) return [{
|
||||
resource: "mana", amount: 10, percentage: true, percentageBasis: "base-mana",
|
||||
}];
|
||||
if (row.sourceSkillId === 498612) return [{ resource: "rage", amount: 15 }];
|
||||
if (row.sourceSkillId === 498642) return [{ resource: "rage", amount: 75, percentage: false }];
|
||||
let resolved = costs(row.costs);
|
||||
if (row.sourceSkillId === 490136) {
|
||||
resolved = resolved.map((cost) => cost.resource === "rage" ? { ...cost, amount: 15 } : cost);
|
||||
@@ -108,26 +167,81 @@ function costsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "c
|
||||
}
|
||||
|
||||
function cooldownMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "cooldownMs">): number {
|
||||
if (row.sourceSkillId === 490274) return 0;
|
||||
if (row.sourceSkillId === 490275 || row.sourceSkillId === 490276) return 18_000;
|
||||
if (row.sourceSkillId === 498612) return 12_000;
|
||||
if (row.sourceSkillId === 498642) return 1_000;
|
||||
// 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;
|
||||
// CoA's current Knight/Shaman Whirlwind Shield is 5s before Compassion.
|
||||
if (row.sourceSkillId === 490175) return 5_000;
|
||||
return row.sourceSkillId === 490136 ? 40_000 : row.cooldownMs;
|
||||
}
|
||||
|
||||
function castTimeMsFor(row: Pick<GeneratedRank | GeneratedSkill, "sourceSkillId" | "castTimeMs">): number {
|
||||
if ([490274, 490275, 490276, 498612].includes(row.sourceSkillId)) return 0;
|
||||
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"] {
|
||||
if ([490274, 490275, 490276, 498612].includes(row.sourceSkillId)) return "instant";
|
||||
return row.sourceSkillId === 491164 || row.sourceSkillId === 498770
|
||||
? "channel"
|
||||
: row.castTimeMs > 0 ? "cast" : "instant";
|
||||
}
|
||||
|
||||
function currentWarlockCoaDescription(sourceSkillId: number, fallback: string): string {
|
||||
// Current CoA Warrior/Warlock skill list: https://chroniclesofarcadia.com/en/wiki/skills/list/1-9
|
||||
// These two local passive rows describe obsolete cooldown-reset children;
|
||||
// the current page instead gives Speed Slash's Rage discount and Excellent
|
||||
// Combat Training's physical-damage bonus.
|
||||
const descriptions: Readonly<Record<number, string>> = {
|
||||
499827: "Passive: Increases Slash damage by an unspecified amount, which is not modeled. Reduces Slash's Rage cost by 10.",
|
||||
499806: "Passive: Grants an additional 20% Physical Damage.",
|
||||
498642: "Converts 75 Rage into 6 Psi. 1-second cooldown.",
|
||||
498634: "Enraged restores additional Focus. Casting Psychic Arrows, Warp Charge, or Weakening Weave Curse can reset Enraged's cooldown. Current CoA does not publish the proc chance or Focus amount, so these riders are not modeled.",
|
||||
498633: "When Psychic Arrows hits, gain 12% healing power and 12% Wisdom for 20 seconds. Warp Charge heals a friendly target instead of dealing damage. The simulator does not model ally-target redirection to that ally's hostile target.",
|
||||
498643: "Rising Tide becomes instant with a 5-second cooldown and restores 1 Psi. A critical hit activates Healing Fortune. The simulator cannot redirect an ally-targeted cast to that ally's hostile target.",
|
||||
498648: "Urgent Heal restores 1 Psi. Defense Net's effect time is reduced by 1 second. Ring of Comfort's reset chance is not specified and is not modeled.",
|
||||
498668: "Passively increases magical damage and healing by 16%.",
|
||||
499856: "Inflicts [497959], [497960], and [497964] on the target. Increases Magical Attack Power by 18% and Magical Critical Bonus by 25% for 30 seconds, and activates Healing Fortune, increasing healing by 12% for 20 seconds. Current CoA lists a 10% MP cost plus 15 Focus and range 150; RuneWaker supplies 15 Focus and range 22.5. The simulator retains those local cost and range values because RoM Warlock has no modeled Mana pool and no verified range-unit conversion.",
|
||||
499868: "Passive: magical damage skills call an Ethereal Hammer for 187.5% main-hand weapon DPS against area targets or an Ethereal Sword for 1,050% main-hand weapon DPS against a single target. The source calls this Physical Dark damage, which the combat model cannot represent as a composite damage type, so this rider is not yet applied. The additional main-hand damage for Slash and Consistent Heart Strike is unspecified and is not modeled.",
|
||||
499870: "Increases Breathing Control's Focus recovery and grants an extra 15% HP. Current CoA does not publish the Focus amount or proc chance, so these riders are not modeled.",
|
||||
};
|
||||
return descriptions[sourceSkillId] ?? fallback;
|
||||
}
|
||||
|
||||
const ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION = "Reworks Shadow Step (490344): the cast deals 4,400 Dark damage, stuns for 1 second, grants 3 Psi, and increases Dark damage by 30% and movement speed by 10% for 30 seconds. This replaces Shadow Step's teleport; the simulator does not model the behind-target positioning.";
|
||||
const ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION = "Instantly moves behind the target. In the Warlock/Rogue build with Shadow Slice (498678), it instead deals 4,400 Dark damage, stuns for 1 second, grants 3 Psi, and increases Dark damage by 30% and movement speed by 10% for 30 seconds. The simulator cannot model the behind-target positioning.";
|
||||
|
||||
function currentPriestCoaDescription(sourceSkillId: number, fallback: string): string {
|
||||
if (sourceSkillId === 490304) {
|
||||
return ROM_PRIEST_HOLY_CANDLE_DESCRIPTION;
|
||||
}
|
||||
if (sourceSkillId === 498620) {
|
||||
return "Creates a 30-second Beacon on one friendly party member. Each direct healing cast restores 770 HP to that member. CoA publishes 770 at skill level +105 (the maximum); the simulator applies this value at every unlocked rank because lower skill ranks are not modeled separately. HoT ticks and channel pulses are outside the simulator's direct-cast trigger boundary.";
|
||||
}
|
||||
if (sourceSkillId === 492933) {
|
||||
return "Passive: Increases Wisdom by 12%.";
|
||||
}
|
||||
if (sourceSkillId === 498612) {
|
||||
return "Inflicts 975.1% CPS Light Damage to nearby targets around you and reduces Blessing of Humility's cooldown by 15 seconds. Cost: 15 Rage. Radius: 80. Cooldown: 12 seconds.";
|
||||
}
|
||||
return romPriestDescription(sourceSkillId, fallback);
|
||||
}
|
||||
|
||||
function currentMageCoaDescription(sourceSkillId: number, fallback: string): string {
|
||||
if (sourceSkillId === 490248) {
|
||||
return "Increases Fire resistance by 10 points and reduces received magical damage by 11.5% within radius 100. CoA lists +230 Fire resistance at skill level 105, but the simulator retains RuneWaker's local +10 because it has no source-backed scaling curve or skill-level state. Costs 120 MP; range 150; instant. CoA omits a duration, so this catalog retains RuneWaker's 10-minute duration.";
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
type GeneratedSemanticRow = Pick<GeneratedSkill, "sourceSkillId" | "name" | "description" | "target" | "passive" | "source">
|
||||
& Partial<Pick<GeneratedSkill, "classId" | "secondaryClassId">>;
|
||||
|
||||
@@ -169,6 +283,8 @@ function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySe
|
||||
}
|
||||
|
||||
function abilityTarget(row: Pick<GeneratedSemanticRow, "sourceSkillId" | "target">): GeneratedSemanticRow["target"] {
|
||||
if (row.sourceSkillId === 490274 || row.sourceSkillId === 498612) return "self";
|
||||
if (row.sourceSkillId === 490275 || row.sourceSkillId === 490276) return "hostile";
|
||||
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.
|
||||
@@ -781,6 +897,85 @@ function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[]
|
||||
}
|
||||
|
||||
function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
if (isRomDruidRoguePair(row) && row.sourceSkillId === 494023) {
|
||||
return romDruidRogueSinisterRequestEffects();
|
||||
}
|
||||
if (row.classId === "rom-priest") {
|
||||
const holyBlessing = romPriestHolyBlessingEffects(row);
|
||||
if (holyBlessing) return holyBlessing;
|
||||
if (row.sourceSkillId === 498620) {
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
aura: ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_AURA,
|
||||
recipient: "ability-target",
|
||||
sourceEffectType: 1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 498620,
|
||||
}];
|
||||
}
|
||||
if (row.sourceSkillId === 490275) {
|
||||
const silence: AbilityEffect = {
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: "rom-priest-vow-of-silence",
|
||||
name: "Vow of Silence",
|
||||
disposition: "debuff",
|
||||
durationMs: 8_400,
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
recipient: "ability-target",
|
||||
control: { kind: "silence" },
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 490275,
|
||||
};
|
||||
return [silence, { kind: "interrupt", lockoutMs: 8_400 }];
|
||||
}
|
||||
if (row.sourceSkillId === 490276) {
|
||||
return [
|
||||
{
|
||||
kind: "damage",
|
||||
coefficient: 0,
|
||||
basePoints: 1_655,
|
||||
pointsPerLevel: 0,
|
||||
sourceLevel: 1,
|
||||
school: "holy",
|
||||
},
|
||||
{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: "rom-priest-holy-void",
|
||||
name: "Voidal Infection",
|
||||
disposition: "debuff",
|
||||
durationMs: 20_000,
|
||||
dispelCategory: "magic",
|
||||
modifiers: [
|
||||
{ kind: "stat", stat: "physical-defense", operation: "percent", value: -0.188 },
|
||||
{ kind: "stat", stat: "magical-defense", operation: "percent", value: -0.188 },
|
||||
],
|
||||
},
|
||||
recipient: "ability-target",
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 490276,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (row.sourceSkillId === 498612) {
|
||||
return [
|
||||
{ kind: "damage", coefficient: 9.751, school: "holy" },
|
||||
{
|
||||
kind: "cooldown-reset",
|
||||
mode: "reduce",
|
||||
abilityName: "Blessing of Humility",
|
||||
amountMs: 15_000,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
const specialized = specializedEffects(row);
|
||||
if (specialized) return specialized;
|
||||
const semantics = translateRomAbilitySemantics(row);
|
||||
@@ -792,6 +987,8 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
utilityTags: [...(effect.aura.utilityTags ?? []), "rom-briar-shield"],
|
||||
},
|
||||
} : effect);
|
||||
const holyCandle = romPriestHolyCandleEffects(row, translated);
|
||||
if (holyCandle) return holyCandle;
|
||||
const priestMageOverride = overrideRomPriestMageEffects(row, translated);
|
||||
if (priestMageOverride) return priestMageOverride;
|
||||
const championOverride = overrideRomChampionEffects(row, translated);
|
||||
@@ -952,7 +1149,7 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
if (row.sourceSkillId === 490269) {
|
||||
return translated.filter((effect) => effect.kind === "hot").slice(0, 1);
|
||||
}
|
||||
if (row.sourceSkillId === 490304 || row.sourceSkillId === 490434) {
|
||||
if (row.sourceSkillId === 490434) {
|
||||
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] },
|
||||
@@ -1010,8 +1207,8 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
} : effect
|
||||
));
|
||||
}
|
||||
if (row.sourceSkillId === 490347) {
|
||||
return [{ kind: "status", status: "sleep", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
|
||||
if (row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID && row.classId === "rom-rogue") {
|
||||
return [romRogueEnergizedHandsApplyEffect()];
|
||||
}
|
||||
if (row.sourceSkillId === 490454) {
|
||||
return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
|
||||
@@ -1042,11 +1239,16 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] },
|
||||
} : effect);
|
||||
}
|
||||
if (row.sourceSkillId === 490329) {
|
||||
if (row.sourceSkillId === 490329 && row.classId === "rom-rogue") {
|
||||
const aura = translated.find((effect) => effect.kind === "apply-aura");
|
||||
return aura?.kind === "apply-aura" ? [{
|
||||
...aura,
|
||||
aura: { ...aura.aura, durationMs: 900_000, utilityTags: ["rom-poisonous"] },
|
||||
aura: {
|
||||
...aura.aura,
|
||||
durationMs: 900_000,
|
||||
exclusiveGroup: ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP,
|
||||
utilityTags: ["rom-poisonous"],
|
||||
},
|
||||
}] : translated;
|
||||
}
|
||||
// RuneWaker's Knight uses Holy Seal as a target-scoped build/spend resource.
|
||||
@@ -1076,14 +1278,28 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
return translated;
|
||||
}
|
||||
|
||||
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
|
||||
function areaRadius(row: Pick<GeneratedSkill, "sourceSkillId" | "description" | "range">): number | undefined {
|
||||
// Rune Overload's local tooltip says only "immediate vicinity". Its
|
||||
// hash-matched source has no numeric radius, so do not apply the generic
|
||||
// heuristic's 10-yard fallback to this self-targeted ability.
|
||||
if (row.sourceSkillId === 498572) return undefined;
|
||||
if (row.description.includes("up to 3 targets") && row.description.includes("Inflicts")) return 6;
|
||||
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 {
|
||||
function sourceTargeting(sourceSkillId: number, classId?: string, secondaryClassId?: string): AbilityDefinition["targeting"] | undefined {
|
||||
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494321) return { shape: "circle", radius: 8 };
|
||||
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494023) return { shape: "circle", radius: 15 };
|
||||
if (sourceSkillId === 498612) return { shape: "circle", radius: 80 };
|
||||
// Current CoA Fire Ward (490248) is a radius-100 raid aura. RoM's local
|
||||
// runtime uses one tenth of the published distance units, matching range 150
|
||||
// to 15 for this same catalog row.
|
||||
if (sourceSkillId === 490248) return { shape: "circle", radius: 10 };
|
||||
// CoA's current Whirlwind Shield (1493719) is a 100-radius multi-target cast;
|
||||
// map the exact-name local RuneWaker row to that current shape.
|
||||
if (sourceSkillId === 490175 || sourceSkillId === 1_493_719) return { shape: "circle", radius: 100 };
|
||||
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 };
|
||||
@@ -1109,28 +1325,116 @@ function sourceTargeting(sourceSkillId: number): AbilityDefinition["targeting"]
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sourceRange(sourceSkillId: number, fallback: number): number {
|
||||
function sourceRange(sourceSkillId: number, fallback: number, classId?: string, secondaryClassId?: string): number {
|
||||
if (sourceSkillId === 490274) return 1;
|
||||
if (classId === "rom-priest" && secondaryClassId === undefined && sourceSkillId === 490304) return 1;
|
||||
if (sourceSkillId === 490275 || sourceSkillId === 490276 || sourceSkillId === 498612) return 150;
|
||||
if (isRomDruidRoguePair({ classId, secondaryClassId }) && [494024, 494321].includes(sourceSkillId)) return 15;
|
||||
if (isRomDruidRoguePair({ classId, secondaryClassId }) && sourceSkillId === 494023) return 5;
|
||||
// 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 = costsFor(row);
|
||||
const currentDruidRogue = isRomDruidRoguePair(parent);
|
||||
const currentDruidMageInspiration = isRomDruidMagePair(parent) && row.sourceSkillId === 494322;
|
||||
const currentMageDruidGreenGuardian = isRomMageDruidPair(parent)
|
||||
&& row.sourceSkillId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID;
|
||||
const currentCoaHolyCandle = parent.classId === "rom-priest"
|
||||
&& parent.secondaryClassId === undefined
|
||||
&& row.sourceSkillId === 490304;
|
||||
const currentCoaRogueEnergizedHands = parent.classId === "rom-rogue"
|
||||
&& row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID;
|
||||
const currentCoaRoguePoisonousHands = parent.classId === "rom-rogue" && row.sourceSkillId === 490329;
|
||||
const currentCoaRogueMageChaosDagger = parent.classId === "rom-rogue"
|
||||
&& parent.secondaryClassId === "rom-mage"
|
||||
&& row.sourceSkillId === ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID;
|
||||
const currentCoaSoulBrandSting = row.sourceSkillId === 498710
|
||||
&& parent.classId === "rom-warlock" && parent.secondaryClassId === "rom-mage";
|
||||
const currentCoaSoulEnlightenment = isRomWarlockPriestSoulEnlightenment({
|
||||
classId: parent.classId,
|
||||
secondaryClassId: parent.secondaryClassId,
|
||||
sourceSkillId: row.sourceSkillId,
|
||||
});
|
||||
const currentCoaHolyPowerExplosion = row.sourceSkillId === 490172
|
||||
&& parent.classId === "rom-knight" && parent.secondaryClassId === undefined;
|
||||
const currentCoaLeopardInstinct = row.sourceSkillId === 495402
|
||||
&& parent.classId === "rom-knight" && parent.secondaryClassId === "rom-rogue";
|
||||
const currentCoaPassive = currentCoaHolyPowerExplosion || currentCoaLeopardInstinct
|
||||
|| currentDruidMageInspiration;
|
||||
const passiveMode = currentCoaPassive || currentCoaRogueMageChaosDagger;
|
||||
const rankCosts = currentMageDruidGreenGuardian ? []
|
||||
: currentCoaHolyCandle ? [ROM_PRIEST_HOLY_CANDLE_COST]
|
||||
: currentCoaRogueEnergizedHands || currentCoaRogueMageChaosDagger ? []
|
||||
: currentDruidRogue && row.sourceSkillId === 494321
|
||||
? [{ resource: "mana" as const, amount: 4, percentage: true }, { resource: "nature-power" as const, amount: 1 }]
|
||||
: currentDruidRogue && row.sourceSkillId === 494024
|
||||
? [{ resource: "energy" as const, amount: 25 }]
|
||||
: currentDruidRogue && row.sourceSkillId === 494023
|
||||
? []
|
||||
: currentCoaSoulBrandSting
|
||||
? [ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST]
|
||||
: currentCoaSoulEnlightenment ? [ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST]
|
||||
: currentCoaPassive ? [] : costsFor(row);
|
||||
const target = abilityTarget({ ...row, target: row.target ?? parent.target });
|
||||
const priestRankName = parent.classId === "rom-priest" ? romPriestName(row.sourceSkillId, row.name) : row.name;
|
||||
const currentClassDescription = currentMageDruidGreenGuardian
|
||||
? ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION
|
||||
: currentDruidMageInspiration
|
||||
? ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION
|
||||
: currentCoaRogueEnergizedHands
|
||||
? ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION
|
||||
: currentCoaRoguePoisonousHands
|
||||
? ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION
|
||||
: currentCoaRogueMageChaosDagger
|
||||
? ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION
|
||||
: parent.classId === "rom-priest"
|
||||
? currentPriestCoaDescription(row.sourceSkillId, row.description)
|
||||
: parent.classId === "rom-mage"
|
||||
? currentMageCoaDescription(row.sourceSkillId, row.description)
|
||||
: currentDruidRogue && row.sourceSkillId === 494321
|
||||
? ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION
|
||||
: currentDruidRogue && row.sourceSkillId === 494024
|
||||
? ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION
|
||||
: currentDruidRogue && row.sourceSkillId === 494023
|
||||
? ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION
|
||||
: row.description;
|
||||
const currentWarlockRogueShadowSlice = row.sourceSkillId === 498678
|
||||
&& parent.classId === "rom-warlock" && parent.secondaryClassId === "rom-rogue";
|
||||
const rankDescription = currentCoaSoulBrandSting
|
||||
? ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION
|
||||
: currentCoaSoulEnlightenment
|
||||
? ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION
|
||||
: currentWarlockRogueShadowSlice
|
||||
? ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION
|
||||
: row.sourceSkillId === 490344 && parent.classId === "rom-rogue"
|
||||
? ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION
|
||||
: currentWarlockCoaDescription(row.sourceSkillId, currentClassDescription);
|
||||
return {
|
||||
rank: row.rank,
|
||||
spellId: row.sourceSkillId,
|
||||
level: row.level,
|
||||
description: parent.classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
|
||||
castMode: castModeFor(row),
|
||||
castTimeMs: castTimeMsFor(row),
|
||||
cooldownMs: cooldownMsFor(row),
|
||||
level: currentMageDruidGreenGuardian ? 25 : row.level,
|
||||
// AbilityRankDefinition has no name field, so retain the canonical CoA
|
||||
// name in the rank copy when a local row was renamed.
|
||||
description: parent.classId === "rom-priest" && priestRankName !== row.name
|
||||
&& !rankDescription.toLowerCase().startsWith(priestRankName.toLowerCase())
|
||||
? `${priestRankName}: ${rankDescription}`
|
||||
: rankDescription,
|
||||
castMode: passiveMode || currentMageDruidGreenGuardian ? "instant" : currentDruidRogue && row.sourceSkillId === 494321 ? "channel" : castModeFor(row),
|
||||
castTimeMs: passiveMode || currentMageDruidGreenGuardian ? 0 : currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? 1_000
|
||||
: currentDruidRogue && row.sourceSkillId === 494321 ? 1_500 : castTimeMsFor(row),
|
||||
cooldownMs: passiveMode || currentMageDruidGreenGuardian || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? 0
|
||||
: currentDruidRogue && row.sourceSkillId === 494321 ? 0
|
||||
: currentDruidRogue && row.sourceSkillId === 494024 ? 1_000
|
||||
: currentCoaHolyCandle ? 30_000
|
||||
: currentDruidRogue && row.sourceSkillId === 494023 ? 15_000 : cooldownMsFor(row),
|
||||
gcdMs: 1_500,
|
||||
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
|
||||
...(currentDruidRogue && row.sourceSkillId === 494321 ? { channelPulses: 3, channelTickMs: 500 } : {}),
|
||||
cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
|
||||
costs: rankCosts,
|
||||
effects: effects({
|
||||
effects: currentCoaPassive ? [] : effects({
|
||||
...row,
|
||||
classId: parent.classId,
|
||||
secondaryClassId: parent.secondaryClassId,
|
||||
@@ -1146,44 +1450,179 @@ function ability(row: GeneratedSkill): AbilityDefinition {
|
||||
const secondaryClassId = row.secondaryClassId && ROM_CLASS_IDS.includes(row.secondaryClassId as RomClassId)
|
||||
? row.secondaryClassId as RomClassId
|
||||
: undefined;
|
||||
const abilityCosts = costsFor(row);
|
||||
const currentDruidRogue = isRomDruidRoguePair({ classId, secondaryClassId });
|
||||
const currentMageDruidGreenGuardian = isRomMageDruidPair({ classId, secondaryClassId })
|
||||
&& row.sourceSkillId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID;
|
||||
const currentDruidMageInspiration = isRomDruidMagePair({ classId, secondaryClassId })
|
||||
&& row.sourceSkillId === 494322;
|
||||
const currentCoaHolyCandle = classId === "rom-priest"
|
||||
&& secondaryClassId === undefined
|
||||
&& row.sourceSkillId === 490304;
|
||||
const currentCoaRogueEnergizedHands = classId === "rom-rogue"
|
||||
&& row.sourceSkillId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID;
|
||||
const currentCoaRoguePoisonousHands = classId === "rom-rogue" && row.sourceSkillId === 490329;
|
||||
const currentCoaRogueMageChaosDagger = classId === "rom-rogue"
|
||||
&& secondaryClassId === "rom-mage"
|
||||
&& row.sourceSkillId === ROM_ROGUE_MAGE_CHAOS_DAGGER_SPELL_ID;
|
||||
const currentCoaCompassion = row.sourceSkillId === 491_635;
|
||||
const currentCoaHolyPowerExplosion = row.sourceSkillId === 490172 && classId === "rom-knight";
|
||||
const currentCoaLeopardInstinct = row.sourceSkillId === 495402
|
||||
&& classId === "rom-knight" && secondaryClassId === "rom-rogue";
|
||||
const currentCoaPsychicWarrior = row.sourceSkillId === 499868
|
||||
&& classId === "rom-warlock" && secondaryClassId === "rom-warrior";
|
||||
const currentCoaShadowSlice = row.sourceSkillId === 498678
|
||||
&& classId === "rom-warlock" && secondaryClassId === "rom-rogue";
|
||||
const currentCoaShadowStep = row.sourceSkillId === 490344 && classId === "rom-rogue";
|
||||
const currentCoaSoulBrandSting = row.sourceSkillId === 498710
|
||||
&& classId === "rom-warlock" && secondaryClassId === "rom-mage";
|
||||
const currentCoaSoulEnlightenment = isRomWarlockPriestSoulEnlightenment({
|
||||
classId,
|
||||
secondaryClassId,
|
||||
sourceSkillId: row.sourceSkillId,
|
||||
});
|
||||
const currentCoaPassive = currentCoaHolyPowerExplosion || currentCoaLeopardInstinct
|
||||
|| currentCoaPsychicWarrior || currentCoaShadowSlice || currentDruidMageInspiration;
|
||||
const currentCoaPassiveMode = currentCoaPassive || currentCoaRogueMageChaosDagger || currentMageDruidGreenGuardian;
|
||||
const abilityCosts = currentMageDruidGreenGuardian ? []
|
||||
: currentCoaHolyCandle ? [ROM_PRIEST_HOLY_CANDLE_COST]
|
||||
: currentCoaRogueEnergizedHands ? []
|
||||
: currentDruidRogue && row.sourceSkillId === 494321
|
||||
? [{ resource: "mana" as const, amount: 4, percentage: true }, { resource: "nature-power" as const, amount: 1 }]
|
||||
: currentDruidRogue && row.sourceSkillId === 494024
|
||||
? [{ resource: "energy" as const, amount: 25 }]
|
||||
: currentDruidRogue && row.sourceSkillId === 494023
|
||||
? []
|
||||
: currentCoaSoulBrandSting
|
||||
? [ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST]
|
||||
: currentCoaSoulEnlightenment ? [ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_COST]
|
||||
: costsFor(row);
|
||||
const target = abilityTarget(row);
|
||||
const currentKnightName = currentCoaRogueEnergizedHands
|
||||
? "Energized Hands"
|
||||
: currentCoaRoguePoisonousHands
|
||||
? "Poisonous Hands"
|
||||
: currentCoaRogueMageChaosDagger
|
||||
? "Chaos Dagger"
|
||||
: row.sourceSkillId === 491_635
|
||||
? "Compassion of Holy Light"
|
||||
: currentCoaPsychicWarrior
|
||||
? "Psychic Warrior"
|
||||
: currentDruidRogue && row.sourceSkillId === 494023
|
||||
? "Sinister Request"
|
||||
: row.name;
|
||||
const currentAbilityName = currentCoaShadowSlice
|
||||
? "Shadow Slice"
|
||||
: classId === "rom-priest"
|
||||
? romPriestName(row.sourceSkillId, currentKnightName)
|
||||
: currentKnightName;
|
||||
const currentKnightDescription = currentMageDruidGreenGuardian
|
||||
? ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION
|
||||
: currentDruidMageInspiration
|
||||
? ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION
|
||||
: currentCoaRogueEnergizedHands
|
||||
? ROM_ROGUE_ENERGIZED_HANDS_DESCRIPTION
|
||||
: currentCoaRoguePoisonousHands
|
||||
? ROM_ROGUE_POISONOUS_HANDS_DESCRIPTION
|
||||
: currentCoaRogueMageChaosDagger
|
||||
? ROM_ROGUE_MAGE_CHAOS_DAGGER_DESCRIPTION
|
||||
: currentCoaShadowSlice
|
||||
? ROM_WARLOCK_ROGUE_SHADOW_SLICE_DESCRIPTION
|
||||
: currentCoaShadowStep
|
||||
? ROM_WARLOCK_ROGUE_SHADOW_STEP_DESCRIPTION
|
||||
: currentDruidRogue && row.sourceSkillId === 494321
|
||||
? ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION
|
||||
: currentDruidRogue && row.sourceSkillId === 494024
|
||||
? ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION
|
||||
: currentDruidRogue && row.sourceSkillId === 494023
|
||||
? ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION
|
||||
: row.sourceSkillId === 491_639
|
||||
? "Increases aggro generated by Whirlwind and reduces its Rage cost and cooldown. On successful hits, Whirlwind inflicts Deadly Wound, reducing the target's received healing by 30% for 10 seconds (5 seconds against players)."
|
||||
: row.sourceSkillId === 491_608
|
||||
? "Increases Punishment damage by 28.5%. When Punishment hits, the target takes 2% more damage for every Holy Seal stack it has for 10 seconds."
|
||||
: row.sourceSkillId === 491_635
|
||||
? "Current CoA lists this reused passive under Knight/Shaman, while the local generated row is Knight/Priest and this simulator has no Shaman class entry. The exact-ID local passive is bridged to Whirlwind Shield (CoA skill 1493719): a successful hit heals nearby allies for 120 + 0.2 x STA and triggers Holy Illumination; cooldown becomes 4 seconds instead of 5. CoA also scales its shield-defense damage term to 5x, but this simulator has no shield-defense stat, so that damage term is not modeled."
|
||||
: currentCoaHolyPowerExplosion
|
||||
? "Passive: increases Aggro generated by Knight abilities by 198%."
|
||||
: currentCoaLeopardInstinct
|
||||
? "Passive: each hit received adds one stack, up to 10. Each stack grants 3% Dodge and 3% Magic Resistance Rate; each Dodge removes one stack. This simulator applies Dodge only because it has no Magic Resistance Rate consumer."
|
||||
: currentWarlockCoaDescription(row.sourceSkillId, row.description);
|
||||
const currentWarriorCoaDescription = row.sourceSkillId === 493248
|
||||
&& classId === "rom-warrior" && secondaryClassId === "rom-rogue"
|
||||
? "Passive: gives the Warrior/Rogue CoA Probing Attack (493283) a 30% chance on a landed hit to restore 25 Energy and 50 Rage."
|
||||
: currentKnightDescription;
|
||||
const currentDescription = currentCoaSoulBrandSting
|
||||
? ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION
|
||||
: currentCoaSoulEnlightenment
|
||||
? ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_DESCRIPTION
|
||||
: classId === "rom-priest"
|
||||
? currentWarlockCoaDescription(row.sourceSkillId, currentPriestCoaDescription(row.sourceSkillId, currentKnightDescription))
|
||||
: classId === "rom-mage"
|
||||
? currentMageCoaDescription(row.sourceSkillId, currentWarriorCoaDescription)
|
||||
: currentWarriorCoaDescription;
|
||||
const currentCoaDescriptionOverride = currentMageDruidGreenGuardian || currentCoaPassive || currentCoaShadowStep || currentCoaSoulBrandSting || currentCoaSoulEnlightenment
|
||||
|| currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands || currentCoaRogueMageChaosDagger
|
||||
|| (currentDruidRogue && [494321, 494024, 494023].includes(row.sourceSkillId)) || (row.sourceSkillId === 493248
|
||||
&& classId === "rom-warrior" && secondaryClassId === "rom-rogue")
|
||||
|| (row.sourceSkillId === 490248 && classId === "rom-mage");
|
||||
const currentRanks = row.ranks.map((entry) => rank(entry, row)).map((entry) => ({
|
||||
...entry,
|
||||
...(currentCoaDescriptionOverride ? { description: currentDescription } : {}),
|
||||
...(currentDruidRogue && row.sourceSkillId === 494023
|
||||
? { effects: romDruidRogueSinisterRequestEffects() }
|
||||
: {}),
|
||||
}));
|
||||
return {
|
||||
id: row.id,
|
||||
classId,
|
||||
...(secondaryClassId ? { secondaryClassId } : {}),
|
||||
dbcSpellId: row.sourceSkillId,
|
||||
name: row.name,
|
||||
unlockLevel: row.unlockLevel,
|
||||
name: currentAbilityName,
|
||||
unlockLevel: currentMageDruidGreenGuardian ? 25 : row.unlockLevel,
|
||||
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,
|
||||
range: { min: 0, max: sourceRange(row.sourceSkillId, row.range) },
|
||||
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
|
||||
? { radius: sourceTargeting(row.sourceSkillId)!.radius }
|
||||
sigil: currentAbilityName.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW",
|
||||
description: currentDescription,
|
||||
target: currentCoaPassiveMode || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands ? "self" : target,
|
||||
range: { min: 0, max: currentCoaPassiveMode || currentCoaRogueEnergizedHands || currentCoaRoguePoisonousHands
|
||||
? 0 : sourceRange(row.sourceSkillId, row.range, classId, secondaryClassId) },
|
||||
...(currentCoaSoulEnlightenment
|
||||
? { radius: ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS }
|
||||
: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)?.radius !== undefined
|
||||
? { radius: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)!.radius }
|
||||
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
|
||||
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
|
||||
castMode: castModeFor(row),
|
||||
castTimeMs: castTimeMsFor(row),
|
||||
cooldownMs: cooldownMsFor(row),
|
||||
...(currentCoaSoulEnlightenment
|
||||
? { targeting: { shape: "circle" as const, radius: ROM_WARLOCK_PRIEST_SOUL_ENLIGHTENMENT_RADIUS } }
|
||||
: sourceTargeting(row.sourceSkillId, classId, secondaryClassId)
|
||||
? { targeting: sourceTargeting(row.sourceSkillId, classId, secondaryClassId) } : {}),
|
||||
castMode: currentCoaPassiveMode ? "instant" : currentDruidRogue && row.sourceSkillId === 494321 ? "channel" : castModeFor(row),
|
||||
castTimeMs: currentCoaPassiveMode ? 0 : currentDruidRogue && row.sourceSkillId === 494321 ? 1_500 : castTimeMsFor(row),
|
||||
cooldownMs: currentCoaPassiveMode ? 0 : currentDruidRogue && row.sourceSkillId === 494321 ? 0
|
||||
: currentDruidRogue && row.sourceSkillId === 494024 ? 1_000
|
||||
: currentCoaHolyCandle ? 30_000
|
||||
: currentDruidRogue && row.sourceSkillId === 494023 ? 15_000 : cooldownMsFor(row),
|
||||
gcdMs: 1_500,
|
||||
...(row.sourceSkillId === 498770 ? { channelPulses: 4 } : {}),
|
||||
cost: abilityCosts[0] ?? { resource: "mana", amount: 0 },
|
||||
costs: abilityCosts,
|
||||
effects: effects({ ...row, target, classId }),
|
||||
passive: row.passive,
|
||||
...(currentDruidRogue && row.sourceSkillId === 494321 ? { channelPulses: 3, channelTickMs: 500 } : {}),
|
||||
cost: currentCoaPassiveMode ? { resource: "mana", amount: 0 } : abilityCosts[0] ?? { resource: "mana", amount: 0 },
|
||||
costs: currentCoaPassiveMode ? [] : abilityCosts,
|
||||
effects: currentCoaPassive ? [] : effects({ ...row, target, classId, secondaryClassId }),
|
||||
passive: currentMageDruidGreenGuardian ? false : currentCoaPassiveMode || row.sourceSkillId === 490274 ? true : row.passive,
|
||||
source: "runewaker",
|
||||
sourceUnlockLevel: row.unlockLevel,
|
||||
ranks: row.ranks.map((entry) => rank(entry, row)),
|
||||
romSkillGroup: row.group,
|
||||
ranks: currentRanks,
|
||||
romSkillGroup: currentMageDruidGreenGuardian ? "elite" : row.group,
|
||||
};
|
||||
}
|
||||
|
||||
const generatedAbilities = generated.map(ability);
|
||||
const generatedAbilities = generated.map(ability)
|
||||
.map(romWarriorDruidRageOfCalmnessCatalogOverride)
|
||||
.map(romScoutPriestScreenAttackCatalogOverride)
|
||||
.map(romScoutPriestDrumsOfWarCatalogOverride)
|
||||
.map(romPriestScoutCuringShotCatalogOverride);
|
||||
export const ROM_ALL_ABILITIES = Object.freeze([
|
||||
...generatedAbilities,
|
||||
...romPriestKnightAdditionalAbilities(generatedAbilities),
|
||||
...romWarriorCoaAdditionalAbilities(generatedAbilities),
|
||||
...romWarlockWarriorEnragedCoaAdditionalAbilities(generatedAbilities),
|
||||
]);
|
||||
|
||||
const romAbilityByGeneratedId = new Map(ROM_ALL_ABILITIES.map((entry) => [entry.id, entry]));
|
||||
@@ -1195,13 +1634,28 @@ for (const parent of generated) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rune Overload's generated player row contains the hidden MagicCollect child
|
||||
// ID on component 621241's one-second on-time trigger. The child itself is not
|
||||
// a learned/player row, but its tooltip-referenced damage MagicObject (621243)
|
||||
// is retained in the hash-matched parent source payload. Alias the child spell
|
||||
// ID to that existing payload so the generic triggered-spell resolver can
|
||||
// expand it without inventing a radius or target cap.
|
||||
const runeOverloadParent = generated.find((entry) => entry.sourceSkillId === 498572);
|
||||
const runeOverloadChildPayload = runeOverloadParent?.source?.components?.find((component) => component.id === 621243);
|
||||
const hiddenTriggeredComponentOwners = new Map<number, { readonly parent: GeneratedSkill; readonly component: RomSourceComponent }>();
|
||||
if (runeOverloadParent && runeOverloadChildPayload) {
|
||||
hiddenTriggeredComponentOwners.set(498574, { parent: runeOverloadParent, component: runeOverloadChildPayload });
|
||||
}
|
||||
|
||||
/** Resolve source-triggered MagicObject rows that are not player-facing skills. */
|
||||
export function romTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
|
||||
const owner = triggeredComponentOwners.get(spellId);
|
||||
const owner = hiddenTriggeredComponentOwners.get(spellId) ?? triggeredComponentOwners.get(spellId);
|
||||
if (!owner) return null;
|
||||
const parentAbility = romAbilityByGeneratedId.get(owner.parent.id);
|
||||
if (!parentAbility) return null;
|
||||
const triggeredEffects = effects({
|
||||
const triggeredEffects = spellId === 498574
|
||||
? [{ kind: "damage", coefficient: 0.6, school: "physical" } satisfies AbilityEffect]
|
||||
: effects({
|
||||
sourceSkillId: spellId,
|
||||
name: `${owner.parent.name} trigger`,
|
||||
description: "",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { preloadAbilityCatalogsForClasses, triggeredAbilityBySpellId } from "./abilityCatalog";
|
||||
import { ROM_ALL_ABILITIES, romTriggeredAbilityBySpellId } from "./romAbilityCatalog";
|
||||
import rawCatalog from "./romPlayerCatalog.generated.json";
|
||||
|
||||
const generated = rawCatalog.skills as unknown as readonly {
|
||||
readonly sourceSkillId: number;
|
||||
readonly target: "hostile" | "friendly" | "self";
|
||||
readonly source?: {
|
||||
readonly sourceTarget?: number;
|
||||
readonly components?: readonly {
|
||||
readonly id: number;
|
||||
readonly attack?: { readonly damagePower: number; readonly damagePowerSkillLevelArg: number };
|
||||
readonly triggers?: { readonly onTimeMagicId?: number; readonly onTimeSeconds?: number };
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
|
||||
describe("RuneWaker Rune Overload hidden trigger catalog", () => {
|
||||
beforeAll(async () => {
|
||||
await preloadAbilityCatalogsForClasses(["rom-champion"]);
|
||||
});
|
||||
|
||||
it("retains the hash-matched one-second child link and supported damage coefficient", () => {
|
||||
const parentSource = generated.find((entry) => entry.sourceSkillId === 498572);
|
||||
expect(parentSource?.target).toBe("self");
|
||||
expect(parentSource?.source?.sourceTarget).toBe(0);
|
||||
expect(parentSource?.source?.components).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 621241,
|
||||
triggers: expect.objectContaining({ onTimeMagicId: 498574, onTimeSeconds: 1 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 621243,
|
||||
attack: expect.objectContaining({ damagePower: -0.6 }),
|
||||
}),
|
||||
]));
|
||||
|
||||
const parent = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 498572);
|
||||
const periodicChildProc = parent?.effects
|
||||
.filter((effect) => effect.kind === "apply-aura")
|
||||
.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.procs ?? [] : [])
|
||||
.find((proc) => proc.action.kind === "custom"
|
||||
&& proc.action.id === "rom-trigger-skill"
|
||||
&& proc.action.data?.spellId === 498574);
|
||||
expect(periodicChildProc).toMatchObject({
|
||||
triggers: "periodic",
|
||||
internalCooldownMs: 1_000,
|
||||
action: { kind: "custom", id: "rom-trigger-skill", data: { spellId: 498574 } },
|
||||
});
|
||||
|
||||
const child = romTriggeredAbilityBySpellId(498574);
|
||||
expect(child).toMatchObject({
|
||||
dbcSpellId: 498574,
|
||||
classId: "rom-champion",
|
||||
target: "self",
|
||||
effects: [{ kind: "damage", coefficient: 0.6, school: "physical" }],
|
||||
});
|
||||
expect(child?.radius).toBeUndefined();
|
||||
expect(child?.targeting).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is visible through the shared triggered-spell resolver used by RoM player and party dispatch", () => {
|
||||
expect(triggeredAbilityBySpellId(498574, false)).toEqual(romTriggeredAbilityBySpellId(498574));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityById, type AbilityEffect } from "./abilityCatalog";
|
||||
import { abilityById, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityNeedsTwoHand, romAbilityRequiredTargetStateTag, romEffectsForCast, romTargetBleedCount, romTargetHasSlashBleed, romWarriorAbilityAdjustment, romWarriorThreatMultiplier, type RomCastMechanics } from "./romCombatMechanics";
|
||||
|
||||
@@ -19,6 +19,26 @@ function kinds(effects: readonly AbilityEffect[]): readonly string[] {
|
||||
}
|
||||
|
||||
describe("RoM primary class conditional combat effects", () => {
|
||||
it("applies learned Priest/Warrior Condensed Rage only to physical damage", () => {
|
||||
const source = ROM_ALL_ABILITIES.find((ability) => ability.classId === "rom-priest")!;
|
||||
const physicalAbility: AbilityDefinition = {
|
||||
...source,
|
||||
effects: [{ kind: "damage", coefficient: 0, basePoints: 100, school: "physical" }],
|
||||
};
|
||||
const holyAbility: AbilityDefinition = {
|
||||
...physicalAbility,
|
||||
effects: [{ kind: "damage", coefficient: 0, basePoints: 100, school: "holy" }],
|
||||
};
|
||||
const otherClassAbility: AbilityDefinition = { ...physicalAbility, classId: "rom-warrior" };
|
||||
const withPassive = { ...empty, learnedPassiveSpellIds: [493038] };
|
||||
const withoutPassive = { ...empty, learnedPassiveSpellIds: [] };
|
||||
|
||||
expect(romEffectsForCast(physicalAbility, withPassive)[0]).toMatchObject({ basePoints: 122, school: "physical" });
|
||||
expect(romEffectsForCast(physicalAbility, withoutPassive)[0]).toMatchObject({ basePoints: 100 });
|
||||
expect(romEffectsForCast(holyAbility, withPassive)[0]).toMatchObject({ basePoints: 100, school: "holy" });
|
||||
expect(romEffectsForCast(otherClassAbility, withPassive)[0]).toMatchObject({ basePoints: 100 });
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -73,7 +93,7 @@ describe("RoM primary class conditional combat effects", () => {
|
||||
expect(cast("rom-knight-general-0")).toEqual([]);
|
||||
const one = cast("rom-knight-general-0", { sealStacks: 1 });
|
||||
const four = cast("rom-knight-general-0", { sealStacks: 4 });
|
||||
expect(kinds(one)).toEqual(["damage", "dot", "combo"]);
|
||||
expect(kinds(one)).toEqual(["damage", "combo"]);
|
||||
expect((four[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints)
|
||||
.toBe(4 * ((one[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints ?? 0));
|
||||
});
|
||||
@@ -139,7 +159,7 @@ describe("RoM primary class conditional combat effects", () => {
|
||||
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [499491]))
|
||||
.toEqual({ rageCostReduction: 25, cooldownReductionMs: 15_000 });
|
||||
expect(romWarriorAbilityAdjustment(sourceAbility(490133), [494377]))
|
||||
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 10_000 });
|
||||
.toEqual({ rageCostReduction: 0, cooldownReductionMs: 0 });
|
||||
expect(romWarriorAbilityAdjustment(sourceAbility(490121), [])).toEqual({
|
||||
rageCostReduction: 0, cooldownReductionMs: 0,
|
||||
});
|
||||
@@ -147,6 +167,28 @@ describe("RoM primary class conditional combat effects", () => {
|
||||
.toEqual({ rageCostReduction: 10, cooldownReductionMs: 0 });
|
||||
});
|
||||
|
||||
it("removes only the four retired Holy Seal DoTs and applies CoA Probing Attack's conditional weapon-DPS value", () => {
|
||||
for (const spellId of [490148, 490149, 490150, 490151]) {
|
||||
const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId)!;
|
||||
const resolved = romEffectsForCast(ability, spellId === 490151 ? { ...empty, sealStacks: 4 } : empty);
|
||||
expect(resolved.some((effect) => effect.kind === "dot")).toBe(false);
|
||||
expect(resolved.some((effect) => effect.kind === "damage")).toBe(true);
|
||||
if (spellId === 490148 || spellId === 490150) {
|
||||
expect(resolved.some((effect) => effect.kind === "combo")).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
const probing = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === 493283)!;
|
||||
const base = romEffectsForCast(probing, empty).find((effect) => effect.kind === "damage");
|
||||
const bleedEmpowered = romEffectsForCast(probing, { ...empty, bleedCount: 1 })
|
||||
.find((effect) => effect.kind === "damage");
|
||||
const hammerEmpowered = romEffectsForCast(probing, { ...empty, mainHandTwoHandHammer: true })
|
||||
.find((effect) => effect.kind === "damage");
|
||||
expect(base).toMatchObject({ kind: "damage", weaponDpsCoefficient: 6.643 });
|
||||
expect(bleedEmpowered).toMatchObject({ kind: "damage", weaponDpsCoefficient: 11.169 });
|
||||
expect(hammerEmpowered).toMatchObject({ kind: "damage", weaponDpsCoefficient: 11.169 });
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -213,10 +255,11 @@ describe("RoM primary class conditional combat effects", () => {
|
||||
});
|
||||
|
||||
it("uses one breakable control effect for each single-target restraint", () => {
|
||||
for (const id of ["rom-scout-primary-4", "rom-rogue-primary-5", "rom-mage-primary-56"]) {
|
||||
for (const id of ["rom-scout-primary-4", "rom-mage-primary-56"]) {
|
||||
const effects = cast(id);
|
||||
expect(effects).toHaveLength(1);
|
||||
expect(effects[0]).toMatchObject({ kind: "status", maxTargets: 1, breakOnDamage: true });
|
||||
}
|
||||
expect(kinds(cast("rom-rogue-primary-5"))).toEqual(["apply-aura"]);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import { romChampionCastEffects } from "./romChampionOverrides";
|
||||
import { romWardenCastEffects } from "./romWardenOverrides";
|
||||
import { romWarlockCastEffects } from "./romWarlockOverrides";
|
||||
import { romKnightCastEffects } from "./romKnightOverrides";
|
||||
import { romPriestWarriorCondensedRagePhysicalDamageMultiplier } from "./romPriestChampionOverrides";
|
||||
import { withoutCurrentCoaHolySealPeriodicDamage } from "./romWarriorCurrentCoaMechanics";
|
||||
import { romCoaWarriorRogueProbingAttackDpsCoefficient } from "./romWarriorRogueExhaustion";
|
||||
import { isRomDruidRoguePair, ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG } from "./romDruidRogueCurrentCoa";
|
||||
|
||||
const ROGUE_BLEED_ABILITIES = new Set([
|
||||
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
|
||||
@@ -21,12 +25,16 @@ export interface RomCastMechanics {
|
||||
readonly bleedCount: number;
|
||||
readonly charged: boolean;
|
||||
readonly mainHandDagger: boolean;
|
||||
readonly mainHandTwoHandHammer?: boolean;
|
||||
readonly casterMaxHealth: number;
|
||||
readonly targetHealthPercent: number;
|
||||
readonly friendlyTargetHealthPercent?: number;
|
||||
readonly naturePower?: number;
|
||||
readonly passiveTags?: readonly string[];
|
||||
readonly learnedPassiveSpellIds?: readonly number[];
|
||||
/** True when the ability's default damage school resolves to Physical. */
|
||||
readonly defaultDamageIsPhysical?: boolean;
|
||||
readonly selectedTargetRelationship?: "friendly" | "hostile";
|
||||
readonly eruptionProcRoll?: number;
|
||||
readonly targetTags?: readonly string[];
|
||||
readonly slashBleed?: boolean;
|
||||
@@ -46,7 +54,7 @@ export function romSelfDrainPercent(spellId: number): number {
|
||||
|
||||
export function romSingleTargetControl(ability: AbilityDefinition | null): boolean {
|
||||
return ability?.source === "runewaker"
|
||||
&& [490347, 490454, 491172].includes(ability.dbcSpellId);
|
||||
&& [490454, 491172].includes(ability.dbcSpellId);
|
||||
}
|
||||
|
||||
export function romAbilityNeedsShield(ability: AbilityDefinition): boolean {
|
||||
@@ -80,6 +88,7 @@ export function romAbilityNeedsRangedWeapon(ability: AbilityDefinition): boolean
|
||||
|
||||
export function romAbilityRequiredTargetStateTag(ability: AbilityDefinition): string | null {
|
||||
if (ability.source !== "runewaker") return null;
|
||||
if (ability.dbcSpellId === 494321 && isRomDruidRoguePair(ability)) return ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG;
|
||||
if (ability.dbcSpellId === 492617) return "rom-weakened";
|
||||
if (ability.dbcSpellId === 491136 || ability.dbcSpellId === 492619) return "rom-vulnerable";
|
||||
return null;
|
||||
@@ -102,7 +111,6 @@ export function romWarriorAbilityAdjustment(
|
||||
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)
|
||||
@@ -195,7 +203,12 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
|
||||
context.friendlyTargetHealthPercent ?? 1,
|
||||
context.passiveTags ?? [],
|
||||
);
|
||||
const knightEffects = romKnightCastEffects(ability, priestWarlockEffects, context.passiveTags ?? []);
|
||||
const knightEffects = romKnightCastEffects(
|
||||
ability,
|
||||
priestWarlockEffects,
|
||||
context.passiveTags ?? [],
|
||||
context.sealStacks,
|
||||
);
|
||||
const mageWarlockEffects = romMageWarlockCastEffects(
|
||||
ability,
|
||||
knightEffects,
|
||||
@@ -216,13 +229,26 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
|
||||
context.targetTags ?? [],
|
||||
context.learnedPassiveSpellIds ?? [],
|
||||
context.targetHealthPercent,
|
||||
context.selectedTargetRelationship ?? "hostile",
|
||||
);
|
||||
const coaEffects = withoutCurrentCoaHolySealPeriodicDamage(ability.dbcSpellId, effects);
|
||||
const warriorPassives = new Set(context.learnedPassiveSpellIds ?? []);
|
||||
const condensedRageMultiplier = ability.classId === "rom-priest"
|
||||
? romPriestWarriorCondensedRagePhysicalDamageMultiplier(
|
||||
(context.learnedPassiveSpellIds ?? []).includes(493038),
|
||||
)
|
||||
: 1;
|
||||
const condensedRageEffects = condensedRageMultiplier > 1
|
||||
? coaEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot")
|
||||
&& (effect.school ?? (context.defaultDamageIsPhysical ? "physical" : undefined)) === "physical"
|
||||
? scaleOutput(effect, condensedRageMultiplier)
|
||||
: effect)
|
||||
: coaEffects;
|
||||
const sourceSchoolAdjustedEffects = romWarriorPhysicalWeaponTooltip(ability)
|
||||
? effects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.school === "arcane"
|
||||
? condensedRageEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.school === "arcane"
|
||||
? { ...effect, school: "physical" as const }
|
||||
: effect)
|
||||
: effects;
|
||||
: condensedRageEffects;
|
||||
const warriorAdjustedEffects = ability.classId === "rom-warrior" && warriorPassives.has(499806)
|
||||
? sourceSchoolAdjustedEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot")
|
||||
&& (effect.school === undefined || effect.school === "physical")
|
||||
@@ -234,6 +260,15 @@ export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMe
|
||||
const damage = warriorAdjustedEffects.filter((effect) => effect.kind === "damage");
|
||||
const seals = Math.max(0, Math.min(4, context.sealStacks));
|
||||
switch (ability.dbcSpellId) {
|
||||
case 493283: { // Current CoA Warrior/Rogue Probing Attack has a sourced empowered weapon-DPS branch.
|
||||
const weaponDpsCoefficient = romCoaWarriorRogueProbingAttackDpsCoefficient({
|
||||
hasCasterOwnedBleed: context.bleedCount > 0,
|
||||
equippedTwoHandHammer: context.mainHandTwoHandHammer ?? false,
|
||||
});
|
||||
return warriorAdjustedEffects.map((effect) => effect.kind === "damage"
|
||||
? { ...effect, weaponDpsCoefficient }
|
||||
: effect);
|
||||
}
|
||||
case 491142: // Tactical Attack's copied Slash bleed only applies when Slash is already bleeding the target.
|
||||
return [
|
||||
...damage,
|
||||
|
||||
@@ -668,7 +668,7 @@ 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", () => {
|
||||
it("casts Priest/Scout Curing Shot as an uncapped-radius friendly heal and HoT", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest", secondaryClassId: "rom-scout", raceId: "rom-human", level: 80,
|
||||
});
|
||||
@@ -683,7 +683,7 @@ describe("RuneWaker combat runtime", () => {
|
||||
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(curingShot.targeting).toEqual({ shape: "circle", radius: 10 });
|
||||
|
||||
expect(useCombatStore.getState().castAbility(curingShot.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(2.1, 3_100);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { romCreatureFamilyForServerEntry } from "./romCreatureFamilies";
|
||||
@@ -41,23 +43,22 @@ describe("RuneWaker creature-family combat rules", () => {
|
||||
.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts the Poisonous state without a bag item and rejects Numbing Dagger on Undead", () => {
|
||||
it("maps Rogue 490347 to the current self-targeted Energized Hands buff", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-rogue", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 50,
|
||||
classId: "rom-rogue", raceId: "rom-human", level: 50,
|
||||
});
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
for (const [id, entry] of [["undead", 106432], ["living", 103946]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, serverEntry: entry, maxHealth: 1_000_000 });
|
||||
setMobPosition(id, [3, 0, 0]);
|
||||
}
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-6", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
useCombatStore.getState().selectMob("undead");
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
useCombatStore.getState().selectMob("living");
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 2_600))
|
||||
const energizedHands = abilityById("rom-rogue-primary-5");
|
||||
expect(energizedHands).toMatchObject({
|
||||
dbcSpellId: 490347,
|
||||
name: "Energized Hands",
|
||||
target: "self",
|
||||
castTimeMs: 1_000,
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility("rom-rogue-primary-5", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(1.1, 2_100);
|
||||
expect(useCombatStore.getState().maxResourcePools.energy).toBe(150);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.utilityTags?.includes("rom-energized-hands"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ROM_COA_DRUID_ADVANCED_REBIRTH_ID,
|
||||
ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID,
|
||||
ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID,
|
||||
ROM_COA_DRUID_REBIRTH_ID,
|
||||
ROM_COA_DRUID_SPIRIT_GUIDANCE_ID,
|
||||
ROM_COA_DRUID_SUPREME_REBIRTH_ID,
|
||||
romCoaDruidRebirthDebtRule,
|
||||
romCoaSpiritGuidanceFactionOutcome,
|
||||
} from "./romDruidCurrentCoaMechanics";
|
||||
|
||||
describe("current CoA Druid faction-conversion and Rebirth rules", () => {
|
||||
it("keeps Spirit Guidance's player-target restriction explicit", () => {
|
||||
expect(ROM_COA_DRUID_SPIRIT_GUIDANCE_ID).toBe(493536);
|
||||
expect(romCoaSpiritGuidanceFactionOutcome({
|
||||
targetKind: "player",
|
||||
casterReputation: 10,
|
||||
})).toBe("player-target-prohibited");
|
||||
});
|
||||
|
||||
it("converts an NPC only when caster reputation is nonnegative", () => {
|
||||
expect(romCoaSpiritGuidanceFactionOutcome({
|
||||
targetKind: "npc",
|
||||
casterReputation: 0,
|
||||
})).toBe("becomes-friendly");
|
||||
expect(romCoaSpiritGuidanceFactionOutcome({
|
||||
targetKind: "npc",
|
||||
casterReputation: -1,
|
||||
})).toBe("remains-hostile");
|
||||
});
|
||||
|
||||
it("does not guess the conversion result when reputation is unavailable", () => {
|
||||
expect(romCoaSpiritGuidanceFactionOutcome({
|
||||
targetKind: "npc",
|
||||
casterReputation: null,
|
||||
})).toBe("unknown-reputation");
|
||||
expect(romCoaSpiritGuidanceFactionOutcome({
|
||||
targetKind: "npc",
|
||||
casterReputation: Number.NaN,
|
||||
})).toBe("unknown-reputation");
|
||||
});
|
||||
|
||||
it("reports basic Rebirth's 30% baseline and leaves its extra amulet bonus unquantified", () => {
|
||||
expect(romCoaDruidRebirthDebtRule(ROM_COA_DRUID_REBIRTH_ID)).toEqual({
|
||||
debtReductionPercent: 30,
|
||||
itemRule: {
|
||||
kind: "optional-unquantified-bonus",
|
||||
itemIds: [ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID, ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID],
|
||||
},
|
||||
extraDebtReductionPercent: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Advanced Rebirth to 45% with its required Amulet of Light", () => {
|
||||
expect(romCoaDruidRebirthDebtRule(ROM_COA_DRUID_ADVANCED_REBIRTH_ID)).toEqual({
|
||||
debtReductionPercent: 45,
|
||||
itemRule: { kind: "required", itemId: ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID },
|
||||
extraDebtReductionPercent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Supreme Rebirth to 75% with its required Golden Amulet", () => {
|
||||
expect(romCoaDruidRebirthDebtRule(ROM_COA_DRUID_SUPREME_REBIRTH_ID)).toEqual({
|
||||
debtReductionPercent: 75,
|
||||
itemRule: { kind: "required", itemId: ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID },
|
||||
extraDebtReductionPercent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not map unrelated or unknown spell IDs", () => {
|
||||
expect(romCoaDruidRebirthDebtRule(493538)).toBeNull();
|
||||
expect(romCoaDruidRebirthDebtRule(0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Small source-backed rules for Druid mechanics that differ from the local
|
||||
* RuneWaker snapshot. These helpers describe outcomes only; the combat model
|
||||
* does not currently track NPC faction, caster reputation, player death debt,
|
||||
* or the required amulets at the moment of resurrection.
|
||||
*
|
||||
* Current CoA sources:
|
||||
* - https://chroniclesofarcadia.com/en/wiki/skills/list/8?locale=en
|
||||
* - https://chroniclesofarcadia.com/en/wiki/skills/list/8-9
|
||||
*
|
||||
* Local RuneWaker IDs/pairs:
|
||||
* - Druid primary Spirit Guidance 493536 (local component 503805)
|
||||
* - Druid primary Rebirth 493537 (component 503806)
|
||||
* - Druid primary Advanced Rebirth 493559 (component 504134; item 203606,
|
||||
* Amulet of Light)
|
||||
* - Druid primary Supreme Rebirth 493560 (component 504135; item 203605,
|
||||
* Golden Amulet)
|
||||
*/
|
||||
|
||||
export const ROM_COA_DRUID_SPIRIT_GUIDANCE_ID = 493536;
|
||||
export const ROM_COA_DRUID_REBIRTH_ID = 493537;
|
||||
export const ROM_COA_DRUID_ADVANCED_REBIRTH_ID = 493559;
|
||||
export const ROM_COA_DRUID_SUPREME_REBIRTH_ID = 493560;
|
||||
|
||||
export const ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID = 203606;
|
||||
export const ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID = 203605;
|
||||
|
||||
export type RomCoaSpiritGuidanceFactionOutcome =
|
||||
| "player-target-prohibited"
|
||||
| "becomes-friendly"
|
||||
| "remains-hostile"
|
||||
| "unknown-reputation";
|
||||
|
||||
/**
|
||||
* CoA says Spirit Guidance cannot be used on players. For NPCs, nonnegative
|
||||
* caster reputation makes the target friendly; negative reputation leaves it
|
||||
* hostile. Unknown reputation stays unknown instead of assuming conversion.
|
||||
*
|
||||
* This intentionally does not supply a duration: the current CoA page lists
|
||||
* 16.2 seconds for the +55 skill value, but gives no rank-to-duration formula.
|
||||
* The local RuneWaker component's 3-second value therefore cannot be carried
|
||||
* over as the current CoA duration for every cast.
|
||||
*/
|
||||
export function romCoaSpiritGuidanceFactionOutcome(args: {
|
||||
readonly targetKind: "npc" | "player";
|
||||
readonly casterReputation: number | null;
|
||||
}): RomCoaSpiritGuidanceFactionOutcome {
|
||||
if (args.targetKind === "player") return "player-target-prohibited";
|
||||
if (args.casterReputation === null || !Number.isFinite(args.casterReputation)) {
|
||||
return "unknown-reputation";
|
||||
}
|
||||
return args.casterReputation < 0 ? "remains-hostile" : "becomes-friendly";
|
||||
}
|
||||
|
||||
export interface RomCoaDruidRebirthDebtRule {
|
||||
/** The exact percentage stated by the current CoA skill tooltip. */
|
||||
readonly debtReductionPercent: 30 | 45 | 75;
|
||||
/** Amulet conditions for the quantified skill variant. */
|
||||
readonly itemRule:
|
||||
| { readonly kind: "optional-unquantified-bonus"; readonly itemIds: readonly [number, number] }
|
||||
| { readonly kind: "required"; readonly itemId: number };
|
||||
/**
|
||||
* Basic Rebirth's optional amulet bonus is stated but not quantified by CoA.
|
||||
* The advanced and supreme tooltips already include their required amulet
|
||||
* in the stated 45% and 75% effects.
|
||||
*/
|
||||
readonly extraDebtReductionPercent: number | null;
|
||||
}
|
||||
|
||||
const DRUID_REBIRTH_RULES: Readonly<Record<number, RomCoaDruidRebirthDebtRule>> = {
|
||||
[ROM_COA_DRUID_REBIRTH_ID]: {
|
||||
debtReductionPercent: 30,
|
||||
itemRule: {
|
||||
kind: "optional-unquantified-bonus",
|
||||
itemIds: [ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID, ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID],
|
||||
},
|
||||
extraDebtReductionPercent: null,
|
||||
},
|
||||
[ROM_COA_DRUID_ADVANCED_REBIRTH_ID]: {
|
||||
debtReductionPercent: 45,
|
||||
itemRule: { kind: "required", itemId: ROM_COA_DRUID_AMULET_OF_LIGHT_ITEM_ID },
|
||||
extraDebtReductionPercent: 0,
|
||||
},
|
||||
[ROM_COA_DRUID_SUPREME_REBIRTH_ID]: {
|
||||
debtReductionPercent: 75,
|
||||
itemRule: { kind: "required", itemId: ROM_COA_DRUID_GOLDEN_AMULET_ITEM_ID },
|
||||
extraDebtReductionPercent: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up the quantified CoA debt-reduction rule by exact local primary-skill
|
||||
* ID. This reports mechanics metadata without inventing a debt unit, rounding
|
||||
* rule, inventory state, or the unspecified optional bonus on basic Rebirth.
|
||||
*/
|
||||
export function romCoaDruidRebirthDebtRule(
|
||||
sourceSkillId: number,
|
||||
): RomCoaDruidRebirthDebtRule | null {
|
||||
return DRUID_REBIRTH_RULES[sourceSkillId] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import {
|
||||
ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION,
|
||||
ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID,
|
||||
} from "./romDruidMageInspirationRuntime";
|
||||
import {
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
|
||||
} from "./romMageDruidGreenGuardianRuntime";
|
||||
|
||||
describe("current CoA Druid/Mage Inspiration catalog entry", () => {
|
||||
const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID
|
||||
&& ability.classId === "rom-druid" && ability.secondaryClassId === "rom-mage");
|
||||
|
||||
it("updates the existing generated exact-pair passive without adding a skill row", () => {
|
||||
expect(passive).toBeDefined();
|
||||
expect(passive).toMatchObject({
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-mage",
|
||||
dbcSpellId: ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID,
|
||||
name: "Inspiration",
|
||||
unlockLevel: 30,
|
||||
sourceUnlockLevel: 30,
|
||||
passive: true,
|
||||
romSkillGroup: "passive",
|
||||
target: "self",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 0,
|
||||
costs: [],
|
||||
effects: [],
|
||||
});
|
||||
expect(passive?.id).toBe("rom-druid-rom-mage-elite-30");
|
||||
expect(ROM_ALL_ABILITIES.filter((ability) => ability.dbcSpellId === ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses current CoA spend-trigger values in both the passive and its rank tooltip", () => {
|
||||
expect(passive?.description).toBe(ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION);
|
||||
expect(passive?.description).toContain("whenever you spend Nature's Power");
|
||||
expect(passive?.description).toContain("Physical Attack Speed and Casting Speed by 2% per stack");
|
||||
expect(passive?.description).toContain("up to 10 stacks for 10 seconds");
|
||||
expect(passive?.description).toContain("cannot be proven identical to local Fireball 490204");
|
||||
expect(passive?.description).not.toMatch(/chance.*Fireball|next spell.*1 second/i);
|
||||
expect(passive?.ranks).toHaveLength(1);
|
||||
expect(passive?.ranks?.[0]).toMatchObject({
|
||||
spellId: ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID,
|
||||
costs: [],
|
||||
effects: [],
|
||||
description: ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("current CoA Mage/Druid Green Guardian catalog entry", () => {
|
||||
const skill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID
|
||||
&& ability.classId === "rom-mage" && ability.secondaryClassId === "rom-druid");
|
||||
|
||||
it("turns only the existing exact-pair row into the current level-25 instant self skill", () => {
|
||||
expect(skill).toMatchObject({
|
||||
id: "rom-mage-rom-druid-elite-30",
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-druid",
|
||||
dbcSpellId: ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
|
||||
unlockLevel: 25,
|
||||
passive: false,
|
||||
romSkillGroup: "elite",
|
||||
target: "self",
|
||||
castMode: "instant",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 0,
|
||||
costs: [],
|
||||
effects: [{ kind: "apply-aura" }],
|
||||
});
|
||||
expect(skill?.description).toBe(ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION);
|
||||
expect(skill?.ranks?.[0]).toMatchObject({
|
||||
level: 25,
|
||||
spellId: ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 0,
|
||||
costs: [],
|
||||
description: ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
|
||||
effects: [{ kind: "apply-aura" }],
|
||||
});
|
||||
expect(ROM_ALL_ABILITIES.filter((ability) => ability.dbcSpellId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import {
|
||||
ROM_DRUID_MAGE_INSPIRATION_AURA_TAG,
|
||||
romDruidMageInspirationApplySpendStack,
|
||||
romDruidMageInspirationHastePercent,
|
||||
} from "./romDruidMageInspirationRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
|
||||
const EARTH_CHAIN_ID = "rom-druid-rom-mage-elite-15";
|
||||
const earthChain = ROM_ALL_ABILITIES.find((ability) => ability.id === EARTH_CHAIN_ID);
|
||||
|
||||
function inspirationAura(targetId: string) {
|
||||
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
|
||||
&& aura.definition.utilityTags?.includes(ROM_DRUID_MAGE_INSPIRATION_AURA_TAG));
|
||||
}
|
||||
|
||||
function priest(name: string, seed: string, index: number) {
|
||||
return createConfiguredPartyMember({
|
||||
name,
|
||||
classId: "rom-priest",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 45, index, seed);
|
||||
}
|
||||
|
||||
describe("CoA Druid/Mage Inspiration player and companion integration", () => {
|
||||
beforeEach(() => {
|
||||
resetPartyRuntimeClock();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest",
|
||||
raceId: "rom-human",
|
||||
level: 45,
|
||||
role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty(
|
||||
"inspiration-party-test", 45, useCombatStore.getState().health, "healer", 4, "rom",
|
||||
);
|
||||
});
|
||||
|
||||
it("awards one nearby-party stack for a player's completed Nature Power spend and applies cast haste", () => {
|
||||
if (!earthChain) throw new Error("Missing generated Druid/Mage Earth Chain row");
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-mage",
|
||||
raceId: "rom-elf",
|
||||
level: 45,
|
||||
role: "damage",
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
|
||||
const seed = "inspiration-player-spend";
|
||||
const edge = priest("Range Edge", seed, 0);
|
||||
const outside = priest("Outside Range", seed, 1);
|
||||
const fallen = { ...priest("Fallen Ally", seed, 2), health: 0 };
|
||||
usePartyStore.setState({ members: [edge, outside, fallen] });
|
||||
registerPartyRuntimePosition(edge.id, [3, 4, 0]);
|
||||
registerPartyRuntimePosition(outside.id, [5.01, 0, 0]);
|
||||
registerPartyRuntimePosition(fallen.id, [1, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, "nature-power": 1 },
|
||||
maxResourcePools: { ...state.maxResourcePools, "nature-power": 10 },
|
||||
}));
|
||||
const targetId = "inspiration-player-target";
|
||||
useCombatStore.getState().registerMob(targetId, { name: "Target", level: 1, maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition(targetId, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(EARTH_CHAIN_ID, {
|
||||
origin: [0, 0, 0], targetActorId: targetId, now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
const unbuffedCastDuration = useCombatStore.getState().activeCast?.originalDurationMs ?? 0;
|
||||
expect(unbuffedCastDuration).toBeGreaterThan(0);
|
||||
useCombatStore.getState().tick(2, 3_000);
|
||||
|
||||
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(0);
|
||||
expect(inspirationAura(PLAYER_AGGRO_ID)).toMatchObject({ stacks: 1, expiresAt: 13_000 });
|
||||
expect(inspirationAura(edge.id)?.stacks).toBe(1);
|
||||
expect(inspirationAura(outside.id)).toBeUndefined();
|
||||
expect(inspirationAura(fallen.id)).toBeUndefined();
|
||||
|
||||
expect(useCombatStore.getState().castAbility(EARTH_CHAIN_ID, {
|
||||
origin: [0, 0, 0], targetActorId: targetId, now: 7_000,
|
||||
}).ok).toBe(true);
|
||||
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeLessThan(unbuffedCastDuration);
|
||||
});
|
||||
|
||||
it("awards one caster-centered stack when a generated Druid/Mage companion spends Nature Power", () => {
|
||||
if (!earthChain) throw new Error("Missing generated Druid/Mage Earth Chain row");
|
||||
const seed = "inspiration-companion-spend";
|
||||
const caster = createConfiguredPartyMember({
|
||||
name: "Druid/Mage",
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-mage",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 45, 0, seed);
|
||||
const outside = priest("Outside Range", seed, 1);
|
||||
usePartyStore.setState({
|
||||
members: [{
|
||||
...caster,
|
||||
resourcePools: { ...caster.resourcePools, "nature-power": 1 },
|
||||
activeCast: {
|
||||
abilityId: EARTH_CHAIN_ID,
|
||||
school: "nature",
|
||||
mode: "cast",
|
||||
startedAt: 0,
|
||||
completesAt: 1_000,
|
||||
originalDurationMs: 1_000,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: "inspiration-companion-target",
|
||||
},
|
||||
activeCastTargetId: "inspiration-companion-target",
|
||||
status: "attacking",
|
||||
}, outside],
|
||||
command: "attack",
|
||||
activeMobId: "inspiration-companion-target",
|
||||
});
|
||||
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
|
||||
registerPartyRuntimePosition(outside.id, [5.01, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("inspiration-companion-target", {
|
||||
name: "Target", level: 1, maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("inspiration-companion-target", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("inspiration-companion-target", 1, 500);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(usePartyStore.getState().members[0]?.resourcePools["nature-power"]).toBe(0);
|
||||
expect(inspirationAura(caster.id)?.stacks).toBe(1);
|
||||
expect(inspirationAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
|
||||
expect(inspirationAura(outside.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies Inspiration spell haste only once to a Mage receiving the party buff", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-druid",
|
||||
raceId: "rom-elf",
|
||||
level: 45,
|
||||
role: "damage",
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
|
||||
const targetId = "inspiration-mage-target";
|
||||
useCombatStore.getState().registerMob(targetId, { name: "Target", level: 1, maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition(targetId, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === "rom-mage-primary-1");
|
||||
expect(ability?.castTimeMs).toBe(3_000);
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", {
|
||||
origin: [0, 0, 0], targetActorId: targetId, now: 1_000,
|
||||
}).ok).toBe(true);
|
||||
const unbuffedDuration = useCombatStore.getState().activeCast?.originalDurationMs ?? 0;
|
||||
expect(unbuffedDuration).toBeGreaterThan(0);
|
||||
useCombatStore.getState().tick(3, 4_000);
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: [...romDruidMageInspirationApplySpendStack(
|
||||
state.auras, "druid-mage-source", [PLAYER_AGGRO_ID], 5_000,
|
||||
)],
|
||||
}));
|
||||
expect(inspirationAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
|
||||
expect(romDruidMageInspirationHastePercent(
|
||||
useCombatStore.getState().auras, PLAYER_AGGRO_ID, 5_000,
|
||||
)).toBe(2);
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", {
|
||||
origin: [0, 0, 0], targetActorId: targetId, now: 5_000,
|
||||
}).ok).toBe(true);
|
||||
expect(useCombatStore.getState().activeCast?.startedAt).toBe(5_000);
|
||||
const buffedDuration = useCombatStore.getState().activeCast?.originalDurationMs ?? 0;
|
||||
const durationReduction = 1 - buffedDuration / unbuffedDuration;
|
||||
expect(durationReduction).toBeGreaterThan(0.01);
|
||||
expect(durationReduction).toBeLessThan(0.03);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isRomDruidMagePair,
|
||||
ROM_DRUID_MAGE_INSPIRATION_DURATION_MS,
|
||||
ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS,
|
||||
ROM_DRUID_MAGE_INSPIRATION_RADIUS,
|
||||
romDruidMageInspirationApplySpendStack,
|
||||
romDruidMageInspirationHastePercent,
|
||||
romDruidMageInspirationRecipientIds,
|
||||
romDruidMageInspirationBonusPercent,
|
||||
romDruidMageInspirationStacksAfterSpend,
|
||||
} from "./romDruidMageInspirationRuntime";
|
||||
|
||||
describe("current CoA Druid/Mage Inspiration", () => {
|
||||
it("gates the current passive to the exact Druid/Mage pair", () => {
|
||||
expect(isRomDruidMagePair({ classId: "rom-druid", secondaryClassId: "rom-mage" })).toBe(true);
|
||||
expect(isRomDruidMagePair({ classId: "rom-mage", secondaryClassId: "rom-druid" })).toBe(false);
|
||||
expect(isRomDruidMagePair({ classId: "rom-druid", secondaryClassId: "rom-rogue" })).toBe(false);
|
||||
});
|
||||
|
||||
it("adds one stack for each positive Nature's Power spend event, regardless of amount", () => {
|
||||
expect(romDruidMageInspirationStacksAfterSpend(0, 1, true)).toBe(1);
|
||||
expect(romDruidMageInspirationStacksAfterSpend(3, 5, true)).toBe(4);
|
||||
expect(romDruidMageInspirationStacksAfterSpend(3, 0, true)).toBe(3);
|
||||
expect(romDruidMageInspirationStacksAfterSpend(3, -1, true)).toBe(3);
|
||||
expect(romDruidMageInspirationStacksAfterSpend(3, 1, false)).toBe(3);
|
||||
});
|
||||
|
||||
it("caps at ten stacks for a 20% total attack-speed and cast-speed bonus", () => {
|
||||
expect(romDruidMageInspirationStacksAfterSpend(9, 1, true)).toBe(ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS);
|
||||
expect(romDruidMageInspirationStacksAfterSpend(10, 1, true)).toBe(10);
|
||||
expect(romDruidMageInspirationBonusPercent(10)).toBe(20);
|
||||
expect(romDruidMageInspirationBonusPercent(99)).toBe(20);
|
||||
expect(ROM_DRUID_MAGE_INSPIRATION_DURATION_MS).toBe(10_000);
|
||||
expect(ROM_DRUID_MAGE_INSPIRATION_RADIUS).toBe(5);
|
||||
});
|
||||
|
||||
it("adds one party-recipient stack per spend and refreshes the shared 10-second aura", () => {
|
||||
const first = romDruidMageInspirationApplySpendStack([], "druid", ["player", "ally"], 1_000);
|
||||
const second = romDruidMageInspirationApplySpendStack(first, "druid", ["player", "player"], 6_000);
|
||||
const playerAura = second.find((aura) => aura.targetId === "player");
|
||||
const allyAura = second.find((aura) => aura.targetId === "ally");
|
||||
|
||||
expect(second).toHaveLength(2);
|
||||
expect(playerAura?.stacks).toBe(2);
|
||||
expect(playerAura?.appliedAt).toBe(6_000);
|
||||
expect(playerAura?.expiresAt).toBe(16_000);
|
||||
expect(playerAura?.definition.modifiers).toContainEqual({
|
||||
kind: "stat", stat: "melee-haste", operation: "flat", value: 2, perStack: true,
|
||||
});
|
||||
expect(playerAura?.definition.modifiers).toContainEqual({
|
||||
kind: "stat", stat: "ranged-haste", operation: "flat", value: 2, perStack: true,
|
||||
});
|
||||
expect(playerAura?.definition.modifiers).toContainEqual({
|
||||
kind: "stat", stat: "spell-haste", operation: "flat", value: 2, perStack: true,
|
||||
});
|
||||
expect(romDruidMageInspirationHastePercent(second, "player", 6_000)).toBe(4);
|
||||
expect(allyAura?.stacks).toBe(1);
|
||||
expect(allyAura?.expiresAt).toBe(11_000);
|
||||
});
|
||||
|
||||
it("selects only living modeled recipients at or inside the interpreted five-unit radius", () => {
|
||||
expect(romDruidMageInspirationRecipientIds([0, 0, 0], [
|
||||
{ id: "caster", position: [0, 0, 0], living: true },
|
||||
{ id: "edge", position: [3, 4, 0], living: true },
|
||||
{ id: "outside", position: [5.01, 0, 0], living: true },
|
||||
{ id: "dead", position: [1, 0, 0], living: false },
|
||||
{ id: "unplaced", position: null, living: true },
|
||||
])).toEqual(["caster", "edge"]);
|
||||
});
|
||||
|
||||
it("normalizes malformed stack counts at the documented zero-to-ten bounds", () => {
|
||||
expect(romDruidMageInspirationStacksAfterSpend(-4, 0, true)).toBe(0);
|
||||
expect(romDruidMageInspirationStacksAfterSpend(2.9, 0, true)).toBe(2);
|
||||
expect(romDruidMageInspirationBonusPercent(-2)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { applyAura, type ActiveAura, type AuraDefinition, type CombatEntityId } from "./combatAuras";
|
||||
|
||||
/** Current CoA Druid/Mage Inspiration stack behavior. */
|
||||
export const ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID = 494322;
|
||||
export const ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS = 10;
|
||||
export const ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT = 2;
|
||||
export const ROM_DRUID_MAGE_INSPIRATION_DURATION_MS = 10_000;
|
||||
/** Current CoA skill page's `Range: 50`, converted with the project's 10:1 CoA range convention. */
|
||||
export const ROM_DRUID_MAGE_INSPIRATION_RADIUS = 5;
|
||||
export const ROM_DRUID_MAGE_INSPIRATION_AURA_TAG = "rom-druid-mage-inspiration";
|
||||
export const ROM_DRUID_MAGE_INSPIRATION_DESCRIPTION =
|
||||
"Passive: whenever you spend Nature's Power, nearby party members gain one Inspiration stack, increasing Physical Attack Speed and Casting Speed by 2% per stack, up to 10 stacks for 10 seconds. CoA also describes a Fireball travel-speed and Nature's Power rider, but current CoA Fireball 499943 cannot be proven identical to local Fireball 490204, so those clauses are not implemented.";
|
||||
|
||||
export type RomDruidMageInspirationPosition = readonly [number, number, number];
|
||||
|
||||
export interface RomDruidMageInspirationRecipient {
|
||||
readonly id: CombatEntityId;
|
||||
readonly position: RomDruidMageInspirationPosition | null;
|
||||
readonly living: boolean;
|
||||
}
|
||||
|
||||
/** Select modeled living party actors inside the interpreted caster-centered CoA Range 50. */
|
||||
export function romDruidMageInspirationRecipientIds(
|
||||
sourcePosition: RomDruidMageInspirationPosition,
|
||||
candidates: readonly RomDruidMageInspirationRecipient[],
|
||||
): readonly CombatEntityId[] {
|
||||
const radiusSquared = ROM_DRUID_MAGE_INSPIRATION_RADIUS ** 2;
|
||||
return [...new Set(candidates.filter((candidate) => {
|
||||
if (!candidate.living || !candidate.position) return false;
|
||||
const dx = candidate.position[0] - sourcePosition[0];
|
||||
const dy = candidate.position[1] - sourcePosition[1];
|
||||
const dz = candidate.position[2] - sourcePosition[2];
|
||||
return dx * dx + dy * dy + dz * dz <= radiusSquared;
|
||||
}).map((candidate) => candidate.id))];
|
||||
}
|
||||
|
||||
export function romDruidMageInspirationAuraDefinition(): AuraDefinition {
|
||||
return {
|
||||
id: "rom:druid-mage:inspiration",
|
||||
name: "Inspiration",
|
||||
description: "Increases physical attack speed and casting speed by 2% per stack.",
|
||||
disposition: "buff",
|
||||
durationMs: ROM_DRUID_MAGE_INSPIRATION_DURATION_MS,
|
||||
maxStacks: ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS,
|
||||
stackBehavior: "add",
|
||||
uniqueBySource: false,
|
||||
utilityTags: [ROM_DRUID_MAGE_INSPIRATION_AURA_TAG],
|
||||
modifiers: [
|
||||
{
|
||||
kind: "stat",
|
||||
stat: "melee-haste",
|
||||
operation: "flat",
|
||||
value: ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT,
|
||||
perStack: true,
|
||||
},
|
||||
{
|
||||
kind: "stat",
|
||||
stat: "ranged-haste",
|
||||
operation: "flat",
|
||||
value: ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT,
|
||||
perStack: true,
|
||||
},
|
||||
{
|
||||
kind: "stat",
|
||||
stat: "spell-haste",
|
||||
operation: "flat",
|
||||
value: ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT,
|
||||
perStack: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply one stack to each recipient selected by the caller; the shared aura refreshes for 10 seconds. */
|
||||
export function romDruidMageInspirationApplySpendStack(
|
||||
current: readonly ActiveAura[],
|
||||
sourceId: CombatEntityId,
|
||||
recipientIds: readonly CombatEntityId[],
|
||||
now: number,
|
||||
): readonly ActiveAura[] {
|
||||
let auras = current;
|
||||
const definition = romDruidMageInspirationAuraDefinition();
|
||||
for (const targetId of new Set(recipientIds)) {
|
||||
auras = applyAura(auras, { definition, sourceId, targetId, now, stacks: 1 }).auras;
|
||||
}
|
||||
return auras;
|
||||
}
|
||||
|
||||
export function romDruidMageInspirationHastePercent(
|
||||
auras: readonly ActiveAura[],
|
||||
targetId: string,
|
||||
now: number,
|
||||
): number {
|
||||
return auras
|
||||
.filter((aura) => aura.targetId === targetId
|
||||
&& aura.definition.utilityTags?.includes(ROM_DRUID_MAGE_INSPIRATION_AURA_TAG)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.reduce((total, aura) => total + romDruidMageInspirationBonusPercent(aura.stacks), 0);
|
||||
}
|
||||
|
||||
export function isRomDruidMagePair(row: {
|
||||
readonly classId?: string;
|
||||
readonly secondaryClassId?: string | null;
|
||||
}): boolean {
|
||||
return row.classId === "rom-druid" && row.secondaryClassId === "rom-mage";
|
||||
}
|
||||
|
||||
/**
|
||||
* CoA gives one Inspiration stack when the caster spends Nature's Power.
|
||||
* This resolves one positive resource-spend event, not each point spent.
|
||||
*/
|
||||
export function romDruidMageInspirationStacksAfterSpend(
|
||||
currentStacks: number,
|
||||
spentNaturePower: number,
|
||||
passiveLearned: boolean,
|
||||
): number {
|
||||
const stacks = Math.max(0, Math.min(ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS, Math.floor(currentStacks)));
|
||||
if (!passiveLearned || !(spentNaturePower > 0)) return stacks;
|
||||
return Math.min(ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS, stacks + 1);
|
||||
}
|
||||
|
||||
export function romDruidMageInspirationBonusPercent(stacks: number): number {
|
||||
const boundedStacks = Math.max(0, Math.min(ROM_DRUID_MAGE_INSPIRATION_MAX_STACKS, Math.floor(stacks)));
|
||||
return boundedStacks * ROM_DRUID_MAGE_INSPIRATION_BONUS_PER_STACK_PERCENT;
|
||||
}
|
||||
@@ -12,10 +12,15 @@ const empty: RomCastMechanics = {
|
||||
targetHealthPercent: 1,
|
||||
};
|
||||
|
||||
function cast(spellId: number, naturePower: number, passiveTags: readonly string[] = []): readonly AbilityEffect[] {
|
||||
function cast(
|
||||
spellId: number,
|
||||
naturePower: number,
|
||||
passiveTags: readonly string[] = [],
|
||||
targetTags: 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 });
|
||||
return romEffectsForCast(ability!, { ...empty, naturePower, passiveTags, targetTags });
|
||||
}
|
||||
|
||||
function passiveTag(spellId: number): string {
|
||||
@@ -188,17 +193,15 @@ describe("RoM Druid passive state triggers", () => {
|
||||
.toMatchObject({ ticks: 6, intervalMs: 2_000 });
|
||||
});
|
||||
|
||||
it("extends Recover by six seconds with Extended Recovery and gates Knee Ulcer's five-second root", () => {
|
||||
it("extends Recover by six seconds and does not retain the obsolete Druid/Rogue Knee Ulcer 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"))
|
||||
expect(cast(494321, 1, [], ["rom-druid-corrosive-poison"])).toHaveLength(3);
|
||||
expect(cast(494321, 1, [passiveTag(499944)], ["rom-druid-corrosive-poison"])
|
||||
.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 }) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
import {
|
||||
isRomDruidRoguePair,
|
||||
romDruidRogueCorrosivePoisonEffect,
|
||||
romDruidRogueCooldownRider,
|
||||
} from "./romDruidRogueCurrentCoa";
|
||||
import {
|
||||
isRomMageDruidPair,
|
||||
romMageDruidGreenGuardianAuraEffect,
|
||||
} from "./romMageDruidGreenGuardianRuntime";
|
||||
|
||||
export const ROM_DRUID_GREEN_GUARDIAN_TAG = "rom-druid-green-guardian";
|
||||
export const ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG = "rom-druid-mother-earth-protection";
|
||||
@@ -85,6 +94,22 @@ export function overrideRomDruidEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
if (isRomDruidRoguePair(row)) {
|
||||
if (row.sourceSkillId === 494024) return [romDruidRogueCorrosivePoisonEffect()];
|
||||
if (row.sourceSkillId === 494321) return [
|
||||
{ kind: "damage", coefficient: 3.297, school: "shadow" },
|
||||
{
|
||||
kind: "utility",
|
||||
action: "class-script",
|
||||
effectType: -1,
|
||||
auraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 494024,
|
||||
durationMs: 12_000,
|
||||
},
|
||||
];
|
||||
if (row.sourceSkillId === 494023) return [];
|
||||
}
|
||||
switch (row.sourceSkillId) {
|
||||
case 493533:
|
||||
case 493558: { // Both Mother Earth's Protection ranks can trigger Green Guardian on expiry.
|
||||
@@ -101,7 +126,9 @@ export function overrideRomDruidEffects(
|
||||
};
|
||||
});
|
||||
}
|
||||
case 494331: // Keep source HoT payloads on the learned passive for its expiry trigger.
|
||||
case 494331:
|
||||
if (isRomMageDruidPair(row)) return [romMageDruidGreenGuardianAuraEffect()];
|
||||
// Keep RuneWaker's legacy HoTs only for rows outside the current CoA pair.
|
||||
return [
|
||||
passiveState(494331, row.name, ROM_DRUID_GREEN_GUARDIAN_TAG),
|
||||
...translated.filter((effect) => effect.kind === "hot"),
|
||||
@@ -143,8 +170,8 @@ export function overrideRomDruidEffects(
|
||||
...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 494321: // The CoA rework replaces RuneWaker's healing reduction and Knee Ulcer root.
|
||||
return translated.filter((effect) => effect.kind === "damage");
|
||||
case 494938: // 505938 supplies the 40-second lifetime; the tooltip states 4-second party pulses.
|
||||
return [spiritOfLifeHot(translated)];
|
||||
default:
|
||||
@@ -293,11 +320,23 @@ export function romDruidCastEffects(
|
||||
&& !(empowered && effect.kind === "resource" && effect.resource === "nature-power"));
|
||||
return [...selectedHits, ...selected, ...(empowered ? [naturePowerDrain()] : [])];
|
||||
}
|
||||
case 499956: { // Briar Technique unlocks this source row, with both DMG branches on distinct components.
|
||||
const selectedHits = empowered
|
||||
? [damage[1], damage[3]].filter((effect) => effect !== undefined)
|
||||
: [damage[0], damage[2]].filter((effect) => effect !== undefined);
|
||||
if (selectedHits.length !== 2) 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;
|
||||
const branch = empowered ? spendBeforeNaturePowerRefund(selected) : selected;
|
||||
return isRomDruidRoguePair(ability)
|
||||
? [...branch, romDruidRogueCooldownRider()]
|
||||
: branch;
|
||||
}
|
||||
case 493528: { // Extended Recovery's source-backed duration increase; proc chance is absent.
|
||||
if (!hasTag(passiveTags, "rom-druid-extended-recovery")) return effects;
|
||||
@@ -305,24 +344,13 @@ export function romDruidCastEffects(
|
||||
? { ...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,
|
||||
}];
|
||||
case 494321: { // CoA's current poison-gated channel replaces the old healing-reduction/root row.
|
||||
if (!isRomDruidRoguePair(ability)) return effects;
|
||||
return [
|
||||
...effects.filter((effect) => effect.kind === "damage" || (effect.kind === "utility"
|
||||
&& effect.action === "class-script" && effect.triggerSpellId === 494024)),
|
||||
romDruidRogueCooldownRider(),
|
||||
];
|
||||
}
|
||||
default:
|
||||
return effects;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||
import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { romAbilityRequiredTargetStateTag, romEffectsForCast, type RomCastMechanics } from "./romCombatMechanics";
|
||||
import { ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG } from "./romDruidRogueCurrentCoa";
|
||||
|
||||
const empty: RomCastMechanics = {
|
||||
sealStacks: 0,
|
||||
bleedCount: 0,
|
||||
charged: false,
|
||||
mainHandDagger: false,
|
||||
casterMaxHealth: 10_000,
|
||||
targetHealthPercent: 1,
|
||||
};
|
||||
|
||||
function ability(spellId: number) {
|
||||
const found = ROM_ALL_ABILITIES.find((entry) => entry.dbcSpellId === spellId
|
||||
&& entry.classId === "rom-druid" && entry.secondaryClassId === "rom-rogue");
|
||||
expect(found, `Druid/Rogue ability ${spellId}`).toBeDefined();
|
||||
return found!;
|
||||
}
|
||||
|
||||
describe("current CoA Druid/Rogue Necrotic Wound profile", () => {
|
||||
it("uses the sourced gate, costs, channel, range, radius, and three full per-hit values", () => {
|
||||
const wound = ability(494321);
|
||||
expect(wound).toMatchObject({
|
||||
name: "Necrotic Wound",
|
||||
target: "hostile",
|
||||
range: { min: 0, max: 15 },
|
||||
radius: 8,
|
||||
targeting: { shape: "circle", radius: 8 },
|
||||
castMode: "channel",
|
||||
castTimeMs: 1_500,
|
||||
channelPulses: 3,
|
||||
channelTickMs: 500,
|
||||
cooldownMs: 0,
|
||||
costs: [
|
||||
{ resource: "mana", amount: 4, percentage: true },
|
||||
{ resource: "nature-power", amount: 1 },
|
||||
],
|
||||
});
|
||||
expect(romAbilityRequiredTargetStateTag(wound)).toBe(ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG);
|
||||
expect(romEffectsForCast(wound, { ...empty, naturePower: 1 })).toEqual([]);
|
||||
|
||||
const currentEffects = romEffectsForCast(wound, {
|
||||
...empty,
|
||||
naturePower: 1,
|
||||
targetTags: [ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG],
|
||||
});
|
||||
expect(currentEffects.filter((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing" && modifier.direction === "taken")))
|
||||
.toHaveLength(0);
|
||||
expect(currentEffects.filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"))
|
||||
.toHaveLength(0);
|
||||
expect(currentEffects.filter((effect) => effect.kind === "damage"))
|
||||
.toEqual([{ kind: "damage", coefficient: 3.297, school: "shadow" }]);
|
||||
expect(currentEffects.filter((effect) => effect.kind === "utility" && effect.action === "class-script"))
|
||||
.toEqual([expect.objectContaining({ triggerSpellId: 494024, durationMs: 12_000 })]);
|
||||
expect(currentEffects.filter((effect) => effect.kind === "cooldown-reset"))
|
||||
.toEqual([{ kind: "cooldown-reset", mode: "reduce", abilityName: "Sinister Request", amountMs: 1_000 }]);
|
||||
|
||||
const channelAmounts = channelAmountEffects(wound, 3);
|
||||
expect(channelAmounts).toEqual([{ effect: currentEffects.find((effect) => effect.kind === "damage"), multiplier: 1 }]);
|
||||
expect(channelPulseCount(wound)).toBe(3);
|
||||
expect([0, 1, 2].map((pulse) => channelPulseAt(wound, 1_000, 1_500, pulse)))
|
||||
.toEqual([1_500, 2_000, 2_500]);
|
||||
});
|
||||
|
||||
it("replaces Corrosive Poison with the current +105, one-second Dark DoT and adds the spread prerequisite", () => {
|
||||
const poison = ability(494024);
|
||||
const dot = poison.effects.find((effect) => effect.kind === "dot");
|
||||
expect(poison).toMatchObject({
|
||||
range: { max: 15 },
|
||||
castMode: "instant",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 1_000,
|
||||
costs: [{ resource: "energy", amount: 25 }],
|
||||
});
|
||||
expect(dot).toMatchObject({
|
||||
kind: "dot",
|
||||
school: "shadow",
|
||||
basePoints: 11_342,
|
||||
sourceLevel: 105,
|
||||
ticks: 12,
|
||||
intervalMs: 1_000,
|
||||
tag: ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the exact one-second cooldown rider to both successful casts", () => {
|
||||
const cooldownRiders = [494022, 494321].map((spellId) => romEffectsForCast(ability(spellId), {
|
||||
...empty,
|
||||
naturePower: 1,
|
||||
targetTags: spellId === 494321 ? [ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG] : [],
|
||||
}).filter((effect) => effect.kind === "cooldown-reset"));
|
||||
expect(cooldownRiders).toEqual([
|
||||
[{ kind: "cooldown-reset", mode: "reduce", abilityName: "Sinister Request", amountMs: 1_000 }],
|
||||
[{ kind: "cooldown-reset", mode: "reduce", abilityName: "Sinister Request", amountMs: 1_000 }],
|
||||
]);
|
||||
|
||||
const kneeUlcer = ROM_ELITE_ABILITIES.find((entry) => entry.dbcSpellId === 499944);
|
||||
expect(kneeUlcer).toBeDefined();
|
||||
const passiveTag = kneeUlcer!.effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(passiveTag?.kind).toBe("apply-aura");
|
||||
const withObsoletePassive = romEffectsForCast(ability(494321), {
|
||||
...empty,
|
||||
naturePower: 1,
|
||||
targetTags: [ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG],
|
||||
passiveTags: passiveTag?.kind === "apply-aura" ? passiveTag.aura.utilityTags : [],
|
||||
});
|
||||
expect(withObsoletePassive.some((effect) => effect.kind === "apply-aura" && effect.control?.kind === "root"))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it("uses the current Sinister Request identity and does not leak the old Speed Catalysis aura", () => {
|
||||
const request = ability(494023);
|
||||
expect(request).toMatchObject({
|
||||
name: "Sinister Request",
|
||||
target: "self",
|
||||
range: { max: 5 },
|
||||
radius: 15,
|
||||
cooldownMs: 15_000,
|
||||
costs: [],
|
||||
effects: expect.arrayContaining([
|
||||
{ kind: "resource", amount: 100, resource: "mana", percentage: true, scope: "party" },
|
||||
{ kind: "resource", amount: 100, resource: "rage", percentage: true, scope: "party" },
|
||||
{ kind: "resource", amount: 100, resource: "focus", percentage: true, scope: "party" },
|
||||
{ kind: "resource", amount: 100, resource: "energy", percentage: true, scope: "party" },
|
||||
{ kind: "resource", amount: 100, resource: "psi", percentage: true, scope: "party" },
|
||||
{ kind: "resource", amount: 100, resource: "nature-power", percentage: true, scope: "party" },
|
||||
]),
|
||||
});
|
||||
expect(request.description).toContain("restores the MP, Rage, Focus, Energy, Psi, and Nature's Power");
|
||||
expect(request.description).toContain("within radius 150");
|
||||
expect(request.description).toContain("Cooldown: 15 seconds");
|
||||
expect(request.description).not.toContain("attack and casting speed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
|
||||
export const ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG = "rom-druid-corrosive-poison";
|
||||
|
||||
export function isRomDruidRoguePair(row: {
|
||||
readonly classId?: string;
|
||||
readonly secondaryClassId?: string;
|
||||
}): boolean {
|
||||
return row.classId === "rom-druid" && row.secondaryClassId === "rom-rogue";
|
||||
}
|
||||
|
||||
/** Current CoA's +105 Corrosive Poison value; lower levels use the simulator's source-level scaling. */
|
||||
export function romDruidRogueCorrosivePoisonEffect(): Extract<AbilityEffect, { kind: "dot" }> {
|
||||
return {
|
||||
kind: "dot",
|
||||
coefficient: 0,
|
||||
school: "shadow",
|
||||
basePoints: 11_342,
|
||||
sourceLevel: 105,
|
||||
ticks: 12,
|
||||
intervalMs: 1_000,
|
||||
tag: ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG,
|
||||
};
|
||||
}
|
||||
|
||||
export function romDruidRogueCooldownRider(): AbilityEffect {
|
||||
return {
|
||||
kind: "cooldown-reset",
|
||||
mode: "reduce",
|
||||
abilityName: "Sinister Request",
|
||||
amountMs: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
export const ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES = Object.freeze([
|
||||
"mana",
|
||||
"rage",
|
||||
"focus",
|
||||
"energy",
|
||||
"psi",
|
||||
"nature-power",
|
||||
] as const);
|
||||
|
||||
export function romDruidRogueSinisterRequestEffects(): readonly AbilityEffect[] {
|
||||
return ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES.map((resource) => ({
|
||||
kind: "resource" as const,
|
||||
amount: 100,
|
||||
resource,
|
||||
percentage: true,
|
||||
scope: "party" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export const ROM_DRUID_ROGUE_NECROTIC_WOUND_DESCRIPTION =
|
||||
"Costs 4% MP and 1 Nature's Power. Requires the target to have Corrosive Poison. Channels for 1.5 seconds and deals 329.7% Dark damage per hit over 3 hits to enemies within radius 80 around the target, then spreads Corrosive Poison to them. No cooldown. Each successful cast reduces Sinister Request's cooldown by 1 second.";
|
||||
|
||||
export const ROM_DRUID_ROGUE_CORROSIVE_POISON_DESCRIPTION =
|
||||
"Costs 25 Energy. Instantly applies Corrosive Poison to one target, dealing 11,342 Dark damage every second for 12 seconds. Range 150; 1-second cooldown.";
|
||||
|
||||
export const ROM_DRUID_ROGUE_SINISTER_REQUEST_DESCRIPTION =
|
||||
"Instantly restores the MP, Rage, Focus, Energy, Psi, and Nature's Power of party members within radius 150 to their maximum values. Cooldown: 15 seconds. Each successful Poisonous Widow Embrace or Necrotic Wound cast reduces this cooldown by 1 second.";
|
||||
@@ -0,0 +1,194 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES } from "./romDruidRogueCurrentCoa";
|
||||
import { abilityAtLevel, type ResourceType } from "./abilityCatalog";
|
||||
|
||||
const RESOURCE_TYPES = ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES as readonly ResourceType[];
|
||||
const STARTING_VALUES: Readonly<Record<ResourceType, number>> = {
|
||||
mana: 20,
|
||||
rage: 10,
|
||||
focus: 10,
|
||||
energy: 10,
|
||||
psi: 1,
|
||||
"nature-power": 1,
|
||||
health: 0,
|
||||
"runic-power": 0,
|
||||
"holy-power": 0,
|
||||
chi: 0,
|
||||
"soul-power": 0,
|
||||
};
|
||||
|
||||
function memberWithPools(seed: string, resources = RESOURCE_TYPES, index = 0): PartyMember {
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Druid/Rogue",
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-elf",
|
||||
specialization: "Druid/Rogue",
|
||||
specializationId: "rom-druid:rom-rogue",
|
||||
talentTreeId: "rom-druid-rom-rogue",
|
||||
talentRanks: {},
|
||||
role: "damage",
|
||||
}, 80, index, seed);
|
||||
const resourcePools = Object.fromEntries(
|
||||
resources.map((resource) => [resource, STARTING_VALUES[resource]]),
|
||||
) as Partial<Record<ResourceType, number>>;
|
||||
return {
|
||||
...member,
|
||||
resource: resourcePools[member.resourceType] ?? member.resource,
|
||||
resourcePools,
|
||||
};
|
||||
}
|
||||
|
||||
function sinisterRequestId(): string {
|
||||
const request = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494023
|
||||
&& ability.classId === "rom-druid" && ability.secondaryClassId === "rom-rogue");
|
||||
if (!request) throw new Error("Druid/Rogue Sinister Request catalog row is missing.");
|
||||
return request.id;
|
||||
}
|
||||
|
||||
function assertPoolsAreFull(actorId: string, expectedResources: readonly ResourceType[]): void {
|
||||
const resources = useCombatStore.getState().actors[actorId]?.resources;
|
||||
expect(resources).toBeDefined();
|
||||
for (const resource of expectedResources) {
|
||||
expect(resources![resource]?.current).toBe(resources![resource]?.maximum);
|
||||
}
|
||||
}
|
||||
|
||||
describe("current CoA Druid/Rogue Sinister Request runtime", () => {
|
||||
beforeEach(() => {
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useDungeonSimulationStore.setState({ sessionMode: "play" });
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-rogue",
|
||||
level: 80,
|
||||
xp: 0,
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty("sinister-request-test", 80,
|
||||
useCombatStore.getState().health, "damage", 3, "rom");
|
||||
});
|
||||
|
||||
it("fills the player's and nearby companions' existing pools, includes radius edge, and leaves outside/absent pools alone", () => {
|
||||
const nearby = memberWithPools("sinister-player-nearby", RESOURCE_TYPES, 0);
|
||||
const edge = memberWithPools("sinister-player-edge", ["mana", "energy"], 1);
|
||||
const outside = memberWithPools("sinister-player-outside", RESOURCE_TYPES, 2);
|
||||
usePartyStore.setState({ members: [nearby, edge, outside] });
|
||||
registerPartyRuntimePosition(nearby.id, [5, 0, 0]);
|
||||
registerPartyRuntimePosition(edge.id, [15, 0, 0]);
|
||||
registerPartyRuntimePosition(outside.id, [15.01, 0, 0]);
|
||||
|
||||
useCombatStore.setState((state) => {
|
||||
const maxResourcePools = {
|
||||
...state.maxResourcePools,
|
||||
mana: 240,
|
||||
rage: 100,
|
||||
focus: 100,
|
||||
energy: 100,
|
||||
psi: 6,
|
||||
"nature-power": 15,
|
||||
};
|
||||
const resourcePools = {
|
||||
...state.resourcePools,
|
||||
mana: 1,
|
||||
rage: 1,
|
||||
focus: 1,
|
||||
energy: 1,
|
||||
psi: 1,
|
||||
"nature-power": 1,
|
||||
};
|
||||
return {
|
||||
maxResourcePools,
|
||||
resourcePools,
|
||||
maxResource: 240,
|
||||
resource: 1,
|
||||
};
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const beforeOutside = { ...useCombatStore.getState().actors[outside.id].resources };
|
||||
const abilityId = sinisterRequestId();
|
||||
const catalogRow = ROM_ALL_ABILITIES.find((ability) => ability.id === abilityId)!;
|
||||
expect(abilityAtLevel(catalogRow, 80).effects).toHaveLength(RESOURCE_TYPES.length);
|
||||
expect(useCombatStore.getState()).toMatchObject({ classId: "rom-druid", secondaryClassId: "rom-rogue" });
|
||||
const castResult = useCombatStore.getState().castAbility(abilityId, { targetActorId: null, now: 1_000 });
|
||||
expect(castResult).toMatchObject({ ok: true });
|
||||
|
||||
assertPoolsAreFull(PLAYER_AGGRO_ID, RESOURCE_TYPES);
|
||||
assertPoolsAreFull(nearby.id, RESOURCE_TYPES);
|
||||
assertPoolsAreFull(edge.id, ["mana", "energy"]);
|
||||
expect(useCombatStore.getState().actors[edge.id].resources.focus).toBeUndefined();
|
||||
for (const resource of RESOURCE_TYPES) {
|
||||
expect(useCombatStore.getState().actors[outside.id].resources[resource]).toEqual(beforeOutside[resource]);
|
||||
}
|
||||
|
||||
const events = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "resource" && event.abilityId === abilityId
|
||||
));
|
||||
expect(events.filter((event) => event.targetActorId === PLAYER_AGGRO_ID)).toHaveLength(RESOURCE_TYPES.length);
|
||||
expect(events.filter((event) => event.targetActorId === nearby.id)).toHaveLength(RESOURCE_TYPES.length);
|
||||
expect(events.filter((event) => event.targetActorId === edge.id)).toHaveLength(2);
|
||||
expect(events.some((event) => event.targetActorId === outside.id)).toBe(false);
|
||||
expect(events.every((event) => event.sourceActorId === PLAYER_AGGRO_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies a companion cast to the in-range party, caps each existing pool, and attributes resource events to the caster", () => {
|
||||
const abilityId = sinisterRequestId();
|
||||
const casterBase = memberWithPools("sinister-companion-caster", RESOURCE_TYPES, 0);
|
||||
const caster = {
|
||||
...casterBase,
|
||||
activeCast: {
|
||||
abilityId,
|
||||
school: "nature" as const,
|
||||
mode: "cast" as const,
|
||||
startedAt: 0,
|
||||
completesAt: 0,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0] as const,
|
||||
targetId: casterBase.id,
|
||||
},
|
||||
activeCastTargetId: casterBase.id,
|
||||
status: "healing" as const,
|
||||
};
|
||||
const nearby = memberWithPools("sinister-companion-nearby", RESOURCE_TYPES.filter((resource) => resource !== "psi"), 1);
|
||||
const edge = memberWithPools("sinister-companion-edge", ["mana", "rage"], 2);
|
||||
const outside = memberWithPools("sinister-companion-outside", RESOURCE_TYPES, 3);
|
||||
usePartyStore.setState({ members: [caster, nearby, edge, outside] });
|
||||
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
|
||||
registerPartyRuntimePosition(nearby.id, [5, 0, 0]);
|
||||
registerPartyRuntimePosition(edge.id, [15, 0, 0]);
|
||||
registerPartyRuntimePosition(outside.id, [15.01, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const beforeOutside = { ...useCombatStore.getState().actors[outside.id].resources };
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
assertPoolsAreFull(PLAYER_AGGRO_ID, ["mana"]);
|
||||
assertPoolsAreFull(caster.id, RESOURCE_TYPES);
|
||||
assertPoolsAreFull(nearby.id, RESOURCE_TYPES.filter((resource) => resource !== "psi"));
|
||||
expect(useCombatStore.getState().actors[nearby.id].resources.psi).toBeUndefined();
|
||||
assertPoolsAreFull(edge.id, ["mana", "rage"]);
|
||||
for (const resource of RESOURCE_TYPES) {
|
||||
expect(useCombatStore.getState().actors[outside.id].resources[resource]).toEqual(beforeOutside[resource]);
|
||||
}
|
||||
|
||||
const events = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "resource" && event.abilityId === abilityId
|
||||
));
|
||||
expect(events.filter((event) => event.targetActorId === PLAYER_AGGRO_ID)).toHaveLength(1);
|
||||
expect(events.filter((event) => event.targetActorId === caster.id)).toHaveLength(RESOURCE_TYPES.length);
|
||||
expect(events.filter((event) => event.targetActorId === nearby.id)).toHaveLength(RESOURCE_TYPES.length - 1);
|
||||
expect(events.filter((event) => event.targetActorId === edge.id)).toHaveLength(2);
|
||||
expect(events.some((event) => event.targetActorId === outside.id)).toBe(false);
|
||||
expect(events.every((event) => event.sourceActorId === caster.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES } from "./romDruidRogueCurrentCoa";
|
||||
|
||||
describe("current CoA Druid/Rogue Sinister Request catalog", () => {
|
||||
it("restores each listed party resource to its maximum with the current 15-second cooldown", () => {
|
||||
const request = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 494023
|
||||
&& ability.classId === "rom-druid" && ability.secondaryClassId === "rom-rogue");
|
||||
|
||||
expect(request).toBeDefined();
|
||||
expect(request).toMatchObject({
|
||||
name: "Sinister Request",
|
||||
target: "self",
|
||||
range: { max: 5 },
|
||||
radius: 15,
|
||||
cooldownMs: 15_000,
|
||||
costs: [],
|
||||
effects: ROM_DRUID_ROGUE_SINISTER_REQUEST_RESOURCES.map((resource) => ({
|
||||
kind: "resource",
|
||||
amount: 100,
|
||||
resource,
|
||||
percentage: true,
|
||||
scope: "party",
|
||||
})),
|
||||
});
|
||||
expect(request!.description).toContain("restores the MP, Rage, Focus, Energy, Psi, and Nature's Power");
|
||||
expect(request!.description).toContain("within radius 150");
|
||||
expect(request!.description).toContain("Cooldown: 15 seconds");
|
||||
expect(request!.description).not.toContain("does not yet apply");
|
||||
expect(request!.description).not.toContain("attack and casting speed");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { abilityById, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { equipmentAdjustedCombatAmount, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { installCombatClock } from "./combatClock";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { companionResourceMaximum } from "./combatActorAuthority";
|
||||
@@ -163,6 +163,73 @@ describe("RuneWaker Druid runtime", () => {
|
||||
expect(useCombatStore.getState().resourcePools["nature-power"]).toBe(4);
|
||||
});
|
||||
|
||||
it("casts the current Druid/Rogue Necrotic Wound only with poison and Nature Power, then spreads three hits", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-elf",
|
||||
level: 80,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: 1_000,
|
||||
maxResource: 1_000,
|
||||
resourcePools: { ...state.resourcePools, mana: 1_000, energy: 100, "nature-power": 1 },
|
||||
maxResourcePools: { ...state.maxResourcePools, mana: 1_000, energy: 100 },
|
||||
cooldowns: { ...state.cooldowns, [ability(494023).id]: 11_000 },
|
||||
}));
|
||||
const primary = "rom-druid-wound-primary";
|
||||
const nearby = "rom-druid-wound-nearby";
|
||||
const outside = "rom-druid-wound-outside";
|
||||
for (const [id, position] of [
|
||||
[primary, [4, 0, 0]],
|
||||
[nearby, [11, 0, 0]],
|
||||
[outside, [13, 0, 0]],
|
||||
] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(id, position);
|
||||
useCombatStore.getState().damageMob(id, 1, 100);
|
||||
}
|
||||
|
||||
const wound = ability(494321);
|
||||
const poison = ability(494024);
|
||||
const runtimePoison = abilityById(poison.id);
|
||||
expect(runtimePoison).toMatchObject({
|
||||
id: poison.id,
|
||||
dbcSpellId: 494024,
|
||||
effects: expect.arrayContaining([expect.objectContaining({
|
||||
kind: "dot",
|
||||
tag: "rom-druid-corrosive-poison",
|
||||
})]),
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility(wound.id, { targetActorId: primary, now: 1_000 }))
|
||||
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().castAbility(poison.id, { targetActorId: primary, now: 2_000 }).ok).toBe(true);
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, "nature-power": 0 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(wound.id, { targetActorId: primary, now: 3_500 }))
|
||||
.toMatchObject({ ok: false, reason: "not-enough-resource" });
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, "nature-power": 1 },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(wound.id, { targetActorId: primary, now: 5_000 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().resourcePools).toMatchObject({ mana: 960, "nature-power": 0, energy: 75 });
|
||||
finishCast(5_000);
|
||||
|
||||
const woundHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.abilityId === wound.id && event.kind === "damage"
|
||||
));
|
||||
expect(woundHits).toHaveLength(6);
|
||||
const activePoisonTargets = useCombatStore.getState().effects.filter((effect) => (
|
||||
effect.kind === "dot" && effect.tag === "rom-druid-corrosive-poison" && effect.remainingTicks === 12
|
||||
)).map((effect) => effect.targetId).sort();
|
||||
expect(activePoisonTargets).toEqual([nearby, primary].sort());
|
||||
expect(useCombatStore.getState().mobs[outside]!.health).toBe(999_999);
|
||||
expect(useCombatStore.getState().cooldowns[ability(494023).id]).toBe(10_000);
|
||||
expect(useCombatStore.getState().mobs[primary]!.statuses.some((status) => status.kind === "root")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies Grace of the Forest's Nature Power bonus only when a point is held", () => {
|
||||
prepareDruid("rom-warden");
|
||||
const grace = ability(494937);
|
||||
@@ -273,19 +340,13 @@ describe("RuneWaker Druid runtime", () => {
|
||||
expect(buffs.every((aura) => aura.targetId === ally.id && aura.definition.disposition === "buff")).toBe(true);
|
||||
});
|
||||
|
||||
it("triggers both Green Guardian HoTs only after the caster's protection expires", () => {
|
||||
it("suppresses the stale Green Guardian HoTs when the exact-pair active skill's protection expires", () => {
|
||||
prepareMageDruid();
|
||||
const state = useCombatStore.getState();
|
||||
const passive = ability(494331);
|
||||
const protection = ability(493533);
|
||||
expect(state.abilities.some((entry) => entry.id === passive.id && entry.passive)).toBe(true);
|
||||
|
||||
const percentHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth !== undefined);
|
||||
const flatHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth === undefined);
|
||||
expect(percentHot).toMatchObject({ kind: "hot", percentMaxHealth: 0.02, ticks: 5, intervalMs: 2_000 });
|
||||
expect(flatHot).toMatchObject({ kind: "hot", basePoints: 39, pointsPerLevel: 9, ticks: 5, intervalMs: 2_000 });
|
||||
|
||||
const targetMaxHealth = state.maxHealth;
|
||||
expect(state.abilities.some((entry) => entry.id === passive.id && !entry.passive)).toBe(true);
|
||||
expect(passive.effects).toEqual([expect.objectContaining({ kind: "apply-aura" })]);
|
||||
const protectionCast = useCombatStore.getState().castAbility(protection.id, {
|
||||
targetActorId: null,
|
||||
now: 1_000,
|
||||
@@ -301,30 +362,8 @@ describe("RuneWaker Druid runtime", () => {
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0);
|
||||
expect(useCombatStore.getState().health).toBe(startingHealth);
|
||||
useCombatStore.getState().tick(0, 6_000);
|
||||
const triggered = useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id);
|
||||
expect(triggered).toHaveLength(2);
|
||||
expect(triggered.every((effect) => effect.kind === "hot" && effect.targetId === null
|
||||
&& effect.remainingTicks === 5 && effect.nextTickAt === 8_000)).toBe(true);
|
||||
expect(useCombatStore.getState().health).toBe(startingHealth);
|
||||
|
||||
const flatTickAmount = equipmentAdjustedCombatAmount(
|
||||
flatHot as Extract<NonNullable<typeof flatHot>, { kind: "hot" }>,
|
||||
80,
|
||||
state.gearStats,
|
||||
"rom-mage",
|
||||
passive,
|
||||
);
|
||||
expect(flatTickAmount).toBe(750);
|
||||
const expectedPerTick = Math.round(targetMaxHealth * 0.02 + flatTickAmount);
|
||||
const expectedTotal = expectedPerTick * 5;
|
||||
let observedTicks = 0;
|
||||
for (const now of [8_000, 10_000, 12_000, 14_000, 16_000]) {
|
||||
useCombatStore.getState().tick(0, now);
|
||||
observedTicks += 1;
|
||||
expect(useCombatStore.getState().health - startingHealth).toBe(expectedPerTick * observedTicks);
|
||||
}
|
||||
expect(useCombatStore.getState().health - startingHealth).toBe(expectedTotal);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0);
|
||||
expect(useCombatStore.getState().health).toBe(startingHealth);
|
||||
});
|
||||
|
||||
it("does not trigger Green Guardian for another pair or another aura caster", () => {
|
||||
@@ -358,7 +397,7 @@ describe("RuneWaker Druid runtime", () => {
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("heals the friendly recipient of Mother Earth's Protection when it expires", () => {
|
||||
it("suppresses stale Green Guardian HoTs on a friendly protection recipient", () => {
|
||||
prepareMageDruid();
|
||||
usePartyStore.getState().initializeParty("green-guardian-recipient", 80, 1, "damage", 5, "rom");
|
||||
const recipient = usePartyStore.getState().members[0]!;
|
||||
@@ -370,10 +409,6 @@ describe("RuneWaker Druid runtime", () => {
|
||||
|
||||
const passive = ability(494331);
|
||||
const protection = ability(493533);
|
||||
const percentHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth !== undefined);
|
||||
const flatHot = passive.effects.find((effect) => effect.kind === "hot" && effect.percentMaxHealth === undefined);
|
||||
const percentMaxHealth = percentHot?.kind === "hot" ? percentHot.percentMaxHealth ?? 0 : 0;
|
||||
expect(percentMaxHealth).toBe(0.02);
|
||||
expect(useCombatStore.getState().castAbility(protection.id, {
|
||||
targetActorId: recipient.id,
|
||||
now: 1_000,
|
||||
@@ -386,26 +421,11 @@ describe("RuneWaker Druid runtime", () => {
|
||||
expect(protectionAura?.expiresAt).toBe(6_000);
|
||||
useCombatStore.getState().tick(0, protectionAura!.expiresAt!);
|
||||
expect(useCombatStore.getState().effects.filter((effect) => effect.sourceAbilityId === passive.id
|
||||
&& effect.targetId === recipient.id)).toHaveLength(2);
|
||||
|
||||
const targetMaxHealth = recipient.maxHealth;
|
||||
const flatTickAmount = equipmentAdjustedCombatAmount(
|
||||
flatHot as Extract<NonNullable<typeof flatHot>, { kind: "hot" }>,
|
||||
80,
|
||||
useCombatStore.getState().gearStats,
|
||||
"rom-mage",
|
||||
passive,
|
||||
);
|
||||
const expectedPerTick = Math.round(targetMaxHealth * percentMaxHealth + flatTickAmount);
|
||||
for (const now of [8_000, 10_000, 12_000, 14_000, 16_000]) {
|
||||
const before = usePartyStore.getState().members.find((member) => member.id === recipient.id)!.health;
|
||||
useCombatStore.getState().tick(0, now);
|
||||
const after = usePartyStore.getState().members.find((member) => member.id === recipient.id)!.health;
|
||||
expect(after - before).toBe(expectedPerTick);
|
||||
}
|
||||
&& effect.targetId === recipient.id)).toHaveLength(0);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === recipient.id)!.health).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a party Mage/Druid's Green Guardian when their protection expires", () => {
|
||||
it("suppresses stale Green Guardian HoTs for a companion's protection expiry", () => {
|
||||
prepareMageDruid();
|
||||
const caster = createConfiguredPartyMember({
|
||||
name: "Mage Druid",
|
||||
@@ -442,8 +462,7 @@ describe("RuneWaker Druid runtime", () => {
|
||||
const triggered = useCombatStore.getState().effects.filter((effect) => (
|
||||
effect.sourceAbilityId === passive.id && effect.sourceActorId === caster.id
|
||||
));
|
||||
expect(triggered).toHaveLength(2);
|
||||
expect(triggered.every((effect) => effect.kind === "hot"
|
||||
&& effect.targetId === null && effect.nextTickAt === 8_000)).toBe(true);
|
||||
expect(passive.passive).toBe(false);
|
||||
expect(triggered).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { abilitiesForCharacter } from "./abilityCatalog";
|
||||
|
||||
const FEARLESS_ABILITY_ID = "rom-knight-rom-warrior-elite-35";
|
||||
|
||||
describe("RoM Knight/Warrior Fearless", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 40 });
|
||||
usePartyStore.getState().initializeParty("rom-knight-fearless", 40, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
});
|
||||
|
||||
it("gives the player source-defined stats and 20 seconds of Fear immunity", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 40, role: "tank",
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
resourcePools: { ...state.resourcePools, rage: 100 },
|
||||
}));
|
||||
|
||||
const cast = useCombatStore.getState().castAbility(FEARLESS_ABILITY_ID, undefined, 1_000);
|
||||
expect(cast).toMatchObject({ ok: true });
|
||||
const auras = useCombatStore.getState().auras.filter((active) => (
|
||||
active.targetId === PLAYER_AURA_ENTITY_ID && active.definition.name.startsWith("Fearless:")
|
||||
));
|
||||
expect(auras).toHaveLength(2);
|
||||
expect(auras.every((aura) => aura.definition.controlImmunities?.includes("fear"))).toBe(true);
|
||||
expect(auras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([
|
||||
{ kind: "stat", stat: "strength", operation: "percent", value: 0.03 },
|
||||
{ kind: "stat", stat: "stamina", operation: "percent", value: 0.03 },
|
||||
]));
|
||||
expect(auras.every((aura) => aura.expiresAt === 21_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("gives a generated Knight/Warrior companion the same Fear immunity", () => {
|
||||
const seed = "rom-knight-fearless-companion";
|
||||
const knight = createConfiguredPartyMember({
|
||||
name: "Fearless Knight",
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
specialization: "Knight/Warrior",
|
||||
role: "tank",
|
||||
}, 40, 0, seed);
|
||||
const targetId = "rom-knight-fearless-target";
|
||||
const activeCooldowns = Object.fromEntries(
|
||||
abilitiesForCharacter("rom-knight", "rom-warrior")
|
||||
.filter((ability) => !ability.passive && ability.id !== FEARLESS_ABILITY_ID)
|
||||
.map((ability) => [ability.id, 60_000]),
|
||||
);
|
||||
usePartyStore.getState().initializeParty(
|
||||
seed, 40, useCombatStore.getState().health, "tank", 2, "rom",
|
||||
);
|
||||
usePartyStore.setState({
|
||||
members: [{
|
||||
...knight,
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
resourcePools: { ...knight.resourcePools, rage: 50 },
|
||||
cooldowns: activeCooldowns,
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
status: "ready",
|
||||
}],
|
||||
command: "attack",
|
||||
activeMobId: targetId,
|
||||
});
|
||||
registerPartyRuntimePosition(knight.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob(targetId, { name: "Fearless target", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition(targetId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(targetId, 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(knight.id, targetId, true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const auras = useCombatStore.getState().auras.filter((active) => (
|
||||
active.sourceId === knight.id && active.targetId === knight.id
|
||||
&& active.definition.name.startsWith("Fearless:")
|
||||
));
|
||||
expect(auras).toHaveLength(2);
|
||||
expect(auras.every((aura) => aura.definition.controlImmunities?.includes("fear"))).toBe(true);
|
||||
expect(auras.every((aura) => aura.expiresAt === 21_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID,
|
||||
ROM_COA_KNIGHT_ROGUE_GODS_SWORD_OFFHAND_DPS_COEFFICIENT,
|
||||
romCoaKnightRogueGodsSwordBonusLightDamage,
|
||||
type RomCoaKnightRogueGodsSwordInput,
|
||||
} from "./romKnightGodsSwordCoaMechanics";
|
||||
|
||||
const eligibleInput: RomCoaKnightRogueGodsSwordInput = {
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-rogue",
|
||||
learnedPassiveSpellIds: [ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID],
|
||||
landedOffensivePhysicalAbilityDamageEvent: true,
|
||||
offhandWeaponDps: 100,
|
||||
};
|
||||
|
||||
describe("current CoA Knight/Rogue God's Sword passive", () => {
|
||||
it("adds 350% of offhand weapon DPS as separate Light damage", () => {
|
||||
expect(ROM_COA_KNIGHT_ROGUE_GODS_SWORD_OFFHAND_DPS_COEFFICIENT).toBe(3.5);
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage(eligibleInput)).toBe(350);
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
|
||||
...eligibleInput,
|
||||
offhandWeaponDps: 12.5,
|
||||
})).toBe(43.75);
|
||||
});
|
||||
|
||||
it("requires the Knight/Rogue pairing and learned passive", () => {
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
|
||||
...eligibleInput,
|
||||
classId: "rom-warrior",
|
||||
})).toBe(0);
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
|
||||
...eligibleInput,
|
||||
secondaryClassId: "rom-warrior",
|
||||
})).toBe(0);
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
|
||||
...eligibleInput,
|
||||
learnedPassiveSpellIds: [],
|
||||
})).toBe(0);
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
|
||||
...eligibleInput,
|
||||
learnedPassiveSpellIds: [491_620],
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
it("requires a landed offensive physical-ability damage event", () => {
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
|
||||
...eligibleInput,
|
||||
landedOffensivePhysicalAbilityDamageEvent: false,
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
it("requires positive finite offhand DPS and returns only finite damage", () => {
|
||||
for (const offhandWeaponDps of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
|
||||
...eligibleInput,
|
||||
offhandWeaponDps,
|
||||
})).toBe(0);
|
||||
}
|
||||
|
||||
expect(romCoaKnightRogueGodsSwordBonusLightDamage({
|
||||
...eligibleInput,
|
||||
offhandWeaponDps: Number.MAX_VALUE,
|
||||
})).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Current CoA Knight/Rogue God's Sword passive (skill 491621). */
|
||||
export const ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID = 491_621;
|
||||
|
||||
/** CoA's passive adds 350% of offhand weapon DPS as Light damage. */
|
||||
export const ROM_COA_KNIGHT_ROGUE_GODS_SWORD_OFFHAND_DPS_COEFFICIENT = 3.5;
|
||||
|
||||
export interface RomCoaKnightRogueGodsSwordInput {
|
||||
readonly classId: string;
|
||||
readonly secondaryClassId: string | null | undefined;
|
||||
readonly learnedPassiveSpellIds: readonly number[];
|
||||
/** True for a landed offensive physical-ability damage event. */
|
||||
readonly landedOffensivePhysicalAbilityDamageEvent: boolean;
|
||||
readonly offhandWeaponDps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return God's Sword's separate Light-damage rider for one qualifying event.
|
||||
*
|
||||
* Current CoA describes God's Sword (491621) as a Knight/Rogue passive whose
|
||||
* offensive physical abilities inflict 350% offhand weapon DPS Light damage
|
||||
* with high aggro generation. The helper models only that documented damage;
|
||||
* it does not invent an aggro multiplier or decide how callers divide
|
||||
* multi-hit, channel, or area actions into damage events.
|
||||
*
|
||||
* The explicit patch chain identifies this as the same reworked ability:
|
||||
* 10.6.1 made it an active offhand-DPS physical rider, 11.1.5 changed its
|
||||
* damage to 350% Light, and 12.1.0 made it passive.
|
||||
*
|
||||
* Sources:
|
||||
* - Current CoA Knight/Rogue skill list: https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru
|
||||
* - CoA patch 10.6.1: https://chroniclesofarcadia.com/en/patch/10-6-1-1000
|
||||
* - CoA patch 11.1.5: https://chroniclesofarcadia.com/tr/patch/11-1-5-3000
|
||||
* - CoA patch 12.1.0: https://chroniclesofarcadia.com/patch/12-1-0-5000
|
||||
*/
|
||||
export function romCoaKnightRogueGodsSwordBonusLightDamage(
|
||||
input: RomCoaKnightRogueGodsSwordInput,
|
||||
): number {
|
||||
if (input.classId !== "rom-knight"
|
||||
|| input.secondaryClassId !== "rom-rogue"
|
||||
|| !input.learnedPassiveSpellIds.includes(ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID)
|
||||
|| !input.landedOffensivePhysicalAbilityDamageEvent
|
||||
|| !Number.isFinite(input.offhandWeaponDps)
|
||||
|| input.offhandWeaponDps <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const damage = input.offhandWeaponDps * ROM_COA_KNIGHT_ROGUE_GODS_SWORD_OFFHAND_DPS_COEFFICIENT;
|
||||
return Number.isFinite(damage) ? damage : 0;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog";
|
||||
import { resolveDamageValue as resolveAuraDamageValue } from "./combatAuras";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS, type WeaponStats } from "./itemStats";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
|
||||
const OFFHAND_ID = "coa-gods-sword-test-offhand";
|
||||
const OFFHAND: WeaponStats = {
|
||||
damageMin: 100,
|
||||
damageMax: 300,
|
||||
damageSchool: "physical",
|
||||
speedMs: 2_000,
|
||||
};
|
||||
|
||||
function offhandItem(instanceId = OFFHAND_ID): InventoryItem {
|
||||
return {
|
||||
instanceId,
|
||||
itemId: 1,
|
||||
itemKey: instanceId,
|
||||
name: "Test Off-hand Dagger",
|
||||
quality: "uncommon",
|
||||
inventorySlot: "off-hand",
|
||||
armorType: null,
|
||||
weaponType: "mace",
|
||||
itemLevel: 1,
|
||||
sourceItemLevel: 1,
|
||||
stats: { ...EMPTY_ITEM_STATS, meleeWeapon: OFFHAND, hitRating: 1_000_000 },
|
||||
quantity: 1,
|
||||
sourceBossKey: "test",
|
||||
sourceBossName: "Test",
|
||||
acquiredAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function registerTarget(id: string): void {
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: id,
|
||||
level: 1,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(id);
|
||||
}
|
||||
|
||||
function expectedLightRiderAmount(actorId: string): number {
|
||||
const auras = useCombatStore.getState().auras.filter((aura) => aura.targetId === actorId);
|
||||
return Math.round(resolveAuraDamageValue(350, auras, {
|
||||
direction: "dealt",
|
||||
school: "holy",
|
||||
attackKind: "spell",
|
||||
}));
|
||||
}
|
||||
|
||||
describe("current CoA Knight/Rogue God's Sword runtime", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
xp: 0,
|
||||
});
|
||||
usePartyStore.getState().initializeParty("coa-gods-sword-runtime", 80, useCombatStore.getState().health);
|
||||
});
|
||||
|
||||
it("adds one separate 350% off-hand-DPS Light hit to a landed player physical ability", () => {
|
||||
const item = offhandItem();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-human",
|
||||
level: 80,
|
||||
xp: 0,
|
||||
inventory: [item],
|
||||
equipment: { player: { "off-hand": item.instanceId } },
|
||||
});
|
||||
registerTarget("coa-gods-sword-player-target");
|
||||
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 490148)!;
|
||||
expect(useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 491621))
|
||||
.toMatchObject({ passive: true, unlockLevel: 15 });
|
||||
expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
|
||||
const events = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === "coa-gods-sword-player-target"
|
||||
&& event.abilityId === ability.id
|
||||
));
|
||||
expect(events.some((event) => event.school === "holy" && event.rawAmount !== 350
|
||||
&& event.effectiveAmount > 0)).toBe(true);
|
||||
expect(events.filter((event) => event.school === "holy"
|
||||
&& event.rawAmount === expectedLightRiderAmount(PLAYER_AGGRO_ID)))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not add the CoA rider before the passive is learned or without an off-hand weapon", () => {
|
||||
const simulate = (level: number, equipOffhand: boolean) => {
|
||||
const item = offhandItem(`coa-gods-sword-gate-${level}-${equipOffhand}`);
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-human",
|
||||
level,
|
||||
xp: 0,
|
||||
...(equipOffhand ? {
|
||||
inventory: [item],
|
||||
equipment: { player: { "off-hand": item.instanceId } },
|
||||
} : {}),
|
||||
});
|
||||
const targetId = `coa-gods-sword-gate-target-${level}-${equipOffhand}`;
|
||||
registerTarget(targetId);
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 490148)!;
|
||||
useCombatStore.getState().castAbility(ability.id, undefined, 1_000);
|
||||
return useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.targetActorId === targetId
|
||||
&& event.abilityId === ability.id
|
||||
&& event.school === "holy"
|
||||
&& event.rawAmount === 350
|
||||
));
|
||||
};
|
||||
|
||||
expect(simulate(14, true)).toHaveLength(0);
|
||||
expect(simulate(80, false)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("adds the same landed-hit rider for a generated Knight/Rogue companion", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", level: 80, xp: 0 });
|
||||
const seed = "coa-gods-sword-party";
|
||||
const companion = createConfiguredPartyMember({
|
||||
name: "Knight/Rogue",
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-human",
|
||||
specialization: "Knight/Rogue",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const sourceAbility = abilitiesForCharacter("rom-knight", "rom-rogue")
|
||||
.find((candidate) => candidate.dbcSpellId === 490148)!;
|
||||
const ability = learnedAbilityForBuild(sourceAbility, 80, companion.talentRanks)!;
|
||||
const targetId = "coa-gods-sword-companion-target";
|
||||
const item = {
|
||||
...offhandItem(`${companion.id}:${OFFHAND_ID}`),
|
||||
};
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.setState({
|
||||
members: [{
|
||||
...companion,
|
||||
gearStats: { ...EMPTY_ITEM_STATS, hitRating: 1_000_000 },
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
activeCast: {
|
||||
abilityId: ability.id,
|
||||
school: "physical",
|
||||
mode: "cast",
|
||||
startedAt: 1_000,
|
||||
completesAt: 1_000,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}],
|
||||
command: "attack",
|
||||
activeMobId: targetId,
|
||||
});
|
||||
useCombatStore.setState((state) => ({
|
||||
inventory: [...state.inventory, item],
|
||||
equipment: { ...state.equipment, [companion.id]: { "off-hand": item.instanceId } },
|
||||
}));
|
||||
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerTarget(targetId);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const events = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === companion.id
|
||||
&& event.targetActorId === targetId
|
||||
&& event.abilityId === ability.id
|
||||
));
|
||||
expect(events.some((event) => event.school === "holy" && event.rawAmount !== 350
|
||||
&& event.effectiveAmount > 0)).toBe(true);
|
||||
expect(events.filter((event) => event.school === "holy"
|
||||
&& event.rawAmount === expectedLightRiderAmount(companion.id)))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import {
|
||||
ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID,
|
||||
ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID,
|
||||
romKnightCompassionWhirlwindShieldCooldownReductionMs,
|
||||
romKnightCompassionWhirlwindShieldHealAmount,
|
||||
romKnightDeadlyWhirlwindAura,
|
||||
romKnightDivinePunishmentAuraForSealStacks,
|
||||
romKnightRogueHolyStrikeCriticalRateIncreasePercent,
|
||||
romKnightWarriorWhirlwindAdjustment,
|
||||
} from "./romKnightOverrides";
|
||||
@@ -50,7 +56,7 @@ describe("RoM Knight/Rogue God's Sword", () => {
|
||||
const holyStrike = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490148)!;
|
||||
const passive = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491621)!;
|
||||
|
||||
it("provides the local 40% critical-rate increase only to Holy Strike for the learned pair passive", () => {
|
||||
it("does not apply the superseded local Holy Strike crit tooltip without a current CoA payload", () => {
|
||||
expect(passive).toMatchObject({
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-rogue",
|
||||
@@ -63,7 +69,7 @@ describe("RoM Knight/Rogue God's Sword", () => {
|
||||
"rom-rogue",
|
||||
holyStrike,
|
||||
[491621],
|
||||
)).toBe(40);
|
||||
)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not apply God's Sword to other pairings, abilities, unlearned passives, or non-RuneWaker spells", () => {
|
||||
@@ -83,3 +89,46 @@ describe("RoM Knight/Rogue God's Sword", () => {
|
||||
expect(bonuses).toEqual([0, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current CoA Knight pair effects", () => {
|
||||
it("applies current Deadly Wound healing reduction and PvP duration", () => {
|
||||
expect(romKnightDeadlyWhirlwindAura(false).aura).toMatchObject({
|
||||
durationMs: 10_000,
|
||||
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: -0.3 }],
|
||||
});
|
||||
expect(romKnightDeadlyWhirlwindAura(true).aura.durationMs).toBe(5_000);
|
||||
});
|
||||
|
||||
it("scales Divine Punishment vulnerability by the pre-spend Holy Seal count", () => {
|
||||
expect(romKnightDivinePunishmentAuraForSealStacks(0)).toBeNull();
|
||||
expect(romKnightDivinePunishmentAuraForSealStacks(1)?.aura.modifiers).toEqual([
|
||||
{ kind: "damage", direction: "taken", operation: "percent", value: 0.02 },
|
||||
]);
|
||||
expect(romKnightDivinePunishmentAuraForSealStacks(4)?.aura.modifiers).toEqual([
|
||||
{ kind: "damage", direction: "taken", operation: "percent", value: 0.08 },
|
||||
]);
|
||||
expect(romKnightDivinePunishmentAuraForSealStacks(8)?.aura.durationMs).toBe(10_000);
|
||||
});
|
||||
|
||||
it("bridges current CoA Knight/Shaman Compassion to the local Knight/Priest row by exact passive and skill IDs", () => {
|
||||
const local = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID)!;
|
||||
const currentAlias = { ...local, dbcSpellId: ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID };
|
||||
const compassion = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 491635)!;
|
||||
expect(local.name).toBe("Whirlwind Shield");
|
||||
expect(compassion).toMatchObject({ classId: "rom-knight", secondaryClassId: "rom-priest" });
|
||||
expect(romKnightCompassionWhirlwindShieldCooldownReductionMs(
|
||||
"rom-knight", "rom-priest", local, [491635],
|
||||
)).toBe(1_000);
|
||||
expect(romKnightCompassionWhirlwindShieldCooldownReductionMs(
|
||||
"rom-knight", "rom-priest", currentAlias, [491635],
|
||||
)).toBe(1_000);
|
||||
expect(romKnightCompassionWhirlwindShieldCooldownReductionMs(
|
||||
"rom-knight", "rom-shaman", local, [491635],
|
||||
)).toBe(0);
|
||||
expect(romKnightCompassionWhirlwindShieldCooldownReductionMs(
|
||||
"rom-knight", "rom-warrior", local, [491635],
|
||||
)).toBe(0);
|
||||
expect(romKnightCompassionWhirlwindShieldHealAmount(0)).toBe(120);
|
||||
expect(romKnightCompassionWhirlwindShieldHealAmount(1_000)).toBe(320);
|
||||
});
|
||||
});
|
||||
+226
-72
@@ -5,9 +5,13 @@ import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
export const ROM_KNIGHT_DIVINE_PUNISHMENT_TAG = "rom-divine-punishment";
|
||||
export const ROM_KNIGHT_GODS_VENGEANCE_TAG = "rom-gods-vengeance";
|
||||
export const ROM_KNIGHT_SHIELD_OF_VALOR_TAG = "rom-shield-of-valor";
|
||||
export const ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG = "rom-holy-lights-fury";
|
||||
export const ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG = "rom-compassion-of-holy-light";
|
||||
export const ROM_KNIGHT_HOLY_ILLUMINATION_TAG = "rom-holy-illumination";
|
||||
export const ROM_KNIGHT_MASTER_OF_PARRY_TAG = "rom-knight-master-of-parry";
|
||||
export const ROM_KNIGHT_MASTER_OF_PARRY_BLOCK_PROC = "rom-knight-master-of-parry-block";
|
||||
export const ROM_KNIGHT_DEADLY_WHIRLWIND_TAG = "rom-deadly-whirlwind";
|
||||
export const ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID = 1_493_719;
|
||||
export const ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID = 490_175;
|
||||
|
||||
/** Shield of Valor reduces Holy Seal's remaining cooldown only on an active-aura parry. */
|
||||
export function romKnightShieldOfValorCooldownReductionMs(auraActive: boolean, parried: boolean): number {
|
||||
@@ -39,22 +43,97 @@ export function romKnightWarriorWhirlwindAdjustment(
|
||||
return { rageCostReduction: 10, cooldownReductionMs: 20_000 };
|
||||
}
|
||||
|
||||
/** RuneWaker Knight/Rogue passive 491621 boosts only Holy Strike's critical rate. */
|
||||
/** Current CoA changed God's Sword to passive, but the current passive payload is unpublished. */
|
||||
export function romKnightRogueHolyStrikeCriticalRateIncreasePercent(
|
||||
characterClassId: string,
|
||||
secondaryClassId: string | null | undefined,
|
||||
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
||||
learnedPassiveSpellIds: readonly number[],
|
||||
): number {
|
||||
if (characterClassId !== "rom-knight"
|
||||
|| secondaryClassId !== "rom-rogue"
|
||||
|| ability.source !== "runewaker"
|
||||
|| ability.classId !== "rom-knight"
|
||||
|| ability.dbcSpellId !== 490148
|
||||
|| !learnedPassiveSpellIds.includes(491621)) return 0;
|
||||
// The RuneWaker snapshot's 40% Holy Strike bonus conflicts with current CoA,
|
||||
// which only says God's Sword became passive and does not publish its payload.
|
||||
void characterClassId;
|
||||
void secondaryClassId;
|
||||
void ability;
|
||||
void learnedPassiveSpellIds;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The populated local tooltip specifies a 40% Holy Strike critical-rate increase.
|
||||
return 40;
|
||||
function hasPassiveTag(
|
||||
passiveTags: readonly string[] | ReadonlySet<string>,
|
||||
tag: string,
|
||||
): boolean {
|
||||
return Array.isArray(passiveTags)
|
||||
? passiveTags.includes(tag)
|
||||
: (passiveTags as ReadonlySet<string>).has(tag);
|
||||
}
|
||||
|
||||
/** CoA 6-1: Deadly Wound reduces healing received by 30%; player targets last 5s. */
|
||||
export function romKnightDeadlyWhirlwindAura(targetIsPlayer: boolean): Extract<AbilityEffect, { kind: "apply-aura" }> {
|
||||
return {
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-knight-deadly-wound",
|
||||
name: "Deadly Wound",
|
||||
disposition: "debuff",
|
||||
durationMs: targetIsPlayer ? 5_000 : 10_000,
|
||||
dispelCategory: "magic",
|
||||
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: -0.3 }],
|
||||
utilityTags: [ROM_KNIGHT_DEADLY_WHIRLWIND_TAG],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 491_639,
|
||||
};
|
||||
}
|
||||
|
||||
/** CoA 10.6.1: Divine Punishment makes the target take 2% more damage per Seal for 10s. */
|
||||
export function romKnightDivinePunishmentAuraForSealStacks(
|
||||
sealStacks: number,
|
||||
): Extract<AbilityEffect, { kind: "apply-aura" }> | null {
|
||||
const stacks = Math.max(0, Math.min(4, Math.trunc(sealStacks)));
|
||||
if (stacks <= 0) return null;
|
||||
return {
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-knight-divine-punishment-damage-taken",
|
||||
name: "Divine Punishment",
|
||||
disposition: "debuff",
|
||||
durationMs: 10_000,
|
||||
dispelCategory: "magic",
|
||||
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: stacks * 0.02 }],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: stacks * 2,
|
||||
triggerSpellId: 491_608,
|
||||
};
|
||||
}
|
||||
|
||||
/** Current CoA 11.1.5 cooldown rider, bridged by the exact Whirlwind Shield name. */
|
||||
export function romKnightCompassionWhirlwindShieldCooldownReductionMs(
|
||||
characterClassId: string,
|
||||
secondaryClassId: string | null | undefined,
|
||||
ability: Pick<AbilityDefinition, "source" | "classId" | "dbcSpellId">,
|
||||
learnedPassiveSpellIds: readonly number[],
|
||||
): number {
|
||||
return characterClassId === "rom-knight"
|
||||
&& secondaryClassId === "rom-priest"
|
||||
&& ability.source === "runewaker"
|
||||
&& ability.classId === "rom-knight"
|
||||
&& (ability.dbcSpellId === ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID
|
||||
|| ability.dbcSpellId === ROM_KNIGHT_CURRENT_COA_WHIRLWIND_SHIELD_ID)
|
||||
&& learnedPassiveSpellIds.includes(491_635)
|
||||
? 1_000
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** CoA 11.1.5 Compassion heal formula; called only after a Whirlwind Shield hit lands. */
|
||||
export function romKnightCompassionWhirlwindShieldHealAmount(stamina: number): number {
|
||||
return Math.max(0, Math.round(120 + Math.max(0, stamina) * 0.2));
|
||||
}
|
||||
|
||||
const ROM_KNIGHT_MASTER_OF_PARRY_PROC: AuraProcDefinition = {
|
||||
@@ -83,11 +162,83 @@ function divinePunishmentPassive(row: RomGeneratedAbilityLike): readonly Ability
|
||||
}];
|
||||
}
|
||||
|
||||
function deadWhirlwindPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-knight-passive-491639",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: [ROM_KNIGHT_DEADLY_WHIRLWIND_TAG],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 491_639,
|
||||
}];
|
||||
}
|
||||
|
||||
function compassionOfHolyLightPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-knight-passive-491635",
|
||||
name: "Compassion of Holy Light",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: [ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 491_635,
|
||||
}];
|
||||
}
|
||||
|
||||
function holyIlluminationPassive(row: RomGeneratedAbilityLike): readonly AbilityEffect[] {
|
||||
return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-knight-passive-491632",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: [ROM_KNIGHT_HOLY_ILLUMINATION_TAG],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 491_632,
|
||||
}];
|
||||
}
|
||||
|
||||
/** Source-backed Knight passive and cast adjustments. */
|
||||
export function overrideRomKnightEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
translated: readonly AbilityEffect[],
|
||||
): readonly AbilityEffect[] | null {
|
||||
if (row.classId === "rom-knight"
|
||||
&& row.secondaryClassId === "rom-warrior"
|
||||
&& row.sourceSkillId === 490088) {
|
||||
// Fearless's local description promises Fear immunity, while its source
|
||||
// component only exposes the +3% Strength and Stamina modifiers. The
|
||||
// [SC_SKILLTIPS_FEAR] suffix is a tooltip category marker; discard the
|
||||
// generic parser's false-positive Fear control and attach immunity to its
|
||||
// actual stat buff rows.
|
||||
return translated
|
||||
.filter((effect) => !(effect.kind === "apply-aura" && effect.control?.kind === "fear"))
|
||||
.map((effect) => effect.kind === "apply-aura" && (effect.aura.modifiers?.length ?? 0) > 0 ? {
|
||||
...effect,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
controlImmunities: [...new Set([...(effect.aura.controlImmunities ?? []), "fear" as const])],
|
||||
},
|
||||
} : effect);
|
||||
}
|
||||
if (row.sourceSkillId === 492935) {
|
||||
const durationMs = Math.max(0, ...(row.source?.components ?? []).map((component) => (
|
||||
Math.max(0, Number(component.effectTime ?? 0)) * 1_000
|
||||
@@ -113,6 +264,7 @@ export function overrideRomKnightEffects(
|
||||
return effect;
|
||||
});
|
||||
}
|
||||
if (row.sourceSkillId === 491639 && row.passive) return deadWhirlwindPassive(row);
|
||||
if (row.sourceSkillId === 491608 && row.passive) return divinePunishmentPassive(row);
|
||||
if (row.sourceSkillId === 499934 && row.passive) return [{
|
||||
kind: "apply-aura",
|
||||
@@ -129,21 +281,8 @@ export function overrideRomKnightEffects(
|
||||
miscValue: 0,
|
||||
triggerSpellId: 499934,
|
||||
}];
|
||||
if (row.sourceSkillId === 491635 && row.passive) return [{
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: {
|
||||
id: "rom-knight-passive-491635",
|
||||
name: row.name,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
utilityTags: [ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 491635,
|
||||
}];
|
||||
if (row.sourceSkillId === 491635 && row.passive) return compassionOfHolyLightPassive(row);
|
||||
if (row.sourceSkillId === 491632 && row.passive) return holyIlluminationPassive(row);
|
||||
if (row.sourceSkillId === 490468) return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
aura: {
|
||||
@@ -159,61 +298,76 @@ export function romKnightCastEffects(
|
||||
ability: AbilityDefinition,
|
||||
effects: readonly AbilityEffect[],
|
||||
passiveTags: readonly string[] | ReadonlySet<string> = [],
|
||||
sealStacks = 0,
|
||||
): readonly AbilityEffect[] {
|
||||
const hasHolyLightsFury = Array.isArray(passiveTags)
|
||||
? passiveTags.includes(ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG)
|
||||
: (passiveTags as ReadonlySet<string>).has(ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG);
|
||||
if (ability.source === "runewaker"
|
||||
&& ability.classId === "rom-knight"
|
||||
&& ability.dbcSpellId === 490149
|
||||
&& hasHolyLightsFury) {
|
||||
// RuneWaker's local tooltip gives a flat 3-second reduction to Holy Seal.
|
||||
// The added damage rider has no numeric payload in this source snapshot.
|
||||
return [...effects, {
|
||||
kind: "cooldown-reset",
|
||||
mode: "reduce",
|
||||
abilityName: "Holy Seal",
|
||||
amountMs: 3_000,
|
||||
}];
|
||||
}
|
||||
|
||||
if (ability.source !== "runewaker"
|
||||
|| ability.classId !== "rom-knight"
|
||||
|| ability.dbcSpellId !== 490151
|
||||
|| !(Array.isArray(passiveTags)
|
||||
? passiveTags.includes(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG)
|
||||
: (passiveTags as ReadonlySet<string>).has(ROM_KNIGHT_DIVINE_PUNISHMENT_TAG))) return effects;
|
||||
|| (ability.classId !== "rom-knight" && ability.dbcSpellId !== 490_133)) return effects;
|
||||
|
||||
const increasedDamage = effects.map((effect) => {
|
||||
let adjusted = effects;
|
||||
if (ability.dbcSpellId === 490_151 && hasPassiveTag(passiveTags, ROM_KNIGHT_DIVINE_PUNISHMENT_TAG)) {
|
||||
adjusted = adjusted.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 }),
|
||||
coefficient: effect.coefficient * 1.285,
|
||||
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * 1.285 }),
|
||||
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * 1.285 }),
|
||||
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * 1.285 }),
|
||||
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * 1.285 }),
|
||||
};
|
||||
});
|
||||
const punishmentAura = romKnightDivinePunishmentAuraForSealStacks(sealStacks);
|
||||
if (punishmentAura) adjusted = [...adjusted, { ...punishmentAura, requiresLandedHit: true }];
|
||||
}
|
||||
|
||||
// Generated passive 491608 includes component 502052: a ten-second, +1%
|
||||
// Light-damage vulnerability. The generic modifier map treats type 225 as
|
||||
// Light resistance, which would make the target take less damage despite
|
||||
// the local tooltip explicitly saying it takes more Light damage.
|
||||
return [...increasedDamage, {
|
||||
kind: "apply-aura",
|
||||
recipient: "ability-target",
|
||||
aura: {
|
||||
id: "rom-knight-divine-punishment-light-vulnerability",
|
||||
name: "Divine Punishment",
|
||||
disposition: "debuff",
|
||||
durationMs: 10_000,
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "holy", operation: "percent", value: 0.01 }],
|
||||
dispelCategory: "magic",
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: 225,
|
||||
miscValue: 1,
|
||||
triggerSpellId: 502052,
|
||||
if (ability.dbcSpellId === 490_133 && hasPassiveTag(passiveTags, ROM_KNIGHT_DEADLY_WHIRLWIND_TAG)) {
|
||||
adjusted = [...adjusted, { ...romKnightDeadlyWhirlwindAura(false), requiresLandedHit: true }];
|
||||
}
|
||||
|
||||
if (ability.dbcSpellId === ROM_KNIGHT_LOCAL_WHIRLWIND_SHIELD_ID) {
|
||||
adjusted = adjusted.map((effect) => effect.kind === "damage"
|
||||
? {
|
||||
...effect,
|
||||
coefficient: 0,
|
||||
basePoints: 0,
|
||||
dieSides: 1,
|
||||
weaponDpsCoefficient: 7.014,
|
||||
school: "holy" as const,
|
||||
}
|
||||
: effect);
|
||||
if (hasPassiveTag(passiveTags, ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG)) {
|
||||
adjusted = [...adjusted, {
|
||||
kind: "heal",
|
||||
coefficient: 0,
|
||||
basePoints: 120,
|
||||
dieSides: 1,
|
||||
staminaCoefficient: 0.2,
|
||||
scope: "party",
|
||||
center: "caster",
|
||||
radius: 100,
|
||||
requiresLandedHit: true,
|
||||
}];
|
||||
}
|
||||
if (hasPassiveTag(passiveTags, ROM_KNIGHT_HOLY_ILLUMINATION_TAG)
|
||||
|| hasPassiveTag(passiveTags, ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG)) {
|
||||
adjusted = [...adjusted, {
|
||||
kind: "apply-aura",
|
||||
scope: "party",
|
||||
radius: 100,
|
||||
requiresLandedHit: true,
|
||||
aura: {
|
||||
id: "rom-knight-holy-illumination-proc",
|
||||
name: "Holy Illumination",
|
||||
disposition: "buff",
|
||||
durationMs: 10_000,
|
||||
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -0.15 }],
|
||||
},
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 491_632,
|
||||
}];
|
||||
}
|
||||
}
|
||||
return adjusted;
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
ROM_KNIGHT_DIVINE_PUNISHMENT_TAG,
|
||||
ROM_KNIGHT_GODS_VENGEANCE_TAG,
|
||||
ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG,
|
||||
ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG,
|
||||
romKnightDivinePunishmentAuraForSealStacks,
|
||||
ROM_KNIGHT_SHIELD_OF_VALOR_TAG,
|
||||
} from "./romKnightOverrides";
|
||||
import { romEffectsForCast } from "./romCombatMechanics";
|
||||
@@ -118,7 +119,7 @@ describe("RuneWaker Knight periodic skills", () => {
|
||||
&& event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("limits Divine Punishment's sourced 1% bonus to Punishment", () => {
|
||||
it("uses current CoA's 28.5% Divine Punishment damage bonus only on Punishment", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80,
|
||||
});
|
||||
@@ -163,9 +164,9 @@ describe("RuneWaker Knight periodic skills", () => {
|
||||
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(damageValues(boosted)[index]!.coefficient).toBeCloseTo(base.coefficient * 1.285);
|
||||
expect(damageValues(boosted)[index]!.basePoints).toBeCloseTo(base.basePoints * 1.285);
|
||||
expect(damageValues(boosted)[index]!.pointsPerLevel).toBeCloseTo(base.pointsPerLevel * 1.285);
|
||||
}
|
||||
expect(romEffectsForCast(abilityById("rom-knight-primary-1")!, {
|
||||
...context,
|
||||
@@ -173,54 +174,38 @@ describe("RuneWaker Knight periodic skills", () => {
|
||||
})).toEqual(romEffectsForCast(abilityById("rom-knight-primary-1")!, context));
|
||||
});
|
||||
|
||||
it("makes Divine Punishment apply its sourced Light-damage vulnerability", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80,
|
||||
});
|
||||
useCombatStore.getState().registerMob("divine-punishment-target", {
|
||||
name: "Divine Punishment Target", maxHealth: 100_000,
|
||||
});
|
||||
setMobPosition("divine-punishment-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("divine-punishment-target");
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-primary-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, 12_000))
|
||||
.toMatchObject({ ok: true });
|
||||
|
||||
const vulnerability = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === "divine-punishment-target"
|
||||
&& aura.definition.id === "rom-knight-divine-punishment-light-vulnerability"
|
||||
));
|
||||
expect(vulnerability?.definition).toMatchObject({
|
||||
it("replaces the old Light-only aura with current CoA's general per-seal vulnerability data", () => {
|
||||
expect(romKnightDivinePunishmentAuraForSealStacks(3)?.aura).toMatchObject({
|
||||
durationMs: 10_000,
|
||||
modifiers: [{ kind: "damage", direction: "taken", school: "holy", operation: "percent", value: 0.01 }],
|
||||
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: 0.06 }],
|
||||
});
|
||||
expect(vulnerability?.expiresAt).toBe(22_000);
|
||||
expect(romKnightDivinePunishmentAuraForSealStacks(3)?.aura.id)
|
||||
.toBe("rom-knight-divine-punishment-damage-taken");
|
||||
});
|
||||
|
||||
it("reduces Holy Seal cooldown by three seconds with Holy Light's Fury", () => {
|
||||
it("uses the local Knight/Priest Compassion row bridged to current CoA behavior and no longer changes Holy Seal cooldown", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
|
||||
});
|
||||
const state = useCombatStore.getState();
|
||||
const holySeal = state.abilities.find((ability) => ability.dbcSpellId === 490149)!;
|
||||
const furyPassive = state.abilities.find((ability) => ability.dbcSpellId === 491635)!;
|
||||
expect(furyPassive.passive).toBe(true);
|
||||
expect(furyPassive.effects).toEqual([
|
||||
const compassionPassive = state.abilities.find((ability) => ability.dbcSpellId === 491635)!;
|
||||
expect(compassionPassive).toMatchObject({ name: "Compassion of Holy Light", passive: true });
|
||||
expect(compassionPassive.effects).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_HOLY_LIGHTS_FURY_TAG] }),
|
||||
aura: expect.objectContaining({ utilityTags: [ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG] }),
|
||||
}),
|
||||
]);
|
||||
|
||||
useCombatStore.getState().registerMob("holy-lights-fury-target", {
|
||||
name: "Holy Light's Fury Target", maxHealth: 100_000,
|
||||
});
|
||||
setMobPosition("holy-lights-fury-target", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("holy-lights-fury-target");
|
||||
expect(useCombatStore.getState().castAbility(holySeal.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns[holySeal.id]).toBe(8_000);
|
||||
const tags = state.auras.find((aura) => aura.definition.utilityTags?.includes(ROM_KNIGHT_COMPASSION_OF_HOLY_LIGHT_TAG))
|
||||
?.definition.utilityTags ?? [];
|
||||
expect(romEffectsForCast(holySeal, {
|
||||
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false,
|
||||
casterMaxHealth: state.maxHealth, targetHealthPercent: 1, passiveTags: tags,
|
||||
})).toEqual(romEffectsForCast(holySeal, {
|
||||
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false,
|
||||
casterMaxHealth: state.maxHealth, targetHealthPercent: 1,
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([true, false])("lets God's Vengeance preserve Holy Seal when the 50% roll is %s", (preservesSeal) => {
|
||||
|
||||
@@ -16,11 +16,32 @@ function playerWhirlwind(state: ReturnType<typeof useCombatStore.getState>): Abi
|
||||
return ability;
|
||||
}
|
||||
|
||||
function testShield(instanceId: string) {
|
||||
return {
|
||||
instanceId,
|
||||
itemId: 1,
|
||||
itemKey: instanceId,
|
||||
name: "Test Shield",
|
||||
quality: "uncommon" as const,
|
||||
inventorySlot: "off-hand" as const,
|
||||
armorType: "shield" as const,
|
||||
weaponType: null,
|
||||
itemLevel: 1,
|
||||
sourceItemLevel: 1,
|
||||
stats: { ...useCombatStore.getState().gearStats },
|
||||
quantity: 1,
|
||||
sourceBossKey: "test",
|
||||
sourceBossName: "Test",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
usePartyStore.setState({ members: [] });
|
||||
});
|
||||
|
||||
it("reduces player Whirlwind to 40 Rage and a 10-second cooldown when Deadly Whirlwind is learned", () => {
|
||||
@@ -51,7 +72,7 @@ describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => {
|
||||
|
||||
const result = useCombatStore.getState().castAbility(whirlwind.id, undefined, 1_000);
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
const final = useCombatStore.getState();
|
||||
expect(final.resourcePools.rage).toBe(0);
|
||||
expect(final.cooldowns[whirlwind.id]).toBe(11_000);
|
||||
@@ -101,4 +122,100 @@ describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => {
|
||||
&& event.sourceActorId === knight.id && event.abilityId === whirlwind.id
|
||||
&& event.targetActorId === "party-knight-whirlwind-target" && event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies current Compassion to player Whirlwind Shield only after a landed hit", () => {
|
||||
const playerShield = testShield("compassion-player-shield");
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80,
|
||||
inventory: [playerShield],
|
||||
equipment: { player: { "off-hand": playerShield.instanceId } },
|
||||
});
|
||||
const initial = useCombatStore.getState();
|
||||
const shield = initial.abilities.find((ability) => ability.dbcSpellId === 490175)!;
|
||||
expect(shield).toMatchObject({ cooldownMs: 5_000, radius: 100 });
|
||||
expect(initial.abilities.find((ability) => ability.dbcSpellId === 491635)).toMatchObject({
|
||||
classId: "rom-knight", secondaryClassId: "rom-priest", passive: true,
|
||||
});
|
||||
|
||||
const allySeed = createConfiguredPartyMember({
|
||||
name: "Wounded Ally", classId: "priest", specialization: "Holy", role: "healer",
|
||||
}, 80, 0, "compassion-player-ally");
|
||||
const ally = { ...allySeed, health: 1 };
|
||||
usePartyStore.setState({ members: [ally] });
|
||||
registerPartyRuntimePosition(ally.id, [2, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("compassion-player-target", {
|
||||
name: "Compassion Target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("compassion-player-target", [8, 0, 0]);
|
||||
useCombatStore.getState().selectMob("compassion-player-target");
|
||||
|
||||
const result = useCombatStore.getState().castAbility(shield.id, undefined, 1_000);
|
||||
const final = useCombatStore.getState();
|
||||
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
expect(final.cooldowns[shield.id]).toBe(5_000);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === ally.id)!.health).toBeGreaterThan(1);
|
||||
expect(final.combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.abilityId === shield.id && event.targetActorId === "compassion-player-target"
|
||||
&& event.effectiveAmount > 0 && event.school === "holy")).toBe(true);
|
||||
expect(final.auras.some((aura) => aura.definition.id === "rom-knight-holy-illumination-proc"
|
||||
&& aura.targetId === ally.id && aura.expiresAt === 11_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies current Compassion heal, Holy Illumination and 4-second cooldown for a generated companion", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const seed = "compassion-party-knight-shaman";
|
||||
const knightSeed = createConfiguredPartyMember({
|
||||
name: "Knight Shaman",
|
||||
classId: "rom-knight",
|
||||
secondaryClassId: "rom-priest",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const abilities = abilitiesForCharacter("rom-knight", "rom-priest");
|
||||
const shield = abilities.find((ability) => ability.dbcSpellId === 490175)!;
|
||||
const knight = {
|
||||
...knightSeed,
|
||||
health: 1,
|
||||
cooldowns: Object.fromEntries(abilities
|
||||
.filter((ability) => ability.id !== shield.id)
|
||||
.map((ability) => [ability.id, 60_000])),
|
||||
};
|
||||
const partyShield = testShield("compassion-party-shield");
|
||||
usePartyStore.setState({ members: [knight], command: "attack" });
|
||||
const assignments = {
|
||||
...useCombatStore.getState().equipment,
|
||||
[knight.id]: { "off-hand": partyShield.instanceId },
|
||||
};
|
||||
useCombatStore.setState((state) => ({
|
||||
equipment: assignments,
|
||||
inventory: [...state.inventory, partyShield],
|
||||
}));
|
||||
usePartyStore.getState().syncEquipment(assignments, useCombatStore.getState().inventory);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(knight.id, [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("compassion-party-target", {
|
||||
name: "Compassion Party Target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("compassion-party-target", [10, 0, 0]);
|
||||
useCombatStore.getState().damageMob("compassion-party-target", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(knight.id, "compassion-party-target", true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const finalMember = usePartyStore.getState().members.find((member) => member.id === knight.id)!;
|
||||
const final = useCombatStore.getState();
|
||||
expect(final.combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === knight.id && event.abilityId === shield.id
|
||||
&& event.targetActorId === "compassion-party-target" && event.effectiveAmount > 0)).toBe(true);
|
||||
expect(finalMember.health).toBeGreaterThan(1);
|
||||
expect(finalMember.cooldowns[shield.id]).toBe(5_000);
|
||||
expect(final.combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === knight.id && event.abilityId === shield.id
|
||||
&& event.targetActorId === "compassion-party-target" && event.effectiveAmount > 0
|
||||
&& event.school === "holy")).toBe(true);
|
||||
expect(final.auras.some((aura) => aura.definition.id === "rom-knight-holy-illumination-proc"
|
||||
&& aura.sourceId === knight.id && aura.targetId === knight.id && aura.expiresAt === 11_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ROM_COA_LEOPARD_INSTINCT_MAX_STACKS,
|
||||
createRomLeopardInstinctState,
|
||||
romLeopardInstinctModifiers,
|
||||
romLeopardInstinctOnDodge,
|
||||
romLeopardInstinctOnHitReceived,
|
||||
} from "./romLeopardInstinctOverrides";
|
||||
|
||||
describe("current CoA Leopard Instinct passive", () => {
|
||||
it("starts as a zero-stack passive state without activation or timing fields", () => {
|
||||
const state = createRomLeopardInstinctState();
|
||||
expect(state).toEqual({ stacks: 0 });
|
||||
expect("expiresAt" in state).toBe(false);
|
||||
expect("cooldownUntil" in state).toBe(false);
|
||||
expect("absorbs" in state).toBe(false);
|
||||
expect(romLeopardInstinctModifiers(state)).toEqual({
|
||||
dodgeRateBonusPercent: 0,
|
||||
magicResistanceRateBonusPercent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds one stack per received hit and keeps both sourced rates separate", () => {
|
||||
let state = createRomLeopardInstinctState();
|
||||
state = romLeopardInstinctOnHitReceived(state);
|
||||
expect(state.stacks).toBe(1);
|
||||
expect(romLeopardInstinctModifiers(state)).toEqual({
|
||||
dodgeRateBonusPercent: 3,
|
||||
magicResistanceRateBonusPercent: 3,
|
||||
});
|
||||
|
||||
state = romLeopardInstinctOnHitReceived(state);
|
||||
expect(romLeopardInstinctModifiers(state)).toEqual({
|
||||
dodgeRateBonusPercent: 6,
|
||||
magicResistanceRateBonusPercent: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("caps received-hit stacks at ten", () => {
|
||||
let state = createRomLeopardInstinctState();
|
||||
for (let hit = 0; hit < ROM_COA_LEOPARD_INSTINCT_MAX_STACKS + 4; hit += 1) {
|
||||
state = romLeopardInstinctOnHitReceived(state);
|
||||
}
|
||||
|
||||
expect(state.stacks).toBe(ROM_COA_LEOPARD_INSTINCT_MAX_STACKS);
|
||||
expect(romLeopardInstinctModifiers(state)).toEqual({
|
||||
dodgeRateBonusPercent: 30,
|
||||
magicResistanceRateBonusPercent: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("loses exactly one stack on dodge and does not underflow", () => {
|
||||
let state = createRomLeopardInstinctState(4);
|
||||
state = romLeopardInstinctOnDodge(state);
|
||||
expect(state).toEqual({ stacks: 3 });
|
||||
expect(romLeopardInstinctModifiers(state)).toEqual({
|
||||
dodgeRateBonusPercent: 9,
|
||||
magicResistanceRateBonusPercent: 9,
|
||||
});
|
||||
|
||||
const empty = createRomLeopardInstinctState();
|
||||
expect(romLeopardInstinctOnDodge(empty)).toBe(empty);
|
||||
});
|
||||
|
||||
it("does not model damage absorption", () => {
|
||||
const state = romLeopardInstinctOnHitReceived(createRomLeopardInstinctState());
|
||||
const modifiers = romLeopardInstinctModifiers(state);
|
||||
expect("absorbs" in modifiers).toBe(false);
|
||||
expect(modifiers).toEqual({
|
||||
dodgeRateBonusPercent: 3,
|
||||
magicResistanceRateBonusPercent: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Source-backed current-CoA state for Knight/Rogue Leopard Instinct (495402).
|
||||
* It is a passive: each hit received adds one stack (up to 10), and
|
||||
* dodging removes one stack. Each stack grants +3% Dodge and +3% Magic
|
||||
* Resistance Rate. The old three-hit absorb, activation, duration, and
|
||||
* cooldown behavior do not apply to this current passive.
|
||||
*
|
||||
* Sources:
|
||||
* - https://chroniclesofarcadia.com/tr/patch/11-1-5-3000
|
||||
* - https://chroniclesofarcadia.com/fr/patch/12-1-2-1000
|
||||
*
|
||||
* The helper exposes Magic Resistance Rate separately. The combat runtime
|
||||
* currently has no matching stat consumer, so callers must not reinterpret
|
||||
* it as elemental resistance without a confirmed conversion.
|
||||
*/
|
||||
|
||||
export const ROM_COA_LEOPARD_INSTINCT_ID = 495402;
|
||||
export const ROM_COA_LEOPARD_INSTINCT_MAX_STACKS = 10;
|
||||
export const ROM_COA_LEOPARD_INSTINCT_BONUS_PER_STACK_PERCENT = 3;
|
||||
|
||||
export interface RomLeopardInstinctState {
|
||||
readonly stacks: number;
|
||||
}
|
||||
|
||||
export interface RomLeopardInstinctModifiers {
|
||||
/** Sourced bonus to Dodge rate. */
|
||||
readonly dodgeRateBonusPercent: number;
|
||||
/** Sourced bonus to Magic Resistance Rate; kept separate from elemental resistance. */
|
||||
readonly magicResistanceRateBonusPercent: number;
|
||||
}
|
||||
|
||||
const NO_MODIFIERS: RomLeopardInstinctModifiers = {
|
||||
dodgeRateBonusPercent: 0,
|
||||
magicResistanceRateBonusPercent: 0,
|
||||
};
|
||||
|
||||
/** Initial passive state; stacks persist until combat events change them. */
|
||||
export function createRomLeopardInstinctState(stacks = 0): RomLeopardInstinctState {
|
||||
return { stacks: clampStacks(stacks) };
|
||||
}
|
||||
|
||||
/** Add one stack for each received hit, capped at ten. */
|
||||
export function romLeopardInstinctOnHitReceived(
|
||||
state: RomLeopardInstinctState,
|
||||
): RomLeopardInstinctState {
|
||||
const stacks = clampStacks(state.stacks);
|
||||
if (stacks >= ROM_COA_LEOPARD_INSTINCT_MAX_STACKS) return state;
|
||||
return { stacks: stacks + 1 };
|
||||
}
|
||||
|
||||
/** A dodge removes one stack; the passive has no absorb charge behavior. */
|
||||
export function romLeopardInstinctOnDodge(
|
||||
state: RomLeopardInstinctState,
|
||||
): RomLeopardInstinctState {
|
||||
const stacks = clampStacks(state.stacks);
|
||||
if (stacks <= 0) return state;
|
||||
return { stacks: stacks - 1 };
|
||||
}
|
||||
|
||||
/** Resolve Dodge and Magic Resistance Rate independently from current stacks. */
|
||||
export function romLeopardInstinctModifiers(
|
||||
state: RomLeopardInstinctState | null,
|
||||
): RomLeopardInstinctModifiers {
|
||||
if (!state) return NO_MODIFIERS;
|
||||
const bonus = clampStacks(state.stacks) * ROM_COA_LEOPARD_INSTINCT_BONUS_PER_STACK_PERCENT;
|
||||
return {
|
||||
dodgeRateBonusPercent: bonus,
|
||||
magicResistanceRateBonusPercent: bonus,
|
||||
};
|
||||
}
|
||||
|
||||
function clampStacks(stacks: number): number {
|
||||
return Math.max(0, Math.min(ROM_COA_LEOPARD_INSTINCT_MAX_STACKS, Math.trunc(stacks)));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import {
|
||||
isRomMageDruidPair,
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG,
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
|
||||
romMageDruidGreenGuardianMovementSpeedMultiplier,
|
||||
} from "./romMageDruidGreenGuardianRuntime";
|
||||
|
||||
const skill = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID
|
||||
&& ability.classId === "rom-mage" && ability.secondaryClassId === "rom-druid");
|
||||
|
||||
function guardianAura(targetId: string) {
|
||||
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
|
||||
&& aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG));
|
||||
}
|
||||
|
||||
describe("CoA Mage/Druid Green Guardian runtime", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-druid",
|
||||
raceId: "rom-elf",
|
||||
level: 45,
|
||||
role: "damage",
|
||||
});
|
||||
usePartyStore.getState().initializeParty(
|
||||
"green-guardian-test", 45, useCombatStore.getState().health, "damage", 2, "rom",
|
||||
);
|
||||
});
|
||||
|
||||
it("casts as a self skill, accrues only after standing, pauses after movement, and speeds player movement", () => {
|
||||
if (!skill) throw new Error("Missing generated Mage/Druid Green Guardian row");
|
||||
expect(isRomMageDruidPair(useCombatStore.getState())).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility(skill.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(0);
|
||||
|
||||
useCombatStore.getState().tick(0.5, 1_500);
|
||||
useCombatStore.getState().tick(0.5, 2_000);
|
||||
useCombatStore.getState().tick(0.5, 2_500);
|
||||
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
|
||||
expect(romMageDruidGreenGuardianMovementSpeedMultiplier(
|
||||
useCombatStore.getState().auras, PLAYER_AGGRO_ID, 2_500,
|
||||
)).toBe(1.01);
|
||||
|
||||
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
|
||||
useCombatStore.getState().tick(0.25, 2_750);
|
||||
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
|
||||
useCombatStore.getState().tick(0.5, 3_250);
|
||||
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(1);
|
||||
useCombatStore.getState().tick(0.5, 3_750);
|
||||
expect(guardianAura(PLAYER_AGGRO_ID)?.stacks).toBe(2);
|
||||
});
|
||||
|
||||
it("starts a generated Mage/Druid companion at zero and accrues against its runtime position", () => {
|
||||
if (!skill) throw new Error("Missing generated Mage/Druid Green Guardian row");
|
||||
const seed = "green-guardian-companion-test";
|
||||
const companion = createConfiguredPartyMember({
|
||||
name: "Green Guardian Mage",
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-druid",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 45, 0, seed);
|
||||
usePartyStore.getState().initializeParty(seed, 45, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.setState({ members: [companion] });
|
||||
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const effect = skill.effects.find((candidate) => candidate.kind === "apply-aura");
|
||||
if (!effect || effect.kind !== "apply-aura") throw new Error("Green Guardian has no self aura effect");
|
||||
expect(useCombatStore.getState().applyActorAura(companion.id, companion.id, skill, effect, 1_000)).toBe(true);
|
||||
expect(guardianAura(companion.id)?.stacks).toBe(0);
|
||||
|
||||
useCombatStore.getState().tick(0.5, 1_500);
|
||||
useCombatStore.getState().tick(0.5, 2_000);
|
||||
useCombatStore.getState().tick(0.5, 2_500);
|
||||
expect(guardianAura(companion.id)?.stacks).toBe(1);
|
||||
expect(romMageDruidGreenGuardianMovementSpeedMultiplier(
|
||||
useCombatStore.getState().auras, companion.id, 2_500,
|
||||
)).toBe(1.01);
|
||||
|
||||
updatePartyRuntimePosition(companion.id, 1, 0, 0);
|
||||
useCombatStore.getState().tick(0.25, 2_750);
|
||||
expect(guardianAura(companion.id)?.stacks).toBe(1);
|
||||
useCombatStore.getState().tick(1, 3_750);
|
||||
expect(guardianAura(companion.id)?.stacks).toBe(2);
|
||||
});
|
||||
|
||||
it("does not grant movement speed to the reverse Druid/Mage pair", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-mage",
|
||||
raceId: "rom-elf",
|
||||
level: 45,
|
||||
role: "damage",
|
||||
});
|
||||
expect(isRomMageDruidPair(useCombatStore.getState())).toBe(false);
|
||||
expect(romMageDruidGreenGuardianMovementSpeedMultiplier([], PLAYER_AGGRO_ID, 2_500)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG, ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID } from "./romMageDruidGreenGuardianRuntime";
|
||||
|
||||
describe("CoA Mage/Druid Green Guardian companion cast selection", () => {
|
||||
beforeEach(() => {
|
||||
resetPartyRuntimeClock();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest",
|
||||
raceId: "rom-elf",
|
||||
level: 45,
|
||||
role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty(
|
||||
"green-guardian-party-test", 45, useCombatStore.getState().health, "damage", 2, "rom",
|
||||
);
|
||||
});
|
||||
|
||||
it("casts the learned zero-cost self skill through party combat and starts at zero stacks", () => {
|
||||
const seed = "green-guardian-party-cast";
|
||||
const companion = createConfiguredPartyMember({
|
||||
name: "Green Guardian Mage",
|
||||
classId: "rom-mage",
|
||||
secondaryClassId: "rom-druid",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 45, 0, seed);
|
||||
usePartyStore.setState({
|
||||
members: [companion],
|
||||
command: "attack",
|
||||
activeMobId: "guardian-target",
|
||||
});
|
||||
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
useCombatStore.getState().registerMob("guardian-target", {
|
||||
name: "Guardian target",
|
||||
level: 1,
|
||||
maxHealth: 1_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition("guardian-target", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("guardian-target", 1, 500);
|
||||
updatePartyRuntimeTargetVisibility(companion.id, "guardian-target", true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === companion.id
|
||||
&& candidate.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG));
|
||||
expect(aura?.stacks).toBe(0);
|
||||
expect(aura?.sourceId).toBe(companion.id);
|
||||
const committed = usePartyStore.getState().members[0];
|
||||
expect(committed.globalCooldownEndsAt).toBe(2_500);
|
||||
expect(committed.cooldowns).not.toHaveProperty(String(ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID));
|
||||
});
|
||||
|
||||
it("does not auto-cast Green Guardian for the reverse Druid/Mage pair", () => {
|
||||
const seed = "green-guardian-party-reverse";
|
||||
const companion = createConfiguredPartyMember({
|
||||
name: "Reverse Pair Druid",
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-mage",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 45, 0, seed);
|
||||
usePartyStore.setState({
|
||||
members: [companion],
|
||||
command: "attack",
|
||||
activeMobId: "guardian-target",
|
||||
});
|
||||
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("guardian-target", {
|
||||
name: "Guardian target",
|
||||
level: 1,
|
||||
maxHealth: 1_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition("guardian-target", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("guardian-target", 1, 500);
|
||||
updatePartyRuntimeTargetVisibility(companion.id, "guardian-target", true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(useCombatStore.getState().auras.some((candidate) => candidate.targetId === companion.id
|
||||
&& candidate.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isRomMageDruidPair,
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS,
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS,
|
||||
ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK,
|
||||
romMageDruidGreenGuardianAdvanceStationaryProgress,
|
||||
romMageDruidGreenGuardianMovementSpeedPercent,
|
||||
romMageDruidGreenGuardianMovementSpeedMultiplier,
|
||||
romMageDruidGreenGuardianStacksAfterStationarySecond,
|
||||
romMageDruidGreenGuardianAuraDefinition,
|
||||
} from "./romMageDruidGreenGuardianRuntime";
|
||||
|
||||
describe("current CoA Mage/Druid Green Guardian stacks", () => {
|
||||
it("applies only to the exact Mage/Druid pair", () => {
|
||||
expect(isRomMageDruidPair({ classId: "rom-mage", secondaryClassId: "rom-druid" })).toBe(true);
|
||||
expect(isRomMageDruidPair({ classId: "rom-druid", secondaryClassId: "rom-mage" })).toBe(false);
|
||||
expect(isRomMageDruidPair({ classId: "rom-mage", secondaryClassId: "rom-priest" })).toBe(false);
|
||||
});
|
||||
|
||||
it("gains one stack for an eligible still second, up to the sourced cap of twenty", () => {
|
||||
expect(ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS).toBe(1_000);
|
||||
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(0)).toBe(1);
|
||||
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(14)).toBe(15);
|
||||
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(19)).toBe(20);
|
||||
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(20)).toBe(20);
|
||||
expect(ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS).toBe(20);
|
||||
});
|
||||
|
||||
it("grants one percentage point of movement speed per stack", () => {
|
||||
expect(ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK).toBe(1);
|
||||
expect(romMageDruidGreenGuardianMovementSpeedPercent(0)).toBe(0);
|
||||
expect(romMageDruidGreenGuardianMovementSpeedPercent(7)).toBe(7);
|
||||
expect(romMageDruidGreenGuardianMovementSpeedPercent(20)).toBe(20);
|
||||
expect(romMageDruidGreenGuardianMovementSpeedPercent(99)).toBe(20);
|
||||
});
|
||||
|
||||
it("pauses gains for one full stationary second after observed movement without removing stacks", () => {
|
||||
const first = romMageDruidGreenGuardianAdvanceStationaryProgress(undefined, [0, 0, 0], 1_000);
|
||||
expect(first).toEqual({ progress: { position: [0, 0, 0], stationaryMs: 0 }, stacksGained: 0 });
|
||||
const moved = romMageDruidGreenGuardianAdvanceStationaryProgress(first.progress, [1, 0, 0], 500);
|
||||
expect(moved.stacksGained).toBe(0);
|
||||
expect(moved.progress.stationaryMs).toBe(0);
|
||||
const half = romMageDruidGreenGuardianAdvanceStationaryProgress(moved.progress, [1, 0, 0], 500);
|
||||
expect(half.stacksGained).toBe(0);
|
||||
const full = romMageDruidGreenGuardianAdvanceStationaryProgress(half.progress, [1, 0, 0], 500);
|
||||
expect(full.stacksGained).toBe(1);
|
||||
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(4)).toBe(5);
|
||||
});
|
||||
|
||||
it("uses an indefinite, initially zero-stack aura and a 1% movement bonus per stack", () => {
|
||||
const definition = romMageDruidGreenGuardianAuraDefinition();
|
||||
expect(definition.durationMs).toBeNull();
|
||||
expect(definition.maxStacks).toBe(20);
|
||||
const aura = {
|
||||
instanceId: "target:green-guardian:source",
|
||||
definition,
|
||||
sourceId: "source",
|
||||
targetId: "target",
|
||||
appliedAt: 0,
|
||||
expiresAt: null,
|
||||
stacks: 7,
|
||||
absorbs: [],
|
||||
procStates: [],
|
||||
};
|
||||
expect(romMageDruidGreenGuardianMovementSpeedMultiplier([aura], "target", 1)).toBe(1.07);
|
||||
});
|
||||
|
||||
it("normalizes malformed stack counts to the sourced zero-to-twenty bounds", () => {
|
||||
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(-5)).toBe(1);
|
||||
expect(romMageDruidGreenGuardianMovementSpeedPercent(4.9)).toBe(4);
|
||||
expect(romMageDruidGreenGuardianStacksAfterStationarySecond(Number.NaN)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
import type { ActiveAura, AuraDefinition } from "./combatAuras";
|
||||
|
||||
/** Current CoA Mage/Druid Green Guardian stack behavior (skill 494331). */
|
||||
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID = 494331;
|
||||
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS = 20;
|
||||
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS = 1_000;
|
||||
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK = 1;
|
||||
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG = "rom-mage-druid-green-guardian";
|
||||
export const ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION = "While standing still, gain one Green Guardian stack each second, up to 20. Each stack grants 1% movement speed. Moving pauses stack generation. Current CoA also describes an MP restore when a stack is lost, but the loss trigger and percentage basis are unspecified, so that clause is not implemented.";
|
||||
|
||||
export function romMageDruidGreenGuardianAuraDefinition(): AuraDefinition {
|
||||
return {
|
||||
id: "rom:mage-druid:green-guardian",
|
||||
name: "Green Guardian",
|
||||
description: ROM_MAGE_DRUID_GREEN_GUARDIAN_DESCRIPTION,
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
maxStacks: ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS,
|
||||
stackBehavior: "replace",
|
||||
utilityTags: [ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG],
|
||||
modifiers: [{
|
||||
kind: "stat",
|
||||
stat: "movement-speed",
|
||||
operation: "percent",
|
||||
value: ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK / 100,
|
||||
perStack: true,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function romMageDruidGreenGuardianAuraEffect(): Extract<AbilityEffect, { kind: "apply-aura" }> {
|
||||
return {
|
||||
kind: "apply-aura",
|
||||
recipient: "caster",
|
||||
aura: romMageDruidGreenGuardianAuraDefinition(),
|
||||
sourceEffectType: -1,
|
||||
sourceAuraType: -1,
|
||||
miscValue: 0,
|
||||
triggerSpellId: ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID,
|
||||
};
|
||||
}
|
||||
|
||||
export function isRomMageDruidPair(row: {
|
||||
readonly classId?: string;
|
||||
readonly secondaryClassId?: string | null;
|
||||
}): boolean {
|
||||
return row.classId === "rom-mage" && row.secondaryClassId === "rom-druid";
|
||||
}
|
||||
|
||||
function boundedStacks(stacks: number): number {
|
||||
if (!Number.isFinite(stacks)) return 0;
|
||||
return Math.max(0, Math.min(ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS, Math.floor(stacks)));
|
||||
}
|
||||
|
||||
/** Apply one source-described stack gain after an eligible second spent standing still. */
|
||||
export function romMageDruidGreenGuardianStacksAfterStationarySecond(stacks: number): number {
|
||||
return Math.min(ROM_MAGE_DRUID_GREEN_GUARDIAN_MAX_STACKS, boundedStacks(stacks) + 1);
|
||||
}
|
||||
|
||||
/** Percentage points of movement speed granted by the current stack count. */
|
||||
export function romMageDruidGreenGuardianMovementSpeedPercent(stacks: number): number {
|
||||
return boundedStacks(stacks) * ROM_MAGE_DRUID_GREEN_GUARDIAN_MOVEMENT_SPEED_PERCENT_PER_STACK;
|
||||
}
|
||||
|
||||
export function romMageDruidGreenGuardianMovementSpeedMultiplier(
|
||||
auras: readonly ActiveAura[],
|
||||
targetId: string,
|
||||
now: number,
|
||||
): number {
|
||||
const stacks = auras
|
||||
.filter((aura) => aura.targetId === targetId
|
||||
&& aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.reduce((total, aura) => total + boundedStacks(aura.stacks), 0);
|
||||
return 1 + romMageDruidGreenGuardianMovementSpeedPercent(stacks) / 100;
|
||||
}
|
||||
|
||||
export interface RomMageDruidGreenGuardianStationaryProgress {
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly stationaryMs: number;
|
||||
}
|
||||
|
||||
export interface RomMageDruidGreenGuardianStationaryAdvance {
|
||||
readonly progress: RomMageDruidGreenGuardianStationaryProgress;
|
||||
readonly stacksGained: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance one simulator tick. Any observed position change resets progress;
|
||||
* this is the documented one-full-stationary-second approximation for CoA's
|
||||
* unspecific “brief” movement pause. It does not remove existing stacks.
|
||||
*/
|
||||
export function romMageDruidGreenGuardianAdvanceStationaryProgress(
|
||||
previous: RomMageDruidGreenGuardianStationaryProgress | undefined,
|
||||
position: readonly [number, number, number],
|
||||
deltaMs: number,
|
||||
): RomMageDruidGreenGuardianStationaryAdvance {
|
||||
const safeDelta = Math.max(0, Number.isFinite(deltaMs) ? deltaMs : 0);
|
||||
if (!previous) return { progress: { position, stationaryMs: 0 }, stacksGained: 0 };
|
||||
const moved = (position[0] - previous.position[0]) ** 2
|
||||
+ (position[1] - previous.position[1]) ** 2
|
||||
+ (position[2] - previous.position[2]) ** 2 > 1e-8;
|
||||
if (moved) return { progress: { position, stationaryMs: 0 }, stacksGained: 0 };
|
||||
const total = Math.max(0, previous.stationaryMs) + safeDelta;
|
||||
return {
|
||||
progress: { position, stationaryMs: total % ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS },
|
||||
stacksGained: Math.floor(total / ROM_MAGE_DRUID_GREEN_GUARDIAN_GAIN_INTERVAL_MS),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { applyAura, resolveDamageValue } from "./combatAuras";
|
||||
|
||||
describe("RoM Mage Fire Ward catalog behavior", () => {
|
||||
it("retains RuneWaker's +10 Fire resistance and applies current CoA's all-magic reduction", () => {
|
||||
const fireWard = abilityById("rom-mage-general-5");
|
||||
expect(fireWard).toMatchObject({
|
||||
dbcSpellId: 490_248,
|
||||
target: "self",
|
||||
castMode: "instant",
|
||||
castTimeMs: 0,
|
||||
cooldownMs: 0,
|
||||
range: { min: 0, max: 15 },
|
||||
radius: 10,
|
||||
targeting: { shape: "circle", radius: 10 },
|
||||
costs: [{ resource: "mana", amount: 120 }],
|
||||
description: expect.stringContaining("+230 Fire resistance at skill level 105"),
|
||||
});
|
||||
expect(fireWard?.description).toContain("Fire resistance by 10 points");
|
||||
expect(fireWard?.description).toContain("11.5%");
|
||||
|
||||
const auraEffect = fireWard?.effects.find((effect) => effect.kind === "apply-aura");
|
||||
expect(auraEffect).toMatchObject({
|
||||
kind: "apply-aura",
|
||||
scope: "party",
|
||||
radius: 10,
|
||||
aura: { name: "Fire Ward: fire-resistance" },
|
||||
});
|
||||
if (!auraEffect || auraEffect.kind !== "apply-aura") throw new Error("Fire Ward aura missing");
|
||||
|
||||
expect(auraEffect.aura.durationMs).toBe(600_000);
|
||||
expect(auraEffect.aura.modifiers).toContainEqual(expect.objectContaining({
|
||||
kind: "stat",
|
||||
stat: "resistance:fire",
|
||||
operation: "flat",
|
||||
value: 10,
|
||||
}));
|
||||
expect(auraEffect.aura.description).toContain("CoA lists +230 at skill level 105");
|
||||
|
||||
const auras = applyAura([], {
|
||||
definition: auraEffect.aura,
|
||||
sourceId: "fire-ward-mage",
|
||||
targetId: "fire-ward-recipient",
|
||||
now: 1_000,
|
||||
}).auras;
|
||||
const magicalSchools = ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const;
|
||||
for (const school of magicalSchools) {
|
||||
expect(resolveDamageValue(100, auras, {
|
||||
direction: "taken",
|
||||
school,
|
||||
attackKind: "spell",
|
||||
})).toBeCloseTo(88.5);
|
||||
}
|
||||
expect(resolveDamageValue(100, auras, {
|
||||
direction: "taken",
|
||||
school: "physical",
|
||||
attackKind: "melee",
|
||||
})).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
|
||||
const FIRE_WARD_ID = "rom-mage-general-5";
|
||||
const FIRE_WARD_AURA_NAME = "Fire Ward: fire-resistance";
|
||||
|
||||
function fireWardAura(targetId: string, now: number) {
|
||||
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
|
||||
&& aura.definition.name === FIRE_WARD_AURA_NAME
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now));
|
||||
}
|
||||
|
||||
function configureParty(seed: string) {
|
||||
const caster = createConfiguredPartyMember({
|
||||
name: "Fire Ward Mage",
|
||||
classId: "rom-mage",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const nearby = createConfiguredPartyMember({
|
||||
name: "Nearby Fire Ward Ally",
|
||||
classId: "rom-priest",
|
||||
specialization: "Primary",
|
||||
role: "healer",
|
||||
}, 80, 1, seed);
|
||||
const distant = createConfiguredPartyMember({
|
||||
name: "Distant Fire Ward Ally",
|
||||
classId: "rom-priest",
|
||||
specialization: "Primary",
|
||||
role: "damage",
|
||||
}, 80, 2, seed);
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.setState({ members: [caster, nearby, distant] });
|
||||
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
|
||||
registerPartyRuntimePosition(nearby.id, [8, 0, 0]);
|
||||
registerPartyRuntimePosition(distant.id, [12, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
return { caster, nearby, distant };
|
||||
}
|
||||
|
||||
describe("RoM Mage Fire Ward current CoA behavior", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useDungeonSimulationStore.setState({ sessionMode: "play" });
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", raceId: "rom-human", level: 80 });
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("applies the player's Fire Ward to self and nearby party members only", () => {
|
||||
const { caster, nearby, distant } = configureParty("player-fire-ward-radius");
|
||||
expect(useCombatStore.getState().castAbility(FIRE_WARD_ID, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
|
||||
expect(fireWardAura(PLAYER_AGGRO_ID, 1_000)).toBeDefined();
|
||||
expect(fireWardAura(caster.id, 1_000)).toBeDefined();
|
||||
expect(fireWardAura(nearby.id, 1_000)).toBeDefined();
|
||||
expect(fireWardAura(distant.id, 1_000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies a generated companion's Fire Ward to itself and nearby party members only", () => {
|
||||
const seed = "companion-fire-ward-radius";
|
||||
const { caster, nearby, distant } = configureParty(seed);
|
||||
const fireWard = abilityById(FIRE_WARD_ID)!;
|
||||
usePartyStore.setState({ members: [{
|
||||
...caster,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...caster.resourcePools, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: fireWard.id,
|
||||
school: "arcane",
|
||||
mode: "cast",
|
||||
startedAt: 1_000,
|
||||
completesAt: 1_000,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: caster.id,
|
||||
},
|
||||
activeCastTargetId: caster.id,
|
||||
status: "attacking",
|
||||
}, nearby, distant] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(fireWardAura(caster.id, 1_000)).toBeDefined();
|
||||
expect(fireWardAura(nearby.id, 1_000)).toBeDefined();
|
||||
expect(fireWardAura(distant.id, 1_000)).toBeUndefined();
|
||||
expect(fireWardAura(PLAYER_AGGRO_ID, 1_000)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -252,14 +252,14 @@ describe("RoM Mage mechanics", () => {
|
||||
.toMatchObject({ ok: true, affectedMobIds: ["arrow-target", "arrow-path"] });
|
||||
});
|
||||
|
||||
it("doubles only Fireball and Flame on the 10% Eruption proc and runs Control Flame once", () => {
|
||||
it("doubles only Fireball and Flame on the 30% 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);
|
||||
const procced = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.299);
|
||||
const ordinary = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.3);
|
||||
expect(procced.filter((effect) => effect.kind === "damage")).toEqual(
|
||||
fireball.effects.filter((effect) => effect.kind === "damage").map((effect) => effect.kind === "damage"
|
||||
? expect.objectContaining({
|
||||
@@ -292,7 +292,7 @@ describe("RoM Mage mechanics", () => {
|
||||
expect(romMageWarlockCastEffects(fireball, fireball.effects, new Set([
|
||||
...tags,
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown,
|
||||
]), 0.099).some((effect) => effect.kind === "apply-aura"
|
||||
]), 0.299).some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id === "rom-mage-druid-control-flame-buff")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
||||
// Elemental Weakness component (500365) is an all-resistance −30 modifier;
|
||||
// expand it so the debuff affects the same fields used by damage resolution.
|
||||
const MODELED_MAGIC_RESISTANCES = ["arcane", "fire", "frost", "nature", "shadow"] as const;
|
||||
const MODELED_MAGICAL_DAMAGE_SCHOOLS = ["arcane", "fire", "frost", "holy", "nature", "shadow"] as const;
|
||||
const ROM_MAGE_FIRE_WARD_RADIUS = 10;
|
||||
|
||||
export function overrideRomMageEffects(
|
||||
row: RomGeneratedAbilityLike,
|
||||
@@ -18,6 +20,31 @@ export function overrideRomMageEffects(
|
||||
// Light of Day is a landed-hit proc for Mage/Knight builds, not an always-on
|
||||
// damage aura. Its trigger and internal cooldown are applied at hit time.
|
||||
if (row.sourceSkillId === 493034) return [];
|
||||
if (row.sourceSkillId === 490248) {
|
||||
return translated.map((effect) => {
|
||||
if (effect.kind !== "apply-aura") return effect;
|
||||
const existingModifiers = effect.aura.modifiers ?? [];
|
||||
return {
|
||||
...effect,
|
||||
scope: "party",
|
||||
radius: ROM_MAGE_FIRE_WARD_RADIUS,
|
||||
aura: {
|
||||
...effect.aura,
|
||||
description: "Current CoA reduces received magical damage by 11.5%. The simulator keeps RuneWaker's +10 Fire resistance and 10-minute duration; CoA lists +230 at skill level 105, but no scaling curve is modeled.",
|
||||
modifiers: [
|
||||
...existingModifiers,
|
||||
...MODELED_MAGICAL_DAMAGE_SCHOOLS.map((school) => ({
|
||||
kind: "damage" as const,
|
||||
direction: "taken" as const,
|
||||
school,
|
||||
operation: "percent" as const,
|
||||
value: -0.115,
|
||||
})),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
if (row.sourceSkillId !== 490247) return null;
|
||||
|
||||
return translated.map((effect) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ 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;
|
||||
export const ROM_COA_MAGE_ERUPTION_BASE_PROC_CHANCE = 0.3;
|
||||
|
||||
function scaledDamage(effect: AbilityEffect, factor: number): AbilityEffect {
|
||||
if (effect.kind !== "damage") return effect;
|
||||
@@ -176,7 +176,7 @@ export function romMageWarlockCastEffects(
|
||||
|
||||
if ([490204, 491150].includes(ability.dbcSpellId)
|
||||
&& passiveTags.has(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption)
|
||||
&& eruptionProcRoll < MAGE_ERUPTION_PROC_CHANCE) {
|
||||
&& eruptionProcRoll < ROM_COA_MAGE_ERUPTION_BASE_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)
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatLine truncated
|
||||
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatLine truncated
|
||||
@@ -38,7 +38,7 @@ describe("generated RuneWaker player catalog", () => {
|
||||
orderedPairs: 66,
|
||||
eliteMilestones: 660,
|
||||
ordinaryFamilies: 269,
|
||||
iconResolved: 929,
|
||||
iconResolved: 930,
|
||||
uniqueNativeIcons: 918,
|
||||
unresolvedVisible: 0,
|
||||
});
|
||||
@@ -46,11 +46,18 @@ 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);
|
||||
// The generated snapshot contains the added Druid/Warrior 499956 row;
|
||||
// the runtime then appends Devotion Halo, two CoA Warrior elite rows, and
|
||||
// current CoA Warlock/Warrior Enraged 499871.
|
||||
expect(ROM_ELITE_ABILITIES).toHaveLength(665);
|
||||
const knownEmptyEffectElites = new Set([
|
||||
"rom-mage-rom-knight-elite-45",
|
||||
"rom-mage-rom-warrior-elite-50",
|
||||
"rom-knight-rom-rogue-elite-40",
|
||||
"rom-warlock-rom-warrior-elite-30",
|
||||
"rom-warlock-rom-warrior-elite-70",
|
||||
"rom-warlock-rom-priest-elite-15",
|
||||
"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) {
|
||||
@@ -59,8 +66,17 @@ describe("generated RuneWaker player catalog", () => {
|
||||
}
|
||||
expect(pairGroups.size).toBe(66);
|
||||
for (const [pair, elites] of pairGroups) {
|
||||
expect(elites.map((ability) => ability.unlockLevel)).toEqual(pair === "rom-priest:rom-knight"
|
||||
? [...ELITE_LEVELS, 70] : ELITE_LEVELS);
|
||||
const expectedLevels = pair === "rom-priest:rom-knight"
|
||||
? [...ELITE_LEVELS, 70]
|
||||
: pair === "rom-warlock:rom-warrior"
|
||||
? [...ELITE_LEVELS, 60]
|
||||
: pair === "rom-druid:rom-warrior"
|
||||
? [...ELITE_LEVELS.slice(0, 9), 60, 70]
|
||||
: pair === "rom-warrior:rom-knight" || pair === "rom-warrior:rom-rogue"
|
||||
? [...ELITE_LEVELS, 50]
|
||||
: ELITE_LEVELS;
|
||||
expect(elites.map((ability) => ability.unlockLevel).sort((left, right) => left - right))
|
||||
.toEqual([...expectedLevels].sort((left, right) => left - right));
|
||||
for (const ability of elites) {
|
||||
expect(ability.source, `${pair}:${ability.id} source`).toBe("runewaker");
|
||||
if (knownEmptyEffectElites.has(ability.id)) {
|
||||
@@ -71,11 +87,23 @@ describe("generated RuneWaker player catalog", () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(ROM_ALL_ABILITIES).toHaveLength(930);
|
||||
expect(ROM_ALL_ABILITIES.filter((ability) => ability.id !== "rom-priest-rom-knight-elite-70-devotion-halo").every((ability) => (
|
||||
/^\/assets\/ui\/rom-spells\/\d+\.png$/.test(ability.icon)
|
||||
&& ability.icon === generated.skills.find((skill) => skill.id === ability.id)?.icon
|
||||
))).toBe(true);
|
||||
// The generated 930-row snapshot is joined with Devotion Halo, current CoA
|
||||
// Warrior/Knight and Warrior/Rogue rows, and CoA Enraged 499871.
|
||||
expect(ROM_ALL_ABILITIES).toHaveLength(934);
|
||||
const runtimeAddedIconSources = new Map([
|
||||
["rom-warrior-rom-knight-elite-50-coa-493287", 491480],
|
||||
["rom-warrior-rom-rogue-elite-50-coa-493283", 491133],
|
||||
["rom-warlock-rom-warrior-general-coa-499871", 490492],
|
||||
]);
|
||||
for (const ability of ROM_ALL_ABILITIES) {
|
||||
expect(ability.icon).toMatch(/^\/assets\/ui\/rom-spells\/\d+\.png$/);
|
||||
if (ability.id === "rom-priest-rom-knight-elite-70-devotion-halo") continue;
|
||||
const runtimeSource = runtimeAddedIconSources.get(ability.id);
|
||||
const expectedIcon = runtimeSource === undefined
|
||||
? generated.skills.find((skill) => skill.id === ability.id)?.icon
|
||||
: generated.skills.find((skill) => skill.sourceSkillId === runtimeSource)?.icon;
|
||||
expect(ability.icon, ability.id).toBe(expectedIcon);
|
||||
}
|
||||
expect(ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-priest-rom-knight-elite-70-devotion-halo")?.icon)
|
||||
.toBe(generated.skills.find((skill) => skill.sourceSkillId === 499957)?.icon);
|
||||
expect(generated.skills.every((ability) => (
|
||||
@@ -114,6 +142,24 @@ describe("generated RuneWaker player catalog", () => {
|
||||
}));
|
||||
const generatedRanks = generated.skills.reduce((sum, ability) => sum + ability.ranks.length, 0);
|
||||
const sourceCosts = (sourceSkillId: number, rows: typeof generated.skills[number]["costs"]) => {
|
||||
// Current CoA represents Holy Power Explosion (490172) as a passive
|
||||
// marker, so it is no longer a cast with the local ten-Mana cost.
|
||||
if (sourceSkillId === 490172) return [];
|
||||
// CoA changed Chaos Dagger (491178) to a passive but still lists 25 MP
|
||||
// without saying whether that is paid per attack. Do not model the old
|
||||
// Enchanted Throw activation cost or guess a recurring proc charge.
|
||||
if (sourceSkillId === 491178) return [];
|
||||
// CoA Leopard Instinct (495402) is passive, with no cast cost.
|
||||
if (sourceSkillId === 495402) return [];
|
||||
if (sourceSkillId === 490274) return [];
|
||||
// Current CoA Holy Candle (490304) has a 5% Mana activation cost;
|
||||
// the generated RuneWaker row omits that cost.
|
||||
if (sourceSkillId === 490304) return [{ resource: "mana", amount: 5, percentage: true }];
|
||||
if (sourceSkillId === 490276) return [{
|
||||
resource: "mana", amount: 10, percentage: true, percentageBasis: "base-mana",
|
||||
}];
|
||||
if (sourceSkillId === 498612) return [{ resource: "rage", amount: 15 }];
|
||||
if (sourceSkillId === 498642) return [{ resource: "rage", amount: 75, percentage: false }];
|
||||
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.
|
||||
@@ -124,7 +170,9 @@ describe("generated RuneWaker player catalog", () => {
|
||||
|| normalized.some((entry) => entry.resource === "nature-power")) return normalized;
|
||||
return [...normalized, { resource: "nature-power", amount: 1 }];
|
||||
};
|
||||
expect(generatedRanks).toBe(934);
|
||||
// Druid/Warrior Cross of Thorns Attacks (499956) is a separately added
|
||||
// generated row with one rank in the current source snapshot.
|
||||
expect(generatedRanks).toBe(935);
|
||||
expect(Math.max(...generated.skills.flatMap((ability) => (
|
||||
ability.costs.filter((cost) => !cost.percentage).map((cost) => cost.amount)
|
||||
)))).toBe(400);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID } from "./romPriestChampionBeaconRuntime";
|
||||
|
||||
const beacon = abilityById(ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID)!;
|
||||
const heal = abilityById("rom-priest-primary-2")!;
|
||||
const beaconTag = "rom-priest-champion-beacon-of-regeneration";
|
||||
|
||||
function setPlayerMana(amount: number): void {
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: amount,
|
||||
maxResource: amount,
|
||||
maxResourcePools: { ...state.maxResourcePools, mana: amount },
|
||||
resourcePools: { ...state.resourcePools, mana: amount },
|
||||
}));
|
||||
}
|
||||
|
||||
function activeCast(abilityId: string, targetId: string, startedAt: number, completesAt: number) {
|
||||
return {
|
||||
abilityId,
|
||||
school: "holy" as const,
|
||||
mode: "cast" as const,
|
||||
startedAt,
|
||||
completesAt,
|
||||
originalDurationMs: Math.max(0, completesAt - startedAt),
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0] as const,
|
||||
targetId,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RoM Priest/Champion Beacon of Regeneration integration", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-champion",
|
||||
raceId: "rom-dwarf",
|
||||
level: 80,
|
||||
role: "healer",
|
||||
});
|
||||
usePartyStore.getState().initializeParty(
|
||||
"rom-priest-champion-beacon", 80, useCombatStore.getState().health, "healer", 2, "rom",
|
||||
);
|
||||
usePartyStore.getState().setCommand("stop");
|
||||
setPlayerMana(10_000);
|
||||
});
|
||||
|
||||
it("applies a player's Beacon with the caster's healing-done modifiers", () => {
|
||||
const seed = "rom-priest-champion-beacon-player-target";
|
||||
const marked = createConfiguredPartyMember({
|
||||
name: "Marked ally",
|
||||
classId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
specialization: "Primary",
|
||||
role: "tank",
|
||||
}, 80, 0, seed);
|
||||
const healTarget = createConfiguredPartyMember({
|
||||
name: "Heal target",
|
||||
classId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
specialization: "Primary",
|
||||
role: "tank",
|
||||
}, 80, 1, seed);
|
||||
usePartyStore.setState({ members: [{ ...marked, health: 1 }, { ...healTarget, health: 1 }] });
|
||||
registerPartyRuntimePosition(marked.id, [1, 0, 0]);
|
||||
registerPartyRuntimePosition(healTarget.id, [2, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
expect(useCombatStore.getState().castAbility(beacon.id, {
|
||||
targetActorId: marked.id,
|
||||
now: 1_000,
|
||||
})).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId: marked.id,
|
||||
expiresAt: 31_000,
|
||||
definition: expect.objectContaining({ utilityTags: [beaconTag] }),
|
||||
}),
|
||||
]));
|
||||
|
||||
expect(useCombatStore.getState().castAbility(heal.id, {
|
||||
targetActorId: healTarget.id,
|
||||
now: 3_000,
|
||||
})).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(3, 5_000);
|
||||
|
||||
const event = useCombatStore.getState().combatEvents.find((candidate) => (
|
||||
candidate.kind === "healing" && candidate.abilityId === beacon.id
|
||||
));
|
||||
const faith = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === PLAYER_AGGRO_ID && aura.definition.name === "Faith: healing-power-percent"
|
||||
));
|
||||
expect(faith?.definition.modifiers).toContainEqual({
|
||||
kind: "healing", direction: "done", operation: "percent", value: 0.05,
|
||||
});
|
||||
expect(event).toMatchObject({
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: marked.id,
|
||||
rawAmount: 809,
|
||||
effectiveAmount: 809,
|
||||
overheal: 0,
|
||||
critical: false,
|
||||
});
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === marked.id)?.health).toBe(810);
|
||||
});
|
||||
|
||||
it("applies a generated companion's Beacon and resolves its direct-heal trigger", () => {
|
||||
const seed = "rom-priest-champion-beacon-companion";
|
||||
const caster = createConfiguredPartyMember({
|
||||
name: "Priest/Champion",
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: "rom-champion",
|
||||
raceId: "rom-dwarf",
|
||||
specialization: "Priest/Champion",
|
||||
role: "healer",
|
||||
}, 80, 0, seed);
|
||||
const marked = createConfiguredPartyMember({
|
||||
name: "Marked companion ally",
|
||||
classId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
specialization: "Primary",
|
||||
role: "tank",
|
||||
}, 80, 1, seed);
|
||||
const healTarget = createConfiguredPartyMember({
|
||||
name: "Heal target companion",
|
||||
classId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
specialization: "Primary",
|
||||
role: "tank",
|
||||
}, 80, 2, seed);
|
||||
usePartyStore.setState({ members: [
|
||||
{ ...caster, resource: 10_000, maxResource: 10_000, resourcePools: { ...caster.resourcePools, mana: 10_000 },
|
||||
activeCast: activeCast(beacon.id, marked.id, 1_000, 1_000), activeCastTargetId: marked.id, status: "attacking" },
|
||||
{ ...marked, health: 1 },
|
||||
{ ...healTarget, health: 1 },
|
||||
] });
|
||||
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
|
||||
registerPartyRuntimePosition(marked.id, [1, 0, 0]);
|
||||
registerPartyRuntimePosition(healTarget.id, [2, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ sourceId: caster.id, targetId: marked.id, definition: expect.objectContaining({ utilityTags: [beaconTag] }) }),
|
||||
]));
|
||||
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === caster.id ? {
|
||||
...member,
|
||||
activeCast: activeCast(heal.id, healTarget.id, 1_000, 3_000),
|
||||
activeCastTargetId: healTarget.id,
|
||||
status: "attacking",
|
||||
} : member),
|
||||
}));
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
advanceDungeonPartyCombat(3_000);
|
||||
|
||||
const faith = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === caster.id && aura.definition.name === "Faith: healing-power-percent"
|
||||
));
|
||||
expect(faith?.definition.modifiers).toContainEqual({
|
||||
kind: "healing", direction: "done", operation: "percent", value: 0.05,
|
||||
});
|
||||
|
||||
const event = useCombatStore.getState().combatEvents.find((candidate) => (
|
||||
candidate.kind === "healing" && candidate.sourceActorId === caster.id && candidate.abilityId === beacon.id
|
||||
));
|
||||
expect(event).toMatchObject({
|
||||
targetActorId: marked.id,
|
||||
// Beacon contributes 770; the companion's ordinary Faith aura applies its +5% healing bonus.
|
||||
rawAmount: 809,
|
||||
effectiveAmount: 809,
|
||||
overheal: 0,
|
||||
critical: false,
|
||||
});
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === marked.id)?.health).toBe(810);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 330 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user